Skip to content

Commit a45e165

Browse files
authored
feat(file-tree): improve parser flexibility and UI enhancements (#15)
1 parent e9a8e69 commit a45e165

File tree

8 files changed

+597
-24
lines changed

8 files changed

+597
-24
lines changed
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"rspress-plugin-file-tree": patch
3+
---
4+
5+
Improve file tree parser and UI:
6+
7+
- Support both 2-space and 4-space indentation formats
8+
- Support comments after filenames (any text after the filename is treated as comment)
9+
- Support `#`, `//`, `<--`, `-->` and other comment styles
10+
- Skip leading `.` line (current directory marker)
11+
- Add HTML file icon support
12+
- Empty directories now default to collapsed state
13+
- Add `...` ellipsis support for omitted content

packages/rspress-plugin-file-tree/src/components/FileTree/FileTree.module.less

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,5 +8,6 @@
88
border-radius: var(--rp-radius);
99
border: var(--rp-code-block-border, 1px solid var(--rp-c-divider-light));
1010
box-shadow: var(--rp-code-block-shadow, none);
11+
background-color: var(--rp-code-block-bg);
1112
padding: 8px;
1213
}

packages/rspress-plugin-file-tree/src/components/FileTree/FileTreeItem.module.less

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,15 @@
5151
text-overflow: ellipsis;
5252
}
5353

