## 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
1066 lines
35 KiB
TypeScript
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>
|
|
);
|
|
};
|