Skip to content

Add file tree sidebar with cmd-click path composition - #728

Closed
AlexBoudreaux wants to merge 2 commits into
manaflow-ai:mainfrom
AlexBoudreaux:alex/file-tree-cmd-click
Closed

AlexBoudreaux wants to merge 2 commits into
manaflow-ai:mainfrom
AlexBoudreaux:alex/file-tree-cmd-click

Conversation

@AlexBoudreaux

@AlexBoudreaux AlexBoudreaux commented Mar 1, 2026 •

Copy link
Copy Markdown

Summary

  • Adds a file tree sidebar that shows the workspace's current directory
  • Cmd-click any file or folder to inject its shell-escaped path into the active terminal pane
  • Regular click expands/collapses directories, selects/highlights files
  • Right-click context menu with Copy Path, Reveal in Finder, Open in Default App, and Insert Path to Terminal
  • Toggle file tree with Cmd+Shift+E keyboard shortcut
  • Sidebar mode toggle (tabs vs file tree) or split layout (both visible with draggable divider)
  • Lazy directory loading, hidden files toggle, manual refresh

Motivation

When working with AI coding agents (Claude Code, etc), you constantly need to reference file paths. Currently you have to type or paste them manually. Cmd-click path composition lets you browse the project tree and inject paths directly into the terminal with a single gesture.