54+
.comment {
55+
margin-left: 8px;
56+
color: var(--rp-c-text-2);
57+
font-style: italic;
58+
white-space: nowrap;
59+
overflow: hidden;
60+
text-overflow: ellipsis;
61+
}
62+
5463
.children {
5564
display: flex;
5665
flex-direction: column;

packages/rspress-plugin-file-tree/src/components/FileTree/FileTreeItem.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@ interface FileTreeItemProps {
1212
const INDENT_SIZE = 12;
1313

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

1719
const icon = useMemo(() => {
1820
if (node.type === 'directory') {
@@ -57,6 +59,7 @@ export const FileTreeItem: React.FC<FileTreeItemProps> = ({ node, depth }) => {
5759
/>
5860
</div>
5961
<span className={styles.name}>{node.name}</span>
62+
{node.comment && <span className={styles.comment}>{node.comment}</span>}
6063
</div>
6164

6265
{isDirectory && node.children.length > 0 && expanded && (

packages/rspress-plugin-file-tree/src/components/languages.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,4 +101,14 @@ export const SUPPORTED_LANGUAGES: LanguageDefinition[] = [
101101
},
102102
],
103103
},
104+
{
105+
id: 'html',
106+
icons: [
107+
{
108+
name: 'html',
109+
content: () => import('material-icon-theme/icons/html.svg?raw'),
110+
matcher: /^.*\.html?$/,
111+
},
112+
],
113+
},
104114
];

packages/rspress-plugin-file-tree/src/components/tree-parser/tree-parser.ts

Lines changed: 140 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -11,17 +11,24 @@ import { ParsedTree, TreeNode } from './types';
1111
* └── another.ts
1212
*/
1313
export function parseTreeContent(content: string): ParsedTree {
14-
const lines = content.split('\n').filter((line) => line.trim());
14+
let lines = content.split('\n').filter((line) => line.trim());
15+
16+
// Skip leading "." line (current directory marker)
17+
if (lines.length > 0 && lines[0].trim() === '.') {
18+
lines = lines.slice(1);
19+
}
20+
1521
const nodes: TreeNode[] = [];
1622
const stack: { node: TreeNode; indent: number }[] = [];
1723

1824
for (const line of lines) {
1925
const indent = calculateIndent(line);
2026
const fullName = extractName(line);
21-
// Split name and potential comment
22-
// e.g. "file.ts // comment" -> name="file.ts", comment="comment"
23-
const commentMatch = fullName.match(/^(.*?)(?:\s*\/\/\s*(.*))?$/);
24-
const name = commentMatch ? commentMatch[1].trim() : fullName;
27+
28+
// Extract name and comment
29+
// Rule: filename ends when we detect a valid file/directory name pattern,
30+
// everything after (with leading spaces) is treated as comment
31+
const { name, comment } = extractNameAndComment(fullName);
2532

2633
if (!name) continue;
2734

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

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

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

68-
// Check for "│ " pattern (vertical line + 3 spaces)
76+
// Check for "│ " pattern (vertical line + 3 spaces) - 4-char indent
6977
if (char === '│' && line.substring(i, i + 4) === '│ ') {
7078
indent++;
7179
i += 4;
7280
continue;
7381
}
7482

83+
// Check for "│ " pattern (vertical line + 1 space) - 2-char indent
84+
// Must check this AFTER 4-char to avoid partial matches
85+
if (char === '│' && line[i + 1] === ' ') {
86+
indent++;
87+
i += 2;
88+
continue;
89+
}
90+
7591
// Check for 4 spaces
7692
if (line.substring(i, i + 4) === ' ') {
7793
indent++;
7894
i += 4;
7995
continue;
8096
}
8197

98+
// Check for 2 spaces (must come after 4-space check)
99+
if (line.substring(i, i + 2) === ' ') {
100+
indent++;
101+
i += 2;
102+
continue;
103+
}
104+
82105
// Check for branch characters (├── or └──)
83106
if (char === '├' || char === '└') {
84107
if (
@@ -108,27 +131,127 @@ function extractName(line: string): string {
108131
.trim();
109132
}
110133

134+
/**
135+
* Extract filename/dirname and comment from a line
136+
*
137+
* Rules for detecting end of filename:
138+
* 1. Files have extensions: name.ext (e.g., file.ts, config.json)
139+
* 2. Hidden files start with dot: .gitignore, .env
140+
* 3. Directories end with / or have no extension
141+
* 4. Special names: ... (ellipsis for omitted content)
142+
* 5. Everything after the name (separated by 2+ spaces) is comment
143+
*
144+
* Note: Filenames can contain spaces (e.g., "0. file.ts"), so we use
145+
* 2+ consecutive spaces as the delimiter between name and comment.
146+
*/
147+
function extractNameAndComment(fullName: string): {
148+
name: string;
149+
comment: string | undefined;
150+
} {
151+
const trimmed = fullName.trim();
152+
if (!trimmed) {
153+
return { name: '', comment: undefined };
154+
}
155+
156+
// Special case: "..." or similar ellipsis patterns (standalone)
157+
if (/^\.{2,}$/.test(trimmed)) {
158+
return { name: trimmed, comment: undefined };
159+
}
160+
161+
// Strategy: Find the last occurrence of a file extension pattern,
162+
// then check if there's content after it (separated by 2+ spaces)
163+
164+
// First, try to split by 2+ spaces (common comment delimiter)
165+
const doubleSpaceMatch = trimmed.match(/^(.+?)\s{2,}(.+)$/);
166+
if (doubleSpaceMatch) {
167+
const potentialName = doubleSpaceMatch[1].trim();
168+
const potentialComment = doubleSpaceMatch[2].trim();
169+
170+
// Verify the name part looks like a valid file/directory
171+
if (isValidName(potentialName)) {
172+
return { name: potentialName, comment: potentialComment };
173+
}
174+
}
175+
176+
// If no double-space delimiter, check if the whole thing is just a name
177+
// For files with extensions or directories, we can be more lenient
178+
// Look for pattern: name.ext followed by single space and non-extension content
179+
const singleSpaceMatch = trimmed.match(
180+
/^(.+?\.[a-zA-Z0-9]+)\s+([^.].*)$/
181+
);
182+
if (singleSpaceMatch) {
183+
const potentialName = singleSpaceMatch[1].trim();
184+
const potentialComment = singleSpaceMatch[2].trim();
185+
return { name: potentialName, comment: potentialComment };
186+
}
187+
188+
// Check for hidden files followed by comment
189+
const hiddenFileMatch = trimmed.match(/^(\.[^\s]+)\s+(.+)$/);
190+
if (hiddenFileMatch) {
191+
return {
192+
name: hiddenFileMatch[1].trim(),
193+
comment: hiddenFileMatch[2].trim(),
194+
};
195+
}
196+
197+
// Check for directory name followed by single space and comment
198+
// Directory names don't have extensions, so look for "word space non-word-start"
199+
// Also handles names starting with numbers like "2. components"
200+
const dirCommentMatch = trimmed.match(/^([\w][\w.\s-]*?)\s+([^a-zA-Z0-9].*)$/);
201+
if (dirCommentMatch) {
202+
const potentialName = dirCommentMatch[1].trim();
203+
// Make sure it's not a file with extension
204+
if (!/\.[a-zA-Z0-9]+$/.test(potentialName)) {
205+
return {
206+
name: potentialName,
207+
comment: dirCommentMatch[2].trim(),
208+
};
209+
}
210+
}
211+
212+
// No comment detected, return the whole thing as name
213+
return { name: trimmed, comment: undefined };
214+
}
215+
216+
/**
217+
* Check if a string looks like a valid file/directory name
218+
*/
219+
function isValidName(name: string): boolean {
220+
if (!name) return false;
221+
222+
// Ends with / (explicit directory)
223+
if (name.endsWith('/')) return true;
224+
225+
// Has a file extension
226+
if (/\.[a-zA-Z0-9]+$/.test(name)) return true;
227+
228+
// Hidden file/directory (starts with dot)
229+
if (name.startsWith('.')) return true;
230+
231+
// Looks like a directory name (no extension, word characters)
232+
if (/^[\w\s.-]+$/.test(name)) return true;
233+
234+
return false;
235+
}
236+
111237
/**
112238
* Check if name represents a directory
113239
* - Ends with /
114240
* - Has no extension
115241
*/
116242
function isDirectoryName(name: string): boolean {
117-
// Strip comments if any (though name passed here usually already has them, let's be safe if logic changes)
118-
const cleanName = name.split(/\s+\/\//)[0].trim();
119-
120-
if (cleanName.endsWith('/')) return true;
243+
if (name.endsWith('/')) return true;
121244

122-
const lastPart = cleanName.split('/').pop() || cleanName;
245+
const lastPart = name.split('/').pop() || name;
123246

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

129252
// If it starts with a dot, it's a file (hidden file), e.g., .gitignore
130253
if (lastPart.startsWith('.')) {
131-
return false; // Treat as file
254+
return false;
132255
}
133256

134257
// If it has an extension (e.g. foo.ts, bar.config.js), it's a file

packages/rspress-plugin-file-tree/src/components/tree-parser/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ export interface TreeNode {
33
type: 'file' | 'directory';
44
children: TreeNode[];
55
extension?: string;
6+
comment?: string;
67
}
78

89
export interface ParsedTree {

0 commit comments

Comments
 (0)