Skip to content

Commit df7ee82

Browse files
authored
🤖 feat: add Explorer Pane tab to RightSidebar (#1627)
Add a VS Code-style file explorer tree view as a new tab in the RightSidebar. ## Features - Lazy-load directories on expand (efficient for large repos) - Tree view with expand/collapse, indented children - Toolbar with workspace path, Refresh, and Collapse All buttons - Auto-refresh after file-modifying tools (bash, file_edit_*) with 2s debounce - File icons via existing FileIcon component (Seti icon theme) - Folder icons with open/closed states - Path shortening (~ for home dir) with full path tooltip ## Backend - Add `listWorkspaceDirectory` oRPC endpoint that returns both files and directories (unlike `listDirectory` which only returns directories) - Sorted: directories first, then files, both alphabetically - Filters out `.git` folder ## Screenshots see comment --- _Generated with `mux` • Model: `mux-gateway:anthropic/claude-opus-4-5` • Thinking: `high` • Cost: `$11.91`_
1 parent 0bcfc6a commit df7ee82

File tree

19 files changed

+792
-40
lines changed

19 files changed

+792
-40
lines changed

bun.lock

Lines changed: 36 additions & 30 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@
9797
"express": "^5.1.0",
9898
"fix-path": "5.0.0",
9999
"ghostty-web": "^0.3.0-next.13.g3dd4aef",
100+
"ignore": "^7.0.5",
100101
"jsdom": "^27.2.0",
101102
"json-schema-to-typescript": "^15.0.4",
102103
"jsonc-parser": "^3.3.1",

src/browser/components/RightSidebar.tsx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,12 +62,14 @@ import {
6262
import { createTerminalSession, openTerminalPopout } from "@/browser/utils/terminal";
6363
import {
6464
CostsTabLabel,
65+
ExplorerTabLabel,
6566
ReviewTabLabel,
6667
StatsTabLabel,
6768
TerminalTabLabel,
6869
getTabContentClassName,
6970
type ReviewStats,
7071
} from "./RightSidebar/tabs";
72+
import { ExplorerTab } from "./RightSidebar/ExplorerTab";
7173
import {
7274
DndContext,
7375
DragOverlay,
@@ -302,6 +304,8 @@ const RightSidebarTabsetNode: React.FC<RightSidebarTabsetNodeProps> = (props) =>
302304
label = <CostsTabLabel sessionCost={props.sessionCost} />;
303305
} else if (tab === "review") {
304306
label = <ReviewTabLabel reviewStats={props.reviewStats} />;
307+
} else if (tab === "explorer") {
308+
label = <ExplorerTabLabel />;
305309
} else if (tab === "stats") {
306310
label = <StatsTabLabel sessionDuration={props.sessionDuration} />;
307311
} else if (isTerminal) {
@@ -333,10 +337,12 @@ const RightSidebarTabsetNode: React.FC<RightSidebarTabsetNodeProps> = (props) =>
333337

334338
const costsPanelId = `${tabsetBaseId}-panel-costs`;
335339
const reviewPanelId = `${tabsetBaseId}-panel-review`;
340+
const explorerPanelId = `${tabsetBaseId}-panel-explorer`;
336341
const statsPanelId = `${tabsetBaseId}-panel-stats`;
337342

338343
const costsTabId = `${tabsetBaseId}-tab-costs`;
339344
const reviewTabId = `${tabsetBaseId}-tab-review`;
345+
const explorerTabId = `${tabsetBaseId}-tab-explorer`;
340346
const statsTabId = `${tabsetBaseId}-tab-stats`;
341347

342348
// Generate sortable IDs for tabs in this tabset
@@ -458,6 +464,17 @@ const RightSidebarTabsetNode: React.FC<RightSidebarTabsetNodeProps> = (props) =>
458464
</div>
459465
)}
460466

467+
{props.node.activeTab === "explorer" && (
468+
<div
469+
role="tabpanel"
470+
id={explorerPanelId}
471+
aria-labelledby={explorerTabId}
472+
className="h-full"
473+
>
474+
<ExplorerTab workspaceId={props.workspaceId} workspacePath={props.workspacePath} />
475+
</div>
476+
)}
477+
461478
{props.node.activeTab === "review" && (
462479
<div role="tabpanel" id={reviewPanelId} aria-labelledby={reviewTabId} className="h-full">
463480
<ReviewPanel
Lines changed: 331 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,331 @@
1+
/**
2+
* ExplorerTab - VS Code-style file explorer tree view.
3+
*
4+
* Features:
5+
* - Lazy-load directories on expand
6+
* - Auto-refresh on file-modifying tool completion (debounced)
7+
* - Toolbar with Refresh and Collapse All buttons
8+
*/
9+
10+
import React from "react";
11+
import { useAPI } from "@/browser/contexts/API";
12+
import { workspaceStore } from "@/browser/stores/WorkspaceStore";
13+
import {
14+
ChevronDown,
15+
ChevronRight,
16+
ChevronsDownUp,
17+
FolderClosed,
18+
FolderOpen,
19+
RefreshCw,
20+
} from "lucide-react";
21+
import { FileIcon } from "../FileIcon";
22+
import { cn } from "@/common/lib/utils";
23+
import type { FileTreeNode } from "@/common/utils/git/numstatParser";
24+
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
25+
26+
interface ExplorerTabProps {
27+
workspaceId: string;
28+
workspacePath: string;
29+
}
30+
31+
interface ExplorerState {
32+
entries: Map<string, FileTreeNode[]>; // relativePath -> children
33+
expanded: Set<string>;
34+
loading: Set<string>;
35+
error: string | null;
36+
}
37+
38+
const DEBOUNCE_MS = 2000;
39+
const INDENT_PX = 12;
40+
41+
export const ExplorerTab: React.FC<ExplorerTabProps> = (props) => {
42+
const { api } = useAPI();
43+
44+
const [state, setState] = React.useState<ExplorerState>({
45+
entries: new Map(),
46+
expanded: new Set(),
47+
loading: new Set(), // starts empty, set when fetch begins
48+
error: null,
49+
});
50+
51+
// Track if we've done initial load
52+
const initialLoadRef = React.useRef(false);
53+
54+
// Fetch a directory's contents and return the entries (for recursive expand)
55+
const fetchDirectory = React.useCallback(
56+
async (relativePath: string, suppressErrors = false): Promise<FileTreeNode[] | null> => {
57+
if (!api) return null;
58+
59+
const key = relativePath; // empty string = root directory
60+
61+
setState((prev) => ({
62+
...prev,
63+
loading: new Set(prev.loading).add(key),
64+
error: null,
65+
}));
66+
67+
try {
68+
const result = await api.general.listWorkspaceDirectory({
69+
workspaceId: props.workspaceId,
70+
relativePath: relativePath || undefined,
71+
});
72+
73+
if (!result.success) {
74+
setState((prev) => {
75+
// On failure, remove from expanded set (dir may have been deleted)
76+
const newExpanded = new Set(prev.expanded);
77+
newExpanded.delete(key);
78+
// Remove stale entries
79+
const newEntries = new Map(prev.entries);
80+
newEntries.delete(key);
81+
return {
82+
...prev,
83+
entries: newEntries,
84+
expanded: newExpanded,
85+
loading: new Set([...prev.loading].filter((k) => k !== key)),
86+
// Only set error for root or if not suppressing
87+
error: suppressErrors ? prev.error : result.error,
88+
};
89+
});
90+
return null;
91+
}
92+
93+
setState((prev) => {
94+
const newEntries = new Map(prev.entries);
95+
newEntries.set(key, result.data);
96+
return {
97+
...prev,
98+
entries: newEntries,
99+
loading: new Set([...prev.loading].filter((k) => k !== key)),
100+
};
101+
});
102+
103+
return result.data;
104+
} catch (err) {
105+
setState((prev) => {
106+
// On error, remove from expanded set
107+
const newExpanded = new Set(prev.expanded);
108+
newExpanded.delete(key);
109+
const newEntries = new Map(prev.entries);
110+
newEntries.delete(key);
111+
return {
112+
...prev,
113+
entries: newEntries,
114+
expanded: newExpanded,
115+
loading: new Set([...prev.loading].filter((k) => k !== key)),
116+
error: suppressErrors ? prev.error : err instanceof Error ? err.message : String(err),
117+
};
118+
});
119+
return null;
120+
}
121+
},
122+
[api, props.workspaceId]
123+
);
124+
125+
// Initial load - retry when api becomes available
126+
React.useEffect(() => {
127+
if (!api) return;
128+
if (!initialLoadRef.current) {
129+
initialLoadRef.current = true;
130+
void fetchDirectory("");
131+
}
132+
}, [api, fetchDirectory]);
133+
134+
// Subscribe to file-modifying tool events and debounce refresh
135+
React.useEffect(() => {
136+
let timeoutId: ReturnType<typeof setTimeout> | null = null;
137+
138+
const unsubscribe = workspaceStore.subscribeFileModifyingTool(() => {
139+
if (timeoutId) clearTimeout(timeoutId);
140+
timeoutId = setTimeout(() => {
141+
// Refresh root and all expanded directories
142+
// Suppress errors for non-root paths (dir may have been deleted)
143+
void fetchDirectory("");
144+
for (const p of state.expanded) {
145+
void fetchDirectory(p, true);
146+
}
147+
}, DEBOUNCE_MS);
148+
}, props.workspaceId);
149+
150+
return () => {
151+
unsubscribe();
152+
if (timeoutId) clearTimeout(timeoutId);
153+
};
154+
}, [props.workspaceId, state.expanded, fetchDirectory]);
155+
156+
// Toggle expand/collapse
157+
const toggleExpand = (node: FileTreeNode) => {
158+
if (!node.isDirectory) return;
159+
160+
const key = node.path;
161+
162+
setState((prev) => {
163+
const newExpanded = new Set(prev.expanded);
164+
165+
if (newExpanded.has(key)) {
166+
newExpanded.delete(key);
167+
return { ...prev, expanded: newExpanded };
168+
}
169+
170+
newExpanded.add(key);
171+
172+
// Always fetch when expanding to ensure fresh data
173+
void fetchDirectory(key);
174+
175+
return { ...prev, expanded: newExpanded };
176+
});
177+
};
178+
179+
// Refresh all expanded paths
180+
const handleRefresh = () => {
181+
const pathsToRefresh = ["", ...state.expanded];
182+
void Promise.all(pathsToRefresh.map((p) => fetchDirectory(p)));
183+
};
184+
185+
// Collapse all
186+
const handleCollapseAll = () => {
187+
setState((prev) => ({
188+
...prev,
189+
expanded: new Set(),
190+
}));
191+
};
192+
193+
const hasExpandedDirs = state.expanded.size > 0;
194+
195+
// Render a tree node recursively
196+
const renderNode = (node: FileTreeNode, depth: number): React.ReactNode => {
197+
const key = node.path;
198+
const isExpanded = state.expanded.has(key);
199+
const isLoading = state.loading.has(key);
200+
const children = state.entries.get(key) ?? [];
201+
const isIgnored = node.ignored === true;
202+
203+
return (
204+
<div key={key}>
205+
<button
206+
type="button"
207+
className={cn(
208+
"flex w-full cursor-pointer items-center gap-1 px-2 py-0.5 text-left text-sm hover:bg-accent/50",
209+
"focus:bg-accent/50 focus:outline-none",
210+
isIgnored && "opacity-50"
211+
)}
212+
style={{ paddingLeft: `${8 + depth * INDENT_PX}px` }}
213+
onClick={() => (node.isDirectory ? toggleExpand(node) : undefined)}
214+
>
215+
{node.isDirectory ? (
216+
<>
217+
{isLoading ? (
218+
<RefreshCw className="text-muted h-3 w-3 shrink-0 animate-spin" />
219+
) : isExpanded ? (
220+
<ChevronDown className="text-muted h-3 w-3 shrink-0" />
221+
) : (
222+
<ChevronRight className="text-muted h-3 w-3 shrink-0" />
223+
)}
224+
{isExpanded ? (
225+
<FolderOpen className="h-4 w-4 shrink-0 text-[var(--color-folder-icon)]" />
226+
) : (
227+
<FolderClosed className="h-4 w-4 shrink-0 text-[var(--color-folder-icon)]" />
228+
)}
229+
</>
230+
) : (
231+
<>
232+
<span className="w-3 shrink-0" />
233+
<FileIcon fileName={node.name} style={{ fontSize: 18 }} className="h-4 w-4" />
234+
</>
235+
)}
236+
<span className="truncate">{node.name}</span>
237+
</button>
238+
239+
{node.isDirectory && isExpanded && (
240+
<div>{children.map((child) => renderNode(child, depth + 1))}</div>
241+
)}
242+
</div>
243+
);
244+
};
245+
246+
const rootEntries = state.entries.get("") ?? [];
247+
const isRootLoading = state.loading.has("");
248+
249+
// Shorten workspace path for display (replace home dir with ~)
250+
const shortenPath = (fullPath: string): string => {
251+
// Match home directory patterns across platforms:
252+
// Linux: /home/username/...
253+
// macOS: /Users/username/...
254+
// Windows: C:\Users\username\... (may come as forward slashes too)
255+
const homePatterns = [
256+
/^\/home\/[^/]+/, // Linux
257+
/^\/Users\/[^/]+/, // macOS
258+
/^[A-Za-z]:[\\/]Users[\\/][^\\/]+/, // Windows
259+
];
260+
261+
for (const pattern of homePatterns) {
262+
const match = fullPath.match(pattern);
263+
if (match) {
264+
return "~" + fullPath.slice(match[0].length);
265+
}
266+
}
267+
return fullPath;
268+
};
269+
270+
const displayPath = shortenPath(props.workspacePath);
271+
272+
return (
273+
<div className="flex h-full flex-col">
274+
{/* Toolbar */}
275+
<div className="border-border-light flex items-center gap-1 border-b px-2 py-1">
276+
<FolderOpen className="h-4 w-4 shrink-0 text-[var(--color-folder-icon)]" />
277+
<Tooltip>
278+
<TooltipTrigger asChild>
279+
<span className="min-w-0 flex-1 truncate text-xs font-medium">{displayPath}</span>
280+
</TooltipTrigger>
281+
<TooltipContent side="bottom">{props.workspacePath}</TooltipContent>
282+
</Tooltip>
283+
<div className="flex shrink-0 items-center gap-0.5">
284+
<Tooltip>
285+
<TooltipTrigger asChild>
286+
<button
287+
type="button"
288+
className="text-muted hover:bg-accent/50 hover:text-foreground rounded p-1"
289+
onClick={handleRefresh}
290+
disabled={isRootLoading}
291+
>
292+
<RefreshCw className={cn("h-3.5 w-3.5", isRootLoading && "animate-spin")} />
293+
</button>
294+
</TooltipTrigger>
295+
<TooltipContent side="bottom">Refresh</TooltipContent>
296+
</Tooltip>
297+
{hasExpandedDirs && (
298+
<Tooltip>
299+
<TooltipTrigger asChild>
300+
<button
301+
type="button"
302+
className="text-muted hover:bg-accent/50 hover:text-foreground rounded p-1"
303+
onClick={handleCollapseAll}
304+
aria-label="Collapse All"
305+
>
306+
<ChevronsDownUp className="h-3.5 w-3.5" />
307+
</button>
308+
</TooltipTrigger>
309+
<TooltipContent side="bottom">Collapse All</TooltipContent>
310+
</Tooltip>
311+
)}
312+
</div>
313+
</div>
314+
315+
{/* Tree */}
316+
<div className="flex-1 overflow-y-auto py-1">
317+
{state.error && <div className="text-destructive px-3 py-2 text-sm">{state.error}</div>}
318+
{isRootLoading && rootEntries.length === 0 ? (
319+
<div className="flex items-center justify-center py-4">
320+
<RefreshCw className="text-muted h-5 w-5 animate-spin" />
321+
</div>
322+
) : (
323+
rootEntries.map((node) => renderNode(node, 0))
324+
)}
325+
{!isRootLoading && rootEntries.length === 0 && !state.error && (
326+
<div className="text-muted px-3 py-2 text-sm">No files found</div>
327+
)}
328+
</div>
329+
</div>
330+
);
331+
};

0 commit comments

Comments
 (0)