Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 22 additions & 2 deletions GhosttyTabs.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */ = {
Expand Down Expand Up @@ -215,7 +220,12 @@
F6000001A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegateShortcutRoutingTests.swift; sourceTree = "<group>"; };
F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceContentViewVisibilityTests.swift; sourceTree = "<group>"; };
F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocketControlPasswordStoreTests.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
A5003010 /* SidebarContentMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileTree/SidebarContentMode.swift; sourceTree = "<group>"; };
A5003011 /* FileTreeNode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileTree/FileTreeNode.swift; sourceTree = "<group>"; };
A5003012 /* FileTreeModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileTree/FileTreeModel.swift; sourceTree = "<group>"; };
A5003013 /* FileTreeRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileTree/FileTreeRow.swift; sourceTree = "<group>"; };
A5003014 /* FileTreeSidebar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileTree/FileTreeSidebar.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */

/* Begin PBXFrameworksBuildPhase section */
A5001030 /* Frameworks */ = {
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down
30 changes: 28 additions & 2 deletions Sources/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -947,19 +947,22 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
let tabManager: TabManager
let sidebarState: SidebarState
let sidebarSelectionState: SidebarSelectionState
let sidebarContentModeState: SidebarContentModeState
weak var window: NSWindow?

init(
windowId: UUID,
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
}
}
Expand Down Expand Up @@ -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?
Expand Down Expand Up @@ -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

