feat: implement DirectoryAutocomplete component and integrate it into DirectoryExplorerDialog for enhanced path selection
This commit is contained in:
@@ -10,6 +10,7 @@ All notable changes to this project will be documented in this file.
|
|||||||
- Autocomplete: added scope badges for commands/agents/skills.
|
- Autocomplete: added scope badges for commands/agents/skills.
|
||||||
- Compact: changed /summarize command to be /compact and use sdk for compaction.
|
- Compact: changed /summarize command to be /compact and use sdk for compaction.
|
||||||
- MCP: added ability to dynamically enabled/disabled configured MCP.
|
- MCP: added ability to dynamically enabled/disabled configured MCP.
|
||||||
|
- Web: refactored project adding UI with autocomplete.
|
||||||
|
|
||||||
|
|
||||||
## [1.4.4] - 2026-01-08
|
## [1.4.4] - 2026-01-08
|
||||||
|
|||||||
@@ -0,0 +1,314 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { RiFolderLine, RiRefreshLine } from '@remixicon/react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { opencodeClient, type FilesystemEntry } from '@/lib/opencode/client';
|
||||||
|
import { useDebouncedValue } from '@/hooks/useDebouncedValue';
|
||||||
|
|
||||||
|
interface DirectoryAutocompleteProps {
|
||||||
|
inputValue: string;
|
||||||
|
homeDirectory: string | null;
|
||||||
|
onSelectSuggestion: (path: string) => void;
|
||||||
|
visible: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
showHidden: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DirectoryAutocompleteHandle {
|
||||||
|
handleKeyDown: (e: React.KeyboardEvent<HTMLInputElement>) => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DirectoryAutocomplete = React.forwardRef<DirectoryAutocompleteHandle, DirectoryAutocompleteProps>(({
|
||||||
|
inputValue,
|
||||||
|
homeDirectory,
|
||||||
|
onSelectSuggestion,
|
||||||
|
visible,
|
||||||
|
onClose,
|
||||||
|
showHidden,
|
||||||
|
}, ref) => {
|
||||||
|
const [suggestions, setSuggestions] = React.useState<FilesystemEntry[]>([]);
|
||||||
|
const [loading, setLoading] = React.useState(false);
|
||||||
|
const [selectedIndex, setSelectedIndex] = React.useState(0);
|
||||||
|
const containerRef = React.useRef<HTMLDivElement | null>(null);
|
||||||
|
const itemRefs = React.useRef<(HTMLDivElement | null)[]>([]);
|
||||||
|
|
||||||
|
// Fuzzy matching score - returns null if no match, higher score = better match
|
||||||
|
const fuzzyScore = React.useCallback((query: string, candidate: string): number | null => {
|
||||||
|
const q = query.trim().toLowerCase();
|
||||||
|
if (!q) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const c = candidate.toLowerCase();
|
||||||
|
let score = 0;
|
||||||
|
let lastIndex = -1;
|
||||||
|
let consecutive = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < q.length; i += 1) {
|
||||||
|
const ch = q[i];
|
||||||
|
if (!ch || ch === ' ') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const idx = c.indexOf(ch, lastIndex + 1);
|
||||||
|
if (idx === -1) {
|
||||||
|
return null; // Character not found - no match
|
||||||
|
}
|
||||||
|
|
||||||
|
const gap = idx - lastIndex - 1;
|
||||||
|
if (gap === 0) {
|
||||||
|
consecutive += 1;
|
||||||
|
} else {
|
||||||
|
consecutive = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
score += 10; // Base score per matched char
|
||||||
|
score += Math.max(0, 18 - idx); // Bonus for early matches
|
||||||
|
score -= Math.max(0, gap); // Penalty for gaps
|
||||||
|
|
||||||
|
// Bonus for match at start or after separator
|
||||||
|
if (idx === 0) {
|
||||||
|
score += 12;
|
||||||
|
} else {
|
||||||
|
const prev = c[idx - 1];
|
||||||
|
if (prev === '/' || prev === '_' || prev === '-' || prev === '.' || prev === ' ') {
|
||||||
|
score += 10;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
score += consecutive > 0 ? 12 : 0; // Bonus for consecutive matches
|
||||||
|
lastIndex = idx;
|
||||||
|
}
|
||||||
|
|
||||||
|
score += Math.max(0, 24 - Math.round(c.length / 3)); // Shorter names score higher
|
||||||
|
return score;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Expand ~ to home directory
|
||||||
|
const expandPath = React.useCallback((path: string): string => {
|
||||||
|
if (path.startsWith('~') && homeDirectory) {
|
||||||
|
return path.replace(/^~/, homeDirectory);
|
||||||
|
}
|
||||||
|
return path;
|
||||||
|
}, [homeDirectory]);
|
||||||
|
|
||||||
|
// Get the directory part of the path for listing
|
||||||
|
const getParentDir = React.useCallback((path: string): string => {
|
||||||
|
const expanded = expandPath(path);
|
||||||
|
// If ends with /, list that directory
|
||||||
|
if (expanded.endsWith('/')) {
|
||||||
|
return expanded;
|
||||||
|
}
|
||||||
|
// Otherwise, get parent directory
|
||||||
|
const lastSlash = expanded.lastIndexOf('/');
|
||||||
|
if (lastSlash === -1) return '';
|
||||||
|
if (lastSlash === 0) return '/';
|
||||||
|
return expanded.substring(0, lastSlash + 1);
|
||||||
|
}, [expandPath]);
|
||||||
|
|
||||||
|
// Get the partial name being typed (for filtering)
|
||||||
|
const getPartialName = React.useCallback((path: string): string => {
|
||||||
|
const expanded = expandPath(path);
|
||||||
|
if (expanded.endsWith('/')) return '';
|
||||||
|
const lastSlash = expanded.lastIndexOf('/');
|
||||||
|
if (lastSlash === -1) return expanded;
|
||||||
|
return expanded.substring(lastSlash + 1);
|
||||||
|
}, [expandPath]);
|
||||||
|
|
||||||
|
const debouncedInputValue = useDebouncedValue(inputValue, 150);
|
||||||
|
|
||||||
|
// Fetch directory suggestions
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!visible || !debouncedInputValue) {
|
||||||
|
setSuggestions([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parentDir = getParentDir(debouncedInputValue);
|
||||||
|
const partialName = getPartialName(debouncedInputValue).toLowerCase();
|
||||||
|
|
||||||
|
if (!parentDir) {
|
||||||
|
setSuggestions([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
opencodeClient.listLocalDirectory(parentDir)
|
||||||
|
.then((entries) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
// Filter to directories only, respect hidden setting
|
||||||
|
const directories = entries.filter((entry) => {
|
||||||
|
if (!entry.isDirectory) return false;
|
||||||
|
if (!showHidden && entry.name.startsWith('.')) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Apply fuzzy matching and sort by score
|
||||||
|
const scored = partialName
|
||||||
|
? directories
|
||||||
|
.map((entry) => {
|
||||||
|
const score = fuzzyScore(partialName, entry.name);
|
||||||
|
return score !== null ? { entry, score } : null;
|
||||||
|
})
|
||||||
|
.filter((item): item is { entry: FilesystemEntry; score: number } => item !== null)
|
||||||
|
.sort((a, b) => b.score - a.score || a.entry.name.localeCompare(b.entry.name))
|
||||||
|
.map((item) => item.entry)
|
||||||
|
: directories.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
|
||||||
|
setSuggestions(scored.slice(0, 10)); // Limit suggestions
|
||||||
|
setSelectedIndex(0);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setSuggestions([]);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [visible, debouncedInputValue, getParentDir, getPartialName, showHidden, fuzzyScore]);
|
||||||
|
|
||||||
|
// Scroll selected item into view
|
||||||
|
React.useEffect(() => {
|
||||||
|
itemRefs.current[selectedIndex]?.scrollIntoView({
|
||||||
|
behavior: 'smooth',
|
||||||
|
block: 'nearest'
|
||||||
|
});
|
||||||
|
}, [selectedIndex]);
|
||||||
|
|
||||||
|
// Handle outside click
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!visible) return;
|
||||||
|
|
||||||
|
const handlePointerDown = (event: MouseEvent | TouchEvent) => {
|
||||||
|
const target = event.target as Node | null;
|
||||||
|
if (!target || !containerRef.current) return;
|
||||||
|
if (containerRef.current.contains(target)) return;
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('pointerdown', handlePointerDown, true);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('pointerdown', handlePointerDown, true);
|
||||||
|
};
|
||||||
|
}, [visible, onClose]);
|
||||||
|
|
||||||
|
const handleSelectSuggestion = React.useCallback((entry: FilesystemEntry) => {
|
||||||
|
// Append the selected directory name to current path, with trailing slash
|
||||||
|
const path = entry.path.endsWith('/') ? entry.path : entry.path + '/';
|
||||||
|
onSelectSuggestion(path);
|
||||||
|
}, [onSelectSuggestion]);
|
||||||
|
|
||||||
|
// Expose key handler to parent
|
||||||
|
React.useImperativeHandle(ref, () => ({
|
||||||
|
handleKeyDown: (e: React.KeyboardEvent<HTMLInputElement>): boolean => {
|
||||||
|
if (!visible || suggestions.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = suggestions.length;
|
||||||
|
|
||||||
|
if (e.key === 'Tab') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (e.shiftKey) {
|
||||||
|
// Shift+Tab: previous suggestion
|
||||||
|
setSelectedIndex((prev) => (prev - 1 + total) % total);
|
||||||
|
} else {
|
||||||
|
// Tab: next suggestion or select if only one
|
||||||
|
if (total === 1) {
|
||||||
|
const selected = suggestions[0];
|
||||||
|
if (selected) {
|
||||||
|
handleSelectSuggestion(selected);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setSelectedIndex((prev) => (prev + 1) % total);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
setSelectedIndex((prev) => (prev + 1) % total);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
setSelectedIndex((prev) => (prev - 1 + total) % total);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
// Select current item and close autocomplete
|
||||||
|
const safeIndex = ((selectedIndex % total) + total) % total;
|
||||||
|
const selected = suggestions[safeIndex];
|
||||||
|
if (selected) {
|
||||||
|
handleSelectSuggestion(selected);
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
return true; // Consume the event, don't let parent confirm yet
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
onClose();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}), [visible, suggestions, selectedIndex, handleSelectSuggestion, onClose]);
|
||||||
|
|
||||||
|
if (!visible || (suggestions.length === 0 && !loading)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="absolute z-[100] w-full max-h-48 bg-background border border-border rounded-lg shadow-lg top-full mt-1 left-0 flex flex-col overflow-hidden"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-3">
|
||||||
|
<RiRefreshLine className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-y-auto py-1">
|
||||||
|
{suggestions.map((entry, index) => {
|
||||||
|
const isSelected = selectedIndex === index;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={entry.path}
|
||||||
|
ref={(el) => { itemRefs.current[index] = el; }}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 px-3 py-1.5 cursor-pointer typography-ui-label",
|
||||||
|
isSelected && "bg-muted"
|
||||||
|
)}
|
||||||
|
onClick={() => { handleSelectSuggestion(entry); onClose(); }}
|
||||||
|
onMouseEnter={() => setSelectedIndex(index)}
|
||||||
|
>
|
||||||
|
<RiFolderLine className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
||||||
|
<span className="truncate">{entry.name}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="px-3 py-1.5 border-t typography-meta text-muted-foreground bg-sidebar/50">
|
||||||
|
Tab cycle • ↑↓ navigate • Enter select
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
DirectoryAutocomplete.displayName = 'DirectoryAutocomplete';
|
||||||
@@ -19,9 +19,9 @@ import {
|
|||||||
RiCheckboxBlankLine,
|
RiCheckboxBlankLine,
|
||||||
RiCheckboxLine,
|
RiCheckboxLine,
|
||||||
} from '@remixicon/react';
|
} from '@remixicon/react';
|
||||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
|
||||||
import { useDeviceInfo } from '@/lib/device';
|
import { useDeviceInfo } from '@/lib/device';
|
||||||
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
|
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
|
||||||
|
import { DirectoryAutocomplete, type DirectoryAutocompleteHandle } from './DirectoryAutocomplete';
|
||||||
|
|
||||||
const SHOW_HIDDEN_STORAGE_KEY = 'directoryTreeShowHidden';
|
const SHOW_HIDDEN_STORAGE_KEY = 'directoryTreeShowHidden';
|
||||||
|
|
||||||
@@ -57,6 +57,8 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
|
|||||||
});
|
});
|
||||||
const { isDesktop, requestAccess, startAccessing } = useFileSystemAccess();
|
const { isDesktop, requestAccess, startAccessing } = useFileSystemAccess();
|
||||||
const { isMobile } = useDeviceInfo();
|
const { isMobile } = useDeviceInfo();
|
||||||
|
const [autocompleteVisible, setAutocompleteVisible] = React.useState(false);
|
||||||
|
const autocompleteRef = React.useRef<DirectoryAutocompleteHandle>(null);
|
||||||
|
|
||||||
// Helper to format path for display
|
// Helper to format path for display
|
||||||
const formatPath = React.useCallback((path: string | null) => {
|
const formatPath = React.useCallback((path: string | null) => {
|
||||||
@@ -69,6 +71,7 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
|
|||||||
if (open) {
|
if (open) {
|
||||||
setHasUserSelection(false);
|
setHasUserSelection(false);
|
||||||
setIsConfirming(false);
|
setIsConfirming(false);
|
||||||
|
setAutocompleteVisible(false);
|
||||||
// Initialize with active project or current directory
|
// Initialize with active project or current directory
|
||||||
const activeProject = getActiveProject();
|
const activeProject = getActiveProject();
|
||||||
const initialPath = activeProject?.path || currentDirectory || homeDirectory || '';
|
const initialPath = activeProject?.path || currentDirectory || homeDirectory || '';
|
||||||
@@ -182,6 +185,8 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
|
|||||||
const value = e.target.value;
|
const value = e.target.value;
|
||||||
setPathInputValue(value);
|
setPathInputValue(value);
|
||||||
setHasUserSelection(true);
|
setHasUserSelection(true);
|
||||||
|
// Show autocomplete when typing a path
|
||||||
|
setAutocompleteVisible(value.startsWith('/') || value.startsWith('~'));
|
||||||
// Update pending path if it looks like a valid path
|
// Update pending path if it looks like a valid path
|
||||||
if (value.startsWith('/') || value.startsWith('~')) {
|
if (value.startsWith('/') || value.startsWith('~')) {
|
||||||
// Expand ~ to home directory
|
// Expand ~ to home directory
|
||||||
@@ -193,62 +198,38 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
|
|||||||
}, [homeDirectory]);
|
}, [homeDirectory]);
|
||||||
|
|
||||||
const handlePathInputKeyDown = React.useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
|
const handlePathInputKeyDown = React.useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
// Let autocomplete handle the key first if visible
|
||||||
|
if (autocompleteRef.current?.handleKeyDown(e)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
handleConfirm();
|
handleConfirm();
|
||||||
}
|
}
|
||||||
}, [handleConfirm]);
|
}, [handleConfirm]);
|
||||||
|
|
||||||
|
const handleAutocompleteSuggestion = React.useCallback((path: string) => {
|
||||||
|
setPendingPath(path);
|
||||||
|
setHasUserSelection(true);
|
||||||
|
setPathInputValue(formatPath(path));
|
||||||
|
// Keep autocomplete open to allow further drilling down
|
||||||
|
}, [formatPath]);
|
||||||
|
|
||||||
|
const handleAutocompleteClose = React.useCallback(() => {
|
||||||
|
setAutocompleteVisible(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const toggleShowHidden = React.useCallback(() => {
|
const toggleShowHidden = React.useCallback(() => {
|
||||||
setShowHidden(prev => !prev);
|
setShowHidden(prev => !prev);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const dialogHeader = (
|
|
||||||
<DialogHeader className="flex-shrink-0 px-4 pb-2 pt-[calc(var(--oc-safe-area-top,0px)+0.5rem)] sm:px-0 sm:pb-3 sm:pt-0">
|
|
||||||
<DialogTitle>Add project directory</DialogTitle>
|
|
||||||
<DialogDescription className="hidden sm:block">
|
|
||||||
Choose a folder to add as a project.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
);
|
|
||||||
|
|
||||||
const pathInputSection = (
|
|
||||||
<Input
|
|
||||||
value={pathInputValue}
|
|
||||||
onChange={handlePathInputChange}
|
|
||||||
onKeyDown={handlePathInputKeyDown}
|
|
||||||
placeholder="Enter path or select from tree..."
|
|
||||||
className="font-mono typography-meta"
|
|
||||||
spellCheck={false}
|
|
||||||
autoComplete="off"
|
|
||||||
autoCorrect="off"
|
|
||||||
autoCapitalize="off"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
const treeSection = (
|
|
||||||
<div className="flex-1 min-h-0 rounded-xl border border-border/40 bg-sidebar/70 p-1.5 sm:p-2 sm:flex-none">
|
|
||||||
<DirectoryTree
|
|
||||||
variant="inline"
|
|
||||||
currentPath={pendingPath ?? currentDirectory}
|
|
||||||
onSelectPath={handleSelectPath}
|
|
||||||
onDoubleClickPath={handleDoubleClickPath}
|
|
||||||
className="h-full sm:min-h-[280px] sm:h-[380px]"
|
|
||||||
selectionBehavior="deferred"
|
|
||||||
showHidden={showHidden}
|
|
||||||
rootDirectory={isHomeReady ? homeDirectory : null}
|
|
||||||
isRootReady={isHomeReady}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const showHiddenToggle = (
|
const showHiddenToggle = (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={toggleShowHidden}
|
onClick={toggleShowHidden}
|
||||||
className="flex items-center gap-2 px-2 py-1.5 rounded-lg hover:bg-accent/40 transition-colors typography-meta text-muted-foreground"
|
className="flex items-center gap-2 px-2 py-1 rounded-lg hover:bg-accent/40 transition-colors typography-meta text-muted-foreground flex-shrink-0"
|
||||||
>
|
>
|
||||||
{showHidden ? (
|
{showHidden ? (
|
||||||
<RiCheckboxLine className="h-4 w-4 text-primary" />
|
<RiCheckboxLine className="h-4 w-4 text-primary" />
|
||||||
@@ -259,6 +240,59 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
|
|||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const dialogHeader = (
|
||||||
|
<DialogHeader className="flex-shrink-0 px-4 pb-2 pt-[calc(var(--oc-safe-area-top,0px)+0.5rem)] sm:px-0 sm:pb-3 sm:pt-0">
|
||||||
|
<DialogTitle>Add project directory</DialogTitle>
|
||||||
|
<div className="hidden sm:flex sm:items-center sm:justify-between sm:gap-4">
|
||||||
|
<DialogDescription className="flex-1">
|
||||||
|
Choose a folder to add as a project.
|
||||||
|
</DialogDescription>
|
||||||
|
{showHiddenToggle}
|
||||||
|
</div>
|
||||||
|
</DialogHeader>
|
||||||
|
);
|
||||||
|
|
||||||
|
const pathInputSection = (
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
value={pathInputValue}
|
||||||
|
onChange={handlePathInputChange}
|
||||||
|
onKeyDown={handlePathInputKeyDown}
|
||||||
|
placeholder="Enter path or select from tree..."
|
||||||
|
className="font-mono typography-meta"
|
||||||
|
spellCheck={false}
|
||||||
|
autoComplete="off"
|
||||||
|
autoCorrect="off"
|
||||||
|
autoCapitalize="off"
|
||||||
|
/>
|
||||||
|
<DirectoryAutocomplete
|
||||||
|
ref={autocompleteRef}
|
||||||
|
inputValue={pathInputValue}
|
||||||
|
homeDirectory={homeDirectory}
|
||||||
|
onSelectSuggestion={handleAutocompleteSuggestion}
|
||||||
|
visible={autocompleteVisible}
|
||||||
|
onClose={handleAutocompleteClose}
|
||||||
|
showHidden={showHidden}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const treeSection = (
|
||||||
|
<div className="flex-1 min-h-0 rounded-xl border border-border/40 bg-sidebar/70 overflow-hidden flex flex-col">
|
||||||
|
<DirectoryTree
|
||||||
|
variant="inline"
|
||||||
|
currentPath={pendingPath ?? currentDirectory}
|
||||||
|
onSelectPath={handleSelectPath}
|
||||||
|
onDoubleClickPath={handleDoubleClickPath}
|
||||||
|
className="flex-1 min-h-0 sm:min-h-[280px] sm:max-h-[380px]"
|
||||||
|
selectionBehavior="deferred"
|
||||||
|
showHidden={showHidden}
|
||||||
|
rootDirectory={isHomeReady ? homeDirectory : null}
|
||||||
|
isRootReady={isHomeReady}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
// Mobile: use flex layout where tree takes remaining space
|
// Mobile: use flex layout where tree takes remaining space
|
||||||
const mobileContent = (
|
const mobileContent = (
|
||||||
<div className="flex flex-col gap-3 h-full">
|
<div className="flex flex-col gap-3 h-full">
|
||||||
@@ -266,13 +300,13 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
|
|||||||
<div className="flex-shrink-0 flex items-center justify-end">
|
<div className="flex-shrink-0 flex items-center justify-end">
|
||||||
{showHiddenToggle}
|
{showHiddenToggle}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-h-0 rounded-xl border border-border/40 bg-sidebar/70 p-1.5 overflow-hidden">
|
<div className="flex-1 min-h-0 rounded-xl border border-border/40 bg-sidebar/70 overflow-hidden flex flex-col">
|
||||||
<DirectoryTree
|
<DirectoryTree
|
||||||
variant="inline"
|
variant="inline"
|
||||||
currentPath={pendingPath ?? currentDirectory}
|
currentPath={pendingPath ?? currentDirectory}
|
||||||
onSelectPath={handleSelectPath}
|
onSelectPath={handleSelectPath}
|
||||||
onDoubleClickPath={handleDoubleClickPath}
|
onDoubleClickPath={handleDoubleClickPath}
|
||||||
className="h-full"
|
className="flex-1 min-h-0"
|
||||||
selectionBehavior="deferred"
|
selectionBehavior="deferred"
|
||||||
showHidden={showHidden}
|
showHidden={showHidden}
|
||||||
rootDirectory={isHomeReady ? homeDirectory : null}
|
rootDirectory={isHomeReady ? homeDirectory : null}
|
||||||
@@ -284,16 +318,10 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
|
|||||||
);
|
);
|
||||||
|
|
||||||
const desktopContent = (
|
const desktopContent = (
|
||||||
<ScrollableOverlay
|
<div className="flex-1 min-h-0 overflow-hidden flex flex-col gap-3">
|
||||||
outerClassName="flex-1 min-h-0 overflow-hidden"
|
|
||||||
className="directory-dialog-body sm:px-0 sm:pb-0 flex flex-col gap-3"
|
|
||||||
>
|
|
||||||
{pathInputSection}
|
{pathInputSection}
|
||||||
<div className="flex items-center justify-end">
|
|
||||||
{showHiddenToggle}
|
|
||||||
</div>
|
|
||||||
{treeSection}
|
{treeSection}
|
||||||
</ScrollableOverlay>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderActionButtons = () => (
|
const renderActionButtons = () => (
|
||||||
@@ -345,7 +373,7 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
|
|||||||
{dialogHeader}
|
{dialogHeader}
|
||||||
{desktopContent}
|
{desktopContent}
|
||||||
<DialogFooter
|
<DialogFooter
|
||||||
className="sticky bottom-0 flex w-full flex-shrink-0 flex-row gap-2 border-t border-border/40 bg-sidebar px-4 py-3 sm:static sm:justify-end sm:border-0 sm:bg-transparent sm:px-0 sm:pt-3"
|
className="sticky bottom-0 flex w-full flex-shrink-0 flex-row gap-2 border-t border-border/40 bg-sidebar px-4 py-3 sm:static sm:justify-end sm:border-0 sm:bg-transparent sm:px-0 sm:pt-4 sm:pb-0"
|
||||||
>
|
>
|
||||||
{renderActionButtons()}
|
{renderActionButtons()}
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|||||||
@@ -8,9 +8,10 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
import { RiAddLine, RiArrowDownSLine, RiArrowRightSLine, RiCheckLine, RiCloseLine, RiFolder6Line, RiPushpin2Line, RiPushpinLine, RiStarLine } from '@remixicon/react';
|
import { RiAddLine, RiArrowDownSLine, RiArrowRightSLine, RiCheckLine, RiCloseLine, RiFolder6Line, RiPushpin2Line, RiPushpinLine } from '@remixicon/react';
|
||||||
import { cn, formatPathForDisplay } from '@/lib/utils';
|
import { cn, formatPathForDisplay } from '@/lib/utils';
|
||||||
import { opencodeClient } from '@/lib/opencode/client';
|
import { opencodeClient } from '@/lib/opencode/client';
|
||||||
|
import { useDeviceInfo } from '@/lib/device';
|
||||||
import { isDesktopRuntime, getDesktopSettings } from '@/lib/desktop';
|
import { isDesktopRuntime, getDesktopSettings } from '@/lib/desktop';
|
||||||
import type { DesktopSettings } from '@/lib/desktop';
|
import type { DesktopSettings } from '@/lib/desktop';
|
||||||
import { updateDesktopSettings } from '@/lib/persistence';
|
import { updateDesktopSettings } from '@/lib/persistence';
|
||||||
@@ -54,6 +55,7 @@ export const DirectoryTree: React.FC<DirectoryTreeProps> = ({
|
|||||||
alwaysShowActions = false,
|
alwaysShowActions = false,
|
||||||
}) => {
|
}) => {
|
||||||
const desktopRuntime = React.useMemo(() => isDesktopRuntime(), []);
|
const desktopRuntime = React.useMemo(() => isDesktopRuntime(), []);
|
||||||
|
const { isMobile } = useDeviceInfo();
|
||||||
const [directories, setDirectories] = React.useState<DirectoryItem[]>([]);
|
const [directories, setDirectories] = React.useState<DirectoryItem[]>([]);
|
||||||
const [expandedPaths, setExpandedPaths] = React.useState<Set<string>>(new Set());
|
const [expandedPaths, setExpandedPaths] = React.useState<Set<string>>(new Set());
|
||||||
const [isLoading, setIsLoading] = React.useState(true);
|
const [isLoading, setIsLoading] = React.useState(true);
|
||||||
@@ -314,10 +316,12 @@ export const DirectoryTree: React.FC<DirectoryTreeProps> = ({
|
|||||||
});
|
});
|
||||||
}, [effectiveRoot, isPathWithinHome, stripTrailingSlashes]);
|
}, [effectiveRoot, isPathWithinHome, stripTrailingSlashes]);
|
||||||
|
|
||||||
|
// Reload directories when showHidden changes, but keep expanded state
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (previousShowHidden.current !== showHidden) {
|
if (previousShowHidden.current !== showHidden) {
|
||||||
previousShowHidden.current = showHidden;
|
previousShowHidden.current = showHidden;
|
||||||
setExpandedPaths(new Set());
|
// Silently reload without clearing state - loadInitialDirectories will be called
|
||||||
|
// via its dependency on loadDirectory which depends on showHidden
|
||||||
}
|
}
|
||||||
}, [showHidden]);
|
}, [showHidden]);
|
||||||
|
|
||||||
@@ -434,6 +438,8 @@ export const DirectoryTree: React.FC<DirectoryTreeProps> = ({
|
|||||||
}
|
}
|
||||||
}, [showHidden, effectiveRoot, stripTrailingSlashes, rootReady]);
|
}, [showHidden, effectiveRoot, stripTrailingSlashes, rootReady]);
|
||||||
|
|
||||||
|
const hasLoadedOnce = React.useRef(false);
|
||||||
|
|
||||||
const loadInitialDirectories = React.useCallback(async () => {
|
const loadInitialDirectories = React.useCallback(async () => {
|
||||||
if (!rootReady || !effectiveRoot) {
|
if (!rootReady || !effectiveRoot) {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
@@ -441,10 +447,14 @@ export const DirectoryTree: React.FC<DirectoryTreeProps> = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsLoading(true);
|
// Only show loading on initial load, not on refreshes (e.g., showHidden toggle)
|
||||||
|
if (!hasLoadedOnce.current) {
|
||||||
|
setIsLoading(true);
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const homeContents = await loadDirectory(effectiveRoot);
|
const homeContents = await loadDirectory(effectiveRoot);
|
||||||
setDirectories(homeContents);
|
setDirectories(homeContents);
|
||||||
|
hasLoadedOnce.current = true;
|
||||||
} catch { /* ignored */ } finally {
|
} catch { /* ignored */ } finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
@@ -616,16 +626,16 @@ export const DirectoryTree: React.FC<DirectoryTreeProps> = ({
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
toggleExpanded(item);
|
toggleExpanded(item);
|
||||||
}}
|
}}
|
||||||
className="p-0.5 hover:bg-accent rounded"
|
className={cn("hover:bg-accent rounded", isMobile ? "p-0.5" : "p-0.5")}
|
||||||
>
|
>
|
||||||
{isExpanded ? (
|
{isExpanded ? (
|
||||||
<RiArrowDownSLine className="h-3 w-3" />
|
<RiArrowDownSLine className={isMobile ? "h-3.5 w-3.5" : "h-3 w-3"} />
|
||||||
) : (
|
) : (
|
||||||
<RiArrowRightSLine className="h-3 w-3" />
|
<RiArrowRightSLine className={isMobile ? "h-3.5 w-3.5" : "h-3 w-3"} />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{!hasChildren && <div className="w-4" />}
|
{!hasChildren && <div className={isMobile ? "w-4.5" : "w-4"} />}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
@@ -642,28 +652,22 @@ export const DirectoryTree: React.FC<DirectoryTreeProps> = ({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center gap-1.5 flex-1 text-left focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/60 rounded',
|
'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'
|
isInlineVariant ? (isSelected ? 'text-primary' : 'text-foreground') : 'text-foreground'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{isExpanded ? (
|
<RiFolder6Line
|
||||||
<RiFolder6Line
|
className={cn(
|
||||||
className={cn(
|
'text-muted-foreground flex-shrink-0',
|
||||||
'h-3.5 w-3.5 text-muted-foreground',
|
isMobile ? 'h-4 w-4' : 'h-3.5 w-3.5',
|
||||||
isInlineVariant && isSelected && 'text-primary'
|
isInlineVariant && isSelected && 'text-primary'
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
) : (
|
|
||||||
<RiFolder6Line
|
|
||||||
className={cn(
|
|
||||||
'h-3.5 w-3.5 text-muted-foreground',
|
|
||||||
isInlineVariant && isSelected && 'text-primary'
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'typography-ui-label font-medium truncate',
|
'font-medium truncate',
|
||||||
|
isMobile ? 'typography-ui-label' : 'typography-ui-label',
|
||||||
isInlineVariant && isSelected ? 'text-primary' : 'text-foreground'
|
isInlineVariant && isSelected ? 'text-primary' : 'text-foreground'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -677,12 +681,13 @@ export const DirectoryTree: React.FC<DirectoryTreeProps> = ({
|
|||||||
startCreatingDirectory(item);
|
startCreatingDirectory(item);
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"p-1 hover:bg-accent rounded transition-opacity",
|
"hover:bg-accent rounded transition-opacity",
|
||||||
alwaysShowActions ? "opacity-70" : "opacity-0 group-hover:opacity-100"
|
isMobile ? "p-1.5" : "p-1",
|
||||||
|
alwaysShowActions ? "opacity-60" : "opacity-0 group-hover:opacity-100"
|
||||||
)}
|
)}
|
||||||
title="Create new directory"
|
title="Create new directory"
|
||||||
>
|
>
|
||||||
<RiAddLine className="h-3 w-3 text-muted-foreground" />
|
<RiAddLine className={cn("text-muted-foreground", isMobile ? "h-3.5 w-3.5" : "h-3 w-3")} />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -691,15 +696,16 @@ export const DirectoryTree: React.FC<DirectoryTreeProps> = ({
|
|||||||
togglePin(item.path);
|
togglePin(item.path);
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"p-1 hover:bg-accent rounded transition-opacity",
|
"hover:bg-accent rounded transition-opacity",
|
||||||
alwaysShowActions ? "opacity-70" : "opacity-0 group-hover:opacity-100"
|
isMobile ? "p-1.5" : "p-1",
|
||||||
|
alwaysShowActions ? "opacity-60" : "opacity-0 group-hover:opacity-100"
|
||||||
)}
|
)}
|
||||||
title={isPinned ? "Unpin directory" : "Pin directory"}
|
title={isPinned ? "Unpin directory" : "Pin directory"}
|
||||||
>
|
>
|
||||||
{isPinned ? (
|
{isPinned ? (
|
||||||
<RiPushpin2Line className="h-3 w-3 text-primary" />
|
<RiPushpin2Line className={cn("text-primary", isMobile ? "h-3.5 w-3.5" : "h-3 w-3")} />
|
||||||
) : (
|
) : (
|
||||||
<RiPushpinLine className="h-3 w-3 text-muted-foreground" />
|
<RiPushpinLine className={cn("text-muted-foreground", isMobile ? "h-3.5 w-3.5" : "h-3 w-3")} />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
@@ -710,10 +716,13 @@ export const DirectoryTree: React.FC<DirectoryTreeProps> = ({
|
|||||||
<div key={item.path}>
|
<div key={item.path}>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'group flex items-center gap-1 rounded px-2 py-1.5 text-left hover:bg-accent/40',
|
'group flex items-center gap-1 rounded-lg mx-1 text-left transition-colors',
|
||||||
isSelected ? 'text-primary' : 'text-foreground'
|
isMobile ? 'px-1.5 py-1' : 'px-2 py-1.5',
|
||||||
|
isSelected
|
||||||
|
? 'bg-primary/10 text-primary'
|
||||||
|
: 'hover:bg-accent/50 text-foreground'
|
||||||
)}
|
)}
|
||||||
style={{ paddingLeft: `${level * 12 + 8}px` }}
|
style={{ paddingLeft: `${level * (isMobile ? 12 : 14) + (isMobile ? 4 : 6)}px` }}
|
||||||
>
|
>
|
||||||
{rowContent}
|
{rowContent}
|
||||||
</div>
|
</div>
|
||||||
@@ -721,8 +730,8 @@ export const DirectoryTree: React.FC<DirectoryTreeProps> = ({
|
|||||||
<>
|
<>
|
||||||
{creatingInPath === item.path && (
|
{creatingInPath === item.path && (
|
||||||
<div
|
<div
|
||||||
className="flex items-center gap-1 px-2 py-1.5"
|
className="flex items-center gap-1 mx-1 px-2 py-1.5"
|
||||||
style={{ paddingLeft: `${(level + 1) * 12 + 8}px` }}
|
style={{ paddingLeft: `${(level + 1) * 14 + 6}px` }}
|
||||||
>
|
>
|
||||||
<div className="w-4" />
|
<div className="w-4" />
|
||||||
<RiFolder6Line className="h-3.5 w-3.5 text-muted-foreground" />
|
<RiFolder6Line className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
@@ -872,7 +881,11 @@ export const DirectoryTree: React.FC<DirectoryTreeProps> = ({
|
|||||||
<div
|
<div
|
||||||
key={path}
|
key={path}
|
||||||
className={cn(
|
className={cn(
|
||||||
'group flex items-center gap-2 px-2 py-1.5 hover:bg-accent/40'
|
'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-accent/50'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
@@ -884,36 +897,38 @@ export const DirectoryTree: React.FC<DirectoryTreeProps> = ({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex flex-1 items-center gap-2 text-left focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/60 rounded',
|
'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'
|
isSelected ? 'text-primary' : 'text-foreground'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<RiFolder6Line
|
<RiFolder6Line
|
||||||
className={cn(
|
className={cn(
|
||||||
'h-3.5 w-3.5 text-muted-foreground',
|
'flex-shrink-0',
|
||||||
isSelected && 'text-primary'
|
isMobile ? 'h-4 w-4' : 'h-3.5 w-3.5',
|
||||||
|
isSelected ? 'text-primary' : 'text-muted-foreground'
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<div className="flex-1 min-w-0">
|
<span
|
||||||
<div
|
className={cn(
|
||||||
className={cn(
|
'typography-ui-label font-medium truncate flex-shrink-0',
|
||||||
'typography-ui-label font-medium truncate',
|
isSelected ? 'text-primary' : 'text-foreground'
|
||||||
isSelected ? 'text-primary' : 'text-foreground'
|
)}
|
||||||
)}
|
>
|
||||||
>
|
{name}
|
||||||
{name}
|
</span>
|
||||||
</div>
|
<span className="typography-meta text-muted-foreground/60 truncate">
|
||||||
<div className="typography-meta text-muted-foreground truncate">
|
{formatPathForDisplay(path, homeDirectory)}
|
||||||
{formatPathForDisplay(path, homeDirectory)}
|
</span>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => togglePin(path)}
|
onClick={() => togglePin(path)}
|
||||||
className="p-1 opacity-0 group-hover:opacity-100 hover:bg-accent rounded transition-opacity"
|
className={cn(
|
||||||
|
"hover:bg-accent rounded-md transition-opacity",
|
||||||
|
isMobile ? "p-1.5 opacity-60" : "p-1 opacity-0 group-hover:opacity-100"
|
||||||
|
)}
|
||||||
title="Unpin directory"
|
title="Unpin directory"
|
||||||
>
|
>
|
||||||
<RiPushpin2Line className="h-3 w-3 text-primary" />
|
<RiPushpin2Line className={cn("text-primary", isMobile ? "h-3.5 w-3.5" : "h-3.5 w-3.5")} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -969,29 +984,33 @@ export const DirectoryTree: React.FC<DirectoryTreeProps> = ({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setIsPinnedExpanded(prev => !prev)}
|
onClick={() => setIsPinnedExpanded(prev => !prev)}
|
||||||
className="flex w-full items-center gap-1.5 px-2 py-1.5 typography-meta font-semibold text-muted-foreground hover:bg-accent/30 rounded transition-colors"
|
className={cn(
|
||||||
|
"flex w-full items-center gap-1.5 typography-meta font-medium text-muted-foreground/80 hover:bg-accent/30 rounded transition-colors uppercase tracking-wide",
|
||||||
|
isMobile ? "px-1.5 py-1" : "px-2 py-1.5"
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{isPinnedExpanded ? (
|
{isPinnedExpanded ? (
|
||||||
<RiArrowDownSLine className="h-3.5 w-3.5" />
|
<RiArrowDownSLine className={isMobile ? "h-3.5 w-3.5" : "h-3 w-3"} />
|
||||||
) : (
|
) : (
|
||||||
<RiArrowRightSLine className="h-3.5 w-3.5" />
|
<RiArrowRightSLine className={isMobile ? "h-3.5 w-3.5" : "h-3 w-3"} />
|
||||||
)}
|
)}
|
||||||
<RiStarLine className="h-3.5 w-3.5" />
|
|
||||||
<span>Pinned</span>
|
<span>Pinned</span>
|
||||||
<span className="ml-auto typography-micro text-muted-foreground/70">
|
<span className="ml-auto typography-micro text-muted-foreground/60 normal-case tracking-normal">
|
||||||
{pinnedDirectories.length}
|
{pinnedDirectories.length}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
{isPinnedExpanded && pinnedDirectories.map(({ name, path }) => renderPinnedRow(name, path))}
|
{isPinnedExpanded && pinnedDirectories.map(({ name, path }) => renderPinnedRow(name, path))}
|
||||||
{variant === 'dropdown' && <DropdownMenuSeparator />}
|
{variant === 'dropdown' && <DropdownMenuSeparator />}
|
||||||
{variant === 'inline' && isPinnedExpanded && (
|
{variant === 'inline' && isPinnedExpanded && (
|
||||||
<div className="mx-2 my-1.5 border-t border-border/30" />
|
<div className="mx-3 my-2 border-t border-border/40" />
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="px-2 py-1.5 typography-meta font-semibold text-muted-foreground flex items-center gap-1.5">
|
<div className={cn(
|
||||||
<RiFolder6Line className="h-3.5 w-3.5" />
|
"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"
|
||||||
|
)}>
|
||||||
Browse
|
Browse
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1015,8 +1034,8 @@ export const DirectoryTree: React.FC<DirectoryTreeProps> = ({
|
|||||||
|
|
||||||
if (variant === 'inline') {
|
if (variant === 'inline') {
|
||||||
return (
|
return (
|
||||||
<div className={cn('overflow-hidden rounded-xl border border-border/40 bg-sidebar/70', className)}>
|
<div className={cn('overflow-hidden flex flex-col', className)}>
|
||||||
<ScrollableOverlay outerClassName="max-h-full" className="w-full">
|
<ScrollableOverlay outerClassName="flex-1 min-h-0" className="w-full py-1">
|
||||||
{directoryContent}
|
{directoryContent}
|
||||||
</ScrollableOverlay>
|
</ScrollableOverlay>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user