Skip to content

Commit

Permalink
feat: add file tree breadcrumb (stackblitz#40)
Browse files Browse the repository at this point in the history
  • Loading branch information
d3lm authored Aug 20, 2024
1 parent f55b4e5 commit fcfef74
Show file tree
Hide file tree
Showing 7 changed files with 863 additions and 25 deletions.
1 change: 1 addition & 0 deletions packages/bolt/app/components/chat/CodeBlock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
15 changes: 8 additions & 7 deletions packages/bolt/app/components/workbench/EditorPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -134,6 +135,7 @@ export const EditorPanel = memo(
<FileTree
className="h-full"
files={files}
hideRoot
unsavedFiles={unsavedFiles}
rootFolder={WORK_DIR}
selectedFile={selectedFile}
Expand All @@ -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}>
Expand Down
148 changes: 148 additions & 0 deletions packages/bolt/app/components/workbench/FileBreadcrumb.tsx
Original file line number Diff line number Diff line change
@@ -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>
);
});
76 changes: 58 additions & 18 deletions packages/bolt/app/components/workbench/FileTree.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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>();

Expand All @@ -42,7 +65,7 @@ export const FileTree = memo(

return newCollapsed;
});
}, [fileList]);
}, [fileList, collapsed]);

const filteredFileList = useMemo(() => {
const list = [];
Expand Down Expand Up @@ -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);
Expand All @@ -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,
Expand Down Expand Up @@ -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: '/' });
}
Expand All @@ -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;
}
Expand Down Expand Up @@ -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>) {
Expand All @@ -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>();
Expand Down Expand Up @@ -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) {
Expand All @@ -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;
}
Expand Down
4 changes: 4 additions & 0 deletions packages/bolt/app/styles/z-index.scss
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ $zIndexMax: 999;
z-index: 3;
}

.z-file-tree-breadcrumb {
z-index: $zIndexMax - 1;
}

.z-max {
z-index: $zIndexMax;
}
1 change: 1 addition & 0 deletions packages/bolt/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading

0 comments on commit fcfef74

Please sign in to comment.