feat: make pinned folders expandable to browse subdirectories in project picker (#1035)
* feat: make pinned folders expandable to browse subdirectories in project picker Pinned folders in the Add Project dialog now show an expand/collapse arrow. When expanded, subdirectories are lazily loaded and rendered as a recursive tree, allowing users to browse into pinned parent folders (e.g. a 'local-dev' folder) and pick any nested subfolder as a project. * chore: trigger CI re-run * fix: remove duplicate expand button in dropdown pinned tree variant * fix: make pinned tree row tooltip conditional on pin state * Fix pinned folder tree cleanup --------- Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
committed by
GitHub
co-authored by
Bohdan Triapitsyn
parent
8fdc1e7e55
commit
43c92babc3
@@ -65,6 +65,8 @@ export const DirectoryTree: React.FC<DirectoryTreeProps> = ({
|
||||
const [creatingInPath, setCreatingInPath] = React.useState<string | null>(null);
|
||||
const [newDirName, setNewDirName] = React.useState('');
|
||||
const [isPinnedExpanded, setIsPinnedExpanded] = React.useState(true);
|
||||
const [pinnedExpandedPaths, setPinnedExpandedPaths] = React.useState<Set<string>>(new Set());
|
||||
const [pinnedItemChildren, setPinnedItemChildren] = React.useState<Map<string, DirectoryItem[]>>(new Map());
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
const { requestAccess, startAccessing, isDesktop } = useFileSystemAccess();
|
||||
const previousShowHidden = React.useRef(showHidden);
|
||||
@@ -311,6 +313,33 @@ export const DirectoryTree: React.FC<DirectoryTreeProps> = ({
|
||||
});
|
||||
}, [effectiveRoot, isPathWithinHome, stripTrailingSlashes]);
|
||||
|
||||
// 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]);
|
||||
|
||||
// Reload directories when showHidden changes, but keep expanded state
|
||||
React.useEffect(() => {
|
||||
if (previousShowHidden.current !== showHidden) {
|
||||
@@ -495,6 +524,30 @@ export const DirectoryTree: React.FC<DirectoryTreeProps> = ({
|
||||
setDirectories((prev) => updateItems(prev));
|
||||
};
|
||||
|
||||
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;
|
||||
});
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
if (creatingInPath && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
@@ -869,100 +922,270 @@ export const DirectoryTree: React.FC<DirectoryTreeProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
const renderPinnedRow = (name: string, path: string) => {
|
||||
if (variant === 'inline') {
|
||||
const isSelected = currentPath === path;
|
||||
return (
|
||||
<div
|
||||
key={path}
|
||||
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 && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
togglePinnedExpanded(item.path);
|
||||
}}
|
||||
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(
|
||||
'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'
|
||||
'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'
|
||||
)}
|
||||
>
|
||||
<button
|
||||
onClick={() => handleDirectorySelect(path)}
|
||||
onDoubleClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (onDoubleClickPath) {
|
||||
onDoubleClickPath(path);
|
||||
}
|
||||
}}
|
||||
<RiFolder6Line
|
||||
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'
|
||||
'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'
|
||||
)}
|
||||
>
|
||||
<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)}
|
||||
{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 ? (
|
||||
<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 (isInlineVariant) {
|
||||
return (
|
||||
<div key={item.path}>
|
||||
<div
|
||||
className={cn(
|
||||
"hover:bg-interactive-hover rounded-md transition-opacity",
|
||||
isMobile ? "p-1.5 opacity-60" : "p-1 opacity-0 group-hover:opacity-100"
|
||||
'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'
|
||||
)}
|
||||
title={t('directoryTree.actions.unpinDirectory')}
|
||||
title={isPinned ? t('directoryTree.actions.unpinDirectory') : undefined}
|
||||
style={{ paddingLeft: `${level * (isMobile ? 12 : 14) + (isMobile ? 4 : 6)}px` }}
|
||||
>
|
||||
<RiPushpin2Line className={cn("text-primary", isMobile ? "h-3.5 w-3.5" : "h-3.5 w-3.5")} />
|
||||
</button>
|
||||
{rowContent}
|
||||
</div>
|
||||
{isExpanded && children && children.map((child) => renderPinnedTreeItem(child, level + 1))}
|
||||
</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();
|
||||
<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();
|
||||
togglePin(path);
|
||||
}}
|
||||
className="p-1 opacity-0 group-hover:opacity-100 hover:bg-interactive-hover rounded transition-opacity"
|
||||
title={t('directoryTree.actions.unpinDirectory')}
|
||||
>
|
||||
<RiPushpin2Line className="h-3 w-3 text-primary" />
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
{rowContent}
|
||||
</DropdownMenuItem>
|
||||
{isExpanded && children && (
|
||||
<div>
|
||||
{children.map((child) => renderPinnedTreeItem(child, level + 1))}
|
||||
</div>
|
||||
)}
|
||||
</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 ? (
|
||||
<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>
|
||||
|
||||
<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={t('directoryTree.actions.unpinDirectory')}
|
||||
>
|
||||
<RiPushpin2Line className={cn("text-primary", isMobile ? "h-3.5 w-3.5" : "h-3.5 w-3.5")} />
|
||||
</button>
|
||||
</div>
|
||||
{isExpanded && children && children.map((child) => renderPinnedTreeItem(child, 1))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={path}>
|
||||
<DropdownMenuItem
|
||||
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'
|
||||
)}
|
||||
>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
togglePinnedExpanded(path);
|
||||
}}
|
||||
className="p-0.5 hover:bg-interactive-hover rounded flex-shrink-0"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<RiArrowDownSLine className="h-3 w-3" />
|
||||
) : (
|
||||
<RiArrowRightSLine className="h-3 w-3" />
|
||||
)}
|
||||
</button>
|
||||
<RiFolder6Line className="h-3.5 w-3.5 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||
<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 flex-shrink-0"
|
||||
title={t('directoryTree.actions.unpinDirectory')}
|
||||
>
|
||||
<RiPushpin2Line className="h-3 w-3 text-primary" />
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
{isExpanded && children && (
|
||||
<div>
|
||||
{children.map((child) => renderPinnedTreeItem(child, 1))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user