Skip to content

Commit 6d7bbd2

Browse files
Add file explorer sidebar with SSH support, git status, and visual styles (#1963)
* 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. * 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. * 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 * DRY resizer, AppKit refactor, style debug window, no animations * 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 * Feature flag: KVO observation for live toggle * Feature flag: notification-based toggle sync * Remove feature flag and titlebar button, Cmd+Option+B always enabled --------- Co-authored-by: Lawrence Chen <lawrencecchen@users.noreply.github.com>
1 parent 165cf6e commit 6d7bbd2

12 files changed

Lines changed: 2461 additions & 49 deletions

GhosttyTabs.xcodeproj/project.pbxproj

Lines changed: 19 additions & 3 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 */; };
@@ -205,6 +209,10 @@
205209
/* End PBXContainerItemProxy section */
206210

207211
/* Begin PBXFileReference section */
212+
FE001001 /* FileExplorerStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileExplorerStore.swift; sourceTree = "<group>"; };
213+
FE001002 /* FileExplorerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileExplorerView.swift; sourceTree = "<group>"; };
214+
FE002001 /* FileExplorerRootResolverTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileExplorerRootResolverTests.swift; sourceTree = "<group>"; };
215+
FE002002 /* FileExplorerStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileExplorerStoreTests.swift; sourceTree = "<group>"; };
208216
A5001000 /* cmux.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = cmux.app; sourceTree = BUILT_PRODUCTS_DIR; };
209217
F1000002A1B2C3D4E5F60718 /* cmuxTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = cmuxTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
210218
7E7E6EF344A568AC7FEE3715 /* cmuxUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = cmuxUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
@@ -518,6 +526,8 @@
518526
A5001651 /* CmuxConfig.swift */,
519527
A5001653 /* CmuxConfigExecutor.swift */,
520528
A5001655 /* CmuxDirectoryTrust.swift */,
529+
FE001001 /* FileExplorerStore.swift */,
530+
FE001002 /* FileExplorerView.swift */,
521531
);
522532
path = Sources;
523533
sourceTree = "<group>";
@@ -612,6 +622,8 @@
612622
491751CE2321474474F27DCF /* TerminalControllerSocketSecurityTests.swift */,
613623
10D684CFFB8CDEF89CE2D9E1 /* TabManagerSessionSnapshotTests.swift */,
614624
C1A2B3C4D5E6F70800000002 /* CmuxConfigTests.swift */,
625+
FE002001 /* FileExplorerRootResolverTests.swift */,
626+
FE002002 /* FileExplorerStoreTests.swift */,
615627
);
616628
path = cmuxTests;
617629
sourceTree = "<group>";
@@ -841,9 +853,11 @@
841853
A5001610 /* SessionPersistence.swift in Sources */,
842854
A5001640 /* RemoteRelayZshBootstrap.swift in Sources */,
843855
A5001650 /* CmuxConfig.swift in Sources */,
844-
A5001652 /* CmuxConfigExecutor.swift in Sources */,
845-
A5001654 /* CmuxDirectoryTrust.swift in Sources */,
846-
);
856+
A5001652 /* CmuxConfigExecutor.swift in Sources */,
857+
A5001654 /* CmuxDirectoryTrust.swift in Sources */,
858+
FE001101 /* FileExplorerStore.swift in Sources */,
859+
FE001102 /* FileExplorerView.swift in Sources */,
860+
);
847861
runOnlyForDeploymentPostprocessing = 0;
848862
};
849863
D1320AA0D1320AA0D1320AB0 /* Sources */ = {
@@ -912,6 +926,8 @@
912926
8C4BBF2DEF6DF93F395A9EE7 /* TerminalControllerSocketSecurityTests.swift in Sources */,
913927
2BB56A710BB1FC50367E5BCF /* TabManagerSessionSnapshotTests.swift in Sources */,
914928
C1A2B3C4D5E6F70800000001 /* CmuxConfigTests.swift in Sources */,
929+
FE002101 /* FileExplorerRootResolverTests.swift in Sources */,
930+
FE002102 /* FileExplorerStoreTests.swift in Sources */,
915931
);
916932
runOnlyForDeploymentPostprocessing = 0;
917933
};

