feat: add file tree breadcrumb (#40)
This commit is contained in:
@@ -2,6 +2,7 @@ import { memo, useEffect, useState } from 'react';
|
||||
import { bundledLanguages, codeToHtml, isSpecialLang, type BundledLanguage, type SpecialLanguage } from 'shiki';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
import { createScopedLogger } from '~/utils/logger';
|
||||
|
||||
import styles from './CodeBlock.module.scss';
|
||||
|
||||
const logger = createScopedLogger('CodeBlock');
|
||||
|
||||
@@ -20,6 +20,7 @@ import { classNames } from '~/utils/classNames';
|
||||
import { WORK_DIR } from '~/utils/constants';
|
||||
import { renderLogger } from '~/utils/logger';
|
||||
import { isMobile } from '~/utils/mobile';
|
||||
import { FileBreadcrumb } from './FileBreadcrumb';
|
||||
import { FileTree } from './FileTree';
|
||||
import { Terminal, type TerminalRef } from './terminal/Terminal';
|
||||
|
||||
@@ -67,12 +68,12 @@ export const EditorPanel = memo(
|
||||
const [activeTerminal, setActiveTerminal] = useState(0);
|
||||
const [terminalCount, setTerminalCount] = useState(1);
|
||||
|
||||
const activeFile = useMemo(() => {
|
||||
const activeFileSegments = useMemo(() => {
|
||||
if (!editorDocument) {
|
||||
return '';
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return editorDocument.filePath.split('/').at(-1);
|
||||
return editorDocument.filePath.split('/');
|
||||
}, [editorDocument]);
|
||||
|
||||
const activeFileUnsaved = useMemo(() => {
|
||||
@@ -134,6 +135,7 @@ export const EditorPanel = memo(
|
||||
<FileTree
|
||||
className="h-full"
|
||||
files={files}
|
||||
hideRoot
|
||||
unsavedFiles={unsavedFiles}
|
||||
rootFolder={WORK_DIR}
|
||||
selectedFile={selectedFile}
|
||||
@@ -143,11 +145,10 @@ export const EditorPanel = memo(
|
||||
</Panel>
|
||||
<PanelResizeHandle />
|
||||
<Panel className="flex flex-col" defaultSize={80} minSize={20}>
|
||||
<PanelHeader>
|
||||
{activeFile && (
|
||||
<PanelHeader className="overflow-x-auto">
|
||||
{activeFileSegments?.length && (
|
||||
<div className="flex items-center flex-1 text-sm">
|
||||
<div className="i-ph:file-duotone mr-2" />
|
||||
{activeFile}
|
||||
<FileBreadcrumb pathSegments={activeFileSegments} files={files} onFileSelect={onFileSelect} />
|
||||
{activeFileUnsaved && (
|
||||
<div className="flex gap-1 ml-auto -mr-1.5">
|
||||
<PanelHeaderButton onClick={onFileSave}>
|
||||
|
||||
148
packages/bolt/app/components/workbench/FileBreadcrumb.tsx
Normal file
148
packages/bolt/app/components/workbench/FileBreadcrumb.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
|
||||
import { AnimatePresence, motion, type Variants } from 'framer-motion';
|
||||
import { memo, useEffect, useRef, useState } from 'react';
|
||||
import type { FileMap } from '~/lib/stores/files';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
import { WORK_DIR } from '~/utils/constants';
|
||||
import { cubicEasingFn } from '~/utils/easings';
|
||||
import { renderLogger } from '~/utils/logger';
|
||||
import FileTree from './FileTree';
|
||||
|
||||
const WORK_DIR_REGEX = new RegExp(`^${WORK_DIR.split('/').slice(0, -1).join('/').replaceAll('/', '\\/')}/`);
|
||||
|
||||
interface FileBreadcrumbProps {
|
||||
files?: FileMap;
|
||||
pathSegments?: string[];
|
||||
onFileSelect?: (filePath: string) => void;
|
||||
}
|
||||
|
||||
const contextMenuVariants = {
|
||||
open: {
|
||||
y: 0,
|
||||
opacity: 1,
|
||||
transition: {
|
||||
duration: 0.15,
|
||||
ease: cubicEasingFn,
|
||||
},
|
||||
},
|
||||
close: {
|
||||
y: 6,
|
||||
opacity: 0,
|
||||
transition: {
|
||||
duration: 0.15,
|
||||
ease: cubicEasingFn,
|
||||
},
|
||||
},
|
||||
} satisfies Variants;
|
||||
|
||||
export const FileBreadcrumb = memo<FileBreadcrumbProps>(({ files, pathSegments = [], onFileSelect }) => {
|
||||
renderLogger.trace('FileBreadcrumb');
|
||||
|
||||
const [activeIndex, setActiveIndex] = useState<number | null>(null);
|
||||
|
||||
const contextMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
const segmentRefs = useRef<(HTMLSpanElement | null)[]>([]);
|
||||
|
||||
const handleSegmentClick = (index: number) => {
|
||||
setActiveIndex((prevIndex) => (prevIndex === index ? null : index));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleOutsideClick = (event: MouseEvent) => {
|
||||
if (
|
||||
activeIndex !== null &&
|
||||
!contextMenuRef.current?.contains(event.target as Node) &&
|
||||
!segmentRefs.current.some((ref) => ref?.contains(event.target as Node))
|
||||
) {
|
||||
setActiveIndex(null);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleOutsideClick);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleOutsideClick);
|
||||
};
|
||||
}, [activeIndex]);
|
||||
|
||||
if (files === undefined || pathSegments.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex">
|
||||
{pathSegments.map((segment, index) => {
|
||||
const isLast = index === pathSegments.length - 1;
|
||||
|
||||
const path = pathSegments.slice(0, index).join('/');
|
||||
|
||||
if (!WORK_DIR_REGEX.test(path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isActive = activeIndex === index;
|
||||
|
||||
return (
|
||||
<div key={index} className="relative flex items-center">
|
||||
<DropdownMenu.Root open={isActive} modal={false}>
|
||||
<DropdownMenu.Trigger asChild>
|
||||
<span
|
||||
ref={(ref) => (segmentRefs.current[index] = ref)}
|
||||
className={classNames('flex items-center gap-1.5 cursor-pointer shrink-0', {
|
||||
'text-bolt-elements-textTertiary hover:text-bolt-elements-textPrimary': !isActive,
|
||||
'text-bolt-elements-textPrimary underline': isActive,
|
||||
'pr-4': isLast,
|
||||
})}
|
||||
onClick={() => handleSegmentClick(index)}
|
||||
>
|
||||
{isLast && <div className="i-ph:file-duotone" />}
|
||||
{segment}
|
||||
</span>
|
||||
</DropdownMenu.Trigger>
|
||||
{index > 0 && !isLast && <span className="i-ph:caret-right inline-block mx-1" />}
|
||||
<AnimatePresence>
|
||||
{isActive && (
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content
|
||||
className="z-file-tree-breadcrumb"
|
||||
asChild
|
||||
align="start"
|
||||
side="bottom"
|
||||
avoidCollisions={false}
|
||||
>
|
||||
<motion.div
|
||||
ref={contextMenuRef}
|
||||
initial="close"
|
||||
animate="open"
|
||||
exit="close"
|
||||
variants={contextMenuVariants}
|
||||
>
|
||||
<div className="rounded-lg overflow-hidden">
|
||||
<div className="max-h-[50vh] min-w-[300px] overflow-scroll bg-bolt-elements-background-depth-1 border border-bolt-elements-borderColor shadow-sm rounded-lg">
|
||||
<FileTree
|
||||
files={files}
|
||||
hideRoot
|
||||
rootFolder={path}
|
||||
collapsed
|
||||
allowFolderSelection
|
||||
selectedFile={`${path}/${segment}`}
|
||||
onFileSelect={(filePath) => {
|
||||
setActiveIndex(null);
|
||||
onFileSelect?.(filePath);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DropdownMenu.Arrow className="fill-bolt-elements-borderColor" />
|
||||
</motion.div>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</DropdownMenu.Root>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -13,24 +13,47 @@ interface Props {
|
||||
selectedFile?: string;
|
||||
onFileSelect?: (filePath: string) => void;
|
||||
rootFolder?: string;
|
||||
hideRoot?: boolean;
|
||||
collapsed?: boolean;
|
||||
allowFolderSelection?: boolean;
|
||||
hiddenFiles?: Array<string | RegExp>;
|
||||
unsavedFiles?: Set<string>;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const FileTree = memo(
|
||||
({ files = {}, onFileSelect, selectedFile, rootFolder, hiddenFiles, className, unsavedFiles }: Props) => {
|
||||
({
|
||||
files = {},
|
||||
onFileSelect,
|
||||
selectedFile,
|
||||
rootFolder,
|
||||
hideRoot = false,
|
||||
collapsed = false,
|
||||
allowFolderSelection = false,
|
||||
hiddenFiles,
|
||||
className,
|
||||
unsavedFiles,
|
||||
}: Props) => {
|
||||
renderLogger.trace('FileTree');
|
||||
|
||||
const computedHiddenFiles = useMemo(() => [...DEFAULT_HIDDEN_FILES, ...(hiddenFiles ?? [])], [hiddenFiles]);
|
||||
|
||||
const fileList = useMemo(() => {
|
||||
return buildFileList(files, rootFolder, computedHiddenFiles);
|
||||
}, [files, rootFolder, computedHiddenFiles]);
|
||||
return buildFileList(files, rootFolder, hideRoot, computedHiddenFiles);
|
||||
}, [files, rootFolder, hideRoot, computedHiddenFiles]);
|
||||
|
||||
const [collapsedFolders, setCollapsedFolders] = useState(() => new Set<string>());
|
||||
const [collapsedFolders, setCollapsedFolders] = useState(() => {
|
||||
return collapsed
|
||||
? new Set(fileList.filter((item) => item.kind === 'folder').map((item) => item.fullPath))
|
||||
: new Set<string>();
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (collapsed) {
|
||||
setCollapsedFolders(new Set(fileList.filter((item) => item.kind === 'folder').map((item) => item.fullPath)));
|
||||
return;
|
||||
}
|
||||
|
||||
setCollapsedFolders((prevCollapsed) => {
|
||||
const newCollapsed = new Set<string>();
|
||||
|
||||
@@ -42,7 +65,7 @@ export const FileTree = memo(
|
||||
|
||||
return newCollapsed;
|
||||
});
|
||||
}, [fileList]);
|
||||
}, [fileList, collapsed]);
|
||||
|
||||
const filteredFileList = useMemo(() => {
|
||||
const list = [];
|
||||
@@ -109,6 +132,7 @@ export const FileTree = memo(
|
||||
<Folder
|
||||
key={fileOrFolder.id}
|
||||
folder={fileOrFolder}
|
||||
selected={allowFolderSelection && selectedFile === fileOrFolder.fullPath}
|
||||
collapsed={collapsedFolders.has(fileOrFolder.fullPath)}
|
||||
onClick={() => {
|
||||
toggleCollapseState(fileOrFolder.fullPath);
|
||||
@@ -131,13 +155,18 @@ export default FileTree;
|
||||
interface FolderProps {
|
||||
folder: FolderNode;
|
||||
collapsed: boolean;
|
||||
selected?: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
function Folder({ folder: { depth, name }, collapsed, onClick }: FolderProps) {
|
||||
function Folder({ folder: { depth, name }, collapsed, selected = false, onClick }: FolderProps) {
|
||||
return (
|
||||
<NodeButton
|
||||
className="group bg-transparent text-bolt-elements-item-contentDefault hover:text-bolt-elements-item-contentActive hover:bg-bolt-elements-item-backgroundActive"
|
||||
className={classNames('group', {
|
||||
'bg-transparent text-bolt-elements-item-contentDefault hover:text-bolt-elements-item-contentActive hover:bg-bolt-elements-item-backgroundActive':
|
||||
!selected,
|
||||
'bg-bolt-elements-item-backgroundAccent text-bolt-elements-item-contentAccent': selected,
|
||||
})}
|
||||
depth={depth}
|
||||
iconClasses={classNames({
|
||||
'i-ph:caret-right scale-98': collapsed,
|
||||
@@ -223,13 +252,18 @@ interface FolderNode extends BaseNode {
|
||||
kind: 'folder';
|
||||
}
|
||||
|
||||
function buildFileList(files: FileMap, rootFolder = '/', hiddenFiles: Array<string | RegExp>): Node[] {
|
||||
function buildFileList(
|
||||
files: FileMap,
|
||||
rootFolder = '/',
|
||||
hideRoot: boolean,
|
||||
hiddenFiles: Array<string | RegExp>,
|
||||
): Node[] {
|
||||
const folderPaths = new Set<string>();
|
||||
const fileList: Node[] = [];
|
||||
|
||||
let defaultDepth = 0;
|
||||
|
||||
if (rootFolder === '/') {
|
||||
if (rootFolder === '/' && !hideRoot) {
|
||||
defaultDepth = 1;
|
||||
fileList.push({ kind: 'folder', name: '/', depth: 0, id: 0, fullPath: '/' });
|
||||
}
|
||||
@@ -251,7 +285,7 @@ function buildFileList(files: FileMap, rootFolder = '/', hiddenFiles: Array<stri
|
||||
const name = segments[i];
|
||||
const fullPath = (currentPath += `/${name}`);
|
||||
|
||||
if (!fullPath.startsWith(rootFolder)) {
|
||||
if (!fullPath.startsWith(rootFolder) || (hideRoot && fullPath === rootFolder)) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
@@ -281,7 +315,7 @@ function buildFileList(files: FileMap, rootFolder = '/', hiddenFiles: Array<stri
|
||||
}
|
||||
}
|
||||
|
||||
return sortFileList(rootFolder, fileList);
|
||||
return sortFileList(rootFolder, fileList, hideRoot);
|
||||
}
|
||||
|
||||
function isHiddenFile(filePath: string, fileName: string, hiddenFiles: Array<string | RegExp>) {
|
||||
@@ -307,7 +341,7 @@ function isHiddenFile(filePath: string, fileName: string, hiddenFiles: Array<str
|
||||
*
|
||||
* @returns A new array of nodes sorted in depth-first order.
|
||||
*/
|
||||
function sortFileList(rootFolder: string, nodeList: Node[]): Node[] {
|
||||
function sortFileList(rootFolder: string, nodeList: Node[], hideRoot: boolean): Node[] {
|
||||
logger.trace('sortFileList');
|
||||
|
||||
const nodeMap = new Map<string, Node>();
|
||||
@@ -335,13 +369,10 @@ function sortFileList(rootFolder: string, nodeList: Node[]): Node[] {
|
||||
const depthFirstTraversal = (path: string): void => {
|
||||
const node = nodeMap.get(path);
|
||||
|
||||
if (!node) {
|
||||
logger.warn(`Node not found for path: ${path}`);
|
||||
return;
|
||||
if (node) {
|
||||
sortedList.push(node);
|
||||
}
|
||||
|
||||
sortedList.push(node);
|
||||
|
||||
const children = childrenMap.get(path);
|
||||
|
||||
if (children) {
|
||||
@@ -355,7 +386,16 @@ function sortFileList(rootFolder: string, nodeList: Node[]): Node[] {
|
||||
}
|
||||
};
|
||||
|
||||
depthFirstTraversal(rootFolder);
|
||||
if (hideRoot) {
|
||||
// if root is hidden, start traversal from its immediate children
|
||||
const rootChildren = childrenMap.get(rootFolder) || [];
|
||||
|
||||
for (const child of rootChildren) {
|
||||
depthFirstTraversal(child.fullPath);
|
||||
}
|
||||
} else {
|
||||
depthFirstTraversal(rootFolder);
|
||||
}
|
||||
|
||||
return sortedList;
|
||||
}
|
||||
|
||||
@@ -24,6 +24,10 @@ $zIndexMax: 999;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.z-file-tree-breadcrumb {
|
||||
z-index: $zIndexMax - 1;
|
||||
}
|
||||
|
||||
.z-max {
|
||||
z-index: $zIndexMax;
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
"@iconify-json/svg-spinners": "^1.1.2",
|
||||
"@lezer/highlight": "^1.2.0",
|
||||
"@nanostores/react": "^0.7.2",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.1",
|
||||
"@remix-run/cloudflare": "^2.10.2",
|
||||
"@remix-run/cloudflare-pages": "^2.10.2",
|
||||
"@remix-run/react": "^2.10.2",
|
||||
|
||||
Reference in New Issue
Block a user