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:
Dave Otero
2026-04-27 13:53:36 +03:00
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>
);
};