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..ffab743a6559 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -3310,6 +3310,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent tabManager = context.tabManager sidebarState = context.sidebarState sidebarSelectionState = context.sidebarSelectionState + TerminalController.shared.setActiveTabManager(context.tabManager) } #if DEBUG @@ -3757,6 +3758,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent tabManager = context.tabManager sidebarState = context.sidebarState sidebarSelectionState = context.sidebarSelectionState + TerminalController.shared.setActiveTabManager(context.tabManager) } @@ -5205,6 +5207,19 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } + if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .toggleFileTree)) { + let current = UserDefaults.standard.string(forKey: "sidebarContentMode") ?? SidebarContentMode.tabs.rawValue + if current == SidebarContentMode.fileTree.rawValue { + UserDefaults.standard.set(SidebarContentMode.tabs.rawValue, forKey: "sidebarContentMode") + } else { + UserDefaults.standard.set(SidebarContentMode.fileTree.rawValue, forKey: "sidebarContentMode") + 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..f077042de5eb 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -1225,6 +1225,8 @@ struct ContentView: View { @EnvironmentObject var notificationStore: TerminalNotificationStore @EnvironmentObject var sidebarState: SidebarState @EnvironmentObject var sidebarSelectionState: SidebarSelectionState + @AppStorage("sidebarContentMode") private var sidebarContentMode = SidebarContentMode.tabs.rawValue + @StateObject private var fileTreeModel = FileTreeModel() @State private var sidebarWidth: CGFloat = 200 @State private var hoveredResizerHandles: Set = [] @State private var isResizerDragging = false @@ -1773,14 +1775,57 @@ 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 sidebarView: some View { + VStack(spacing: 0) { + if sidebarContentMode == SidebarContentMode.fileTree.rawValue { + fileTreeSidebarContent + } else { + tabsSidebarContent + } + } .frame(width: sidebarWidth) + .background(SidebarBackdrop().ignoresSafeArea()) + } + + 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 escaped = path.replacingOccurrences(of: "'", with: "'\\''") + return "'\(escaped)'" } /// Space at top of content area for the titlebar. This must be at least the actual titlebar @@ -1868,6 +1913,17 @@ struct ContentView: View { notificationStore: TerminalNotificationStore.shared, viewModel: fullscreenControlsViewModel, onToggleSidebar: { sidebarState.toggle() }, + onToggleFileTree: { + let current = UserDefaults.standard.string(forKey: "sidebarContentMode") ?? SidebarContentMode.tabs.rawValue + if current == SidebarContentMode.fileTree.rawValue { + UserDefaults.standard.set(SidebarContentMode.tabs.rawValue, forKey: "sidebarContentMode") + } else { + UserDefaults.standard.set(SidebarContentMode.fileTree.rawValue, forKey: "sidebarContentMode") + if !sidebarState.isVisible { + sidebarState.toggle() + } + } + }, onToggleNotifications: { [fullscreenControlsViewModel] in AppDelegate.shared?.toggleNotificationsPopover( animated: true, @@ -2493,7 +2549,7 @@ struct ContentView: View { windowId: windowId, tabManager: tabManager, sidebarState: sidebarState, - sidebarSelectionState: sidebarSelectionState + sidebarSelectionState: sidebarSelectionState, ) installFileDropOverlay(on: window, tabManager: tabManager) })) @@ -5665,7 +5721,6 @@ struct VerticalTabsSidebar: View { } .accessibilityIdentifier("Sidebar") .ignoresSafeArea() - .background(SidebarBackdrop().ignoresSafeArea()) .background( WindowAccessor { window in commandKeyMonitor.setHostWindow(window) @@ -8374,7 +8429,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..0d354c353e12 --- /dev/null +++ b/Sources/FileTree/FileTreeModel.swift @@ -0,0 +1,248 @@ +import Foundation +import SwiftUI + +@MainActor +final class FileTreeModel: ObservableObject { + @Published var rootPath: String = "" + @Published var rootNodes: [FileTreeNode] = [] + @Published var showHiddenFiles: Bool = true + + private var fsEventStream: FSEventStreamRef? + private var refreshCoalesceTask: Task? + private var scanVersion: UInt64 = 0 + + deinit { + if let stream = fsEventStream { + FSEventStreamStop(stream) + FSEventStreamInvalidate(stream) + FSEventStreamRelease(stream) + } + } + + func loadDirectory(_ path: String) { + rootPath = path + scanVersion &+= 1 + let version = scanVersion + Task { [weak self] in + guard let self else { return } + let nodes = await self.scanDirectory(path) + guard version == self.scanVersion else { return } + self.rootNodes = nodes + } + startWatching(path: path) + } + + 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 path = rootPath + let expandedIds = collectExpandedIds(rootNodes) + scanVersion &+= 1 + let version = scanVersion + Task { [weak self] in + guard let self else { return } + let nodes = await self.scanDirectory(path) + guard version == self.scanVersion else { return } + var result = nodes + self.restoreExpandedState(in: &result, expandedIds: expandedIds) + self.rootNodes = result + } + } + + func toggleHiddenFiles() { + showHiddenFiles.toggle() + refresh() + } + + func clearDirectory() { + refreshCoalesceTask?.cancel() + stopWatching() + rootPath = "" + rootNodes = [] + } + + // MARK: - FSEvents Watching + + private func startWatching(path: String) { + stopWatching() + + var context = FSEventStreamContext() + context.info = Unmanaged.passUnretained(self).toOpaque() + + let paths = [path] as CFArray + let flags: FSEventStreamCreateFlags = + UInt32(kFSEventStreamCreateFlagUseCFTypes) + | UInt32(kFSEventStreamCreateFlagFileEvents) + | UInt32(kFSEventStreamCreateFlagNoDefer) + + guard let stream = FSEventStreamCreate( + nil, + fsEventsCallback, + &context, + paths, + FSEventStreamEventId(kFSEventStreamEventIdSinceNow), + 0.5, // 500ms latency for coalescing rapid changes + flags + ) else { return } + + FSEventStreamSetDispatchQueue(stream, DispatchQueue.main) + FSEventStreamStart(stream) + fsEventStream = stream + } + + private func stopWatching() { + guard let stream = fsEventStream else { return } + FSEventStreamStop(stream) + FSEventStreamInvalidate(stream) + FSEventStreamRelease(stream) + fsEventStream = nil + } + + fileprivate func handleFSEvent() { + // Coalesce rapid events into a single refresh + refreshCoalesceTask?.cancel() + refreshCoalesceTask = Task { @MainActor [weak self] in + try? await Task.sleep(nanoseconds: 300_000_000) // 300ms debounce + guard !Task.isCancelled, let self else { return } + self.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 } + var children = await self.scanDirectory(path) + self.restoreExpandedState(in: &children, expandedIds: expandedIds) + 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 + } + } + } +} + +// FSEvents callback must be a C function pointer, so it's outside the class +private func fsEventsCallback( + _ streamRef: ConstFSEventStreamRef, + _ clientCallBackInfo: UnsafeMutableRawPointer?, + _ numEvents: Int, + _ eventPaths: UnsafeMutableRawPointer, + _ eventFlags: UnsafePointer, + _ eventIds: UnsafePointer +) { + guard let info = clientCallBackInfo else { return } + let model = Unmanaged.fromOpaque(info).takeUnretainedValue() + Task { @MainActor in + model.handleFSEvent() + } +} 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..fbb33ef2f661 --- /dev/null +++ b/Sources/FileTree/FileTreeSidebar.swift @@ -0,0 +1,91 @@ +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? + + private let trafficLightPadding: CGFloat = 28 + + var body: some View { + VStack(spacing: 0) { + // Space for traffic lights / titlebar controls + Spacer() + .frame(height: trafficLightPadding) + + // Header with directory name + 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() + } + .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) { + let trimmed = workspace.currentDirectory.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { + model.clearDirectory() + selectedFilePath = nil + } else if 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..4234caae811f --- /dev/null +++ b/Sources/FileTree/SidebarContentMode.swift @@ -0,0 +1,6 @@ +import SwiftUI + +enum SidebarContentMode: String { + case tabs + case fileTree +} 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/Update/UpdateTitlebarAccessory.swift b/Sources/Update/UpdateTitlebarAccessory.swift index 7ff7d03be3ac..baf655286283 100644 --- a/Sources/Update/UpdateTitlebarAccessory.swift +++ b/Sources/Update/UpdateTitlebarAccessory.swift @@ -35,7 +35,7 @@ enum TitlebarControlsStyle: Int, CaseIterable, Identifiable { switch self { case .classic: return TitlebarControlsStyleConfig( - spacing: 10, + spacing: 8, iconSize: 15, buttonSize: 24, badgeSize: 14, @@ -61,7 +61,7 @@ enum TitlebarControlsStyle: Int, CaseIterable, Identifiable { ) case .roomy: return TitlebarControlsStyleConfig( - spacing: 14, + spacing: 12, iconSize: 16, buttonSize: 28, badgeSize: 16, @@ -230,8 +230,10 @@ struct TitlebarControlsView: View { @ObservedObject var notificationStore: TerminalNotificationStore @ObservedObject var viewModel: TitlebarControlsViewModel let onToggleSidebar: () -> Void + let onToggleFileTree: () -> Void let onToggleNotifications: () -> Void let onNewTab: () -> Void + @AppStorage("sidebarContentMode") private var sidebarContentMode = SidebarContentMode.tabs.rawValue @AppStorage("titlebarControlsStyle") private var styleRawValue = TitlebarControlsStyle.classic.rawValue @AppStorage(ShortcutHintDebugSettings.titlebarHintXKey) private var titlebarShortcutHintXOffset = ShortcutHintDebugSettings.defaultTitlebarHintX @AppStorage(ShortcutHintDebugSettings.titlebarHintYKey) private var titlebarShortcutHintYOffset = ShortcutHintDebugSettings.defaultTitlebarHintY @@ -244,6 +246,7 @@ struct TitlebarControlsView: View { private enum HintSlot: Int, CaseIterable { case toggleSidebar + case toggleFileTree case showNotifications case newTab @@ -251,6 +254,8 @@ struct TitlebarControlsView: View { switch self { case .toggleSidebar: return .toggleSidebar + case .toggleFileTree: + return .toggleFileTree case .showNotifications: return .showNotifications case .newTab: @@ -323,6 +328,21 @@ struct TitlebarControlsView: View { .accessibilityLabel("Toggle Sidebar") .help(KeyboardShortcutSettings.Action.toggleSidebar.tooltip("Show or hide the sidebar")) + TitlebarControlButton(config: config, action: { + #if DEBUG + dlog("titlebar.toggleFileTree") + #endif + onToggleFileTree() + }) { + iconLabel( + systemName: sidebarContentMode == SidebarContentMode.fileTree.rawValue ? "folder.fill" : "folder", + config: config + ) + } + .accessibilityIdentifier("titlebarControl.toggleFileTree") + .accessibilityLabel("Toggle File Tree") + .help(KeyboardShortcutSettings.Action.toggleFileTree.tooltip("Switch between tabs and file tree")) + TitlebarControlButton(config: config, action: { #if DEBUG dlog("titlebar.notifications") @@ -712,6 +732,17 @@ final class TitlebarControlsAccessoryViewController: NSTitlebarAccessoryViewCont init(notificationStore: TerminalNotificationStore) { self.notificationStore = notificationStore let toggleSidebar = { _ = AppDelegate.shared?.sidebarState?.toggle() } + let toggleFileTree = { + let current = UserDefaults.standard.string(forKey: "sidebarContentMode") ?? SidebarContentMode.tabs.rawValue + if current == SidebarContentMode.fileTree.rawValue { + UserDefaults.standard.set(SidebarContentMode.tabs.rawValue, forKey: "sidebarContentMode") + } else { + UserDefaults.standard.set(SidebarContentMode.fileTree.rawValue, forKey: "sidebarContentMode") + if AppDelegate.shared?.sidebarState?.isVisible == false { + _ = AppDelegate.shared?.sidebarState?.toggle() + } + } + } let toggleNotifications: () -> Void = { _ = AppDelegate.shared?.toggleNotificationsPopover(animated: true) } let newTab = { _ = AppDelegate.shared?.tabManager?.addTab() } @@ -720,6 +751,7 @@ final class TitlebarControlsAccessoryViewController: NSTitlebarAccessoryViewCont notificationStore: notificationStore, viewModel: viewModel, onToggleSidebar: toggleSidebar, + onToggleFileTree: toggleFileTree, onToggleNotifications: toggleNotifications, onNewTab: newTab ) diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 41b8f1600525..a79fd72baa31 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() + private let primaryWindowId = UUID() @AppStorage(AppearanceSettings.appearanceModeKey) private var appearanceMode = AppearanceSettings.defaultMode.rawValue @AppStorage("titlebarControlsStyle") private var titlebarControlsStyle = TitlebarControlsStyle.classic.rawValue @@ -34,6 +35,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 +496,18 @@ struct cmuxApp: App { } } + splitCommandButton(title: "Toggle File Tree", shortcut: toggleFileTreeMenuShortcut) { + let current = UserDefaults.standard.string(forKey: "sidebarContentMode") ?? SidebarContentMode.tabs.rawValue + if current == SidebarContentMode.fileTree.rawValue { + UserDefaults.standard.set(SidebarContentMode.tabs.rawValue, forKey: "sidebarContentMode") + } else { + UserDefaults.standard.set(SidebarContentMode.fileTree.rawValue, forKey: "sidebarContentMode") + 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) }