Skip to content
13 changes: 13 additions & 0 deletions .changeset/file-tree-improvements.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"rspress-plugin-file-tree": patch
---

Improve file tree parser and UI:

- Support both 2-space and 4-space indentation formats
- Support comments after filenames (any text after the filename is treated as comment)
- Support `#`, `//`, `<--`, `-->` and other comment styles
- Skip leading `.` line (current directory marker)
- Add HTML file icon support
- Empty directories now default to collapsed state
- Add `...` ellipsis support for omitted content
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,6 @@
border-radius: var(--rp-radius);
border: var(--rp-code-block-border, 1px solid var(--rp-c-divider-light));
box-shadow: var(--rp-code-block-shadow, none);
background-color: var(--rp-code-block-bg);
padding: 8px;
}
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,15 @@
text-overflow: ellipsis;
}

.comment {
margin-left: 8px;
color: var(--rp-c-text-2);
font-style: italic;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}

.children {
display: flex;
flex-direction: column;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ interface FileTreeItemProps {
const INDENT_SIZE = 12;

export const FileTreeItem: React.FC<FileTreeItemProps> = ({ node, depth }) => {
const [expanded, setExpanded] = useState(true);
// Default to collapsed if directory has no children
const hasChildren = node.type === 'directory' && node.children.length > 0;
const [expanded, setExpanded] = useState(hasChildren);

const icon = useMemo(() => {
if (node.type === 'directory') {
Expand Down Expand Up @@ -57,6 +59,7 @@ export const FileTreeItem: React.FC<FileTreeItemProps> = ({ node, depth }) => {
/>
</div>
<span className={styles.name}>{node.name}</span>
{node.comment && <span className={styles.comment}>{node.comment}</span>}
</div>

{isDirectory && node.children.length > 0 && expanded && (
Expand Down
10 changes: 10 additions & 0 deletions packages/rspress-plugin-file-tree/src/components/languages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,4 +101,14 @@ export const SUPPORTED_LANGUAGES: LanguageDefinition[] = [
},
],
},
{
id: 'html',
icons: [
{
name: 'html',
content: () => import('material-icon-theme/icons/html.svg?raw'),
matcher: /^.*\.html?$/,
},
],
},
];
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,24 @@ import { ParsedTree, TreeNode } from './types';
* └── another.ts
*/
export function parseTreeContent(content: string): ParsedTree {
const lines = content.split('\n').filter((line) => line.trim());
let lines = content.split('\n').filter((line) => line.trim());

// Skip leading "." line (current directory marker)
if (lines.length > 0 && lines[0].trim() === '.') {
lines = lines.slice(1);
}

const nodes: TreeNode[] = [];
const stack: { node: TreeNode; indent: number }[] = [];

for (const line of lines) {
const indent = calculateIndent(line);
const fullName = extractName(line);
// Split name and potential comment
// e.g. "file.ts // comment" -> name="file.ts", comment="comment"
const commentMatch = fullName.match(/^(.*?)(?:\s*\/\/\s*(.*))?$/);
const name = commentMatch ? commentMatch[1].trim() : fullName;

// Extract name and comment
// Rule: filename ends when we detect a valid file/directory name pattern,
// everything after (with leading spaces) is treated as comment
const { name, comment } = extractNameAndComment(fullName);

if (!name) continue;

Expand All @@ -32,6 +39,7 @@ export function parseTreeContent(content: string): ParsedTree {
type: isDirectory ? 'directory' : 'file',
children: [],
extension: isDirectory ? undefined : getExtension(name),
comment,
};

// Find parent node by popping items with equal or greater indent
Expand All @@ -56,7 +64,7 @@ export function parseTreeContent(content: string): ParsedTree {

/**
* Calculate indent level from line
* Each level is typically 4 characters: "│ " or " "
* Supports both 4-character ("│ ") and 2-character ("│ ") indent patterns
*/
function calculateIndent(line: string): number {
let indent = 0;
Expand All @@ -65,20 +73,35 @@ function calculateIndent(line: string): number {
while (i < line.length) {
const char = line[i];

// Check for "│ " pattern (vertical line + 3 spaces)
// Check for "│ " pattern (vertical line + 3 spaces) - 4-char indent
if (char === '│' && line.substring(i, i + 4) === '│ ') {
indent++;
i += 4;
continue;
}

// Check for "│ " pattern (vertical line + 1 space) - 2-char indent
// Must check this AFTER 4-char to avoid partial matches
if (char === '│' && line[i + 1] === ' ') {
indent++;
i += 2;
continue;
}

// Check for 4 spaces
if (line.substring(i, i + 4) === ' ') {
indent++;
i += 4;
continue;
}

// Check for 2 spaces (must come after 4-space check)
if (line.substring(i, i + 2) === ' ') {
indent++;
i += 2;
continue;
}

// Check for branch characters (├── or └──)
if (char === '├' || char === '└') {
if (
Expand Down Expand Up @@ -108,27 +131,127 @@ function extractName(line: string): string {
.trim();
}

/**
* Extract filename/dirname and comment from a line
*
* Rules for detecting end of filename:
* 1. Files have extensions: name.ext (e.g., file.ts, config.json)
* 2. Hidden files start with dot: .gitignore, .env
* 3. Directories end with / or have no extension
* 4. Special names: ... (ellipsis for omitted content)
* 5. Everything after the name (separated by 2+ spaces) is comment
*
* Note: Filenames can contain spaces (e.g., "0. file.ts"), so we use
* 2+ consecutive spaces as the delimiter between name and comment.
*/
function extractNameAndComment(fullName: string): {
name: string;
comment: string | undefined;
} {
const trimmed = fullName.trim();
if (!trimmed) {
return { name: '', comment: undefined };
}

// Special case: "..." or similar ellipsis patterns (standalone)
if (/^\.{2,}$/.test(trimmed)) {
return { name: trimmed, comment: undefined };
}

// Strategy: Find the last occurrence of a file extension pattern,
// then check if there's content after it (separated by 2+ spaces)

// First, try to split by 2+ spaces (common comment delimiter)
const doubleSpaceMatch = trimmed.match(/^(.+?)\s{2,}(.+)$/);
if (doubleSpaceMatch) {
const potentialName = doubleSpaceMatch[1].trim();
const potentialComment = doubleSpaceMatch[2].trim();

// Verify the name part looks like a valid file/directory
if (isValidName(potentialName)) {
return { name: potentialName, comment: potentialComment };
}
}

// If no double-space delimiter, check if the whole thing is just a name
// For files with extensions or directories, we can be more lenient
// Look for pattern: name.ext followed by single space and non-extension content
const singleSpaceMatch = trimmed.match(
/^(.+?\.[a-zA-Z0-9]+)\s+([^.].*)$/
);
if (singleSpaceMatch) {
const potentialName = singleSpaceMatch[1].trim();
const potentialComment = singleSpaceMatch[2].trim();
return { name: potentialName, comment: potentialComment };
}

// Check for hidden files followed by comment
const hiddenFileMatch = trimmed.match(/^(\.[^\s]+)\s+(.+)$/);
if (hiddenFileMatch) {
return {
name: hiddenFileMatch[1].trim(),
comment: hiddenFileMatch[2].trim(),
};
}

// Check for directory name followed by single space and comment
// Directory names don't have extensions, so look for "word space non-word-start"
// Also handles names starting with numbers like "2. components"
const dirCommentMatch = trimmed.match(/^([\w][\w.\s-]*?)\s+([^a-zA-Z0-9].*)$/);
if (dirCommentMatch) {
const potentialName = dirCommentMatch[1].trim();
// Make sure it's not a file with extension
if (!/\.[a-zA-Z0-9]+$/.test(potentialName)) {
return {
name: potentialName,
comment: dirCommentMatch[2].trim(),
};
}
}

// No comment detected, return the whole thing as name
return { name: trimmed, comment: undefined };
}

/**
* Check if a string looks like a valid file/directory name
*/
function isValidName(name: string): boolean {
if (!name) return false;

// Ends with / (explicit directory)
if (name.endsWith('/')) return true;

// Has a file extension
if (/\.[a-zA-Z0-9]+$/.test(name)) return true;

// Hidden file/directory (starts with dot)
if (name.startsWith('.')) return true;

// Looks like a directory name (no extension, word characters)
if (/^[\w\s.-]+$/.test(name)) return true;

return false;
}

/**
* Check if name represents a directory
* - Ends with /
* - Has no extension
*/
function isDirectoryName(name: string): boolean {
// Strip comments if any (though name passed here usually already has them, let's be safe if logic changes)
const cleanName = name.split(/\s+\/\//)[0].trim();

if (cleanName.endsWith('/')) return true;
if (name.endsWith('/')) return true;

const lastPart = cleanName.split('/').pop() || cleanName;
const lastPart = name.split('/').pop() || name;

// Use a more robust check: files usually have extensions.
// Directories usually don't.
// Exception: Dotfiles (.gitignore) are files.
// exception: names with dots but known extensions are files.
// Special case: ellipsis is not a directory
if (/^\.{2,}$/.test(lastPart)) {
return false;
}

// If it starts with a dot, it's a file (hidden file), e.g., .gitignore
if (lastPart.startsWith('.')) {
return false; // Treat as file
return false;
}

// If it has an extension (e.g. foo.ts, bar.config.js), it's a file
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export interface TreeNode {
type: 'file' | 'directory';
children: TreeNode[];
extension?: string;
comment?: string;
}

export interface ParsedTree {
Expand Down
Loading