Skip to content

Commit 84dc4ea

Browse files
committed
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.
1 parent b42f64f commit 84dc4ea

11 files changed

Lines changed: 1532 additions & 4 deletions

GhosttyTabs.xcodeproj/project.pbxproj

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@
77
objects = {
88

99
/* Begin PBXBuildFile section */
10+
FE001101 /* FileExplorerStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE001001 /* FileExplorerStore.swift */; };
11+
FE001102 /* FileExplorerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE001002 /* FileExplorerView.swift */; };
12+
FE002101 /* FileExplorerRootResolverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE002001 /* FileExplorerRootResolverTests.swift */; };
13+
FE002102 /* FileExplorerStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE002002 /* FileExplorerStoreTests.swift */; };
1014
A5001001 /* cmuxApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001011 /* cmuxApp.swift */; };
1115
A5001002 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001012 /* ContentView.swift */; };
1216
E62155868BB29FEB5DAAAF25 /* SidebarSelectionState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AD52285508B1D6A9875E7B3 /* SidebarSelectionState.swift */; };
@@ -180,6 +184,10 @@
180184
/* End PBXContainerItemProxy section */
181185

182186
/* Begin PBXFileReference section */
187+
FE001001 /* FileExplorerStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileExplorerStore.swift; sourceTree = "<group>"; };
188+
FE001002 /* FileExplorerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileExplorerView.swift; sourceTree = "<group>"; };
189+
FE002001 /* FileExplorerRootResolverTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileExplorerRootResolverTests.swift; sourceTree = "<group>"; };
190+
FE002002 /* FileExplorerStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileExplorerStoreTests.swift; sourceTree = "<group>"; };
183191
A5001000 /* cmux.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = cmux.app; sourceTree = BUILT_PRODUCTS_DIR; };
184192
F1000002A1B2C3D4E5F60718 /* cmuxTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = cmuxTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
185193
7E7E6EF344A568AC7FEE3715 /* cmuxUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = cmuxUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
@@ -469,6 +477,8 @@
469477
A5001651 /* CmuxConfig.swift */,
470478
A5001653 /* CmuxConfigExecutor.swift */,
471479
A5001655 /* CmuxDirectoryTrust.swift */,
480+
FE001001 /* FileExplorerStore.swift */,
481+
FE001002 /* FileExplorerView.swift */,
472482
);
473483
path = Sources;
474484
sourceTree = "<group>";
@@ -559,6 +569,8 @@
559569
491751CE2321474474F27DCF /* TerminalControllerSocketSecurityTests.swift */,
560570
10D684CFFB8CDEF89CE2D9E1 /* TabManagerSessionSnapshotTests.swift */,
561571
C1A2B3C4D5E6F70800000002 /* CmuxConfigTests.swift */,
572+
FE002001 /* FileExplorerRootResolverTests.swift */,
573+
FE002002 /* FileExplorerStoreTests.swift */,
562574
);
563575
path = cmuxTests;
564576
sourceTree = "<group>";
@@ -768,6 +780,8 @@
768780
A5001650 /* CmuxConfig.swift in Sources */,
769781
A5001652 /* CmuxConfigExecutor.swift in Sources */,
770782
A5001654 /* CmuxDirectoryTrust.swift in Sources */,
783+
FE001101 /* FileExplorerStore.swift in Sources */,
784+
FE001102 /* FileExplorerView.swift in Sources */,
771785
);
772786
runOnlyForDeploymentPostprocessing = 0;
773787
};
@@ -826,6 +840,8 @@
826840
8C4BBF2DEF6DF93F395A9EE7 /* TerminalControllerSocketSecurityTests.swift in Sources */,
827841
2BB56A710BB1FC50367E5BCF /* TabManagerSessionSnapshotTests.swift in Sources */,
828842
C1A2B3C4D5E6F70800000001 /* CmuxConfigTests.swift in Sources */,
843+
FE002101 /* FileExplorerRootResolverTests.swift in Sources */,
844+
FE002102 /* FileExplorerStoreTests.swift in Sources */,
829845
);
830846
runOnlyForDeploymentPostprocessing = 0;
831847
};

