From 43c92babc39fc783c414280f25382e024a2b3345 Mon Sep 17 00:00:00 2001 From: Dave Otero <31802309+daveotero@users.noreply.github.com> Date: Mon, 27 Apr 2026 04:53:36 -0600 Subject: [PATCH] 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 --- .../src/components/session/DirectoryTree.tsx | 377 ++++++++++++++---- 1 file changed, 300 insertions(+), 77 deletions(-) diff --git a/packages/ui/src/components/session/DirectoryTree.tsx b/packages/ui/src/components/session/DirectoryTree.tsx index 107b4e98..59e1633c 100644 --- a/packages/ui/src/components/session/DirectoryTree.tsx +++ b/packages/ui/src/components/session/DirectoryTree.tsx @@ -65,6 +65,8 @@ export const DirectoryTree: React.FC = ({ const [creatingInPath, setCreatingInPath] = React.useState(null); const [newDirName, setNewDirName] = React.useState(''); const [isPinnedExpanded, setIsPinnedExpanded] = React.useState(true); + const [pinnedExpandedPaths, setPinnedExpandedPaths] = React.useState>(new Set()); + const [pinnedItemChildren, setPinnedItemChildren] = React.useState>(new Map()); const inputRef = React.useRef(null); const { requestAccess, startAccessing, isDesktop } = useFileSystemAccess(); const previousShowHidden = React.useRef(showHidden); @@ -311,6 +313,33 @@ export const DirectoryTree: React.FC = ({ }); }, [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 = ({ 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 = ({ ); }; - const renderPinnedRow = (name: string, path: string) => { - if (variant === 'inline') { - const isSelected = currentPath === path; - return ( -
{ + 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 && ( + + )} + {!hasChildren &&
} + + - + + + + ); + + if (isInlineVariant) { + return ( +
+
- - + {rowContent} +
+ {isExpanded && children && children.map((child) => renderPinnedTreeItem(child, level + 1))}
); } return ( - { - 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' - )} - > - -
-
{name}
-
- {formatPathForDisplay(path, homeDirectory)} -
-
- -
+ {rowContent} + + {isExpanded && children && ( +
+ {children.map((child) => renderPinnedTreeItem(child, level + 1))} +
+ )} +
+ ); + }; + + 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 ( +
+
+ + + + +
+ {isExpanded && children && children.map((child) => renderPinnedTreeItem(child, 1))} +
+ ); + } + + return ( +
+ { + 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' + )} + > + + +
+
{name}
+
+ {formatPathForDisplay(path, homeDirectory)} +
+
+ +
+ {isExpanded && children && ( +
+ {children.map((child) => renderPinnedTreeItem(child, 1))} +
+ )} +
); };