Expand All @@ -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(
Expand Down Expand Up @@ -3310,6 +3317,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
tabManager = context.tabManager
sidebarState = context.sidebarState
sidebarSelectionState = context.sidebarSelectionState
sidebarContentModeState = context.sidebarContentModeState

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

鈿狅笍 Potential issue | 馃煚 Major

sidebarContentModeState can drift from the active window context

Line 3320 and Line 3768 only update this state in fallback branches. The primary activation path (setActiveMainWindow) never assigns it, so Cmd+Shift+E can toggle the wrong window mode or no-op after window switches.

馃敡 Proposed fix
 private func setActiveMainWindow(_ window: NSWindow) {
     guard let context = contextForMainTerminalWindow(window) else { return }
 `#if` DEBUG
     let beforeManagerToken = debugManagerToken(tabManager)
 `#endif`
     tabManager = context.tabManager
     sidebarState = context.sidebarState
     sidebarSelectionState = context.sidebarSelectionState
+    sidebarContentModeState = context.sidebarContentModeState
     TerminalController.shared.setActiveTabManager(context.tabManager)
 `#if` DEBUG
     dlog(
         "mainWindow.active window={\(debugWindowToken(window))} context={\(debugContextToken(context))} beforeMgr=\(beforeManagerToken) afterMgr=\(debugManagerToken(tabManager)) \(debugShortcutRouteSnapshot())"
     )
 `#endif`
 }
         if tabManager === removed.tabManager {
             // Repoint "active" pointers to any remaining main terminal window.
             let nextContext: MainWindowContext? = {
                 if let keyWindow = NSApp.keyWindow,
                    let ctx = contextForMainTerminalWindow(keyWindow, reindex: false) {
                     return ctx
                 }
                 return mainWindowContexts.values.first
             }()

             if let nextContext {
                 tabManager = nextContext.tabManager
                 sidebarState = nextContext.sidebarState
                 sidebarSelectionState = nextContext.sidebarSelectionState
+                sidebarContentModeState = nextContext.sidebarContentModeState
                 TerminalController.shared.setActiveTabManager(nextContext.tabManager)
             } else {
                 tabManager = nil
                 sidebarState = nil
                 sidebarSelectionState = nil
+                sidebarContentModeState = nil
                 TerminalController.shared.setActiveTabManager(nil)
             }
         }

Also applies to: 3768-3768

馃 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/AppDelegate.swift` at line 3320, The app's sidebarContentModeState
can become out of sync because setActiveMainWindow doesn't update it; update
sidebarContentModeState inside setActiveMainWindow to assign the active window's
context.sidebarContentModeState (the same assignment used in the fallback
branches) so the global state always mirrors the newly activated window; ensure
you reference and use the active window's context when setting
sidebarContentModeState so Cmd+Shift+E operates on the correct window mode.

TerminalController.shared.setActiveTabManager(context.tabManager)
}
#if DEBUG
Expand Down Expand Up @@ -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)
}

Expand Down Expand Up @@ -3789,13 +3798,15 @@ 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)
.environmentObject(tabManager)
.environmentObject(notificationStore)
.environmentObject(sidebarState)
.environmentObject(sidebarSelectionState)
.environmentObject(sidebarContentModeState)

let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 460, height: 360),
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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
}
Comment on lines +5220 to +5232

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

鈿狅笍 Potential issue | 馃煛 Minor

Add DEBUG dlog() for the new file-tree shortcut path

This adds key-shortcut behavior without unified debug logging, which makes shortcut routing/debugging inconsistent.

馃摑 Proposed fix
         if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .toggleFileTree)) {
+#if DEBUG
+            dlog("shortcut.action name=toggleFileTree \(debugShortcutRouteSnapshot(event: event))")
+#endif
             if let modeState = sidebarContentModeState {
                 if modeState.mode == .fileTree {
                     modeState.mode = .tabs
                 } else {
                     modeState.mode = .fileTree
                     if sidebarState?.isVisible == false {
                         sidebarState?.toggle()
                     }
                 }
             }
             return true
         }

As per coding guidelines, "**/*.swift: All debug events (keys, mouse, focus, splits, tabs) must go to a unified log in DEBUG builds using the dlog() free function".

馃摑 Committable suggestion

鈥硷笍 IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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: .toggleFileTree)) {
`#if` DEBUG
dlog("shortcut.action name=toggleFileTree \(debugShortcutRouteSnapshot(event: event))")
`#endif`
if let modeState = sidebarContentModeState {
if modeState.mode == .fileTree {
modeState.mode = .tabs
} else {
modeState.mode = .fileTree
if sidebarState?.isVisible == false {
sidebarState?.toggle()
}
}
}
return true
}
馃 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/AppDelegate.swift` around lines 5220 - 5232, The new keyboard
shortcut handling for matchShortcut / KeyboardShortcutSettings.shortcut(for:
.toggleFileTree) currently toggles sidebarContentModeState.mode and
sidebarState.toggle() but lacks DEBUG logging; update the branch inside that if
to call dlog(...) in DEBUG builds (use the dlog free function) to emit a unified
debug message indicating the shortcut was received, the previous and new mode
(from sidebarContentModeState.mode) and whether sidebarState?.isVisible was
false (and that you called toggle), so developers can trace shortcut routing and
side-effect (reference matchShortcut, KeyboardShortcutSettings.shortcut,
sidebarContentModeState, and sidebarState.toggle).


if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .newTab)) {
#if DEBUG
dlog("shortcut.action name=newWorkspace \(debugShortcutRouteSnapshot(event: event))")
Expand Down
147 changes: 143 additions & 4 deletions Sources/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<SidebarResizerHandle> = []
@State private var isResizerDragging = false
Expand Down Expand Up @@ -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)
}
Comment on lines +1865 to +1889

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

鈿狅笍 Potential issue | 馃煚 Major

Fix split-divider math to avoid drift and unstable sizing.

Line 1865 uses a hard-coded height offset, and Line 1887 compounds drag translation by adding it to a value already derived from the current ratio. This causes divider drift/jumps during drag and can produce unstable behavior on small heights.

馃挕 Proposed fix
+    `@State` private var splitDragStartRatio: CGFloat?
+
     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
+        VStack(spacing: 0) {
+            sidebarModeHeader
+            GeometryReader { geo in
+                let totalHeight = max(geo.size.height, 1)
+                let tabsHeight = totalHeight * splitDividerRatio
+                let fileTreeHeight = totalHeight * (1 - splitDividerRatio)
 
                 tabsSidebarContent
                     .frame(height: tabsHeight)
@@
                             .gesture(
                                 DragGesture()
                                     .onChanged { value in
-                                        let newRatio = (tabsHeight + value.translation.height) / totalHeight
+                                        if splitDragStartRatio == nil {
+                                            splitDragStartRatio = splitDividerRatio
+                                        }
+                                        let baseRatio = splitDragStartRatio ?? splitDividerRatio
+                                        let newRatio = baseRatio + (value.translation.height / totalHeight)
                                         splitDividerRatio = min(max(newRatio, 0.15), 0.85)
                                     }
+                                    .onEnded { _ in
+                                        splitDragStartRatio = nil
+                                    }
                             )
馃 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/ContentView.swift` around lines 1865 - 1889, The split-divider math
drifts because totalHeight uses a hard-coded 30 and the DragGesture adds
translation onto tabsHeight (which is recomputed each event) instead of
anchoring to the ratio at drag start; fix by replacing the magic 30 with a named
headerHeight constant (or a measured headerHeight) when computing totalHeight,
and change the gesture to anchor to an initial drag ratio (e.g., add a `@State`
optional dragStartRatio) so on the first onChanged set dragStartRatio =
splitDividerRatio and then compute newRatio = (dragStartRatio * totalHeight +
value.translation.height) / totalHeight, clamped to 0.15...0.85, and reset
dragStartRatio on onEnded; update references to tabsHeight/totalHeight and the
DragGesture handlers (the splitDividerRatio state, DragGesture
.onChanged/.onEnded, and any tabsHeight calculation) accordingly.

)
.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("!")
Comment on lines +1914 to +1918

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shell escaping doesn't check for all metacharacters. Missing: *, ?, [, ], {, }, ~, #, <, >, newlines, tabs. While rare in macOS paths, these could cause unexpected shell behavior.