Resources/Localizable.xcstrings

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63638,6 +63638,91 @@
6363863638
}
6363963639
}
6364063640
},
63641+
"shortcut.toggleFileExplorer.label": {
63642+
"extractionState": "manual",
63643+
"localizations": {
63644+
"en": {
63645+
"stringUnit": {
63646+
"state": "translated",
63647+
"value": "Toggle File Explorer"
63648+
}
63649+
},
63650+
"ja": {
63651+
"stringUnit": {
63652+
"state": "translated",
63653+
"value": "ファイルエクスプローラーを切替"
63654+
}
63655+
}
63656+
}
63657+
},
63658+
"fileExplorer.empty": {
63659+
"extractionState": "manual",
63660+
"localizations": {
63661+
"en": {
63662+
"stringUnit": {
63663+
"state": "translated",
63664+
"value": "No folder open"
63665+
}
63666+
},
63667+
"ja": {
63668+
"stringUnit": {
63669+
"state": "translated",
63670+
"value": "フォルダが開かれていません"
63671+
}
63672+
}
63673+
}
63674+
},
63675+
"fileExplorer.error.unavailable": {
63676+
"extractionState": "manual",
63677+
"localizations": {
63678+
"en": {
63679+
"stringUnit": {
63680+
"state": "translated",
63681+
"value": "File explorer is not available"
63682+
}
63683+
},
63684+
"ja": {
63685+
"stringUnit": {
63686+
"state": "translated",
63687+
"value": "ファイルエクスプローラーは利用できません"
63688+
}
63689+
}
63690+
}
63691+
},
63692+
"titlebar.fileExplorer.accessibilityLabel": {
63693+
"extractionState": "manual",
63694+
"localizations": {
63695+
"en": {
63696+
"stringUnit": {
63697+
"state": "translated",
63698+
"value": "Toggle File Explorer"
63699+
}
63700+
},
63701+
"ja": {
63702+
"stringUnit": {
63703+
"state": "translated",
63704+
"value": "ファイルエクスプローラーを切替"
63705+
}
63706+
}
63707+
}
63708+
},
63709+
"titlebar.fileExplorer.tooltip": {
63710+
"extractionState": "manual",
63711+
"localizations": {
63712+
"en": {
63713+
"stringUnit": {
63714+
"state": "translated",
63715+
"value": "Show or hide the file explorer"
63716+
}
63717+
},
63718+
"ja": {
63719+
"stringUnit": {
63720+
"state": "translated",
63721+
"value": "ファイルエクスプローラーの表示/非表示"
63722+
}
63723+
}
63724+
}
63725+
},
6364163726
"shortcut.toggleTerminalCopyMode.label": {
6364263727
"extractionState": "manual",
6364363728
"localizations": {

Sources/AppDelegate.swift

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2018,6 +2018,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
20182018
weak var tabManager: TabManager?
20192019
weak var notificationStore: TerminalNotificationStore?
20202020
weak var sidebarState: SidebarState?
2021+
weak var fileExplorerState: FileExplorerState?
20212022
weak var fullscreenControlsViewModel: TitlebarControlsViewModel?
20222023
weak var sidebarSelectionState: SidebarSelectionState?
20232024
var shortcutLayoutCharacterProvider: (UInt16, NSEvent.ModifierFlags) -> String? = KeyboardLayout.character(forKeyCode:modifierFlags:)
@@ -5881,11 +5882,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
58815882
cmuxConfigStore.wireDirectoryTracking(tabManager: tabManager)
58825883
cmuxConfigStore.loadAll()
58835884

5885+
let fileExplorerState = FileExplorerState()
5886+
58845887
let root = ContentView(updateViewModel: updateViewModel, windowId: windowId)
58855888
.environmentObject(tabManager)
58865889
.environmentObject(notificationStore)
58875890
.environmentObject(sidebarState)
58885891
.environmentObject(sidebarSelectionState)
5892+
.environmentObject(fileExplorerState)
58895893
.environmentObject(cmuxConfigStore)
58905894

58915895
// Use the current key window's size for new windows so Cmd+Shift+N
@@ -9447,6 +9451,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
94479451
return true
94489452
}
94499453

9454+
if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .toggleFileExplorer)) {
9455+
fileExplorerState?.toggle()
9456+
return true
9457+
}
9458+
94509459
if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .sendFeedback)) {
94519460
guard let targetContext = preferredMainWindowContextForShortcuts(event: event),
94529461
let targetWindow = targetContext.window ?? windowForMainWindowId(targetContext.windowId) else {

Sources/ContentView.swift

Lines changed: 67 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1554,6 +1554,7 @@ struct ContentView: View {
15541554
@EnvironmentObject var sidebarState: SidebarState
15551555
@EnvironmentObject var sidebarSelectionState: SidebarSelectionState
15561556
@EnvironmentObject var cmuxConfigStore: CmuxConfigStore
1557+
@EnvironmentObject var fileExplorerState: FileExplorerState
15571558
@State private var sidebarWidth: CGFloat = 200
15581559
@State private var hoveredResizerHandles: Set<SidebarResizerHandle> = []
15591560
@State private var isResizerDragging = false
@@ -1565,6 +1566,7 @@ struct ContentView: View {
15651566
@State private var isFullScreen: Bool = false
15661567
@State private var observedWindow: NSWindow?
15671568
@StateObject private var fullscreenControlsViewModel = TitlebarControlsViewModel()
1569+
@StateObject private var fileExplorerStore = FileExplorerStore()
15681570
@State private var previousSelectedWorkspaceId: UUID?
15691571
@State private var retiringWorkspaceId: UUID?
15701572
@State private var workspaceHandoffGeneration: UInt64 = 0
@@ -2413,10 +2415,17 @@ struct ContentView: View {
24132415
}
24142416

24152417
private var terminalContentWithSidebarDropOverlay: some View {
2416-
terminalContent
2417-
.overlay {
2418-
SidebarExternalDropOverlay(draggedTabId: sidebarDraggedTabId)
2418+
HStack(spacing: 0) {
2419+
terminalContent
2420+
.overlay {
2421+
SidebarExternalDropOverlay(draggedTabId: sidebarDraggedTabId)
2422+
}
2423+
if fileExplorerState.isVisible {
2424+
Divider()
2425+
FileExplorerView(store: fileExplorerStore, state: fileExplorerState)
2426+
.frame(width: fileExplorerState.width)
24192427
}
2428+
}
24202429
}
24212430

24222431
@AppStorage("sidebarBlendMode") private var sidebarBlendMode = SidebarBlendModeOption.withinWindow.rawValue
@@ -2569,6 +2578,59 @@ struct ContentView: View {
25692578
)
25702579
}
25712580

2581+
private func syncFileExplorerDirectory() {
2582+
guard let selectedId = tabManager.selectedTabId,
2583+
let tab = tabManager.tabs.first(where: { $0.id == selectedId }) else {
2584+
return
2585+
}
2586+
2587+
let dir = tab.currentDirectory.trimmingCharacters(in: .whitespacesAndNewlines)
2588+
guard !dir.isEmpty else { return }
2589+
2590+
if tab.isRemoteWorkspace {
2591+
let config = tab.remoteConfiguration
2592+
let remotePath = tab.remoteDaemonStatus.remotePath
2593+
let isReady = tab.remoteDaemonStatus.state == .ready
2594+
let homePath = remotePath.flatMap { path -> String? in
2595+
// Extract home from remote path context
2596+
// For SSH, try to derive home from the working directory if it starts with /home/
2597+
let components = dir.split(separator: "/")
2598+
if components.count >= 2, components[0] == "home" {
2599+
return "/home/\(components[1])"
2600+
}
2601+
if dir.hasPrefix("/root") {
2602+
return "/root"
2603+
}
2604+
return nil
2605+
} ?? ""
2606+
2607+
if let existingProvider = fileExplorerStore.provider as? SSHFileExplorerProvider,
2608+
existingProvider.destination == config?.destination {
2609+
existingProvider.updateAvailability(isReady, homePath: isReady ? homePath : nil)
2610+
if isReady {
2611+
fileExplorerStore.setRootPath(dir)
2612+
fileExplorerStore.hydrateExpandedNodes()
2613+
}
2614+
} else if let config {
2615+
let provider = SSHFileExplorerProvider(
2616+
destination: config.destination,
2617+
port: config.port,
2618+
identityFile: config.identityFile,
2619+
sshOptions: config.sshOptions,
2620+
homePath: homePath,
2621+
isAvailable: isReady
2622+
)
2623+
fileExplorerStore.setProvider(provider)
2624+
fileExplorerStore.setRootPath(dir)
2625+
}
2626+
} else {
2627+
if !(fileExplorerStore.provider is LocalFileExplorerProvider) {
2628+
fileExplorerStore.setProvider(LocalFileExplorerProvider())
2629+
}
2630+
fileExplorerStore.setRootPath(dir)
2631+
}
2632+
}
2633+
25722634
private var focusedDirectory: String? {
25732635
guard let selectedId = tabManager.selectedTabId,
25742636
let tab = tabManager.tabs.first(where: { $0.id == selectedId }) else {
@@ -2657,6 +2719,7 @@ struct ContentView: View {
26572719
syncSidebarSelectedWorkspaceIds()
26582720
applyUITestSidebarSelectionIfNeeded(tabs: tabManager.tabs)
26592721
updateTitlebarText()
2722+
syncFileExplorerDirectory()
26602723

26612724
// Startup recovery (#399): if session restore or a race condition leaves the
26622725
// view in a broken state (empty tabs, no selection, unmounted workspaces),
@@ -2726,6 +2789,7 @@ struct ContentView: View {
27262789
lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == newValue }
27272790
}
27282791
updateTitlebarText()
2792+
syncFileExplorerDirectory()
27292793
})
27302794

27312795
view = AnyView(view.onChange(of: selectedTabIds) { _ in

0 commit comments

Comments
 (0)