Files
openchamber/packages/ui/src/components/session/DirectoryTree.tsx
T
Bohdan Triapitsyn 83ffb1af34 refactor(desktop): make Tauri thin shell running web sidecar (#273)
## What / Why
This PR finishes the desktop refactor: the Tauri app is now a thin shell that launches the web server as a sidecar and loads the UI from `http://127.0.0.1:<port>`. All real backend logic lives in `packages/web/server/index.js`; desktop Rust keeps only native integrations (menu/dialog/notifications/updater/deep-link + window chrome).
This unblocks:
- consistent behavior across web/desktop/vscode (single backend)
- simpler desktop maintenance (no duplicated Rust backend)
- host switching between Local + remote instances in desktop
- reliable cold-start behavior on slow machines (VSCode + desktop)
## Key changes
- Desktop sidecar runtime
  - build pipeline to bundle web dist + `openchamber-server` sidecar (`packages/desktop/scripts/build-sidecar.mjs`)
  - robust local port selection (prefer saved/default, fallback to random; persisted in `~/.config/openchamber/settings.json`)
  - improved PATH handling so the sidecar can locate `opencode` CLI (incl `~/.opencode/bin`, overrides, common bins)
  - disable native right-click context menu in production builds (dev keeps it)
- Desktop instance switcher (Tauri-only)
  - header button + modal to add/edit/delete remote hosts, set default, probe status/ping, switch back to Local escape hatch
  - auth gate includes host switcher so you can recover when a remote host is broken/auth-required
  - host list stored desktop-locally (not tied to the currently selected remote server)
- Notifications
  - decision logic moved server-side; desktop notifications emitted via sidecar stdout and shown natively by Tauri
  - prevent double-notifications on desktop Local origin (UI ignores SSE notification when native path is active)
  - restore macOS notification sound
- Updates
  - Tauri updater used only when viewing Local instance in desktop shell (avoid “remote web update” triggering desktop restart)
- Settings persistence & UX polish
  - persist model favorites/recents via `/api/config/settings` (works for web + desktop; not origin-dependent)
  - persist per-project sidebar collapse state in `projects[].sidebarCollapsed` via `/api/config/settings` (with debounce on toggles)
  - macOS header sizing/traffic-lights offsets fixed (marketing macOS major injected from desktop; MultiRun header aligned)
  - VSCode cold-start: keep retrying provider/agent loads after connection to avoid empty UI on slow machines
  - misc lint/type fixes + bun.lock sync
- Desktop bootstrap / resiliency
  - show onboarding screen when OpenCode CLI is missing (desktop Local origin), with retry hook to restart OpenCode after install
## Testing notes
- Desktop (macOS): switch Local <-> remote, set default host, verify auth gate recovery, native notifications (with sound), updater gated to Local
- Web: favorites/recents + per-project collapsed state persist across reload/restart
- VSCode: slow startup no longer results in missing providers/agents/models
2026-02-05 01:59:49 +02:00

1066 lines
35 KiB
TypeScript

import React from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenuSeparator,
} from '@/components/ui/dropdown-menu';
import { RiAddLine, RiArrowDownSLine, RiArrowRightSLine, RiCheckLine, RiCloseLine, RiFolder6Line, RiPushpin2Line, RiPushpinLine } from '@remixicon/react';
import { cn, formatPathForDisplay } from '@/lib/utils';
import { opencodeClient } from '@/lib/opencode/client';
import { useDeviceInfo } from '@/lib/device';
import type { DesktopSettings } from '@/lib/desktop';
import { updateDesktopSettings } from '@/lib/persistence';
import { useFileSystemAccess } from '@/hooks/useFileSystemAccess';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
interface DirectoryItem {
name: string;
path: string;
isDirectory: boolean;
children?: DirectoryItem[];
isExpanded?: boolean;
}
interface DirectoryTreeProps {
currentPath: string;
onSelectPath: (path: string) => void;
triggerClassName?: string;
variant?: 'dropdown' | 'inline';
className?: string;
selectionBehavior?: 'immediate' | 'deferred';
onDoubleClickPath?: (path: string) => void;
showHidden?: boolean;
rootDirectory?: string | null;
isRootReady?: boolean;
/** Always show action icons (add, pin) instead of only on hover */
alwaysShowActions?: boolean;
}
export const DirectoryTree: React.FC<DirectoryTreeProps> = ({
currentPath,
onSelectPath,
triggerClassName,
variant = 'dropdown',
className,
selectionBehavior = 'immediate',
onDoubleClickPath,
showHidden = false,
rootDirectory = null,
isRootReady,
alwaysShowActions = false,
}) => {
const { isMobile } = useDeviceInfo();
const [directories, setDirectories] = React.useState<DirectoryItem[]>([]);
const [expandedPaths, setExpandedPaths] = React.useState<Set<string>>(new Set());
const [isLoading, setIsLoading] = React.useState(true);
const [isOpen, setIsOpen] = React.useState(false);
const [homeDirectory, setHomeDirectory] = React.useState<string>('');
const [pinnedPaths, setPinnedPaths] = React.useState<Set<string>>(new Set());
const [creatingInPath, setCreatingInPath] = React.useState<string | null>(null);
const [newDirName, setNewDirName] = React.useState('');
const [isPinnedExpanded, setIsPinnedExpanded] = React.useState(true);
const inputRef = React.useRef<HTMLInputElement>(null);
const { requestAccess, startAccessing, isDesktop } = useFileSystemAccess();
const previousShowHidden = React.useRef(showHidden);
const stripTrailingSlashes = React.useCallback((value: string | null | undefined) => {
if (!value) {
return value;
}
if (value === '/' || value.length === 0) {
return '/';
}
let trimmed = value;
while (trimmed.length > 1 && trimmed.endsWith('/')) {
trimmed = trimmed.slice(0, -1);
}
return trimmed.length === 0 ? '/' : trimmed;
}, []);
const normalizedHomeDirectory = React.useMemo(() => {
if (!homeDirectory) {
return null;
}
const normalized = homeDirectory.replace(/\\/g, '/');
return stripTrailingSlashes(normalized) as string;
}, [homeDirectory, stripTrailingSlashes]);
const effectiveRoot = React.useMemo(() => {
if (typeof rootDirectory === 'string' && rootDirectory.length > 0) {
const normalized = rootDirectory.replace(/\\/g, '/');
const stripped = stripTrailingSlashes(normalized);
if (stripped && stripped !== '/') {
return stripped as string;
}
}
if (normalizedHomeDirectory && normalizedHomeDirectory !== '/') {
return normalizedHomeDirectory;
}
return null;
}, [rootDirectory, normalizedHomeDirectory, stripTrailingSlashes]);
const rootReady = React.useMemo(() => {
if (typeof isRootReady === 'boolean') {
return Boolean(isRootReady && effectiveRoot);
}
return Boolean(effectiveRoot);
}, [isRootReady, effectiveRoot]);
React.useEffect(() => {
if (!rootReady) {
setIsLoading(true);
setDirectories([]);
}
}, [rootReady]);
const isPathWithinHome = React.useCallback(
(targetPath: string | null | undefined): boolean => {
if (!targetPath) {
return false;
}
if (!rootReady || !effectiveRoot) {
return false;
}
const normalizedTargetRaw = targetPath.replace(/\\/g, '/');
const normalizedTarget =
(stripTrailingSlashes(normalizedTargetRaw) as string) ?? normalizedTargetRaw;
if (normalizedTarget === effectiveRoot) {
return true;
}
const prefix = `${effectiveRoot}/`;
return normalizedTarget.startsWith(prefix);
},
[rootReady, effectiveRoot, stripTrailingSlashes]
);
const handleDirectorySelect = async (path: string) => {
if (!rootReady) {
return;
}
if (selectionBehavior === 'deferred') {
onSelectPath(path);
return;
}
if (isDesktop) {
const accessResult = await requestAccess(path);
if (accessResult.success && accessResult.path) {
await startAccessing(accessResult.path);
onSelectPath(accessResult.path);
} else {
console.error('Failed to get directory access:', accessResult.error);
onSelectPath(path);
}
} else {
onSelectPath(path);
}
};
React.useEffect(() => {
let cancelled = false;
const applyRootDirectory = (candidate: string | null | undefined) => {
if (!candidate) {
return false;
}
const normalized = stripTrailingSlashes(candidate.replace(/\\/g, '/'));
if (!normalized || normalized === '/') {
return false;
}
setHomeDirectory(typeof normalized === 'string' ? normalized : candidate.replace(/\\/g, '/'));
return true;
};
const appliedInitialRoot = rootDirectory ? applyRootDirectory(rootDirectory) : false;
const resolveHomeDirectory = async () => {
try {
const fsHome = await opencodeClient.getFilesystemHome();
if (!cancelled && applyRootDirectory(fsHome)) {
return;
}
} catch (error) {
console.warn('Failed to resolve filesystem home directory:', error);
}
try {
const info = await opencodeClient.getSystemInfo();
if (!cancelled && applyRootDirectory(info?.homeDirectory)) {
return;
}
} catch (error) {
console.warn('Failed to resolve home directory from system info:', error);
}
};
if (!appliedInitialRoot) {
resolveHomeDirectory();
}
return () => {
cancelled = true;
};
}, [rootDirectory, stripTrailingSlashes]);
React.useEffect(() => {
let cancelled = false;
const applyPinned = (paths: string[]) => {
if (cancelled) {
return;
}
const normalized = paths
.filter((path): path is string => typeof path === 'string' && path.length > 0)
.map((path) => {
const normalizedPath = path.replace(/\\/g, '/');
return (stripTrailingSlashes(normalizedPath) as string) ?? normalizedPath;
});
setPinnedPaths(new Set(normalized));
};
const loadFromLocalStorage = () => {
try {
const raw = localStorage.getItem('pinnedDirectories');
if (!raw) {
return;
}
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) {
applyPinned(parsed);
}
} catch (error) {
console.warn('Failed to load pinned directories from local storage:', error);
}
};
const loadPinnedDirectories = async () => {
try {
let pinned: string[] = [];
const response = await fetch('/api/config/settings', {
method: 'GET',
headers: { Accept: 'application/json' },
});
if (response.ok) {
const data = await response.json();
pinned = Array.isArray(data?.pinnedDirectories) ? data.pinnedDirectories : [];
}
if (cancelled) {
return;
}
applyPinned(pinned);
} catch (error) {
console.warn('Failed to load pinned directories:', error);
}
};
loadFromLocalStorage();
const handleSettingsSynced = (event: Event) => {
const detail = (event as CustomEvent<DesktopSettings>).detail;
if (detail && Array.isArray(detail.pinnedDirectories)) {
applyPinned(detail.pinnedDirectories);
}
};
window.addEventListener('openchamber:settings-synced', handleSettingsSynced);
void loadPinnedDirectories();
return () => {
cancelled = true;
window.removeEventListener('openchamber:settings-synced', handleSettingsSynced);
};
}, [stripTrailingSlashes]);
const isInitialPinnedSync = React.useRef(true);
React.useEffect(() => {
if (isInitialPinnedSync.current) {
isInitialPinnedSync.current = false;
return;
}
const payload = {
pinnedDirectories: Array.from(pinnedPaths),
};
void updateDesktopSettings(payload);
}, [pinnedPaths]);
React.useEffect(() => {
if (!effectiveRoot) {
return;
}
setPinnedPaths((prev) => {
const filtered = Array.from(prev)
.map((path) => (stripTrailingSlashes(path.replace(/\\/g, '/')) as string) ?? path)
.filter((path) => isPathWithinHome(path));
return new Set(filtered);
});
}, [effectiveRoot, isPathWithinHome, stripTrailingSlashes]);
// Reload directories when showHidden changes, but keep expanded state
React.useEffect(() => {
if (previousShowHidden.current !== showHidden) {
previousShowHidden.current = showHidden;
// Silently reload without clearing state - loadInitialDirectories will be called
// via its dependency on loadDirectory which depends on showHidden
}
}, [showHidden]);
const togglePin = (path: string) => {
setPinnedPaths(prev => {
if (!isPathWithinHome(path)) {
return prev;
}
const normalizedPath =
(stripTrailingSlashes(path.replace(/\\/g, '/')) as string) ?? path.replace(/\\/g, '/');
const newSet = new Set(prev);
if (newSet.has(normalizedPath)) {
newSet.delete(normalizedPath);
} else {
newSet.add(normalizedPath);
}
return newSet;
});
};
const pinnedDirectories = React.useMemo(() => {
return Array.from(pinnedPaths)
.map((rawPath) => {
const normalizedPath = stripTrailingSlashes(rawPath.replace(/\\/g, '/')) ?? rawPath;
return normalizedPath;
})
.filter((path) => isPathWithinHome(path))
.map((path) => ({
path,
name: path.split('/').pop() || path
}))
.sort((a, b) => a.name.localeCompare(b.name));
}, [pinnedPaths, isPathWithinHome, stripTrailingSlashes]);
const loadDirectory = React.useCallback(async (path: string): Promise<DirectoryItem[]> => {
const shouldInclude = (name: string) => showHidden || !name.startsWith('.');
const normalizedHome = effectiveRoot;
if (!rootReady || !normalizedHome) {
return [];
}
const normalizedPathRaw = path && path.length > 0 ? path : normalizedHome;
const normalizedPath = normalizedPathRaw ? normalizedPathRaw.replace(/\\/g, '/') : null;
const normalizedTarget = normalizedPath
? stripTrailingSlashes(normalizedPath) ?? normalizedPath
: null;
if (normalizedTarget) {
const homePrefix = `${normalizedHome}/`;
const withinHome = normalizedTarget === normalizedHome || normalizedTarget.startsWith(homePrefix);
if (!withinHome) {
return [];
}
}
try {
const filesystemEntries = await opencodeClient.listLocalDirectory(path);
return filesystemEntries
.filter((entry) => {
if (!entry.isDirectory) {
return false;
}
if (!shouldInclude(entry.name)) {
return false;
}
const normalizedEntryRaw = entry.path.replace(/\\/g, '/');
const normalizedEntryPath = stripTrailingSlashes(normalizedEntryRaw) ?? normalizedEntryRaw;
const entryPrefix = normalizedEntryPath === normalizedHome ? normalizedHome : `${normalizedHome}/`;
return normalizedEntryPath === normalizedHome || normalizedEntryPath.startsWith(entryPrefix);
})
.map((entry) => ({
name: entry.name,
path: entry.path.replace(/\\/g, '/'),
isDirectory: true
}))
.sort((a, b) => a.name.localeCompare(b.name));
} catch {
try {
const tempClient = opencodeClient.getApiClient();
const response = await tempClient.file.list({
path: '.',
directory: path
});
if (!response.data) {
return [];
}
return response.data
.filter((item: { type?: string; name?: string; absolute?: string; path?: string }) => {
if (item.type !== 'directory') {
return false;
}
if (!item.name || !shouldInclude(item.name)) {
return false;
}
const rawPath = String(item.absolute || item.path || item.name).replace(/\\/g, '/');
const absolutePath = stripTrailingSlashes(rawPath) ?? rawPath;
const entryPrefix = absolutePath === normalizedHome ? normalizedHome : `${normalizedHome}/`;
return absolutePath === normalizedHome || absolutePath.startsWith(entryPrefix);
})
.map((item: { name?: string; absolute?: string; path?: string }) => ({
name: item.name || '',
path: String(item.absolute || item.path || item.name).replace(/\\/g, '/'),
isDirectory: true
}))
.filter((item): item is DirectoryItem => item.name !== '')
.sort((a: DirectoryItem, b: DirectoryItem) => a.name.localeCompare(b.name));
} catch {
return [];
}
}
}, [showHidden, effectiveRoot, stripTrailingSlashes, rootReady]);
const hasLoadedOnce = React.useRef(false);
const loadInitialDirectories = React.useCallback(async () => {
if (!rootReady || !effectiveRoot) {
setIsLoading(true);
setDirectories([]);
return;
}
// Only show loading on initial load, not on refreshes (e.g., showHidden toggle)
if (!hasLoadedOnce.current) {
setIsLoading(true);
}
try {
const homeContents = await loadDirectory(effectiveRoot);
setDirectories(homeContents);
hasLoadedOnce.current = true;
} catch { /* ignored */ } finally {
setIsLoading(false);
}
}, [rootReady, effectiveRoot, loadDirectory]);
React.useEffect(() => {
if (!rootReady) {
return;
}
if ((variant === 'inline' || isOpen)) {
loadInitialDirectories();
}
}, [variant, isOpen, rootReady, loadInitialDirectories]);
const toggleExpanded = async (item: DirectoryItem) => {
if (!rootReady) {
return;
}
const isCurrentlyExpanded = expandedPaths.has(item.path);
const newExpanded = new Set(expandedPaths);
if (isCurrentlyExpanded) {
newExpanded.delete(item.path);
setExpandedPaths(newExpanded);
return;
}
newExpanded.add(item.path);
setExpandedPaths(newExpanded);
const children = await loadDirectory(item.path);
const updateItems = (items: DirectoryItem[]): DirectoryItem[] => {
return items.map((i) => {
if (i.path === item.path) {
return { ...i, children };
}
if (i.children) {
return { ...i, children: updateItems(i.children) };
}
return i;
});
};
setDirectories((prev) => updateItems(prev));
};
React.useEffect(() => {
if (creatingInPath && inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
}
}, [creatingInPath]);
const generateUniqueDirName = (parentPath: string, children: DirectoryItem[] = []): string => {
const baseName = 'new_directory';
const existingNames = children.map(child => child.name);
if (!existingNames.includes(baseName)) {
return baseName;
}
let maxNumber = 1;
const numberPattern = new RegExp(`^${baseName}(\\d+)$`);
for (const name of existingNames) {
const match = name.match(numberPattern);
if (match) {
const num = parseInt(match[1], 10);
if (num > maxNumber) {
maxNumber = num;
}
}
}
let counter = 2;
while (existingNames.includes(`${baseName}${counter}`)) {
counter++;
}
return `${baseName}${Math.max(counter, maxNumber + 1)}`;
};
const startCreatingDirectory = async (parentItem: DirectoryItem) => {
if (!rootReady) {
return;
}
if (!expandedPaths.has(parentItem.path)) {
const newExpanded = new Set(expandedPaths);
newExpanded.add(parentItem.path);
setExpandedPaths(newExpanded);
if (!parentItem.children) {
const children = await loadDirectory(parentItem.path);
const updateItems = (items: DirectoryItem[]): DirectoryItem[] => {
return items.map(i => {
if (i.path === parentItem.path) {
return { ...i, children };
}
if (i.children) {
return { ...i, children: updateItems(i.children) };
}
return i;
});
};
setDirectories((prev) => updateItems(prev));
const uniqueName = generateUniqueDirName(parentItem.path, children);
setNewDirName(uniqueName);
} else {
const uniqueName = generateUniqueDirName(parentItem.path, parentItem.children);
setNewDirName(uniqueName);
}
} else {
const uniqueName = generateUniqueDirName(parentItem.path, parentItem.children);
setNewDirName(uniqueName);
}
setCreatingInPath(parentItem.path);
};
const createDirectory = async () => {
if (!creatingInPath || !rootReady) return;
const dirName = newDirName.trim() || 'new_directory';
const fullPath = `${creatingInPath}/${dirName}`;
try {
await opencodeClient.createDirectory(fullPath, { allowOutsideWorkspace: true });
const children = await loadDirectory(creatingInPath);
const updateItems = (items: DirectoryItem[]): DirectoryItem[] => {
return items.map(i => {
if (i.path === creatingInPath) {
return { ...i, children };
}
if (i.children) {
return { ...i, children: updateItems(i.children) };
}
return i;
});
};
setDirectories((prev) => updateItems(prev));
setCreatingInPath(null);
setNewDirName('');
} catch (error) {
console.error('Failed to create directory:', error);
}
};
const cancelCreatingDirectory = () => {
setCreatingInPath(null);
setNewDirName('');
};
const renderTreeItem = (item: DirectoryItem, level: number = 0) => {
const isExpanded = expandedPaths.has(item.path);
const hasChildren = item.isDirectory;
const isPinned = pinnedPaths.has(item.path);
const isSelected = currentPath === item.path;
const isInlineVariant = variant === 'inline';
const rowContent = (
<>
{hasChildren && (
<button
onClick={(e) => {
e.stopPropagation();
toggleExpanded(item);
}}
className={cn("hover:bg-interactive-hover rounded", isMobile ? "p-0.5" : "p-0.5")}
>
{isExpanded ? (
<RiArrowDownSLine className={isMobile ? "h-3.5 w-3.5" : "h-3 w-3"} />
) : (
<RiArrowRightSLine className={isMobile ? "h-3.5 w-3.5" : "h-3 w-3"} />
)}
</button>
)}
{!hasChildren && <div className={isMobile ? "w-4.5" : "w-4"} />}
<button
onClick={(e) => {
e.stopPropagation();
handleDirectorySelect(item.path);
if (variant === 'dropdown' && selectionBehavior === 'immediate') {
setIsOpen(false);
}
}}
onDoubleClick={(e) => {
e.stopPropagation();
if (onDoubleClickPath) {
onDoubleClickPath(item.path);
}
}}
className={cn(
'flex items-center flex-1 text-left focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/60 rounded',
isMobile ? 'gap-1.5' : 'gap-1.5',
isInlineVariant ? (isSelected ? 'text-primary' : 'text-foreground') : 'text-foreground'
)}
>
<RiFolder6Line
className={cn(
'text-muted-foreground flex-shrink-0',
isMobile ? 'h-4 w-4' : 'h-3.5 w-3.5',
isInlineVariant && isSelected && 'text-primary'
)}
/>
<span
className={cn(
'font-medium truncate',
isMobile ? 'typography-ui-label' : 'typography-ui-label',
isInlineVariant && isSelected ? 'text-primary' : 'text-foreground'
)}
>
{item.name}
</span>
</button>
<button
onClick={(e) => {
e.stopPropagation();
startCreatingDirectory(item);
}}
className={cn(
"hover:bg-interactive-hover rounded transition-opacity",
isMobile ? "p-1.5" : "p-1",
alwaysShowActions ? "opacity-60" : "opacity-0 group-hover:opacity-100"
)}
title="Create new directory"
>
<RiAddLine className={cn("text-muted-foreground", isMobile ? "h-3.5 w-3.5" : "h-3 w-3")} />
</button>
<button
onClick={(e) => {
e.stopPropagation();
togglePin(item.path);
}}
className={cn(
"hover:bg-interactive-hover rounded transition-opacity",
isMobile ? "p-1.5" : "p-1",
alwaysShowActions ? "opacity-60" : "opacity-0 group-hover:opacity-100"
)}
title={isPinned ? "Unpin directory" : "Pin directory"}
>
{isPinned ? (
<RiPushpin2Line className={cn("text-primary", isMobile ? "h-3.5 w-3.5" : "h-3 w-3")} />
) : (
<RiPushpinLine className={cn("text-muted-foreground", isMobile ? "h-3.5 w-3.5" : "h-3 w-3")} />
)}
</button>
</>
);
if (variant === 'inline') {
return (
<div key={item.path}>
<div
className={cn(
'group flex items-center gap-1 rounded-lg mx-1 text-left transition-colors',
isMobile ? 'px-1.5 py-1' : 'px-2 py-1.5',
isSelected
? 'bg-primary/10 text-primary'
: 'hover:bg-interactive-hover/50 text-foreground'
)}
style={{ paddingLeft: `${level * (isMobile ? 12 : 14) + (isMobile ? 4 : 6)}px` }}
>
{rowContent}
</div>
{isExpanded && (
<>
{creatingInPath === item.path && (
<div
className="flex items-center gap-1 mx-1 px-2 py-1.5"
style={{ paddingLeft: `${(level + 1) * 14 + 6}px` }}
>
<div className="w-4" />
<RiFolder6Line className="h-3.5 w-3.5 text-muted-foreground" />
<Input
ref={inputRef}
value={newDirName}
onChange={(e) => setNewDirName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
e.stopPropagation();
createDirectory();
} else if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
cancelCreatingDirectory();
}
}}
onBlur={createDirectory}
className="h-6 typography-meta flex-1 selection:bg-interactive-selection selection:text-interactive-selection-foreground"
placeholder="new_directory"
/>
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
createDirectory();
}}
className="p-1 hover:bg-interactive-hover rounded"
title="Create directory"
>
<RiCheckLine className="h-3 w-3 text-green-600" />
</button>
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
cancelCreatingDirectory();
}}
className="p-1 hover:bg-interactive-hover rounded"
title="Cancel"
>
<RiCloseLine className="h-3 w-3 text-muted-foreground" />
</button>
</div>
)}
{item.children && item.children.map((child) => renderTreeItem(child, level + 1))}
</>
)}
</div>
);
}
return (
<div key={item.path}>
<DropdownMenuItem
className={cn(
'flex items-center gap-1 cursor-pointer group',
currentPath === item.path && 'bg-interactive-selection'
)}
style={{ paddingLeft: `${level * 12 + 8}px` }}
onSelect={(e) => {
e.preventDefault();
}}
>
{hasChildren && (
<button
onClick={(e) => {
e.stopPropagation();
toggleExpanded(item);
}}
className="p-0.5 hover:bg-interactive-hover rounded"
>
{isExpanded ? (
<RiArrowDownSLine className="h-3 w-3" />
) : (
<RiArrowRightSLine className="h-3 w-3" />
)}
</button>
)}
{!hasChildren && <div className="w-4" />}
{rowContent}
</DropdownMenuItem>
{isExpanded && (
<div>
{creatingInPath === item.path && (
<div
className="flex items-center gap-1 px-2 py-1.5"
style={{ paddingLeft: `${(level + 1) * 12 + 8}px` }}
>
<div className="w-4" />
<RiFolder6Line className="h-3.5 w-3.5 text-muted-foreground" />
<Input
ref={inputRef}
value={newDirName}
onChange={(e) => setNewDirName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
e.stopPropagation();
createDirectory();
} else if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
cancelCreatingDirectory();
}
}}
onBlur={createDirectory}
className="h-6 typography-meta flex-1 selection:bg-interactive-selection selection:text-interactive-selection-foreground"
placeholder="new_directory"
/>
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
createDirectory();
}}
className="p-1 hover:bg-interactive-hover rounded"
title="Create directory"
>
<RiCheckLine className="h-3 w-3 text-green-600" />
</button>
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
cancelCreatingDirectory();
}}
className="p-1 hover:bg-interactive-hover rounded"
title="Cancel"
>
<RiCloseLine className="h-3 w-3 text-muted-foreground" />
</button>
</div>
)}
{item.children && item.children.map((child) => renderTreeItem(child, level + 1))}
</div>
)}
</div>
);
};
const renderPinnedRow = (name: string, path: string) => {
if (variant === 'inline') {
const isSelected = currentPath === path;
return (
<div
key={path}
className={cn(
'group flex items-center gap-2 mx-1 rounded-lg transition-colors',
isMobile ? 'px-1.5 py-1' : 'px-2 py-1.5',
isSelected
? 'bg-primary/10'
: 'hover:bg-interactive-hover/50'
)}
>
<button
onClick={() => handleDirectorySelect(path)}
onDoubleClick={(e) => {
e.stopPropagation();
if (onDoubleClickPath) {
onDoubleClickPath(path);
}
}}
className={cn(
'flex flex-1 items-center gap-1.5 text-left focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/60 rounded min-w-0',
isSelected ? 'text-primary' : 'text-foreground'
)}
>
<RiFolder6Line
className={cn(
'flex-shrink-0',
isMobile ? 'h-4 w-4' : 'h-3.5 w-3.5',
isSelected ? 'text-primary' : 'text-muted-foreground'
)}
/>
<span
className={cn(
'typography-ui-label font-medium truncate flex-shrink-0',
isSelected ? 'text-primary' : 'text-foreground'
)}
>
{name}
</span>
<span className="typography-meta text-muted-foreground/60 truncate">
{formatPathForDisplay(path, homeDirectory)}
</span>
</button>
<button
onClick={() => togglePin(path)}
className={cn(
"hover:bg-interactive-hover rounded-md transition-opacity",
isMobile ? "p-1.5 opacity-60" : "p-1 opacity-0 group-hover:opacity-100"
)}
title="Unpin directory"
>
<RiPushpin2Line className={cn("text-primary", isMobile ? "h-3.5 w-3.5" : "h-3.5 w-3.5")} />
</button>
</div>
);
}
return (
<DropdownMenuItem
key={path}
onSelect={(e) => {
e.preventDefault();
handleDirectorySelect(path);
if (selectionBehavior === 'immediate') {
setIsOpen(false);
}
}}
className={cn(
'flex items-start gap-2 cursor-pointer group py-2',
currentPath === path && 'bg-interactive-selection'
)}
>
<RiFolder6Line className="h-3.5 w-3.5 text-muted-foreground mt-0.5" />
<div className="flex-1 min-w-0">
<div className="typography-ui-label font-medium">{name}</div>
<div className="typography-meta text-muted-foreground">
{formatPathForDisplay(path, homeDirectory)}
</div>
</div>
<button
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
togglePin(path);
}}
className="p-1 opacity-0 group-hover:opacity-100 hover:bg-interactive-hover rounded transition-opacity"
title="Unpin directory"
>
<RiPushpin2Line className="h-3 w-3 text-primary" />
</button>
</DropdownMenuItem>
);
};
const directoryContent = (
<>
{!rootReady ? (
<div className="px-3 py-2 typography-ui-label text-muted-foreground">
Locating home directory...
</div>
) : (
<>
{pinnedDirectories.length > 0 && (
<>
<button
type="button"
onClick={() => setIsPinnedExpanded(prev => !prev)}
className={cn(
"flex w-full items-center gap-1.5 typography-meta font-medium text-muted-foreground/80 hover:bg-interactive-hover/30 rounded transition-colors uppercase tracking-wide",
isMobile ? "px-1.5 py-1" : "px-2 py-1.5"
)}
>
{isPinnedExpanded ? (
<RiArrowDownSLine className={isMobile ? "h-3.5 w-3.5" : "h-3 w-3"} />
) : (
<RiArrowRightSLine className={isMobile ? "h-3.5 w-3.5" : "h-3 w-3"} />
)}
<span>Pinned</span>
<span className="ml-auto typography-micro text-muted-foreground/60 normal-case tracking-normal">
{pinnedDirectories.length}
</span>
</button>
{isPinnedExpanded && pinnedDirectories.map(({ name, path }) => renderPinnedRow(name, path))}
{variant === 'dropdown' && <DropdownMenuSeparator />}
{variant === 'inline' && isPinnedExpanded && (
<div className="mx-3 my-2 border-t border-border/40" />
)}
</>
)}
<div className={cn(
"typography-meta font-medium text-muted-foreground/80 flex items-center gap-1.5 uppercase tracking-wide",
isMobile ? "px-1.5 py-1" : "px-2 py-1.5"
)}>
Browse
</div>
{isLoading ? (
<div className="px-3 py-2 typography-ui-label text-muted-foreground">
Loading...
</div>
) : (
directories.map((item) => renderTreeItem(item))
)}
{!isLoading && directories.length === 0 && (
<div className="px-3 py-2 typography-ui-label text-muted-foreground">
No directories found
</div>
)}
</>
)}
</>
);
if (variant === 'inline') {
return (
<div className={cn('overflow-hidden flex flex-col', className)}>
<ScrollableOverlay outerClassName="flex-1 min-h-0" className="w-full py-1">
{directoryContent}
</ScrollableOverlay>
</div>
);
}
return (
<DropdownMenu open={isOpen} onOpenChange={setIsOpen}>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className={cn(
'w-full h-8 px-2.5 justify-between items-center rounded-lg border border-transparent bg-sidebar-accent/40 text-foreground/90 hover:bg-sidebar-accent/60 typography-meta',
triggerClassName
)}
aria-label="Select working directory"
>
<span className="flex items-center gap-1.5 min-w-0 flex-1">
<RiFolder6Line className="h-3 w-3 flex-shrink-0 text-muted-foreground" />
<span className="truncate" title={currentPath}>
{formatPathForDisplay(currentPath, homeDirectory)}
</span>
</span>
<RiArrowDownSLine className="h-3 w-3 flex-shrink-0 text-muted-foreground" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-[350px]">
<ScrollableOverlay outerClassName="h-full" className="w-full">
{directoryContent}
</ScrollableOverlay>
</DropdownMenuContent>
</DropdownMenu>
);
};