Consider always quoting paths or adding more characters to the check:

Suggested change
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("!")
let needsQuoting = path.contains(" ") || path.contains("'") || path.contains("\"")
|| path.contains("(") || path.contains(")") || path.contains("&")
|| path.contains("|") || path.contains(";") || path.contains("$")
|| path.contains("`") || 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)'"
}
Comment on lines +1914 to 1923

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

鈿狅笍 Potential issue | 馃敶 Critical

馃З Analysis chain

馃弫 Script executed:

# First, let's find and examine the file
fd Sources/ContentView.swift

Repository: manaflow-ai/cmux

Length of output: 42


馃弫 Script executed:

# Get the line count to understand file size
wc -l Sources/ContentView.swift

Repository: manaflow-ai/cmux

Length of output: 90


馃弫 Script executed:

# Read the specific lines mentioned in the review (1914-1923)
sed -n '1914,1923p' Sources/ContentView.swift

Repository: manaflow-ai/cmux

Length of output: 641


馃弫 Script executed:

# Search for all usages of shellEscapePath to understand context
rg "shellEscapePath" -B2 -A2

Repository: manaflow-ai/cmux

Length of output: 777


馃弫 Script executed:

# Look for any other shell escaping utilities or patterns in the codebase
rg "shell.*escape|escape.*shell" -i

Repository: manaflow-ai/cmux

Length of output: 2194


馃弫 Script executed:

# Examine the escapeDropForShell implementation in GhosttyTerminalView.swift
rg "escapeDropForShell|fileprivate static func escape" -A 10 Sources/GhosttyTerminalView.swift

Repository: manaflow-ai/cmux

Length of output: 1433


馃弫 Script executed:

# Check test cases to see what edge cases are covered
cat tests/test_file_drop_paths.py

Repository: manaflow-ai/cmux

Length of output: 2106


馃弫 Script executed:

# Search for any newline handling in existing code
rg "newline|\\\\n" Sources/ContentView.swift

Repository: manaflow-ai/cmux

Length of output: 92


馃弫 Script executed:

# Find all callers of shellEscapePath to confirm it's used for untrusted input
rg "shellEscapePath" -B5 -A5

Repository: manaflow-ai/cmux

Length of output: 1231


Always quote file paths with single quotes to prevent shell metacharacter injection.

The current implementation uses a character allowlist to decide whether quoting is needed, but misses shell-significant characters including glob chars ([, ], *, ?, \), brackets ({}), and others. Unquoted paths containing these characters will be subject to shell expansion or escape sequences.

Adopt the safer POSIX standard approach: always wrap in single quotes and escape inner single quotes with '\''.

Proposed fix
     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)'"
+        // Always single-quote for literal shell argument safety.
+        // Handles whitespace, glob chars, newlines, and metacharacters.
+        let escaped = path.replacingOccurrences(of: "'", with: "'\\''")
+        return "'\(escaped)'"
     }
馃 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/ContentView.swift` around lines 1914 - 1923, The shellEscapePath(_:)
function currently conditionally quotes paths; change it to always wrap the
input in single quotes and escape any inner single quotes by replacing "'" with
"'\\''". Locate shellEscapePath and remove the needsQuoting guard, compute
escaped = path.replacingOccurrences(of: "'", with: "'\\''") and return
"'\(escaped)'" for all inputs so every path is safely single-quoted per the
POSIX approach.


/// Space at top of content area for the titlebar. This must be at least the actual titlebar
Expand Down Expand Up @@ -2493,7 +2632,8 @@ struct ContentView: View {
windowId: windowId,
tabManager: tabManager,
sidebarState: sidebarState,
sidebarSelectionState: sidebarSelectionState
sidebarSelectionState: sidebarSelectionState,
sidebarContentModeState: sidebarContentModeState
)
installFileDropOverlay(on: window, tabManager: tabManager)
}))
Expand Down Expand Up @@ -5665,7 +5805,6 @@ struct VerticalTabsSidebar: View {
}
.accessibilityIdentifier("Sidebar")
.ignoresSafeArea()
.background(SidebarBackdrop().ignoresSafeArea())
.background(
WindowAccessor { window in
commandKeyMonitor.setHostWindow(window)
Expand Down Expand Up @@ -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
Expand Down
Loading