Design decisions

  • Cmd-click rather than regular click for path injection so default behavior (expand/select) isn't disrupted
  • Shell-escaped paths so spaces and special characters don't break commands
  • Keystroke injection via sendText rather than clipboard paste to avoid bracketed paste mode artifacts
  • No code editor panel (unlike PR Add file tree sidebar and code editor panel #601) to keep scope small and focused

Credit

File tree foundation ported from PR #601 by @Shehryar. This PR strips the code editor panel, rebases on clean main, and adds the cmd-click path composition behavior.

Test plan

  • Build and run with ./scripts/reload.sh
  • Toggle file tree sidebar with Cmd+Shift+E
  • Verify tree loads from workspace current directory
  • Verify directories expand/collapse on click
  • Verify cmd-click inserts shell-escaped path into terminal
  • Verify right-click context menu works
  • Verify split mode (tabs + file tree) with draggable divider
  • Test paths with spaces and special characters

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Integrated file tree sidebar for browsing and selecting files.
    • Toggle between file-tree and tabbed sidebar views (Cmd+Shift+E).
    • Persistent layout options: toggle and split modes with draggable divider.
    • Toggle hidden-file visibility and refresh directory contents.
    • Send file paths directly to the focused terminal.
    • New menu command to toggle file-tree view and ensure sidebar visibility.

Port file tree sidebar from PR manaflow-ai#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 <noreply@anthropic.com>
@vercel

vercel Bot commented Mar 1, 2026

Copy link
Copy Markdown

@AlexBoudreaux is attempting to deploy a commit to the Manaflow Team on Vercel.

A member of the Team first needs to authorize it.

@coderabbitai

coderabbitai Bot commented Mar 1, 2026 •

Copy link
Copy Markdown

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between f6e4cb5 and 00faf68.

📒 Files selected for processing (1)
  • Sources/cmuxApp.swift
🚧 Files skipped from review as they are similar to previous changes (1)
  • Sources/cmuxApp.swift

📝 Walkthrough

Walkthrough

Adds a file-tree sidebar feature: five new FileTree Swift modules, a SidebarContentMode state, UI integration in ContentView and App lifecycle, a keyboard shortcut and menu command to toggle between tabs and file-tree modes, and Xcode project updates to include the new files.

Changes

Cohort / File(s) Summary
Xcode Project Configuration
GhosttyTabs.xcodeproj/project.pbxproj
Registered five new Swift sources in the Sources group and PBXSourcesBuildPhase: Sources/FileTree/SidebarContentMode.swift, .../FileTreeNode.swift, .../FileTreeModel.swift, .../FileTreeRow.swift, .../FileTreeSidebar.swift.
File Tree Module
Sources/FileTree/SidebarContentMode.swift, Sources/FileTree/FileTreeNode.swift, Sources/FileTree/FileTreeModel.swift, Sources/FileTree/FileTreeRow.swift, Sources/FileTree/FileTreeSidebar.swift
New types and UI: sidebar mode enums and ObservableObject state, FileTreeNode data model, FileTreeModel view-model (async directory scanning, expansion state), FileTreeRow SwiftUI row view (selection, context menu), FileTreeSidebar view (header, list, refresh/hidden toggle).
App & Window State Integration
Sources/AppDelegate.swift, Sources/cmuxApp.swift
Added SidebarContentModeState wiring: App-level @StateObject and AppDelegate property, MainWindowContext now carries sidebarContentModeState, registration paths updated to propagate state to windows and environments.
UI Integration & Utilities
Sources/ContentView.swift
Integrated environment object sidebarContentModeState, FileTreeModel state, split divider state and layout modes; added sendPathToFocusedTerminal/shellEscapePath utilities; adjusted ClearScrollBackground visibility.
Keyboard Shortcuts & Commands
Sources/KeyboardShortcutSettings.swift, Sources/cmuxApp.swift
Added toggleFileTree action with default Cmd+Shift+E, AppStorage for persisted shortcut, and a menu command that toggles sidebar mode and ensures sidebar visibility when switching to fileTree.

Sequence Diagram

sequenceDiagram
    participant User
    participant Menu as Menu/Keyboard
    participant App as cmuxApp
    participant Delegate as AppDelegate
    participant State as SidebarContentModeState
    participant View as ContentView
    participant Model as FileTreeModel

    User->>Menu: Activate "Toggle File Tree" (shortcut/menu)
    Menu->>App: invoke command
    App->>Delegate: request toggle (via AppDelegate.shared)
    Delegate->>State: toggle mode (.tabs ↔ .fileTree)
    State->>State: publish new mode
    State->>View: EnvironmentObject update
    View->>Model: instantiate/load when mode==fileTree
    Model->>View: provide rootNodes (async)
    View->>User: render FileTreeSidebar or Tabs based on mode
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐰 I dug a trail of bytes and stems,
Through folders, nodes, and little gems.
A shortcut hop — tabs fade away,
The tree pops out to start the play. 🌿✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 13.64% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title 'Add file tree sidebar with cmd-click path composition' clearly and specifically summarizes the main change: introducing a file tree sidebar with cmd-click functionality for path injection.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 6

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@Sources/AppDelegate.swift`:
- 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.
- Around line 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).

In `@Sources/ContentView.swift`:
- Around line 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.
- Around line 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.

In `@Sources/FileTree/FileTreeModel.swift`:
- Around line 130-146: restoreExpandedState currently loads children
asynchronously in restoreExpandedState -> Task but never reapplies the saved
expandedIds to the newly loaded subtree, so nested directories remain collapsed;
fix by, after awaiting self.scanDirectory(path) and assigning children (inside
the Task/@MainActor closure where you call findAndUpdate and set n.children),
call self.restoreExpandedState(in: &n.children, expandedIds: expandedIds) (or an
equivalent helper) so the expansion state is reapplied recursively to the loaded
children; reference restoreExpandedState, scanDirectory, findAndUpdate,
rootNodes and children to locate where to add this recursive reapplication.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 2b66a2f and f6e4cb5.

📒 Files selected for processing (10)
  • GhosttyTabs.xcodeproj/project.pbxproj
  • Sources/AppDelegate.swift
  • Sources/ContentView.swift
  • Sources/FileTree/FileTreeModel.swift
  • Sources/FileTree/FileTreeNode.swift
  • Sources/FileTree/FileTreeRow.swift
  • Sources/FileTree/FileTreeSidebar.swift
  • Sources/FileTree/SidebarContentMode.swift
  • Sources/KeyboardShortcutSettings.swift
  • Sources/cmuxApp.swift

Comment thread Sources/AppDelegate.swift
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.

Comment thread Sources/AppDelegate.swift
Comment on lines +5220 to +5232
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
}

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).

Comment thread Sources/ContentView.swift
Comment on lines +1865 to +1889
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)
}

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.

Comment thread Sources/ContentView.swift
Comment on lines +1914 to 1923
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)'"
}

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.

Comment on lines +10 to +15
func loadDirectory(_ path: String) {
rootPath = path
Task {
let nodes = await scanDirectory(path)
self.rootNodes = nodes
}

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

Prevent stale async scans from overwriting newer tree state.

Concurrent scans can finish out of order (e.g., quick directory switches / refreshes), causing older results to replace newer rootNodes.

🔧 Proposed fix (token-gate async writes)
 `@MainActor`
 final class FileTreeModel: ObservableObject {
     `@Published` var rootPath: String = ""
     `@Published` var rootNodes: [FileTreeNode] = []
     `@Published` var showHiddenFiles: Bool = true
+    private var scanGeneration: UInt64 = 0

     func loadDirectory(_ path: String) {
         rootPath = path
+        scanGeneration &+= 1
+        let generation = scanGeneration
         Task {
             let nodes = await scanDirectory(path)
+            guard generation == self.scanGeneration, path == self.rootPath else { return }
             self.rootNodes = nodes
         }
     }

     func refresh() {
         guard !rootPath.isEmpty else { return }
+        let path = rootPath
+        scanGeneration &+= 1
+        let generation = scanGeneration
         let expandedIds = collectExpandedIds(rootNodes)
         Task {
-            let nodes = await scanDirectory(rootPath)
+            let nodes = await scanDirectory(path)
             var result = nodes
             restoreExpandedState(in: &result, expandedIds: expandedIds)
+            guard generation == self.scanGeneration, path == self.rootPath else { return }
             self.rootNodes = result
         }
     }
 }

Also applies to: 26-34, 40-48, 138-146

Comment on lines +130 to +146
private func restoreExpandedState(in nodes: inout [FileTreeNode], expandedIds: Set<String>) {
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: &current, id: nodeId) { n in
n.children = children
}
self.rootNodes = current
}

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

Nested expanded directories are not restored after refresh.

When child nodes are loaded asynchronously in restoreExpandedState, the saved expandedIds are not reapplied to that newly loaded subtree, so deeper expansions collapse after refresh.

🔧 Proposed fix (reapply expansion state to loaded children)
                 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 children = await self.scanDirectory(path)
+                        self.restoreExpandedState(in: &children, expandedIds: expandedIds)
                         var current = self.rootNodes
                         let _ = self.findAndUpdate(in: &current, id: nodeId) { n in
                             n.children = children
                         }
                         self.rootNodes = current
                     }
                 }
📝 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
private func restoreExpandedState(in nodes: inout [FileTreeNode], expandedIds: Set<String>) {
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: &current, id: nodeId) { n in
n.children = children
}
self.rootNodes = current
}
private func restoreExpandedState(in nodes: inout [FileTreeNode], expandedIds: Set<String>) {
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: &current, id: nodeId) { n in
n.children = children
}
self.rootNodes = current
}
}
}
}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/FileTree/FileTreeModel.swift` around lines 130 - 146,
restoreExpandedState currently loads children asynchronously in
restoreExpandedState -> Task but never reapplies the saved expandedIds to the
newly loaded subtree, so nested directories remain collapsed; fix by, after
awaiting self.scanDirectory(path) and assigning children (inside the
Task/@MainActor closure where you call findAndUpdate and set n.children), call
self.restoreExpandedState(in: &n.children, expandedIds: expandedIds) (or an
equivalent helper) so the expansion state is reapplied recursively to the loaded
children; reference restoreExpandedState, scanDirectory, findAndUpdate,
rootNodes and children to locate where to add this recursive reapplication.

@greptile-apps

greptile-apps Bot commented Mar 1, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

Adds a file tree sidebar feature that displays the workspace's current directory structure with lazy loading and cmd-click path composition. The implementation includes:

  • New FileTreeModel with async directory scanning and expand/collapse state management
  • FileTreeSidebar component that watches workspace directory changes and auto-reloads
  • Cmd-click gesture on files/folders to inject shell-escaped paths into the active terminal
  • Right-click context menu with Copy Path, Reveal in Finder, Open, and Insert Path actions
  • Keyboard shortcut (Cmd+Shift+E) to toggle between tabs and file tree views
  • Two layout modes: toggle (tabs OR file tree) and split (both visible with draggable divider)
  • Integration with existing ContentView, AppDelegate, and keyboard shortcut infrastructure

The PR successfully strips the code editor functionality from the original PR #601 and focuses solely on the file tree browser with path composition, keeping the scope manageable.

Confidence Score: 4/5

  • This PR is safe to merge with minor style improvements recommended
  • Well-structured feature addition with clean separation of concerns. Two minor style suggestions: (1) potential race condition in concurrent directory expansion that's unlikely in practice, and (2) shell escaping could be more comprehensive for edge-case metacharacters. No critical bugs or security issues detected.
  • Pay attention to Sources/FileTree/FileTreeModel.swift and Sources/ContentView.swift for the style improvements noted in comments

Important Files Changed

Filename Overview
Sources/FileTree/FileTreeModel.swift Added file tree model with directory scanning and lazy loading; potential race condition in concurrent directory expansion
Sources/ContentView.swift Integrated file tree sidebar with split/toggle modes, draggable divider, and shell-escaped path injection; shell escaping could be more comprehensive
Sources/AppDelegate.swift Added SidebarContentModeState to MainWindowContext and keyboard shortcut handling for toggle file tree

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[User presses Cmd+Shift+E] --> B{SidebarContentModeState}
    B -->|Toggle mode| C[.tabs or .fileTree]
    C -->|Show sidebar if hidden| D[SidebarState.toggle]
    
    E[ContentView] --> F{fileTreeLayout}
    F -->|.toggle| G[Show tabs OR file tree]
    F -->|.split| H[Show tabs AND file tree with divider]
    
    I[FileTreeSidebar] -->|Watch| J[workspace.currentDirectory]
    J -->|Directory changes| K[FileTreeModel.loadDirectory]
    K --> L[Scan directory async]
    L --> M[Display file tree nodes]
    
    N[User clicks file/folder] --> O{Cmd key held?}
    O -->|Yes| P[sendPathToFocusedTerminal]
    O -->|No + Directory| Q[toggleExpand]
    O -->|No + File| R[Select file]
    
    P --> S[shellEscapePath]
    S --> T[terminalPanel.sendText]
    
    U[User right-clicks] --> V[Context Menu]
    V --> W[Copy Path / Reveal in Finder / Open / Insert Path]
Loading

Last reviewed commit: f6e4cb5

@greptile-apps greptile-apps Bot left a comment

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.

10 files reviewed, 2 comments

Edit Code Review Agent Settings | Greptile

Comment on lines +26 to +34
Task { @MainActor [weak self] in
guard let self else { return }
let children = await self.scanDirectory(path)
var current = self.rootNodes
let _ = self.findAndUpdate(in: &current, id: node.id) { n in
n.children = children
}
self.rootNodes = current
}

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.

Potential race condition: if user rapidly expands multiple directories, concurrent Tasks could overwrite self.rootNodes and lose updates. Between reading self.rootNodes (line 29) and writing it back (line 33), another Task might have modified the state.

Consider using a serial queue or actor-isolated state updates to prevent concurrent modifications.

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

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(">")

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 <noreply@anthropic.com>
@AlexBoudreaux
AlexBoudreaux deleted the alex/file-tree-cmd-click branch March 1, 2026 23:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant