Skip to content
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
66 changes: 36 additions & 30 deletions bun.lock

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@
"express": "^5.1.0",
"fix-path": "5.0.0",
"ghostty-web": "^0.3.0-next.13.g3dd4aef",
"ignore": "^7.0.5",
"jsdom": "^27.2.0",
"json-schema-to-typescript": "^15.0.4",
"jsonc-parser": "^3.3.1",
Expand Down
17 changes: 17 additions & 0 deletions src/browser/components/RightSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,12 +62,14 @@ import {
import { createTerminalSession, openTerminalPopout } from "@/browser/utils/terminal";
import {
CostsTabLabel,
ExplorerTabLabel,
ReviewTabLabel,
StatsTabLabel,
TerminalTabLabel,
getTabContentClassName,
type ReviewStats,
} from "./RightSidebar/tabs";
import { ExplorerTab } from "./RightSidebar/ExplorerTab";
import {
DndContext,
DragOverlay,
Expand Down Expand Up @@ -302,6 +304,8 @@ const RightSidebarTabsetNode: React.FC<RightSidebarTabsetNodeProps> = (props) =>
label = <CostsTabLabel sessionCost={props.sessionCost} />;
} else if (tab === "review") {
label = <ReviewTabLabel reviewStats={props.reviewStats} />;
} else if (tab === "explorer") {
label = <ExplorerTabLabel />;
} else if (tab === "stats") {
label = <StatsTabLabel sessionDuration={props.sessionDuration} />;
} else if (isTerminal) {
Expand Down Expand Up @@ -333,10 +337,12 @@ const RightSidebarTabsetNode: React.FC<RightSidebarTabsetNodeProps> = (props) =>

const costsPanelId = `${tabsetBaseId}-panel-costs`;
const reviewPanelId = `${tabsetBaseId}-panel-review`;
const explorerPanelId = `${tabsetBaseId}-panel-explorer`;
const statsPanelId = `${tabsetBaseId}-panel-stats`;

const costsTabId = `${tabsetBaseId}-tab-costs`;
const reviewTabId = `${tabsetBaseId}-tab-review`;
const explorerTabId = `${tabsetBaseId}-tab-explorer`;
const statsTabId = `${tabsetBaseId}-tab-stats`;

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

{props.node.activeTab === "explorer" && (
<div
role="tabpanel"
id={explorerPanelId}
aria-labelledby={explorerTabId}
className="h-full"
>
<ExplorerTab workspaceId={props.workspaceId} workspacePath={props.workspacePath} />
</div>
)}

{props.node.activeTab === "review" && (
<div role="tabpanel" id={reviewPanelId} aria-labelledby={reviewTabId} className="h-full">
<ReviewPanel
Expand Down
309 changes: 309 additions & 0 deletions src/browser/components/RightSidebar/ExplorerTab.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,309 @@
/**
* ExplorerTab - VS Code-style file explorer tree view.
*
* Features:
* - Lazy-load directories on expand
* - Auto-refresh on file-modifying tool completion (debounced)
* - Toolbar with Refresh and Collapse All buttons
*/

import React from "react";
import { useAPI } from "@/browser/contexts/API";
import { workspaceStore } from "@/browser/stores/WorkspaceStore";
import {
ChevronDown,
ChevronRight,
ChevronsDownUp,
FolderClosed,
FolderOpen,
RefreshCw,
} from "lucide-react";
import { FileIcon } from "../FileIcon";
import { cn } from "@/common/lib/utils";
import type { FileTreeNode } from "@/common/utils/git/numstatParser";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";

interface ExplorerTabProps {
workspaceId: string;
workspacePath: string;
}

interface ExplorerState {
entries: Map<string, FileTreeNode[]>; // relativePath -> children
expanded: Set<string>;
loading: Set<string>;
error: string | null;
}

const DEBOUNCE_MS = 2000;
const INDENT_PX = 12;

export const ExplorerTab: React.FC<ExplorerTabProps> = (props) => {
const { api } = useAPI();

const [state, setState] = React.useState<ExplorerState>({
entries: new Map(),
expanded: new Set(),
loading: new Set(), // starts empty, set when fetch begins
error: null,
});

// Track if we've done initial load
const initialLoadRef = React.useRef(false);

// Fetch a directory's contents and return the entries (for recursive expand)
const fetchDirectory = React.useCallback(
async (relativePath: string): Promise<FileTreeNode[] | null> => {
if (!api) return null;

const key = relativePath; // empty string = root directory

setState((prev) => ({
...prev,
loading: new Set(prev.loading).add(key),
error: null,
}));

try {
const result = await api.general.listWorkspaceDirectory({
workspaceId: props.workspaceId,
workspacePath: props.workspacePath,
relativePath: relativePath || undefined,
});

if (!result.success) {
setState((prev) => ({
...prev,
loading: new Set([...prev.loading].filter((k) => k !== key)),
error: result.error,
}));
return null;
}

setState((prev) => {
const newEntries = new Map(prev.entries);
newEntries.set(key, result.data);
return {
...prev,
entries: newEntries,
loading: new Set([...prev.loading].filter((k) => k !== key)),
};
});

return result.data;
} catch (err) {
setState((prev) => ({
...prev,
loading: new Set([...prev.loading].filter((k) => k !== key)),
error: err instanceof Error ? err.message : String(err),
}));
return null;
}
},
[api, props.workspaceId, props.workspacePath]
);

// Initial load - retry when api becomes available
React.useEffect(() => {
if (!api) return;
if (!initialLoadRef.current) {
initialLoadRef.current = true;
void fetchDirectory("");
}
}, [api, fetchDirectory]);

// Subscribe to file-modifying tool events and debounce refresh
React.useEffect(() => {
let timeoutId: ReturnType<typeof setTimeout> | null = null;

const unsubscribe = workspaceStore.subscribeFileModifyingTool(() => {
if (timeoutId) clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
// Refresh root and all expanded directories
const pathsToRefresh = ["", ...state.expanded];
void Promise.all(pathsToRefresh.map((p) => fetchDirectory(p)));
}, DEBOUNCE_MS);
}, props.workspaceId);

return () => {
unsubscribe();
if (timeoutId) clearTimeout(timeoutId);
};
}, [props.workspaceId, state.expanded, fetchDirectory]);

// Toggle expand/collapse
const toggleExpand = (node: FileTreeNode) => {
if (!node.isDirectory) return;

const key = node.path;

setState((prev) => {
const newExpanded = new Set(prev.expanded);

if (newExpanded.has(key)) {
newExpanded.delete(key);
return { ...prev, expanded: newExpanded };
}

newExpanded.add(key);

// Always fetch when expanding to ensure fresh data
void fetchDirectory(key);

return { ...prev, expanded: newExpanded };
});
};

// Refresh all expanded paths
const handleRefresh = () => {
const pathsToRefresh = ["", ...state.expanded];
void Promise.all(pathsToRefresh.map((p) => fetchDirectory(p)));
};

// Collapse all
const handleCollapseAll = () => {
setState((prev) => ({
...prev,
expanded: new Set(),
}));
};

const hasExpandedDirs = state.expanded.size > 0;

// Render a tree node recursively
const renderNode = (node: FileTreeNode, depth: number): React.ReactNode => {
const key = node.path;
const isExpanded = state.expanded.has(key);
const isLoading = state.loading.has(key);
const children = state.entries.get(key) ?? [];
const isIgnored = node.ignored === true;

return (
<div key={key}>
<button
type="button"
className={cn(
"flex w-full cursor-pointer items-center gap-1 px-2 py-0.5 text-left text-sm hover:bg-accent/50",
"focus:bg-accent/50 focus:outline-none",
isIgnored && "opacity-50"
)}
style={{ paddingLeft: `${8 + depth * INDENT_PX}px` }}
onClick={() => (node.isDirectory ? toggleExpand(node) : undefined)}
>
{node.isDirectory ? (
<>
{isLoading ? (
<RefreshCw className="text-muted h-3 w-3 shrink-0 animate-spin" />
) : isExpanded ? (
<ChevronDown className="text-muted h-3 w-3 shrink-0" />
) : (
<ChevronRight className="text-muted h-3 w-3 shrink-0" />
)}
{isExpanded ? (
<FolderOpen className="h-4 w-4 shrink-0 text-[var(--color-folder-icon)]" />
) : (
<FolderClosed className="h-4 w-4 shrink-0 text-[var(--color-folder-icon)]" />
)}
</>
) : (
<>
<span className="w-3 shrink-0" />
<FileIcon fileName={node.name} style={{ fontSize: 18 }} className="h-4 w-4" />
</>
)}
<span className="truncate">{node.name}</span>
</button>

{node.isDirectory && isExpanded && (
<div>{children.map((child) => renderNode(child, depth + 1))}</div>
)}
</div>
);
};

const rootEntries = state.entries.get("") ?? [];
const isRootLoading = state.loading.has("");

// Shorten workspace path for display (replace home dir with ~)
const shortenPath = (fullPath: string): string => {
// Match home directory patterns across platforms:
// Linux: /home/username/...
// macOS: /Users/username/...
// Windows: C:\Users\username\... (may come as forward slashes too)
const homePatterns = [
/^\/home\/[^/]+/, // Linux
/^\/Users\/[^/]+/, // macOS
/^[A-Za-z]:[\\/]Users[\\/][^\\/]+/, // Windows
];

for (const pattern of homePatterns) {
const match = fullPath.match(pattern);
if (match) {
return "~" + fullPath.slice(match[0].length);
}
}
return fullPath;
};

const displayPath = shortenPath(props.workspacePath);

return (
<div className="flex h-full flex-col">
{/* Toolbar */}
<div className="border-border-light flex items-center gap-1 border-b px-2 py-1">
<FolderOpen className="h-4 w-4 shrink-0 text-[var(--color-folder-icon)]" />
<Tooltip>
<TooltipTrigger asChild>
<span className="min-w-0 flex-1 truncate text-xs font-medium">{displayPath}</span>
</TooltipTrigger>
<TooltipContent side="bottom">{props.workspacePath}</TooltipContent>
</Tooltip>
<div className="flex shrink-0 items-center gap-0.5">
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="text-muted hover:bg-accent/50 hover:text-foreground rounded p-1"
onClick={handleRefresh}
disabled={isRootLoading}
>
<RefreshCw className={cn("h-3.5 w-3.5", isRootLoading && "animate-spin")} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">Refresh</TooltipContent>
</Tooltip>
{hasExpandedDirs && (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="text-muted hover:bg-accent/50 hover:text-foreground rounded p-1"
onClick={handleCollapseAll}
aria-label="Collapse All"
>
<ChevronsDownUp className="h-3.5 w-3.5" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">Collapse All</TooltipContent>
</Tooltip>
)}
</div>
</div>

{/* Tree */}
<div className="flex-1 overflow-y-auto py-1">
{state.error && <div className="text-destructive px-3 py-2 text-sm">{state.error}</div>}
{isRootLoading && rootEntries.length === 0 ? (
<div className="flex items-center justify-center py-4">
<RefreshCw className="text-muted h-5 w-5 animate-spin" />
</div>
) : (
rootEntries.map((node) => renderNode(node, 0))
)}
{!isRootLoading && rootEntries.length === 0 && !state.error && (
<div className="text-muted px-3 py-2 text-sm">No files found</div>
)}
</div>
</div>
);
};
10 changes: 9 additions & 1 deletion src/browser/components/RightSidebar/tabs/TabLabels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
*/

import React from "react";
import { ExternalLink, Terminal as TerminalIcon, X } from "lucide-react";
import { ExternalLink, FolderTree, Terminal as TerminalIcon, X } from "lucide-react";
import { Tooltip, TooltipContent, TooltipTrigger } from "../../ui/tooltip";
import { formatTabDuration, type ReviewStats } from "./registry";
import { formatKeybind, KEYBINDS } from "@/browser/utils/ui/keybinds";
Expand Down Expand Up @@ -62,6 +62,14 @@ export const StatsTabLabel: React.FC<StatsTabLabelProps> = ({ sessionDuration })
</>
);

/** Explorer tab label with folder tree icon */
export const ExplorerTabLabel: React.FC = () => (
<span className="inline-flex items-center gap-1">
<FolderTree className="h-3 w-3 shrink-0" />
Explorer
</span>
);

interface TerminalTabLabelProps {
/** Dynamic title from OSC sequences, if available */
dynamicTitle?: string;
Expand Down
Loading
Loading