Skip to content

Commit 5839e4a

Browse files
committed
feat: add complete Fiddle file tree
1 parent 1d4f70d commit 5839e4a

7 files changed

Lines changed: 395 additions & 41 deletions

File tree

lynxtron-go/src/app/fiddle/Sidebar/FiddleSidebar.css

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,36 @@
5252
min-height: 0;
5353
padding: 4px 0;
5454
}
55+
.FiddleSidebar-Directory {
56+
display: flex;
57+
flex-direction: row;
58+
align-items: center;
59+
column-gap: 6px;
60+
padding-top: 6px;
61+
padding-right: 12px;
62+
padding-bottom: 6px;
63+
cursor: pointer;
64+
}
65+
.FiddleSidebar-Directory:hover { background-color: var(--background-3); }
66+
.FiddleSidebar-DirectoryChevron {
67+
color: var(--foreground-3);
68+
flex-shrink: 0;
69+
}
70+
.FiddleSidebar-DirectoryIcon {
71+
color: var(--bp-text-muted);
72+
flex-shrink: 0;
73+
}
74+
.FiddleSidebar-DirectoryName {
75+
flex: 1;
76+
min-width: 0;
77+
font-size: 13px;
78+
color: var(--text-color-1);
79+
font-weight: 600;
80+
lines: 1;
81+
overflow: hidden;
82+
text-overflow: ellipsis;
83+
white-space: nowrap;
84+
}
5585
.FiddleSidebar-Item {
5686
display: flex;
5787
flex-direction: row;

lynxtron-go/src/app/fiddle/Sidebar/FiddleSidebar.tsx

Lines changed: 54 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
import { useState, useRef, useCallback } from '@lynx-js/react';
1+
import { useState, useRef, useCallback, useEffect } from '@lynx-js/react';
22
import { Button, Icon, InputGroup, AppToaster } from '../bp';
33
import { isSafeRelativePath } from '../state/FiddleState';
44
import { searchNpm, parseDependencies, addDependency, removeDependency, type NpmSearchResult } from './npm-search';
5+
import { flattenFileTree } from './file-tree';
56
import { DEFAULT_EDITORS } from '../types';
67
import type { FiddleFile } from '../state/FiddleState';
78
import './FiddleSidebar.css';
@@ -39,6 +40,7 @@ export interface FiddleSidebarProps {
3940
*/
4041
export function FiddleSidebar(props: FiddleSidebarProps) {
4142
const editors = Array.from(props.files.values()).sort((a, b) => a.id.localeCompare(b.id));
43+
const [collapsedDirectories, setCollapsedDirectories] = useState<Set<string>>(() => new Set());
4244
const [moduleQuery, setModuleQuery] = useState('');
4345
const [searchResults, setSearchResults] = useState<NpmSearchResult[]>([]);
4446
const [searching, setSearching] = useState(false);
@@ -106,6 +108,24 @@ export function FiddleSidebar(props: FiddleSidebarProps) {
106108
if (next) props.onSetFileContent(DEFAULT_EDITORS.PACKAGE, next);
107109
// eslint-disable-next-line react-hooks/exhaustive-deps
108110
}, [packageJson, props.onSetFileContent]);
111+
const toggleDirectory = useCallback((path: string) => {
112+
setCollapsedDirectories(previous => {
113+
const next = new Set(previous);
114+
if (next.has(path)) next.delete(path);
115+
else next.add(path);
116+
return next;
117+
});
118+
}, []);
119+
120+
// A Gallery case owns its own tree state. Do not carry collapsed paths or an
121+
// inline add/rename operation into the next showcase.
122+
useEffect(() => {
123+
setCollapsedDirectories(new Set());
124+
setAddingName(null);
125+
setRenaming(null);
126+
}, [props.rootPath]);
127+
128+
const treeRows = flattenFileTree(editors.map(editor => editor.id), collapsedDirectories);
109129

110130
return (
111131
<view className="FiddleSidebar">
@@ -122,15 +142,44 @@ export function FiddleSidebar(props: FiddleSidebarProps) {
122142
</view>
123143
</view>
124144
<scroll-view className="FiddleSidebar-List" scroll-orientation="vertical">
125-
{editors.map(f => {
145+
{treeRows.map(row => {
146+
if (row.kind === 'directory') {
147+
return (
148+
<view
149+
key={`directory:${row.path}`}
150+
className="FiddleSidebar-Directory"
151+
style={{ paddingLeft: `${12 + row.depth * 14}px` }}
152+
bindtap={() => toggleDirectory(row.path)}
153+
>
154+
<Icon
155+
icon={row.expanded ? 'chevron-down' : 'chevron-right'}
156+
size={11}
157+
className="FiddleSidebar-DirectoryChevron"
158+
/>
159+
<Icon
160+
icon={row.expanded ? 'folder-open' : 'folder-close'}
161+
size={14}
162+
className="FiddleSidebar-DirectoryIcon"
163+
/>
164+
<text className="FiddleSidebar-DirectoryName" text-maxline="1">{row.name}</text>
165+
</view>
166+
);
167+
}
168+
169+
const f = props.files.get(row.path);
170+
if (!f) return null;
126171
const isActive = f.id === props.activeEditorId;
127172
const canDelete = f.id !== DEFAULT_EDITORS.MAIN && f.id !== DEFAULT_EDITORS.PACKAGE;
128173
const cls = 'FiddleSidebar-Item'
129174
+ (isActive ? ' FiddleSidebar-Item--active' : '')
130175
+ (f.isDirty ? ' FiddleSidebar-Item--dirty' : '');
131176
if (renaming?.id === f.id) {
132177
return (
133-
<view key={f.id} className="FiddleSidebar-AddRow">
178+
<view
179+
key={f.id}
180+
className="FiddleSidebar-AddRow"
181+
style={{ paddingLeft: `${8 + row.depth * 14}px` }}
182+
>
134183
<view className="FiddleSidebar-AddRowInput">
135184
<Icon icon="document" size={14} className="FiddleSidebar-ItemIcon" />
136185
<InputGroup
@@ -157,11 +206,12 @@ export function FiddleSidebar(props: FiddleSidebarProps) {
157206
<view
158207
key={f.id}
159208
className={cls}
209+
style={{ paddingLeft: `${(isActive ? 9 : 12) + row.depth * 14}px` }}
160210
bindtap={() => props.onSelectEditor(f.id)}
161211
>
162212
<Icon icon="document" size={14} className="FiddleSidebar-ItemIcon" />
163213
<view className="FiddleSidebar-ItemLabel">
164-
<text className="FiddleSidebar-ItemName" text-maxline="1">{f.id}</text>
214+
<text className="FiddleSidebar-ItemName" text-maxline="1">{row.name}</text>
165215
</view>
166216
{f.isDirty ? <text className="FiddleSidebar-Dot"></text> : null}
167217
{/* rename/delete only on the active row — upstream uses a
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { flattenFileTree } from './file-tree';
3+
4+
describe('flattenFileTree', () => {
5+
const files = [
6+
'package.json',
7+
'src/app/index.tsx',
8+
'src/main/desktop/main.ts',
9+
'src/main/desktop/preload.ts',
10+
];
11+
12+
it('builds directory-first rows with the file basename', () => {
13+
expect(flattenFileTree(files, new Set())).toEqual([
14+
{ kind: 'directory', path: 'src', name: 'src', depth: 0, expanded: true },
15+
{ kind: 'directory', path: 'src/app', name: 'app', depth: 1, expanded: true },
16+
{ kind: 'file', path: 'src/app/index.tsx', name: 'index.tsx', depth: 2 },
17+
{ kind: 'directory', path: 'src/main', name: 'main', depth: 1, expanded: true },
18+
{ kind: 'directory', path: 'src/main/desktop', name: 'desktop', depth: 2, expanded: true },
19+
{ kind: 'file', path: 'src/main/desktop/main.ts', name: 'main.ts', depth: 3 },
20+
{ kind: 'file', path: 'src/main/desktop/preload.ts', name: 'preload.ts', depth: 3 },
21+
{ kind: 'file', path: 'package.json', name: 'package.json', depth: 0 },
22+
]);
23+
});
24+
25+
it('hides descendants of collapsed directories', () => {
26+
expect(flattenFileTree(files, new Set(['src/main']))).toEqual([
27+
{ kind: 'directory', path: 'src', name: 'src', depth: 0, expanded: true },
28+
{ kind: 'directory', path: 'src/app', name: 'app', depth: 1, expanded: true },
29+
{ kind: 'file', path: 'src/app/index.tsx', name: 'index.tsx', depth: 2 },
30+
{ kind: 'directory', path: 'src/main', name: 'main', depth: 1, expanded: false },
31+
{ kind: 'file', path: 'package.json', name: 'package.json', depth: 0 },
32+
]);
33+
});
34+
});
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
export interface FileTreeDirectoryRow {
2+
kind: 'directory';
3+
path: string;
4+
name: string;
5+
depth: number;
6+
expanded: boolean;
7+
}
8+
9+
export interface FileTreeFileRow {
10+
kind: 'file';
11+
path: string;
12+
name: string;
13+
depth: number;
14+
}
15+
16+
export type FileTreeRow = FileTreeDirectoryRow | FileTreeFileRow;
17+
18+
interface MutableDirectory {
19+
name: string;
20+
path: string;
21+
directories: Map<string, MutableDirectory>;
22+
files: Array<{ name: string; path: string }>;
23+
}
24+
25+
function createDirectory(name: string, path: string): MutableDirectory {
26+
return { name, path, directories: new Map(), files: [] };
27+
}
28+
29+
function compareNames(a: { name: string }, b: { name: string }): number {
30+
return a.name.localeCompare(b.name);
31+
}
32+
33+
/**
34+
* Derive a visible directory tree from the Fiddle's path-keyed file map.
35+
* Directories are implicit in EditorIds, so switching snapshots naturally
36+
* rebuilds the tree without a second filesystem state model.
37+
*/
38+
export function flattenFileTree(
39+
filePaths: string[],
40+
collapsedDirectories: ReadonlySet<string>,
41+
): FileTreeRow[] {
42+
const root = createDirectory('', '');
43+
44+
for (const filePath of filePaths) {
45+
const segments = filePath.split('/').filter(Boolean);
46+
if (segments.length === 0) continue;
47+
48+
let directory = root;
49+
for (let i = 0; i < segments.length - 1; i += 1) {
50+
const name = segments[i];
51+
const path = directory.path ? `${directory.path}/${name}` : name;
52+
let child = directory.directories.get(name);
53+
if (!child) {
54+
child = createDirectory(name, path);
55+
directory.directories.set(name, child);
56+
}
57+
directory = child;
58+
}
59+
directory.files.push({ name: segments[segments.length - 1], path: filePath });
60+
}
61+
62+
const rows: FileTreeRow[] = [];
63+
const append = (directory: MutableDirectory, depth: number) => {
64+
const directories = [...directory.directories.values()].sort(compareNames);
65+
const files = [...directory.files].sort(compareNames);
66+
67+
for (const child of directories) {
68+
const expanded = !collapsedDirectories.has(child.path);
69+
rows.push({
70+
kind: 'directory',
71+
path: child.path,
72+
name: child.name,
73+
depth,
74+
expanded,
75+
});
76+
if (expanded) append(child, depth + 1);
77+
}
78+
for (const file of files) {
79+
rows.push({ kind: 'file', path: file.path, name: file.name, depth });
80+
}
81+
};
82+
83+
append(root, 0);
84+
return rows;
85+
}

lynxtron-go/src/app/fiddle/runner/showcase-open.ts

Lines changed: 10 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import { showcaseApi, foundationApi, SHOWCASE_LOCAL_WORKSPACE, type ShowcaseEntry } from '../../store';
22
import { detectLanguage } from '../../syntax';
33
import { isSafeRelativePath, type FiddleSnapshot, type FiddleFile, type EditorId } from '../state/FiddleState';
4+
import { collectWorkspaceTextFiles } from './workspace-files';
45

56
// Opening a showcase in the Fiddle = Electron Fiddle's "load from the web":
67
// download/extract the package to a workspace, surface its source files in
78
// the editor mosaic, and let Run execute the workspace.
9+
const DEFAULT_PANE_FILE = /\.(cjs|mjs|js|jsx|ts|tsx|css|scss|less|json|html)$/i;
810

911
/** Download (or locally resolve) a showcase's workspace folder. */
1012
export async function resolveShowcaseWorkspace(entry: ShowcaseEntry): Promise<string | null> {
@@ -21,54 +23,25 @@ export async function resolveShowcaseWorkspace(entry: ShowcaseEntry): Promise<st
2123
return workspace || null;
2224
}
2325

24-
const CODE_FILE = /\.(cjs|mjs|js|jsx|ts|tsx|css|scss|less|json|html)$/;
25-
const SKIP_DIRS = new Set(['node_modules', 'dist', 'output', 'build', '.git', '.rspeedy', 'coverage']);
26-
const SKIP_FILES = new Set(['package-lock.json', 'pnpm-lock.yaml', 'yarn.lock', 'tsconfig.tsbuildinfo']);
27-
const MAX_FILES = 14;
28-
const MAX_FILE_BYTES = 120 * 1024;
29-
30-
/** Collect the showcase's source files (root + up to 2 levels) into a snapshot. */
26+
/** Collect the showcase's complete editable source tree into a snapshot. */
3127
export function loadShowcaseFiddle(entry: ShowcaseEntry, workspaceRoot: string): FiddleSnapshot | null {
3228
const fs = foundationApi()?.fs;
3329
if (!fs) return null;
3430

35-
const collected: Array<{ rel: string; content: string }> = [];
36-
37-
const walk = (dir: string, relPrefix: string, depth: number) => {
38-
if (collected.length >= MAX_FILES || depth > 2) return;
39-
let entries: string[] = [];
40-
try { entries = fs.readdir?.(dir) ?? []; } catch (_) { return; }
41-
entries.sort();
42-
// files first so shallow files win the MAX_FILES budget over deep ones
43-
for (const name of entries) {
44-
if (collected.length >= MAX_FILES) return;
45-
if (SKIP_FILES.has(name) || name.startsWith('.')) continue;
46-
if (!CODE_FILE.test(name)) continue;
47-
const p = fs.join?.(dir, name) ?? dir + '/' + name;
48-
try {
49-
const content: string = fs.readFile?.(p) ?? '';
50-
if (content.length > MAX_FILE_BYTES) continue;
51-
collected.push({ rel: relPrefix + name, content });
52-
} catch (_) {}
53-
}
54-
for (const name of entries) {
55-
if (collected.length >= MAX_FILES) return;
56-
if (SKIP_DIRS.has(name) || name.startsWith('.')) continue;
57-
const p = fs.join?.(dir, name) ?? dir + '/' + name;
58-
try {
59-
if (fs.readdir?.(p) != null) walk(p, relPrefix + name + '/', depth + 1);
60-
} catch (_) { /* not a directory */ }
61-
}
62-
};
63-
walk(workspaceRoot, '', 0);
31+
const collected = collectWorkspaceTextFiles(fs, workspaceRoot);
6432

6533
if (collected.length === 0) return null;
6634

6735
const files = new Map<EditorId, FiddleFile>();
6836
let visibleBudget = 4;
6937
for (const f of collected) {
7038
const isMeta = f.rel === 'package.json' || f.rel.endsWith('.config.js') || f.rel.endsWith('.config.ts');
71-
const visible = !isMeta && f.content.length > 0 && visibleBudget > 0;
39+
// Documentation/config assets belong in the complete tree but should not
40+
// displace the primary code panes when a showcase first opens.
41+
const visible = !isMeta
42+
&& DEFAULT_PANE_FILE.test(f.rel)
43+
&& f.content.length > 0
44+
&& visibleBudget > 0;
7245
if (visible) visibleBudget -= 1;
7346
files.set(f.rel, {
7447
id: f.rel,

0 commit comments

Comments
 (0)