feat: add file tree breadcrumb (#40)

This commit is contained in:
Dominic Elm
2024-08-20 18:35:44 +02:00
committed by GitHub
parent f55b4e5ab9
commit fcfef742d5
7 changed files with 863 additions and 25 deletions

View File

@@ -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');

View File

@@ -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}>

View 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>
);
});

View File

@@ -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;
}

View File

@@ -24,6 +24,10 @@ $zIndexMax: 999;
z-index: 3;
}
.z-file-tree-breadcrumb {
z-index: $zIndexMax - 1;
}
.z-max {
z-index: $zIndexMax;
}

View File

@@ -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",