2025-12-07 19:32:53 +02:00
|
|
|
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 { cn, formatPathForDisplay } from '@/lib/utils';
|
|
|
|
|
import { opencodeClient } from '@/lib/opencode/client';
|
2026-01-08 23:53:03 +02:00
|
|
|
import { useDeviceInfo } from '@/lib/device';
|
2025-12-07 19:32:53 +02:00
|
|
|
import type { DesktopSettings } from '@/lib/desktop';
|
|
|
|
|
import { updateDesktopSettings } from '@/lib/persistence';
|
|
|
|
|
import { useFileSystemAccess } from '@/hooks/useFileSystemAccess';
|
|
|
|
|
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
2026-05-13 13:26:15 +03:00
|
|
|
import { Icon } from "@/components/icon/Icon";
|
2026-04-26 14:03:39 +03:00
|
|
|
import { useI18n } from '@/lib/i18n';
|
2026-06-02 00:43:05 +03:00
|
|
|
import { runtimeFetch } from '@/lib/runtime-fetch';
|
2025-12-07 19:32:53 +02:00
|
|
|
|
|
|
|
|
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;
|
2025-12-19 17:22:02 +02:00
|
|
|
/** Always show action icons (add, pin) instead of only on hover */
|
|
|
|
|
alwaysShowActions?: boolean;
|
2026-04-27 15:45:14 +03:00
|
|
|
disabledPaths?: Iterable<string>;
|
2025-12-07 19:32:53 +02:00
|
|
|
}
|
|
|
|
|
|
2026-05-24 08:46:11 -04:00
|
|
|
const areStringSetsEqual = (left: Set<string>, right: Set<string>) => {
|
|
|
|
|
if (left.size !== right.size) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
for (const value of left) {
|
|
|
|
|
if (!right.has(value)) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return true;
|
|
|
|
|
};
|
|
|
|
|
|
2025-12-07 19:32:53 +02:00
|
|
|
export const DirectoryTree: React.FC<DirectoryTreeProps> = ({
|
|
|
|
|
currentPath,
|
|
|
|
|
onSelectPath,
|
|
|
|
|
triggerClassName,
|
|
|
|
|
variant = 'dropdown',
|
|
|
|
|
className,
|
|
|
|
|
selectionBehavior = 'immediate',
|
|
|
|
|
onDoubleClickPath,
|
|
|
|
|
showHidden = false,
|
|
|
|
|
rootDirectory = null,
|
|
|
|
|
isRootReady,
|
2025-12-19 17:22:02 +02:00
|
|
|
alwaysShowActions = false,
|
2026-04-27 15:45:14 +03:00
|
|
|
disabledPaths,
|
2025-12-07 19:32:53 +02:00
|
|
|
}) => {
|
2026-04-26 14:03:39 +03:00
|
|
|
const { t } = useI18n();
|
2026-05-03 17:19:55 +03:00
|
|
|
const { isMobile, isTablet } = useDeviceInfo();
|
|
|
|
|
const showHoverActions = alwaysShowActions || isTablet;
|
2025-12-07 19:32:53 +02:00
|
|
|
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('');
|
2025-12-19 17:22:02 +02:00
|
|
|
const [isPinnedExpanded, setIsPinnedExpanded] = React.useState(true);
|
2026-04-27 04:53:36 -06:00
|
|
|
const [pinnedExpandedPaths, setPinnedExpandedPaths] = React.useState<Set<string>>(new Set());
|
|
|
|
|
const [pinnedItemChildren, setPinnedItemChildren] = React.useState<Map<string, DirectoryItem[]>>(new Map());
|
2025-12-07 19:32:53 +02:00
|
|
|
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;
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-04-27 15:45:14 +03:00
|
|
|
const normalizedDisabledPaths = React.useMemo(() => {
|
|
|
|
|
const normalized = new Set<string>();
|
|
|
|
|
for (const path of disabledPaths ?? []) {
|
|
|
|
|
const value = stripTrailingSlashes(path.replace(/\\/g, '/'));
|
|
|
|
|
if (value) normalized.add(value.toLowerCase());
|
|
|
|
|
}
|
|
|
|
|
return normalized;
|
|
|
|
|
}, [disabledPaths, stripTrailingSlashes]);
|
|
|
|
|
|
|
|
|
|
const isPathDisabled = React.useCallback((path: string) => {
|
|
|
|
|
const normalized = stripTrailingSlashes(path.replace(/\\/g, '/'));
|
|
|
|
|
return normalized ? normalizedDisabledPaths.has(normalized.toLowerCase()) : false;
|
|
|
|
|
}, [normalizedDisabledPaths, stripTrailingSlashes]);
|
|
|
|
|
|
2025-12-07 19:32:53 +02:00
|
|
|
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;
|
|
|
|
|
});
|
2026-05-24 08:46:11 -04:00
|
|
|
setPinnedPaths((prev) => {
|
|
|
|
|
const next = new Set(normalized);
|
|
|
|
|
return areStringSetsEqual(prev, next) ? prev : next;
|
|
|
|
|
});
|
2025-12-07 19:32:53 +02:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-05 01:59:49 +02:00
|
|
|
const loadPinnedDirectories = async () => {
|
|
|
|
|
try {
|
|
|
|
|
let pinned: string[] = [];
|
|
|
|
|
|
2026-06-02 00:43:05 +03:00
|
|
|
const response = await runtimeFetch('/api/config/settings', {
|
2026-02-05 01:59:49 +02:00
|
|
|
method: 'GET',
|
|
|
|
|
headers: { Accept: 'application/json' },
|
|
|
|
|
});
|
|
|
|
|
if (response.ok) {
|
|
|
|
|
const data = await response.json();
|
|
|
|
|
pinned = Array.isArray(data?.pinnedDirectories) ? data.pinnedDirectories : [];
|
2025-12-07 19:32:53 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
};
|
2026-02-05 01:59:49 +02:00
|
|
|
}, [stripTrailingSlashes]);
|
2025-12-07 19:32:53 +02:00
|
|
|
|
|
|
|
|
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));
|
2026-05-24 08:46:11 -04:00
|
|
|
const next = new Set(filtered);
|
|
|
|
|
return areStringSetsEqual(prev, next) ? prev : next;
|
2025-12-07 19:32:53 +02:00
|
|
|
});
|
|
|
|
|
}, [effectiveRoot, isPathWithinHome, stripTrailingSlashes]);
|
|
|
|
|
|
2026-04-27 04:53:36 -06:00
|
|
|
// Clean up pinned expansion state when pinned paths are removed (e.g., unpinned or filtered out)
|
|
|
|
|
React.useEffect(() => {
|
|
|
|
|
const pinnedRoots = Array.from(pinnedPaths);
|
|
|
|
|
const isWithinPinnedRoot = (path: string) => pinnedRoots.some((root) => (
|
|
|
|
|
path === root || path.startsWith(`${root}/`)
|
|
|
|
|
));
|
|
|
|
|
|
|
|
|
|
setPinnedExpandedPaths(prev => {
|
|
|
|
|
const next = new Set(prev);
|
|
|
|
|
for (const path of prev) {
|
|
|
|
|
if (!isWithinPinnedRoot(path)) {
|
|
|
|
|
next.delete(path);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return next;
|
|
|
|
|
});
|
|
|
|
|
setPinnedItemChildren(prev => {
|
|
|
|
|
const next = new Map(prev);
|
|
|
|
|
for (const [path] of prev) {
|
|
|
|
|
if (!isWithinPinnedRoot(path)) {
|
|
|
|
|
next.delete(path);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return next;
|
|
|
|
|
});
|
|
|
|
|
}, [pinnedPaths]);
|
|
|
|
|
|
2026-01-08 23:53:03 +02:00
|
|
|
// Reload directories when showHidden changes, but keep expanded state
|
2025-12-07 19:32:53 +02:00
|
|
|
React.useEffect(() => {
|
|
|
|
|
if (previousShowHidden.current !== showHidden) {
|
|
|
|
|
previousShowHidden.current = showHidden;
|
2026-01-08 23:53:03 +02:00
|
|
|
// Silently reload without clearing state - loadInitialDirectories will be called
|
|
|
|
|
// via its dependency on loadDirectory which depends on showHidden
|
2025-12-07 19:32:53 +02:00
|
|
|
}
|
|
|
|
|
}, [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({
|
2026-01-03 13:39:59 +02:00
|
|
|
path: '.',
|
|
|
|
|
directory: path
|
2025-12-07 19:32:53 +02:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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]);
|
|
|
|
|
|
2026-01-08 23:53:03 +02:00
|
|
|
const hasLoadedOnce = React.useRef(false);
|
|
|
|
|
|
2025-12-07 19:32:53 +02:00
|
|
|
const loadInitialDirectories = React.useCallback(async () => {
|
|
|
|
|
if (!rootReady || !effectiveRoot) {
|
|
|
|
|
setIsLoading(true);
|
|
|
|
|
setDirectories([]);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-08 23:53:03 +02:00
|
|
|
// Only show loading on initial load, not on refreshes (e.g., showHidden toggle)
|
|
|
|
|
if (!hasLoadedOnce.current) {
|
|
|
|
|
setIsLoading(true);
|
|
|
|
|
}
|
2025-12-07 19:32:53 +02:00
|
|
|
try {
|
|
|
|
|
const homeContents = await loadDirectory(effectiveRoot);
|
|
|
|
|
setDirectories(homeContents);
|
2026-01-08 23:53:03 +02:00
|
|
|
hasLoadedOnce.current = true;
|
2025-12-07 19:32:53 +02:00
|
|
|
} 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));
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-27 04:53:36 -06:00
|
|
|
const togglePinnedExpanded = async (path: string) => {
|
|
|
|
|
if (!rootReady) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const isCurrentlyExpanded = pinnedExpandedPaths.has(path);
|
|
|
|
|
const newExpanded = new Set(pinnedExpandedPaths);
|
|
|
|
|
|
|
|
|
|
if (isCurrentlyExpanded) {
|
|
|
|
|
newExpanded.delete(path);
|
|
|
|
|
setPinnedExpandedPaths(newExpanded);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
newExpanded.add(path);
|
|
|
|
|
setPinnedExpandedPaths(newExpanded);
|
|
|
|
|
|
|
|
|
|
const children = await loadDirectory(path);
|
|
|
|
|
setPinnedItemChildren(prev => {
|
|
|
|
|
const next = new Map(prev);
|
|
|
|
|
next.set(path, children);
|
|
|
|
|
return next;
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
2025-12-07 19:32:53 +02:00
|
|
|
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 {
|
2026-01-31 17:36:10 -03:00
|
|
|
await opencodeClient.createDirectory(fullPath, { allowOutsideWorkspace: true });
|
2025-12-07 19:32:53 +02:00
|
|
|
|
|
|
|
|
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';
|
2026-04-27 15:45:14 +03:00
|
|
|
const isDisabled = isPathDisabled(item.path);
|
2025-12-07 19:32:53 +02:00
|
|
|
|
|
|
|
|
const rowContent = (
|
|
|
|
|
<>
|
|
|
|
|
{hasChildren && (
|
|
|
|
|
<button
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
toggleExpanded(item);
|
|
|
|
|
}}
|
2026-02-01 18:29:34 +02:00
|
|
|
className={cn("hover:bg-interactive-hover rounded", isMobile ? "p-0.5" : "p-0.5")}
|
2025-12-07 19:32:53 +02:00
|
|
|
>
|
|
|
|
|
{isExpanded ? (
|
2026-05-13 13:26:15 +03:00
|
|
|
<Icon name="arrow-down-s" className={isMobile ? "h-3.5 w-3.5" : "h-3 w-3"} />
|
2025-12-07 19:32:53 +02:00
|
|
|
) : (
|
2026-05-13 13:26:15 +03:00
|
|
|
<Icon name="arrow-right-s" className={isMobile ? "h-3.5 w-3.5" : "h-3 w-3"} />
|
2025-12-07 19:32:53 +02:00
|
|
|
)}
|
|
|
|
|
</button>
|
|
|
|
|
)}
|
2026-01-08 23:53:03 +02:00
|
|
|
{!hasChildren && <div className={isMobile ? "w-4.5" : "w-4"} />}
|
2025-12-07 19:32:53 +02:00
|
|
|
|
|
|
|
|
<button
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
e.stopPropagation();
|
2026-04-27 15:45:14 +03:00
|
|
|
if (isDisabled) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2025-12-07 19:32:53 +02:00
|
|
|
handleDirectorySelect(item.path);
|
|
|
|
|
if (variant === 'dropdown' && selectionBehavior === 'immediate') {
|
|
|
|
|
setIsOpen(false);
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
onDoubleClick={(e) => {
|
|
|
|
|
e.stopPropagation();
|
2026-04-27 15:45:14 +03:00
|
|
|
if (!isDisabled && onDoubleClickPath) {
|
2025-12-07 19:32:53 +02:00
|
|
|
onDoubleClickPath(item.path);
|
|
|
|
|
}
|
|
|
|
|
}}
|
2026-04-27 15:45:14 +03:00
|
|
|
disabled={isDisabled}
|
2025-12-07 19:32:53 +02:00
|
|
|
className={cn(
|
2026-01-08 23:53:03 +02:00
|
|
|
'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',
|
2026-04-27 15:45:14 +03:00
|
|
|
isDisabled && 'cursor-not-allowed opacity-45',
|
2025-12-07 19:32:53 +02:00
|
|
|
isInlineVariant ? (isSelected ? 'text-primary' : 'text-foreground') : 'text-foreground'
|
|
|
|
|
)}
|
|
|
|
|
>
|
2026-05-13 13:26:15 +03:00
|
|
|
<Icon name="folder-6"
|
2026-01-08 23:53:03 +02:00
|
|
|
className={cn(
|
|
|
|
|
'text-muted-foreground flex-shrink-0',
|
|
|
|
|
isMobile ? 'h-4 w-4' : 'h-3.5 w-3.5',
|
2026-04-27 15:45:14 +03:00
|
|
|
isDisabled && 'text-muted-foreground',
|
2026-01-08 23:53:03 +02:00
|
|
|
isInlineVariant && isSelected && 'text-primary'
|
|
|
|
|
)}
|
|
|
|
|
/>
|
2025-12-07 19:32:53 +02:00
|
|
|
<span
|
|
|
|
|
className={cn(
|
2026-01-08 23:53:03 +02:00
|
|
|
'font-medium truncate',
|
|
|
|
|
isMobile ? 'typography-ui-label' : 'typography-ui-label',
|
2026-04-27 15:45:14 +03:00
|
|
|
isDisabled && 'text-muted-foreground',
|
2025-12-07 19:32:53 +02:00
|
|
|
isInlineVariant && isSelected ? 'text-primary' : 'text-foreground'
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
{item.name}
|
|
|
|
|
</span>
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
<button
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
startCreatingDirectory(item);
|
|
|
|
|
}}
|
2025-12-19 17:22:02 +02:00
|
|
|
className={cn(
|
2026-02-01 18:29:34 +02:00
|
|
|
"hover:bg-interactive-hover rounded transition-opacity",
|
2026-01-08 23:53:03 +02:00
|
|
|
isMobile ? "p-1.5" : "p-1",
|
|
|
|
|
alwaysShowActions ? "opacity-60" : "opacity-0 group-hover:opacity-100"
|
2025-12-19 17:22:02 +02:00
|
|
|
)}
|
2026-04-26 14:03:39 +03:00
|
|
|
title={t('directoryTree.actions.createNewDirectory')}
|
2025-12-07 19:32:53 +02:00
|
|
|
>
|
2026-05-13 13:26:15 +03:00
|
|
|
<Icon name="add" className={cn("text-muted-foreground", isMobile ? "h-3.5 w-3.5" : "h-3 w-3")} />
|
2025-12-07 19:32:53 +02:00
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
<button
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
togglePin(item.path);
|
|
|
|
|
}}
|
2025-12-19 17:22:02 +02:00
|
|
|
className={cn(
|
2026-02-01 18:29:34 +02:00
|
|
|
"hover:bg-interactive-hover rounded transition-opacity",
|
2026-01-08 23:53:03 +02:00
|
|
|
isMobile ? "p-1.5" : "p-1",
|
|
|
|
|
alwaysShowActions ? "opacity-60" : "opacity-0 group-hover:opacity-100"
|
2025-12-19 17:22:02 +02:00
|
|
|
)}
|
2026-04-26 14:03:39 +03:00
|
|
|
title={isPinned ? t('directoryTree.actions.unpinDirectory') : t('directoryTree.actions.pinDirectory')}
|
2025-12-07 19:32:53 +02:00
|
|
|
>
|
|
|
|
|
{isPinned ? (
|
2026-05-13 13:26:15 +03:00
|
|
|
<Icon name="pushpin-2" className={cn("text-primary", isMobile ? "h-3.5 w-3.5" : "h-3 w-3")} />
|
2025-12-07 19:32:53 +02:00
|
|
|
) : (
|
2026-05-13 13:26:15 +03:00
|
|
|
<Icon name="pushpin" className={cn("text-muted-foreground", isMobile ? "h-3.5 w-3.5" : "h-3 w-3")} />
|
2025-12-07 19:32:53 +02:00
|
|
|
)}
|
|
|
|
|
</button>
|
|
|
|
|
</>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (variant === 'inline') {
|
|
|
|
|
return (
|
|
|
|
|
<div key={item.path}>
|
|
|
|
|
<div
|
|
|
|
|
className={cn(
|
2026-01-08 23:53:03 +02:00
|
|
|
'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'
|
2026-04-27 15:45:14 +03:00
|
|
|
: isDisabled
|
|
|
|
|
? 'text-muted-foreground'
|
|
|
|
|
: 'hover:bg-interactive-hover/50 text-foreground'
|
2025-12-07 19:32:53 +02:00
|
|
|
)}
|
2026-01-08 23:53:03 +02:00
|
|
|
style={{ paddingLeft: `${level * (isMobile ? 12 : 14) + (isMobile ? 4 : 6)}px` }}
|
2025-12-07 19:32:53 +02:00
|
|
|
>
|
|
|
|
|
{rowContent}
|
|
|
|
|
</div>
|
|
|
|
|
{isExpanded && (
|
|
|
|
|
<>
|
|
|
|
|
{creatingInPath === item.path && (
|
|
|
|
|
<div
|
2026-01-08 23:53:03 +02:00
|
|
|
className="flex items-center gap-1 mx-1 px-2 py-1.5"
|
|
|
|
|
style={{ paddingLeft: `${(level + 1) * 14 + 6}px` }}
|
2025-12-07 19:32:53 +02:00
|
|
|
>
|
|
|
|
|
<div className="w-4" />
|
2026-05-13 13:26:15 +03:00
|
|
|
<Icon name="folder-6" className="h-3.5 w-3.5 text-muted-foreground" />
|
2025-12-07 19:32:53 +02:00
|
|
|
<Input
|
|
|
|
|
ref={inputRef}
|
|
|
|
|
value={newDirName}
|
|
|
|
|
onChange={(e) => setNewDirName(e.target.value)}
|
|
|
|
|
onKeyDown={(e) => {
|
2026-05-17 23:48:05 +03:00
|
|
|
e.stopPropagation();
|
2025-12-07 19:32:53 +02:00
|
|
|
if (e.key === 'Enter') {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
createDirectory();
|
|
|
|
|
} else if (e.key === 'Escape') {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
cancelCreatingDirectory();
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
onBlur={createDirectory}
|
2026-02-01 18:29:34 +02:00
|
|
|
className="h-6 typography-meta flex-1 selection:bg-interactive-selection selection:text-interactive-selection-foreground"
|
2026-04-26 14:03:39 +03:00
|
|
|
placeholder={t('directoryTree.field.newDirectoryPlaceholder')}
|
2025-12-07 19:32:53 +02:00
|
|
|
/>
|
|
|
|
|
<button
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
createDirectory();
|
|
|
|
|
}}
|
2026-02-01 18:29:34 +02:00
|
|
|
className="p-1 hover:bg-interactive-hover rounded"
|
2026-04-26 14:03:39 +03:00
|
|
|
title={t('directoryTree.actions.createDirectory')}
|
2025-12-07 19:32:53 +02:00
|
|
|
>
|
2026-05-13 13:26:15 +03:00
|
|
|
<Icon name="check" className="h-3 w-3 text-green-600" />
|
2025-12-07 19:32:53 +02:00
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
cancelCreatingDirectory();
|
|
|
|
|
}}
|
2026-02-01 18:29:34 +02:00
|
|
|
className="p-1 hover:bg-interactive-hover rounded"
|
2026-04-26 14:03:39 +03:00
|
|
|
title={t('directoryTree.actions.cancel')}
|
2025-12-07 19:32:53 +02:00
|
|
|
>
|
2026-05-13 13:26:15 +03:00
|
|
|
<Icon name="close" className="h-3 w-3 text-muted-foreground" />
|
2025-12-07 19:32:53 +02:00
|
|
|
</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',
|
2026-02-01 18:29:34 +02:00
|
|
|
currentPath === item.path && 'bg-interactive-selection'
|
2025-12-07 19:32:53 +02:00
|
|
|
)}
|
|
|
|
|
style={{ paddingLeft: `${level * 12 + 8}px` }}
|
|
|
|
|
onSelect={(e) => {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{hasChildren && (
|
|
|
|
|
<button
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
toggleExpanded(item);
|
|
|
|
|
}}
|
2026-02-01 18:29:34 +02:00
|
|
|
className="p-0.5 hover:bg-interactive-hover rounded"
|
2025-12-07 19:32:53 +02:00
|
|
|
>
|
|
|
|
|
{isExpanded ? (
|
2026-05-13 13:26:15 +03:00
|
|
|
<Icon name="arrow-down-s" className="h-3 w-3" />
|
2025-12-07 19:32:53 +02:00
|
|
|
) : (
|
2026-05-13 13:26:15 +03:00
|
|
|
<Icon name="arrow-right-s" className="h-3 w-3" />
|
2025-12-07 19:32:53 +02:00
|
|
|
)}
|
|
|
|
|
</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" />
|
2026-05-13 13:26:15 +03:00
|
|
|
<Icon name="folder-6" className="h-3.5 w-3.5 text-muted-foreground" />
|
2025-12-07 19:32:53 +02:00
|
|
|
<Input
|
|
|
|
|
ref={inputRef}
|
|
|
|
|
value={newDirName}
|
|
|
|
|
onChange={(e) => setNewDirName(e.target.value)}
|
|
|
|
|
onKeyDown={(e) => {
|
2026-05-17 23:48:05 +03:00
|
|
|
e.stopPropagation();
|
2025-12-07 19:32:53 +02:00
|
|
|
if (e.key === 'Enter') {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
createDirectory();
|
|
|
|
|
} else if (e.key === 'Escape') {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
cancelCreatingDirectory();
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
onBlur={createDirectory}
|
2026-02-01 18:29:34 +02:00
|
|
|
className="h-6 typography-meta flex-1 selection:bg-interactive-selection selection:text-interactive-selection-foreground"
|
2026-04-26 14:03:39 +03:00
|
|
|
placeholder={t('directoryTree.field.newDirectoryPlaceholder')}
|
2025-12-07 19:32:53 +02:00
|
|
|
/>
|
|
|
|
|
<button
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
createDirectory();
|
|
|
|
|
}}
|
2026-02-01 18:29:34 +02:00
|
|
|
className="p-1 hover:bg-interactive-hover rounded"
|
2026-04-26 14:03:39 +03:00
|
|
|
title={t('directoryTree.actions.createDirectory')}
|
2025-12-07 19:32:53 +02:00
|
|
|
>
|
2026-05-13 13:26:15 +03:00
|
|
|
<Icon name="check" className="h-3 w-3 text-green-600" />
|
2025-12-07 19:32:53 +02:00
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
cancelCreatingDirectory();
|
|
|
|
|
}}
|
2026-02-01 18:29:34 +02:00
|
|
|
className="p-1 hover:bg-interactive-hover rounded"
|
2026-04-26 14:03:39 +03:00
|
|
|
title={t('directoryTree.actions.cancel')}
|
2025-12-07 19:32:53 +02:00
|
|
|
>
|
2026-05-13 13:26:15 +03:00
|
|
|
<Icon name="close" className="h-3 w-3 text-muted-foreground" />
|
2025-12-07 19:32:53 +02:00
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
{item.children && item.children.map((child) => renderTreeItem(child, level + 1))}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-27 04:53:36 -06:00
|
|
|
const renderPinnedTreeItem = (item: DirectoryItem, level: number = 0) => {
|
|
|
|
|
const isExpanded = pinnedExpandedPaths.has(item.path);
|
|
|
|
|
const children = pinnedItemChildren.get(item.path);
|
|
|
|
|
const hasChildren = item.isDirectory;
|
|
|
|
|
const isPinned = pinnedPaths.has(item.path);
|
|
|
|
|
const isSelected = currentPath === item.path;
|
|
|
|
|
const isInlineVariant = variant === 'inline';
|
|
|
|
|
|
|
|
|
|
const rowContent = (
|
|
|
|
|
<>
|
|
|
|
|
{hasChildren && (
|
2025-12-07 19:32:53 +02:00
|
|
|
<button
|
2026-04-27 04:53:36 -06:00
|
|
|
onClick={(e) => {
|
2025-12-07 19:32:53 +02:00
|
|
|
e.stopPropagation();
|
2026-04-27 04:53:36 -06:00
|
|
|
togglePinnedExpanded(item.path);
|
2025-12-07 19:32:53 +02:00
|
|
|
}}
|
2026-04-27 04:53:36 -06:00
|
|
|
className={cn("hover:bg-interactive-hover rounded", isMobile ? "p-0.5" : "p-0.5")}
|
|
|
|
|
>
|
|
|
|
|
{isExpanded ? (
|
2026-05-13 13:26:15 +03:00
|
|
|
<Icon name="arrow-down-s" className={isMobile ? "h-3.5 w-3.5" : "h-3 w-3"} />
|
2026-04-27 04:53:36 -06:00
|
|
|
) : (
|
2026-05-13 13:26:15 +03:00
|
|
|
<Icon name="arrow-right-s" className={isMobile ? "h-3.5 w-3.5" : "h-3 w-3"} />
|
2026-04-27 04:53:36 -06:00
|
|
|
)}
|
|
|
|
|
</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'
|
|
|
|
|
)}
|
|
|
|
|
>
|
2026-05-13 13:26:15 +03:00
|
|
|
<Icon name="folder-6"
|
2026-04-27 04:53:36 -06:00
|
|
|
className={cn(
|
|
|
|
|
'text-muted-foreground flex-shrink-0',
|
|
|
|
|
isMobile ? 'h-4 w-4' : 'h-3.5 w-3.5',
|
|
|
|
|
isInlineVariant && isSelected && 'text-primary'
|
|
|
|
|
)}
|
|
|
|
|
/>
|
|
|
|
|
<span
|
2025-12-07 19:32:53 +02:00
|
|
|
className={cn(
|
2026-04-27 04:53:36 -06:00
|
|
|
'font-medium truncate',
|
|
|
|
|
isMobile ? 'typography-ui-label' : 'typography-ui-label',
|
|
|
|
|
isInlineVariant && isSelected ? 'text-primary' : 'text-foreground'
|
2025-12-07 19:32:53 +02:00
|
|
|
)}
|
|
|
|
|
>
|
2026-04-27 04:53:36 -06:00
|
|
|
{item.name}
|
|
|
|
|
</span>
|
|
|
|
|
</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 ? t('directoryTree.actions.unpinDirectory') : t('directoryTree.actions.pinDirectory')}
|
|
|
|
|
>
|
|
|
|
|
{isPinned ? (
|
2026-05-13 13:26:15 +03:00
|
|
|
<Icon name="pushpin-2" className={cn("text-primary", isMobile ? "h-3.5 w-3.5" : "h-3 w-3")} />
|
2026-04-27 04:53:36 -06:00
|
|
|
) : (
|
2026-05-13 13:26:15 +03:00
|
|
|
<Icon name="pushpin" className={cn("text-muted-foreground", isMobile ? "h-3.5 w-3.5" : "h-3 w-3")} />
|
2026-04-27 04:53:36 -06:00
|
|
|
)}
|
|
|
|
|
</button>
|
|
|
|
|
</>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (isInlineVariant) {
|
|
|
|
|
return (
|
|
|
|
|
<div key={item.path}>
|
|
|
|
|
<div
|
2026-01-08 23:53:03 +02:00
|
|
|
className={cn(
|
2026-04-27 04:53:36 -06:00
|
|
|
'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'
|
2026-01-08 23:53:03 +02:00
|
|
|
)}
|
2026-04-27 04:53:36 -06:00
|
|
|
title={isPinned ? t('directoryTree.actions.unpinDirectory') : undefined}
|
|
|
|
|
style={{ paddingLeft: `${level * (isMobile ? 12 : 14) + (isMobile ? 4 : 6)}px` }}
|
2025-12-07 19:32:53 +02:00
|
|
|
>
|
2026-04-27 04:53:36 -06:00
|
|
|
{rowContent}
|
|
|
|
|
</div>
|
|
|
|
|
{isExpanded && children && children.map((child) => renderPinnedTreeItem(child, level + 1))}
|
2025-12-07 19:32:53 +02:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
2026-04-27 04:53:36 -06:00
|
|
|
<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();
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{rowContent}
|
|
|
|
|
</DropdownMenuItem>
|
|
|
|
|
{isExpanded && children && (
|
|
|
|
|
<div>
|
|
|
|
|
{children.map((child) => renderPinnedTreeItem(child, level + 1))}
|
|
|
|
|
</div>
|
2025-12-07 19:32:53 +02:00
|
|
|
)}
|
2026-04-27 04:53:36 -06:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const renderPinnedRow = (name: string, path: string) => {
|
|
|
|
|
const isExpanded = pinnedExpandedPaths.has(path);
|
|
|
|
|
const children = pinnedItemChildren.get(path);
|
|
|
|
|
const isSelected = currentPath === path;
|
|
|
|
|
const isInlineVariant = variant === 'inline';
|
|
|
|
|
|
|
|
|
|
if (isInlineVariant) {
|
|
|
|
|
return (
|
|
|
|
|
<div key={path}>
|
|
|
|
|
<div
|
|
|
|
|
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={(e) => {
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
togglePinnedExpanded(path);
|
|
|
|
|
}}
|
|
|
|
|
className={cn("hover:bg-interactive-hover rounded flex-shrink-0", isMobile ? "p-0.5" : "p-0.5")}
|
|
|
|
|
>
|
|
|
|
|
{isExpanded ? (
|
2026-05-13 13:26:15 +03:00
|
|
|
<Icon name="arrow-down-s" className={isMobile ? "h-3.5 w-3.5" : "h-3 w-3"} />
|
2026-04-27 04:53:36 -06:00
|
|
|
) : (
|
2026-05-13 13:26:15 +03:00
|
|
|
<Icon name="arrow-right-s" className={isMobile ? "h-3.5 w-3.5" : "h-3 w-3"} />
|
2026-04-27 04:53:36 -06:00
|
|
|
)}
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
<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'
|
|
|
|
|
)}
|
|
|
|
|
>
|
2026-05-13 13:26:15 +03:00
|
|
|
<Icon name="folder-6"
|
2026-04-27 04:53:36 -06:00
|
|
|
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",
|
2026-05-03 17:19:55 +03:00
|
|
|
showHoverActions ? "p-1.5 opacity-60" : "p-1 opacity-0 group-hover:opacity-100"
|
2026-04-27 04:53:36 -06:00
|
|
|
)}
|
|
|
|
|
title={t('directoryTree.actions.unpinDirectory')}
|
|
|
|
|
>
|
2026-05-13 13:26:15 +03:00
|
|
|
<Icon name="pushpin-2" className={cn("text-primary", isMobile ? "h-3.5 w-3.5" : "h-3.5 w-3.5")} />
|
2026-04-27 04:53:36 -06:00
|
|
|
</button>
|
2025-12-07 19:32:53 +02:00
|
|
|
</div>
|
2026-04-27 04:53:36 -06:00
|
|
|
{isExpanded && children && children.map((child) => renderPinnedTreeItem(child, 1))}
|
2025-12-07 19:32:53 +02:00
|
|
|
</div>
|
2026-04-27 04:53:36 -06:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div key={path}>
|
|
|
|
|
<DropdownMenuItem
|
|
|
|
|
onSelect={(e) => {
|
2025-12-07 19:32:53 +02:00
|
|
|
e.preventDefault();
|
2026-04-27 04:53:36 -06:00
|
|
|
handleDirectorySelect(path);
|
|
|
|
|
if (selectionBehavior === 'immediate') {
|
|
|
|
|
setIsOpen(false);
|
|
|
|
|
}
|
2025-12-07 19:32:53 +02:00
|
|
|
}}
|
2026-04-27 04:53:36 -06:00
|
|
|
className={cn(
|
|
|
|
|
'flex items-start gap-2 cursor-pointer group py-2',
|
|
|
|
|
currentPath === path && 'bg-interactive-selection'
|
|
|
|
|
)}
|
2025-12-07 19:32:53 +02:00
|
|
|
>
|
2026-04-27 04:53:36 -06:00
|
|
|
<button
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
togglePinnedExpanded(path);
|
|
|
|
|
}}
|
|
|
|
|
className="p-0.5 hover:bg-interactive-hover rounded flex-shrink-0"
|
|
|
|
|
>
|
|
|
|
|
{isExpanded ? (
|
2026-05-13 13:26:15 +03:00
|
|
|
<Icon name="arrow-down-s" className="h-3 w-3" />
|
2026-04-27 04:53:36 -06:00
|
|
|
) : (
|
2026-05-13 13:26:15 +03:00
|
|
|
<Icon name="arrow-right-s" className="h-3 w-3" />
|
2026-04-27 04:53:36 -06:00
|
|
|
)}
|
|
|
|
|
</button>
|
2026-05-13 13:26:15 +03:00
|
|
|
<Icon name="folder-6" className="h-3.5 w-3.5 text-muted-foreground mt-0.5 flex-shrink-0" />
|
2026-04-27 04:53:36 -06:00
|
|
|
<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);
|
|
|
|
|
}}
|
2026-05-03 17:19:55 +03:00
|
|
|
className={cn(
|
|
|
|
|
"hover:bg-interactive-hover rounded transition-opacity flex-shrink-0",
|
|
|
|
|
showHoverActions ? "p-1.5 opacity-60" : "p-1 opacity-0 group-hover:opacity-100"
|
|
|
|
|
)}
|
2026-04-27 04:53:36 -06:00
|
|
|
title={t('directoryTree.actions.unpinDirectory')}
|
|
|
|
|
>
|
2026-05-13 13:26:15 +03:00
|
|
|
<Icon name="pushpin-2" className="h-3 w-3 text-primary" />
|
2026-04-27 04:53:36 -06:00
|
|
|
</button>
|
|
|
|
|
</DropdownMenuItem>
|
|
|
|
|
{isExpanded && children && (
|
|
|
|
|
<div>
|
|
|
|
|
{children.map((child) => renderPinnedTreeItem(child, 1))}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
2025-12-07 19:32:53 +02:00
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const directoryContent = (
|
|
|
|
|
<>
|
|
|
|
|
{!rootReady ? (
|
|
|
|
|
<div className="px-3 py-2 typography-ui-label text-muted-foreground">
|
2026-04-26 14:03:39 +03:00
|
|
|
{t('directoryTree.state.locatingHomeDirectory')}
|
2025-12-07 19:32:53 +02:00
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<>
|
|
|
|
|
{pinnedDirectories.length > 0 && (
|
|
|
|
|
<>
|
2025-12-19 17:22:02 +02:00
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => setIsPinnedExpanded(prev => !prev)}
|
2026-01-08 23:53:03 +02:00
|
|
|
className={cn(
|
2026-02-01 18:29:34 +02:00
|
|
|
"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",
|
2026-01-08 23:53:03 +02:00
|
|
|
isMobile ? "px-1.5 py-1" : "px-2 py-1.5"
|
|
|
|
|
)}
|
2025-12-19 17:22:02 +02:00
|
|
|
>
|
|
|
|
|
{isPinnedExpanded ? (
|
2026-05-13 13:26:15 +03:00
|
|
|
<Icon name="arrow-down-s" className={isMobile ? "h-3.5 w-3.5" : "h-3 w-3"} />
|
2025-12-19 17:22:02 +02:00
|
|
|
) : (
|
2026-05-13 13:26:15 +03:00
|
|
|
<Icon name="arrow-right-s" className={isMobile ? "h-3.5 w-3.5" : "h-3 w-3"} />
|
2025-12-19 17:22:02 +02:00
|
|
|
)}
|
2026-04-26 14:03:39 +03:00
|
|
|
<span>{t('directoryTree.section.pinned')}</span>
|
2026-01-08 23:53:03 +02:00
|
|
|
<span className="ml-auto typography-micro text-muted-foreground/60 normal-case tracking-normal">
|
2025-12-19 17:22:02 +02:00
|
|
|
{pinnedDirectories.length}
|
|
|
|
|
</span>
|
|
|
|
|
</button>
|
|
|
|
|
{isPinnedExpanded && pinnedDirectories.map(({ name, path }) => renderPinnedRow(name, path))}
|
2025-12-07 19:32:53 +02:00
|
|
|
{variant === 'dropdown' && <DropdownMenuSeparator />}
|
2025-12-19 17:22:02 +02:00
|
|
|
{variant === 'inline' && isPinnedExpanded && (
|
2026-01-08 23:53:03 +02:00
|
|
|
<div className="mx-3 my-2 border-t border-border/40" />
|
2025-12-19 17:22:02 +02:00
|
|
|
)}
|
2025-12-07 19:32:53 +02:00
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-01-08 23:53:03 +02:00
|
|
|
<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"
|
|
|
|
|
)}>
|
2026-04-26 14:03:39 +03:00
|
|
|
{t('directoryTree.section.browse')}
|
2025-12-07 19:32:53 +02:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{isLoading ? (
|
|
|
|
|
<div className="px-3 py-2 typography-ui-label text-muted-foreground">
|
2026-04-26 14:03:39 +03:00
|
|
|
{t('directoryTree.state.loading')}
|
2025-12-07 19:32:53 +02:00
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
directories.map((item) => renderTreeItem(item))
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{!isLoading && directories.length === 0 && (
|
|
|
|
|
<div className="px-3 py-2 typography-ui-label text-muted-foreground">
|
2026-04-26 14:03:39 +03:00
|
|
|
{t('directoryTree.state.noDirectoriesFound')}
|
2025-12-07 19:32:53 +02:00
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (variant === 'inline') {
|
|
|
|
|
return (
|
2026-01-08 23:53:03 +02:00
|
|
|
<div className={cn('overflow-hidden flex flex-col', className)}>
|
|
|
|
|
<ScrollableOverlay outerClassName="flex-1 min-h-0" className="w-full py-1">
|
2025-12-07 19:32:53 +02:00
|
|
|
{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
|
|
|
|
|
)}
|
2026-04-26 14:03:39 +03:00
|
|
|
aria-label={t('directoryTree.actions.selectWorkingDirectoryAria')}
|
2025-12-07 19:32:53 +02:00
|
|
|
>
|
|
|
|
|
<span className="flex items-center gap-1.5 min-w-0 flex-1">
|
2026-05-13 13:26:15 +03:00
|
|
|
<Icon name="folder-6" className="h-3 w-3 flex-shrink-0 text-muted-foreground" />
|
2025-12-07 19:32:53 +02:00
|
|
|
<span className="truncate" title={currentPath}>
|
|
|
|
|
{formatPathForDisplay(currentPath, homeDirectory)}
|
|
|
|
|
</span>
|
|
|
|
|
</span>
|
2026-05-13 13:26:15 +03:00
|
|
|
<Icon name="arrow-down-s" className="h-3 w-3 flex-shrink-0 text-muted-foreground" />
|
2025-12-07 19:32:53 +02:00
|
|
|
</Button>
|
|
|
|
|
</DropdownMenuTrigger>
|
|
|
|
|
<DropdownMenuContent align="start" className="w-[350px]">
|
|
|
|
|
<ScrollableOverlay outerClassName="h-full" className="w-full">
|
|
|
|
|
{directoryContent}
|
|
|
|
|
</ScrollableOverlay>
|
|
|
|
|
</DropdownMenuContent>
|
|
|
|
|
</DropdownMenu>
|
|
|
|
|
);
|
|
|
|
|
};
|