feat: improve project directory picker

Adds command-palette style project browsing
Keeps native Finder picker as optional desktop action
Supports disabled existing projects and create-and-add paths
This commit is contained in:
Bohdan Triapitsyn
2026-04-27 15:45:14 +03:00
parent 54a09914ac
commit c83986cd94
14 changed files with 642 additions and 412 deletions
@@ -39,6 +39,7 @@ interface DirectoryTreeProps {
isRootReady?: boolean;
/** Always show action icons (add, pin) instead of only on hover */
alwaysShowActions?: boolean;
disabledPaths?: Iterable<string>;
}
export const DirectoryTree: React.FC<DirectoryTreeProps> = ({
@@ -53,6 +54,7 @@ export const DirectoryTree: React.FC<DirectoryTreeProps> = ({
rootDirectory = null,
isRootReady,
alwaysShowActions = false,
disabledPaths,
}) => {
const { t } = useI18n();
const { isMobile } = useDeviceInfo();
@@ -85,6 +87,20 @@ export const DirectoryTree: React.FC<DirectoryTreeProps> = ({
return trimmed.length === 0 ? '/' : trimmed;
}, []);
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]);
const normalizedHomeDirectory = React.useMemo(() => {
if (!homeDirectory) {
return null;
@@ -665,6 +681,7 @@ export const DirectoryTree: React.FC<DirectoryTreeProps> = ({
const isPinned = pinnedPaths.has(item.path);
const isSelected = currentPath === item.path;
const isInlineVariant = variant === 'inline';
const isDisabled = isPathDisabled(item.path);
const rowContent = (
<>
@@ -688,6 +705,9 @@ export const DirectoryTree: React.FC<DirectoryTreeProps> = ({
<button
onClick={(e) => {
e.stopPropagation();
if (isDisabled) {
return;
}
handleDirectorySelect(item.path);
if (variant === 'dropdown' && selectionBehavior === 'immediate') {
setIsOpen(false);
@@ -695,13 +715,15 @@ export const DirectoryTree: React.FC<DirectoryTreeProps> = ({
}}
onDoubleClick={(e) => {
e.stopPropagation();
if (onDoubleClickPath) {
if (!isDisabled && onDoubleClickPath) {
onDoubleClickPath(item.path);
}
}}
disabled={isDisabled}
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',
isDisabled && 'cursor-not-allowed opacity-45',
isInlineVariant ? (isSelected ? 'text-primary' : 'text-foreground') : 'text-foreground'
)}
>
@@ -709,6 +731,7 @@ export const DirectoryTree: React.FC<DirectoryTreeProps> = ({
className={cn(
'text-muted-foreground flex-shrink-0',
isMobile ? 'h-4 w-4' : 'h-3.5 w-3.5',
isDisabled && 'text-muted-foreground',
isInlineVariant && isSelected && 'text-primary'
)}
/>
@@ -716,6 +739,7 @@ export const DirectoryTree: React.FC<DirectoryTreeProps> = ({
className={cn(
'font-medium truncate',
isMobile ? 'typography-ui-label' : 'typography-ui-label',
isDisabled && 'text-muted-foreground',
isInlineVariant && isSelected ? 'text-primary' : 'text-foreground'
)}
>
@@ -768,7 +792,9 @@ export const DirectoryTree: React.FC<DirectoryTreeProps> = ({
isMobile ? 'px-1.5 py-1' : 'px-2 py-1.5',
isSelected
? 'bg-primary/10 text-primary'
: 'hover:bg-interactive-hover/50 text-foreground'
: isDisabled
? 'text-muted-foreground'
: 'hover:bg-interactive-hover/50 text-foreground'
)}
style={{ paddingLeft: `${level * (isMobile ? 12 : 14) + (isMobile ? 4 : 6)}px` }}
>