Resources/Localizable.xcstrings

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69323,6 +69323,91 @@
6932369323
}
6932469324
}
6932569325
},
69326+
"shortcut.toggleFileExplorer.label": {
69327+
"extractionState": "manual",
69328+
"localizations": {
69329+
"en": {
69330+
"stringUnit": {
69331+
"state": "translated",
69332+
"value": "Toggle File Explorer"
69333+
}
69334+
},
69335+
"ja": {
69336+
"stringUnit": {
69337+
"state": "translated",
69338+
"value": "ファイルエクスプローラーを切替"
69339+
}
69340+
}
69341+
}
69342+
},
69343+
"fileExplorer.empty": {
69344+
"extractionState": "manual",
69345+
"localizations": {
69346+
"en": {
69347+
"stringUnit": {
69348+
"state": "translated",
69349+
"value": "No folder open"
69350+
}
69351+
},
69352+
"ja": {
69353+
"stringUnit": {
69354+
"state": "translated",
69355+
"value": "フォルダが開かれていません"
69356+
}
69357+
}
69358+
}
69359+
},
69360+
"fileExplorer.error.unavailable": {
69361+
"extractionState": "manual",
69362+
"localizations": {
69363+
"en": {
69364+
"stringUnit": {
69365+
"state": "translated",
69366+
"value": "File explorer is not available"
69367+
}
69368+
},
69369+
"ja": {
69370+
"stringUnit": {
69371+
"state": "translated",
69372+
"value": "ファイルエクスプローラーは利用できません"
69373+
}
69374+
}
69375+
}
69376+
},
69377+
"titlebar.fileExplorer.accessibilityLabel": {
69378+
"extractionState": "manual",
69379+
"localizations": {
69380+
"en": {
69381+
"stringUnit": {
69382+
"state": "translated",
69383+
"value": "Toggle File Explorer"
69384+
}
69385+
},
69386+
"ja": {
69387+
"stringUnit": {
69388+
"state": "translated",
69389+
"value": "ファイルエクスプローラーを切替"
69390+
}
69391+
}
69392+
}
69393+
},
69394+
"titlebar.fileExplorer.tooltip": {
69395+
"extractionState": "manual",
69396+
"localizations": {
69397+
"en": {
69398+
"stringUnit": {
69399+
"state": "translated",
69400+
"value": "Show or hide the file explorer"
69401+
}
69402+
},
69403+
"ja": {
69404+
"stringUnit": {
69405+
"state": "translated",
69406+
"value": "ファイルエクスプローラーの表示/非表示"
69407+
}
69408+
}
69409+
}
69410+
},
6932669411
"shortcut.toggleTerminalCopyMode.label": {
6932769412
"extractionState": "manual",
6932869413
"localizations": {

Sources/AppDelegate.swift

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2275,6 +2275,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
22752275
weak var tabManager: TabManager?
22762276
weak var notificationStore: TerminalNotificationStore?
22772277
weak var sidebarState: SidebarState?
2278+
weak var fileExplorerState: FileExplorerState?
22782279
weak var fullscreenControlsViewModel: TitlebarControlsViewModel?
22792280
weak var sidebarSelectionState: SidebarSelectionState?
22802281
var shortcutLayoutCharacterProvider: (UInt16, NSEvent.ModifierFlags) -> String? = KeyboardLayout.character(forKeyCode:modifierFlags:)
@@ -7100,11 +7101,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
71007101
cmuxConfigStore.wireDirectoryTracking(tabManager: tabManager)
71017102
cmuxConfigStore.loadAll()
71027103

7104+
let fileExplorerState = FileExplorerState()
7105+
71037106
let root = ContentView(updateViewModel: updateViewModel, windowId: windowId)
71047107
.environmentObject(tabManager)
71057108
.environmentObject(notificationStore)
71067109
.environmentObject(sidebarState)
71077110
.environmentObject(sidebarSelectionState)
7111+
.environmentObject(fileExplorerState)
71087112
.environmentObject(cmuxConfigStore)
71097113

71107114
// Use the current key window's size for new windows so Cmd+Shift+N
@@ -11065,6 +11069,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
1106511069
return true
1106611070
}
1106711071

11072+
if matchConfiguredShortcut(event: event, action: .toggleFileExplorer) {
11073+
// Dispatch async to escape AppKit's performKeyEquivalent animation context.
11074+
// Without this, NSAnimationContext implicitly animates the layout change.
11075+
DispatchQueue.main.async { [weak self] in
11076+
self?.fileExplorerState?.toggle()
11077+
}
11078+
return true
11079+
}
11080+
1106811081
if matchConfiguredShortcut(event: event, action: .sendFeedback) {
1106911082
guard let targetContext = preferredMainWindowContextForShortcuts(event: event),
1107011083
let targetWindow = targetContext.window ?? windowForMainWindowId(targetContext.windowId) else {

0 commit comments

Comments
 (0)