Skip to content

Commit c258755

Browse files
authored
feat(lynxtron-go): improve editor tree and console scrolling (#80)
* feat(lynxtron-go): add compact editor file tree * fix(lynxtron-go): keep console scrolled to latest log * fix(lynxtron-go): auto-scroll fiddle console output
1 parent c6e04b4 commit c258755

7 files changed

Lines changed: 373 additions & 92 deletions

File tree

.changeset/compact-editor-tree.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"lynxtron-go": patch
3+
---
4+
5+
Render Fiddle editor files as a collapsible directory tree with compact single-folder paths, and keep Console logs scrolled to the latest output.
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import fs from 'node:fs';
2+
import path from 'node:path';
3+
import { describe, expect, it } from 'vitest';
4+
5+
describe('Fiddle Console auto-scroll', () => {
6+
it('scrolls to the latest output after content layout and console updates', () => {
7+
const source = fs.readFileSync(path.join(__dirname, 'Outputs.tsx'), 'utf-8');
8+
9+
expect(source).toContain("const OUTPUTS_SCROLL_ID = 'fiddle-console-scroll'");
10+
expect(source).toContain('const AUTO_SCROLL_DELAYS_MS = [0, 80]');
11+
expect(source).toContain('bindcontentsizechanged={scrollToBottom}');
12+
expect(source).toContain('AUTO_SCROLL_DELAYS_MS.map(delay');
13+
expect(source).toContain('setTimeout(scrollToBottom, delay)');
14+
expect(source).toContain("method: 'scrollTo'");
15+
expect(source).toContain('params: { offset: 999999, smooth: false }');
16+
expect(source).toContain('return () => timers.forEach(timer => clearTimeout(timer))');
17+
});
18+
});

lynxtron-go/src/app/fiddle/Outputs/Outputs.tsx

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ import {
1010
import { Tag, Spinner } from '../bp';
1111
import './Outputs.css';
1212

13+
const OUTPUTS_SCROLL_ID = 'fiddle-console-scroll';
14+
const AUTO_SCROLL_DELAYS_MS = [0, 80];
15+
1316
export interface OutputsProps {
1417
runningPid: number | null;
1518
runStartMs: number | null;
@@ -77,6 +80,28 @@ export function Outputs(props: OutputsProps) {
7780
const uptimeMs = props.runningPid != null && props.runStartMs != null
7881
? nowMs - props.runStartMs
7982
: null;
83+
84+
const scrollToBottom = useCallback(() => {
85+
try {
86+
lynx.createSelectorQuery()
87+
.select(`#${OUTPUTS_SCROLL_ID}`)
88+
.invoke({
89+
method: 'scrollTo',
90+
params: { offset: 999999, smooth: false },
91+
success: () => {},
92+
fail: () => {},
93+
})
94+
.exec();
95+
} catch (_) {}
96+
}, []);
97+
98+
useEffect(() => {
99+
const timers = AUTO_SCROLL_DELAYS_MS.map(delay => (
100+
setTimeout(scrollToBottom, delay)
101+
));
102+
return () => timers.forEach(timer => clearTimeout(timer));
103+
}, [entries, scrollToBottom]);
104+
80105
return (
81106
<view className="Outputs">
82107
<view className="Outputs-Header">
@@ -112,7 +137,12 @@ export function Outputs(props: OutputsProps) {
112137
</view>
113138
</view>
114139
</view>
115-
<scroll-view className="Outputs-Body" scroll-orientation="vertical">
140+
<scroll-view
141+
id={OUTPUTS_SCROLL_ID}
142+
className="Outputs-Body"
143+
scroll-orientation="vertical"
144+
bindcontentsizechanged={scrollToBottom}
145+
>
116146
<text
117147
className={entries.length === 0 ? 'Outputs-Log Outputs-IdleHint' : 'Outputs-Log'}
118148
text-selection={true}

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

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,45 @@
104104
cursor: pointer;
105105
}
106106
.FiddleSidebar-Item:hover { background-color: var(--background-3); } /* background-3 */
107+
.FiddleSidebar-TreeGroup {
108+
display: flex;
109+
flex-direction: column;
110+
width: 100%;
111+
flex-shrink: 0;
112+
}
113+
.FiddleSidebar-FolderRow {
114+
display: flex;
115+
flex-direction: row;
116+
align-items: center;
117+
column-gap: 6px;
118+
height: 24px;
119+
padding-right: 10px;
120+
border-radius: 5px;
121+
margin: 0 4px;
122+
cursor: pointer;
123+
width: 100%;
124+
}
125+
.FiddleSidebar-FolderRow:hover { background-color: var(--background-3); }
126+
.FiddleSidebar-FolderChevron {
127+
color: var(--foreground-3);
128+
flex-shrink: 0;
129+
}
130+
.FiddleSidebar-TreeFolderIcon {
131+
color: var(--bp-text-muted);
132+
flex-shrink: 0;
133+
}
134+
.FiddleSidebar-FolderName {
135+
display: block;
136+
flex-grow: 1;
137+
flex-basis: auto;
138+
min-width: 0;
139+
overflow: hidden;
140+
text-overflow: ellipsis;
141+
white-space: nowrap;
142+
color: var(--text-color-1);
143+
font-size: 12px;
144+
font-weight: 500;
145+
}
107146
/* Selection speaks the same language as the pane titles: chrome marks the one
108147
thing you are working in. A saturated block would now be the only one in the
109148
window, competing with the syntax colours it sits beside. */
@@ -229,6 +268,9 @@
229268
padding: 0;
230269
margin: 0 4px;
231270
}
271+
.FiddleSidebar-AddRow--tree {
272+
margin-right: 4px;
273+
}
232274
.FiddleSidebar-AddRowInput {
233275
display: flex;
234276
flex-direction: row;

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

Lines changed: 119 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { searchNpm, parseDependencies, addDependency, removeDependency, type Npm
66
import { fileIcon } from '../../store';
77
import { DEFAULT_EDITORS } from '../types';
88
import type { FiddleFile } from '../state/FiddleState';
9+
import { buildCompactFileTree, type FileTreeNode } from './file-tree';
910
import './FiddleSidebar.css';
1011

1112
// Upstream sidebar-file-tree validation: supported editor extensions only,
@@ -42,6 +43,7 @@ export interface FiddleSidebarProps {
4243
*/
4344
export function FiddleSidebar(props: FiddleSidebarProps) {
4445
const editors = Array.from(props.files.values()).sort((a, b) => a.id.localeCompare(b.id));
46+
const editorTree = buildCompactFileTree(editors.map(editor => editor.id));
4547
const [moduleQuery, setModuleQuery] = useState('');
4648
const [searchResults, setSearchResults] = useState<NpmSearchResult[]>([]);
4749
const [searching, setSearching] = useState(false);
@@ -50,6 +52,7 @@ export function FiddleSidebar(props: FiddleSidebarProps) {
5052
// the native editors never need to detach for either flow.
5153
const [addingName, setAddingName] = useState<string | null>(null);
5254
const [renaming, setRenaming] = useState<{ id: string; name: string } | null>(null);
55+
const [collapsedFolders, setCollapsedFolders] = useState<Set<string>>(new Set());
5356
const addError = addingName && addingName.trim()
5457
? validateNewFileName(addingName.trim(), editors.map(e => e.id))
5558
: null;
@@ -110,6 +113,121 @@ export function FiddleSidebar(props: FiddleSidebarProps) {
110113
// eslint-disable-next-line react-hooks/exhaustive-deps
111114
}, [packageJson, props.onSetFileContent]);
112115

116+
const toggleFolder = useCallback((path: string) => {
117+
setCollapsedFolders(previous => {
118+
const next = new Set(previous);
119+
if (next.has(path)) next.delete(path);
120+
else next.add(path);
121+
return next;
122+
});
123+
}, []);
124+
125+
const renderEditorNode = (node: FileTreeNode, depth: number): any => {
126+
const indent = 10 + depth * 14;
127+
if (node.kind === 'folder') {
128+
const collapsed = collapsedFolders.has(node.path);
129+
return (
130+
<view key={`folder:${node.path}`} className="FiddleSidebar-TreeGroup">
131+
<view
132+
className="FiddleSidebar-FolderRow"
133+
style={{ paddingLeft: `${indent}px` }}
134+
bindtap={() => toggleFolder(node.path)}
135+
>
136+
<Icon
137+
icon={collapsed ? 'chevron-right' : 'chevron-down'}
138+
size={10}
139+
className="FiddleSidebar-FolderChevron"
140+
/>
141+
<Icon
142+
icon={collapsed ? 'folder-close' : 'folder-open'}
143+
size={13}
144+
className="FiddleSidebar-TreeFolderIcon"
145+
/>
146+
<text className="FiddleSidebar-FolderName" text-maxline="1">{node.name}</text>
147+
</view>
148+
{collapsed ? null : node.children.map(child => renderEditorNode(child, depth + 1))}
149+
</view>
150+
);
151+
}
152+
153+
const f = props.files.get(node.id);
154+
if (!f) return null;
155+
const isActive = f.id === props.activeEditorId;
156+
const canDelete = f.id !== DEFAULT_EDITORS.MAIN && f.id !== DEFAULT_EDITORS.PACKAGE;
157+
const cls = 'FiddleSidebar-Item'
158+
+ (isActive ? ' FiddleSidebar-Item--active' : '')
159+
+ (f.isDirty ? ' FiddleSidebar-Item--dirty' : '');
160+
161+
if (renaming?.id === f.id) {
162+
return (
163+
<view
164+
key={f.id}
165+
className="FiddleSidebar-AddRow FiddleSidebar-AddRow--tree"
166+
style={{ marginLeft: `${indent + 10}px` }}
167+
>
168+
<view className="FiddleSidebar-AddRowInput">
169+
<text className="FiddleSidebar-ItemGlyph">{fileIcon(renaming.name || f.id)}</text>
170+
<InputGroup
171+
fill
172+
placeholder={f.id}
173+
value={renaming.name}
174+
onChange={(v) => setRenaming(r => (r ? { ...r, name: v } : r))}
175+
onSubmit={commitRename}
176+
/>
177+
<view className="FiddleSidebar-EyeBtn" catchtap={() => commitRename(renaming.name)}>
178+
<Icon icon="tick" size={14} color="#9feafa" />
179+
</view>
180+
<view className="FiddleSidebar-EyeBtn" catchtap={() => setRenaming(null)}>
181+
<Icon icon="cross" size={14} color="#a7b6c2" />
182+
</view>
183+
</view>
184+
{renameError ? <text className="FiddleSidebar-AddRowError">{renameError}</text> : null}
185+
</view>
186+
);
187+
}
188+
189+
return (
190+
<view
191+
key={f.id}
192+
className={cls}
193+
style={{ paddingLeft: `${indent + 16}px` }}
194+
bindtap={() => props.onSelectEditor(f.id)}
195+
>
196+
<text className="FiddleSidebar-ItemGlyph">{fileIcon(f.id)}</text>
197+
<view className="FiddleSidebar-ItemLabel">
198+
<text className="FiddleSidebar-ItemName" text-maxline="1">{node.name}</text>
199+
</view>
200+
{f.isDirty ? <text className="FiddleSidebar-Dot"></text> : null}
201+
<view className="FiddleSidebar-RowActions">
202+
{isActive ? (
203+
<view className="FiddleSidebar-ActiveActions">
204+
{f.id !== DEFAULT_EDITORS.PACKAGE ? (
205+
<view
206+
className="FiddleSidebar-EyeBtn"
207+
catchtap={() => setRenaming({ id: f.id, name: f.id })}
208+
>
209+
<Icon icon="edit" size={12} color="#8ac7d6" />
210+
</view>
211+
) : null}
212+
{canDelete ? (
213+
<view className="FiddleSidebar-EyeBtn" catchtap={() => props.onRemoveFile(f.id)}>
214+
<Icon icon="trash" size={12} color="#df3434" />
215+
</view>
216+
) : null}
217+
</view>
218+
) : null}
219+
<view className="FiddleSidebar-EyeBtn" catchtap={() => props.onToggleEditor(f.id)}>
220+
<Icon
221+
icon={f.visible ? 'eye-open' : 'eye-off'}
222+
size={14}
223+
color={f.visible ? '#dcdcdc' : '#5c5f71'}
224+
/>
225+
</view>
226+
</view>
227+
</view>
228+
);
229+
};
230+
113231
return (
114232
<view className="FiddleSidebar">
115233
{/* Editors and Modules are two panels sharing one column, and the line
@@ -132,97 +250,7 @@ export function FiddleSidebar(props: FiddleSidebarProps) {
132250
</view>
133251
</view>
134252
<scroll-view className="FiddleSidebar-List" scroll-orientation="vertical">
135-
{editors.map(f => {
136-
const isActive = f.id === props.activeEditorId;
137-
const canDelete = f.id !== DEFAULT_EDITORS.MAIN && f.id !== DEFAULT_EDITORS.PACKAGE;
138-
const cls = 'FiddleSidebar-Item'
139-
+ (isActive ? ' FiddleSidebar-Item--active' : '')
140-
+ (f.isDirty ? ' FiddleSidebar-Item--dirty' : '');
141-
if (renaming?.id === f.id) {
142-
return (
143-
<view key={f.id} className="FiddleSidebar-AddRow">
144-
<view className="FiddleSidebar-AddRowInput">
145-
{/* Of the name being typed, not of the file as it stands: rename
146-
main.js to main.css and the glyph turns over before you commit,
147-
which is the cheapest possible confirmation that the extension
148-
landed the way you meant it to. */}
149-
<text className="FiddleSidebar-ItemGlyph">{fileIcon(renaming.name || f.id)}</text>
150-
<InputGroup
151-
fill
152-
placeholder={f.id}
153-
value={renaming.name}
154-
onChange={(v) => setRenaming(r => (r ? { ...r, name: v } : r))}
155-
onSubmit={commitRename}
156-
/>
157-
<view className="FiddleSidebar-EyeBtn" catchtap={() => commitRename(renaming.name)}>
158-
<Icon icon="tick" size={14} color="#9feafa" />
159-
</view>
160-
<view className="FiddleSidebar-EyeBtn" catchtap={() => setRenaming(null)}>
161-
<Icon icon="cross" size={14} color="#a7b6c2" />
162-
</view>
163-
</view>
164-
{renameError ? (
165-
<text className="FiddleSidebar-AddRowError">{renameError}</text>
166-
) : null}
167-
</view>
168-
);
169-
}
170-
return (
171-
<view
172-
key={f.id}
173-
className={cls}
174-
bindtap={() => props.onSelectEditor(f.id)}
175-
>
176-
{/* The same glyph Quick Open uses for the same file. Two file
177-
lists in one app were speaking two icon languages — a tinted
178-
monochrome document here, an emoji there — and the one you
179-
reach for by keyboard is not the one you reach for by eye, so
180-
the mismatch showed up every time you used both. One list,
181-
one language; `fileIcon` is the single map. */}
182-
<text className="FiddleSidebar-ItemGlyph">{fileIcon(f.id)}</text>
183-
<view className="FiddleSidebar-ItemLabel">
184-
<text className="FiddleSidebar-ItemName" text-maxline="1">{f.id}</text>
185-
</view>
186-
{f.isDirty ? <text className="FiddleSidebar-Dot"></text> : null}
187-
{/* rename/delete only on the active row — upstream uses a
188-
right-click context menu, which Lynx doesn't deliver, and
189-
CSS :hover display-flips leave stale paint behind (icons
190-
ghost over the eye toggle after unhover) */}
191-
<view className="FiddleSidebar-RowActions">
192-
{isActive ? (
193-
<view className="FiddleSidebar-ActiveActions">
194-
{f.id !== DEFAULT_EDITORS.PACKAGE ? (
195-
<view
196-
className="FiddleSidebar-EyeBtn"
197-
catchtap={() => setRenaming({ id: f.id, name: f.id })}
198-
>
199-
<Icon icon="edit" size={12} color="#8ac7d6" />
200-
</view>
201-
) : null}
202-
{canDelete ? (
203-
<view
204-
className="FiddleSidebar-EyeBtn"
205-
catchtap={() => props.onRemoveFile(f.id)}
206-
>
207-
<Icon icon="trash" size={12} color="#df3434" />
208-
</view>
209-
) : null}
210-
</view>
211-
) : null}
212-
<view
213-
className="FiddleSidebar-EyeBtn"
214-
catchtap={() => props.onToggleEditor(f.id)}
215-
>
216-
<Icon
217-
icon={f.visible ? 'eye-open' : 'eye-off'}
218-
size={14}
219-
color={f.visible ? '#dcdcdc' : '#5c5f71'}
220-
/>
221-
</view>
222-
</view>
223-
</view>
224-
);
225-
})}
253+
{editorTree.map(node => renderEditorNode(node, 0))}
226254
{addingName != null ? (
227255
<view className="FiddleSidebar-AddRow">
228256
<view className="FiddleSidebar-AddRowInput">

0 commit comments

Comments
 (0)