create-termui-app 0.1.4 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -4
- package/dist/index.js +1007 -157
- package/dist/index.js.map +1 -1
- package/package.json +13 -9
- package/templates/ai-assistant/package.json +27 -0
- package/templates/ai-assistant/src/index.tsx +254 -0
- package/templates/dashboard/README.md +16 -0
- package/templates/dashboard/package.json +26 -0
- package/templates/dashboard/src/index.tsx +325 -0
- package/templates/file-manager/package.json +26 -0
- package/templates/file-manager/src/index.tsx +296 -0
- package/LICENSE +0 -21
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
/** @jsxImportSource @termuijs/jsx */
|
|
2
|
+
import { render, useState, useEffect, useRef, useMemo, useKeymap, ErrorBoundary } from '@termuijs/jsx';
|
|
3
|
+
import { AutoThemeProvider, useTheme } from '@termuijs/tss';
|
|
4
|
+
import { AppShell, Tabs } from '@termuijs/ui';
|
|
5
|
+
import { Box, Text, LineChart, BarChart, type BarGroup } from '@termuijs/widgets';
|
|
6
|
+
|
|
7
|
+
interface CpuMetrics {
|
|
8
|
+
percent: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface MemoryMetrics {
|
|
12
|
+
percent: number;
|
|
13
|
+
used: string;
|
|
14
|
+
free: string;
|
|
15
|
+
total: string;
|
|
16
|
+
raw: { used: number; free: number; total: number };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface DiskMetrics {
|
|
20
|
+
percent: number;
|
|
21
|
+
used: string;
|
|
22
|
+
free: string;
|
|
23
|
+
total: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface ProcessInfo {
|
|
27
|
+
pid: number;
|
|
28
|
+
name: string;
|
|
29
|
+
cpu: number;
|
|
30
|
+
mem: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
type OptionalDataHooks = {
|
|
34
|
+
useCpu?: (intervalMs?: number) => CpuMetrics;
|
|
35
|
+
useMemory?: (intervalMs?: number) => MemoryMetrics;
|
|
36
|
+
useDisk?: (intervalMs?: number) => DiskMetrics;
|
|
37
|
+
useTopProcesses?: (limit?: number, intervalMs?: number) => ProcessInfo[];
|
|
38
|
+
} | null;
|
|
39
|
+
|
|
40
|
+
let dataHooks: OptionalDataHooks = null;
|
|
41
|
+
try {
|
|
42
|
+
const mod = (await import('@termuijs/data')) as any;
|
|
43
|
+
dataHooks = {
|
|
44
|
+
useCpu: mod.useCpu,
|
|
45
|
+
useMemory: mod.useMemory,
|
|
46
|
+
useDisk: mod.useDisk,
|
|
47
|
+
useTopProcesses: mod.useTopProcesses,
|
|
48
|
+
};
|
|
49
|
+
} catch {
|
|
50
|
+
dataHooks = null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function clamp(value: number, min: number, max: number) {
|
|
54
|
+
return Math.min(max, Math.max(min, value));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function randomStep(value: number, step: number, min: number, max: number) {
|
|
58
|
+
return clamp(value + (Math.random() - 0.5) * step, min, max);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function useFallbackCpu(intervalMs = 1000) {
|
|
62
|
+
const [metric, setMetric] = useState<CpuMetrics>({ percent: 45 });
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
const id = setInterval(() => setMetric((current) => ({
|
|
65
|
+
percent: randomStep(current.percent, 8, 5, 95),
|
|
66
|
+
})), intervalMs);
|
|
67
|
+
return () => clearInterval(id);
|
|
68
|
+
}, [intervalMs]);
|
|
69
|
+
return metric;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function useFallbackMemory(intervalMs = 1000) {
|
|
73
|
+
const [metric, setMetric] = useState<MemoryMetrics>({
|
|
74
|
+
percent: 62,
|
|
75
|
+
used: '12.5 GB',
|
|
76
|
+
free: '7.6 GB',
|
|
77
|
+
total: '20.1 GB',
|
|
78
|
+
raw: { used: 12.5 * 1024 ** 3, free: 7.6 * 1024 ** 3, total: 20.1 * 1024 ** 3 },
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
useEffect(() => {
|
|
82
|
+
const id = setInterval(() => {
|
|
83
|
+
setMetric((current) => {
|
|
84
|
+
const percent = randomStep(current.percent, 5, 20, 95);
|
|
85
|
+
const total = 20.1 * 1024 ** 3;
|
|
86
|
+
const used = Math.round((percent / 100) * total);
|
|
87
|
+
const free = total - used;
|
|
88
|
+
return {
|
|
89
|
+
percent,
|
|
90
|
+
used: `${(used / 1024 ** 3).toFixed(1)} GB`,
|
|
91
|
+
free: `${(free / 1024 ** 3).toFixed(1)} GB`,
|
|
92
|
+
total: '20.1 GB',
|
|
93
|
+
raw: { used, free, total },
|
|
94
|
+
};
|
|
95
|
+
});
|
|
96
|
+
}, intervalMs);
|
|
97
|
+
return () => clearInterval(id);
|
|
98
|
+
}, [intervalMs]);
|
|
99
|
+
|
|
100
|
+
return metric;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function useFallbackDisk(intervalMs = 2000) {
|
|
104
|
+
const [metric, setMetric] = useState<DiskMetrics>({
|
|
105
|
+
percent: 38,
|
|
106
|
+
used: '120 GB',
|
|
107
|
+
free: '190 GB',
|
|
108
|
+
total: '310 GB',
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
useEffect(() => {
|
|
112
|
+
const id = setInterval(() => {
|
|
113
|
+
setMetric((current) => {
|
|
114
|
+
const percent = randomStep(current.percent, 3, 10, 95);
|
|
115
|
+
const total = 310;
|
|
116
|
+
const used = Math.round((percent / 100) * total);
|
|
117
|
+
const free = total - used;
|
|
118
|
+
return {
|
|
119
|
+
percent,
|
|
120
|
+
used: `${used} GB`,
|
|
121
|
+
free: `${free} GB`,
|
|
122
|
+
total: '310 GB',
|
|
123
|
+
};
|
|
124
|
+
});
|
|
125
|
+
}, intervalMs);
|
|
126
|
+
return () => clearInterval(id);
|
|
127
|
+
}, [intervalMs]);
|
|
128
|
+
|
|
129
|
+
return metric;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function useFallbackTopProcesses(limit = 5, intervalMs = 2000) {
|
|
133
|
+
const baseline: ProcessInfo[] = [
|
|
134
|
+
{ pid: 3245, name: 'termui', cpu: 12.4, mem: 6.2 },
|
|
135
|
+
{ pid: 4190, name: 'bun', cpu: 8.1, mem: 4.0 },
|
|
136
|
+
{ pid: 2187, name: 'node', cpu: 4.8, mem: 3.1 },
|
|
137
|
+
{ pid: 9032, name: 'code', cpu: 3.6, mem: 2.8 },
|
|
138
|
+
{ pid: 1540, name: 'docker', cpu: 2.0, mem: 2.4 },
|
|
139
|
+
];
|
|
140
|
+
|
|
141
|
+
const [list, setList] = useState<ProcessInfo[]>(baseline.slice(0, limit));
|
|
142
|
+
|
|
143
|
+
useEffect(() => {
|
|
144
|
+
const id = setInterval(() => {
|
|
145
|
+
setList((current) => current.map((proc) => ({
|
|
146
|
+
...proc,
|
|
147
|
+
cpu: clamp(proc.cpu + (Math.random() - 0.5) * 2.5, 0.1, 30),
|
|
148
|
+
mem: clamp(proc.mem + (Math.random() - 0.5) * 1.5, 0.1, 16),
|
|
149
|
+
})));
|
|
150
|
+
}, intervalMs);
|
|
151
|
+
return () => clearInterval(id);
|
|
152
|
+
}, [intervalMs]);
|
|
153
|
+
|
|
154
|
+
return list;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function useOptionalCpu(intervalMs = 1000) {
|
|
158
|
+
return dataHooks?.useCpu ? dataHooks.useCpu(intervalMs) : useFallbackCpu(intervalMs);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function useOptionalMemory(intervalMs = 1000) {
|
|
162
|
+
return dataHooks?.useMemory ? dataHooks.useMemory(intervalMs) : useFallbackMemory(intervalMs);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function useOptionalDisk(intervalMs = 2000) {
|
|
166
|
+
return dataHooks?.useDisk ? dataHooks.useDisk(intervalMs) : useFallbackDisk(intervalMs);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function useOptionalTopProcesses(limit = 5, intervalMs = 2000) {
|
|
170
|
+
return dataHooks?.useTopProcesses ? dataHooks.useTopProcesses(limit, intervalMs) : useFallbackTopProcesses(limit, intervalMs);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function createMetricCard(title: string, value: string, detail: string, colorName?: string) {
|
|
174
|
+
const card = new Box({ flexDirection: 'column', border: 'single', padding: 1, flexGrow: 1, minWidth: 20, minHeight: 6 });
|
|
175
|
+
card.addChild(new Text(title, { color: colorName ?? 'white', bold: true }));
|
|
176
|
+
card.addChild(new Text(value, { bold: true, marginTop: 1 } as any));
|
|
177
|
+
card.addChild(new Text(detail, { dim: true, marginTop: 1 } as any));
|
|
178
|
+
return card;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function createProcessRow(proc: ProcessInfo) {
|
|
182
|
+
const row = new Box({ flexDirection: 'row', gap: 2, padding: 0 });
|
|
183
|
+
row.addChild(new Text(String(proc.pid), { color: 'cyan' }));
|
|
184
|
+
row.addChild(new Text(proc.name, { color: 'white', bold: true }));
|
|
185
|
+
row.addChild(new Text(`${proc.cpu.toFixed(1)}% CPU`, { color: 'green' }));
|
|
186
|
+
row.addChild(new Text(`${proc.mem.toFixed(1)}% MEM`, { color: 'yellow' }));
|
|
187
|
+
return row;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function Dashboard() {
|
|
191
|
+
const theme = useTheme();
|
|
192
|
+
const [activeTab, setActiveTab] = useState(0);
|
|
193
|
+
const [requestHistory, setRequestHistory] = useState<number[]>(() => Array.from({ length: 18 }, () => 20 + Math.random() * 35));
|
|
194
|
+
const [errorCount, setErrorCount] = useState(5);
|
|
195
|
+
const [processMetric, setProcessMetric] = useState(46);
|
|
196
|
+
|
|
197
|
+
const cpu = useOptionalCpu(1000);
|
|
198
|
+
const memory = useOptionalMemory(1000);
|
|
199
|
+
const disk = useOptionalDisk(2000);
|
|
200
|
+
const topProcesses = useOptionalTopProcesses(5, 2500);
|
|
201
|
+
|
|
202
|
+
const lineChartRef = useRef<LineChart | null>(null);
|
|
203
|
+
if (!lineChartRef.current) {
|
|
204
|
+
lineChartRef.current = new LineChart(requestHistory, { flexGrow: 1 }, { min: 0, max: 100, showYAxis: true, color: { type: 'named', name: 'cyan' } });
|
|
205
|
+
} else {
|
|
206
|
+
lineChartRef.current.setData(requestHistory);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const barChartData: BarGroup[] = [
|
|
210
|
+
{
|
|
211
|
+
label: 'Health',
|
|
212
|
+
bars: [
|
|
213
|
+
{ value: errorCount, label: 'Errors', color: { type: 'named', name: 'red' } },
|
|
214
|
+
{ value: processMetric, label: 'Processes', color: { type: 'named', name: 'yellow' } },
|
|
215
|
+
],
|
|
216
|
+
},
|
|
217
|
+
];
|
|
218
|
+
|
|
219
|
+
const barChartRef = useRef<BarChart | null>(null);
|
|
220
|
+
if (!barChartRef.current) {
|
|
221
|
+
barChartRef.current = new BarChart(barChartData, { flexGrow: 1 }, { direction: 'horizontal', barWidth: 1, barGap: 1, max: 100 });
|
|
222
|
+
} else {
|
|
223
|
+
barChartRef.current.setData(barChartData);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const tabs = useMemo(() => new Tabs([
|
|
227
|
+
{ label: 'Overview', content: new Box({ flexGrow: 1 }) },
|
|
228
|
+
{ label: 'Processes', content: new Box({ flexGrow: 1 }) },
|
|
229
|
+
]), []);
|
|
230
|
+
|
|
231
|
+
useEffect(() => {
|
|
232
|
+
tabs.selectTab(activeTab);
|
|
233
|
+
}, [activeTab, tabs]);
|
|
234
|
+
|
|
235
|
+
useEffect(() => {
|
|
236
|
+
const id = setInterval(() => {
|
|
237
|
+
setRequestHistory((current) => {
|
|
238
|
+
const next = [...current, clamp(20 + Math.random() * 45, 5, 95)];
|
|
239
|
+
return next.length > 20 ? next.slice(next.length - 20) : next;
|
|
240
|
+
});
|
|
241
|
+
setErrorCount((current) => Math.max(0, Math.min(100, current + Math.round((Math.random() - 0.5) * 6))));
|
|
242
|
+
setProcessMetric((current) => Math.max(12, Math.min(96, current + Math.round((Math.random() - 0.5) * 4))));
|
|
243
|
+
}, 1200);
|
|
244
|
+
return () => clearInterval(id);
|
|
245
|
+
}, []);
|
|
246
|
+
|
|
247
|
+
const header = useMemo(() => new Box({ flexDirection: 'row', border: 'single', padding: 1, gap: 2 }), []);
|
|
248
|
+
useEffect(() => {
|
|
249
|
+
header.clearChildren();
|
|
250
|
+
header.addChild(new Text('Live Dashboard', { bold: true, color: theme.colors.primary }));
|
|
251
|
+
header.addChild(new Text('Overview / Processes', { dim: true }));
|
|
252
|
+
header.addChild(new Text(`Data ${dataHooks ? 'hooks' : 'fallback'}`, { color: dataHooks ? 'green' : 'yellow' }));
|
|
253
|
+
}, [dataHooks, header, theme.colors.primary]);
|
|
254
|
+
|
|
255
|
+
const footer = useMemo(() => new Box({ flexDirection: 'row', border: 'single', padding: 1, gap: 2 }), []);
|
|
256
|
+
useEffect(() => {
|
|
257
|
+
footer.clearChildren();
|
|
258
|
+
footer.addChild(new Text('q: quit', { dim: true }));
|
|
259
|
+
footer.addChild(new Text('Tab: switch tabs', { dim: true }));
|
|
260
|
+
footer.addChild(new Text('r: refresh metrics', { dim: true }));
|
|
261
|
+
}, [footer]);
|
|
262
|
+
|
|
263
|
+
const main = useMemo(() => new Box({ flexDirection: 'column', gap: 1, padding: 1, flexGrow: 1 }), []);
|
|
264
|
+
useEffect(() => {
|
|
265
|
+
main.clearChildren();
|
|
266
|
+
|
|
267
|
+
const metricsRow = new Box({ flexDirection: 'row', gap: 1, flexGrow: 1 });
|
|
268
|
+
metricsRow.addChild(createMetricCard('CPU', `${Math.round(cpu.percent)}%`, `Load`, 'cyan'));
|
|
269
|
+
metricsRow.addChild(createMetricCard('Memory', `${Math.round(memory.percent)}%`, `${memory.used} used`, 'magenta'));
|
|
270
|
+
metricsRow.addChild(createMetricCard('Disk', `${Math.round(disk.percent)}%`, `${disk.used} used`, 'yellow'));
|
|
271
|
+
metricsRow.addChild(createMetricCard('Processes', `${topProcesses.length}`, `${processMetric} active`, 'green'));
|
|
272
|
+
|
|
273
|
+
const chartRow = new Box({ flexDirection: 'row', gap: 1, flexGrow: 1 });
|
|
274
|
+
const lineChartPanel = new Box({ flexDirection: 'column', border: 'single', padding: 1, flexGrow: 1 });
|
|
275
|
+
lineChartPanel.addChild(new Text('Requests / History', { bold: true, color: 'cyan' }));
|
|
276
|
+
lineChartPanel.addChild(lineChartRef.current!);
|
|
277
|
+
|
|
278
|
+
const barChartPanel = new Box({ flexDirection: 'column', border: 'single', padding: 1, flexGrow: 1 });
|
|
279
|
+
barChartPanel.addChild(new Text('Errors / Process Load', { bold: true, color: 'red' }));
|
|
280
|
+
barChartPanel.addChild(barChartRef.current!);
|
|
281
|
+
|
|
282
|
+
chartRow.addChild(lineChartPanel);
|
|
283
|
+
chartRow.addChild(barChartPanel);
|
|
284
|
+
|
|
285
|
+
const overviewPanel = new Box({ flexDirection: 'column', gap: 1, flexGrow: 1 });
|
|
286
|
+
overviewPanel.addChild(metricsRow);
|
|
287
|
+
overviewPanel.addChild(chartRow);
|
|
288
|
+
|
|
289
|
+
const processesPanel = new Box({ flexDirection: 'column', gap: 1, flexGrow: 1 });
|
|
290
|
+
processesPanel.addChild(new Text('Top Processes', { bold: true, color: theme.colors.primary }));
|
|
291
|
+
topProcesses.forEach((proc) => processesPanel.addChild(createProcessRow(proc)));
|
|
292
|
+
|
|
293
|
+
main.addChild(tabs);
|
|
294
|
+
main.addChild(activeTab === 0 ? overviewPanel : processesPanel);
|
|
295
|
+
}, [activeTab, cpu.percent, memory.percent, memory.used, disk.percent, disk.used, processMetric, topProcesses, lineChartRef, barChartRef, theme.colors.primary]);
|
|
296
|
+
|
|
297
|
+
const shell = useMemo(() => new AppShell({ header, footer, main }), [header, footer, main]);
|
|
298
|
+
|
|
299
|
+
useKeymap([
|
|
300
|
+
{ key: 'q', action: () => process.exit(0), description: 'Quit' },
|
|
301
|
+
{ key: 'c', ctrl: true, action: () => process.exit(0), description: 'Quit' },
|
|
302
|
+
{ key: 'tab', action: () => setActiveTab((current) => (current + 1) % 2), description: 'Next tab' },
|
|
303
|
+
{ key: 'tab', shift: true, action: () => setActiveTab((current) => (current + 1) % 2), description: 'Previous tab' },
|
|
304
|
+
{ key: 'r', action: () => setRequestHistory((current) => [...current]), description: 'Refresh data' },
|
|
305
|
+
]);
|
|
306
|
+
|
|
307
|
+
return shell;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function App() {
|
|
311
|
+
return (
|
|
312
|
+
<AutoThemeProvider>
|
|
313
|
+
<ErrorBoundary fallback={(err) => {
|
|
314
|
+
const errorBox = new Box({ border: 'single', padding: 1 });
|
|
315
|
+
errorBox.addChild(new Text('Dashboard Error', { color: 'red', bold: true }));
|
|
316
|
+
errorBox.addChild(new Text(err.message, { color: 'red' }));
|
|
317
|
+
return errorBox;
|
|
318
|
+
}}>
|
|
319
|
+
<Dashboard />
|
|
320
|
+
</ErrorBoundary>
|
|
321
|
+
</AutoThemeProvider>
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
render(<App />, { title: '{{name}} Dashboard' });
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "file-manager",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "bun --watch src/index.tsx",
|
|
8
|
+
"build": "tsup src/index.tsx --format esm",
|
|
9
|
+
"start": "bun dist/index.js"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"@termuijs/core": "latest",
|
|
13
|
+
"@termuijs/widgets": "latest",
|
|
14
|
+
"@termuijs/ui": "latest",
|
|
15
|
+
"@termuijs/jsx": "latest",
|
|
16
|
+
"@termuijs/tss": "latest"
|
|
17
|
+
},
|
|
18
|
+
"devDependencies": {
|
|
19
|
+
"@types/bun": "latest",
|
|
20
|
+
"tsup": "^8.0.0",
|
|
21
|
+
"typescript": "^5.3.0"
|
|
22
|
+
},
|
|
23
|
+
"engines": {
|
|
24
|
+
"bun": ">=1.3.0"
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
/** @jsxImportSource @termuijs/jsx */
|
|
2
|
+
import * as fs from 'node:fs';
|
|
3
|
+
import * as path from 'node:path';
|
|
4
|
+
import { render, useEffect, useKeymap, useMemo, useRef, useState, ErrorBoundary } from '@termuijs/jsx';
|
|
5
|
+
import { AppShell, FilePicker } from '@termuijs/ui';
|
|
6
|
+
import { Box, DiffView, Tree, Text, type DiffLine, type TreeNode } from '@termuijs/widgets';
|
|
7
|
+
|
|
8
|
+
function readPreview(filePath: string): DiffLine[] {
|
|
9
|
+
try {
|
|
10
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
11
|
+
return content.replace(/\r/g, '').split('\n').map((line, index) => ({
|
|
12
|
+
type: 'context' as const,
|
|
13
|
+
content: line || ' ',
|
|
14
|
+
lineNo: index + 1,
|
|
15
|
+
}));
|
|
16
|
+
} catch (error) {
|
|
17
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
18
|
+
return [{ type: 'context', content: message }];
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function findFirstPreviewPath(rootPath: string): string {
|
|
23
|
+
try {
|
|
24
|
+
const entries = fs.readdirSync(rootPath, { withFileTypes: true });
|
|
25
|
+
for (const entry of entries) {
|
|
26
|
+
if (entry.isDirectory() || entry.name.startsWith('.')) continue;
|
|
27
|
+
return path.join(rootPath, entry.name);
|
|
28
|
+
}
|
|
29
|
+
} catch {
|
|
30
|
+
// Fall back to the directory path when reading fails.
|
|
31
|
+
}
|
|
32
|
+
return rootPath;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function buildTree(rootPath: string, depth = 0, maxDepth = 3): TreeNode[] {
|
|
36
|
+
if (depth === 0) {
|
|
37
|
+
return [{
|
|
38
|
+
label: path.basename(rootPath) || rootPath,
|
|
39
|
+
expanded: true,
|
|
40
|
+
children: buildTree(rootPath, depth + 1, maxDepth),
|
|
41
|
+
}];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (depth > maxDepth) {
|
|
45
|
+
return [];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const entries: TreeNode[] = [];
|
|
49
|
+
try {
|
|
50
|
+
const dirents = fs.readdirSync(rootPath, { withFileTypes: true });
|
|
51
|
+
const sorted = [...dirents].sort((left, right) => left.name.localeCompare(right.name));
|
|
52
|
+
|
|
53
|
+
for (const entry of sorted) {
|
|
54
|
+
if (entry.name.startsWith('.')) continue;
|
|
55
|
+
const fullPath = path.join(rootPath, entry.name);
|
|
56
|
+
if (entry.isDirectory()) {
|
|
57
|
+
entries.push({
|
|
58
|
+
label: entry.name,
|
|
59
|
+
expanded: depth < 2,
|
|
60
|
+
data: { path: fullPath, type: 'directory' },
|
|
61
|
+
children: buildTree(fullPath, depth + 1, maxDepth),
|
|
62
|
+
});
|
|
63
|
+
} else {
|
|
64
|
+
entries.push({
|
|
65
|
+
label: entry.name,
|
|
66
|
+
data: { path: fullPath, type: 'file' },
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
} catch (error) {
|
|
71
|
+
entries.push({
|
|
72
|
+
label: error instanceof Error ? error.message : String(error),
|
|
73
|
+
data: { path: rootPath, type: 'error' },
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return entries;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function setPaneFocus(widget: Box, focused: boolean): void {
|
|
81
|
+
widget.setStyle({
|
|
82
|
+
borderColor: focused ? { type: 'named', name: 'cyan' } : { type: 'named', name: 'brightBlack' },
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
type Pane = 'tree' | 'picker' | 'preview';
|
|
87
|
+
|
|
88
|
+
function FileManager() {
|
|
89
|
+
const rootPath = process.cwd();
|
|
90
|
+
const [cwd, setCwd] = useState(rootPath);
|
|
91
|
+
const [previewPath, setPreviewPath] = useState(findFirstPreviewPath(rootPath));
|
|
92
|
+
const [focusedPane, setFocusedPane] = useState<Pane>('picker');
|
|
93
|
+
|
|
94
|
+
const tree = useRef(new Tree({
|
|
95
|
+
nodes: buildTree(rootPath),
|
|
96
|
+
onSelect: (node) => {
|
|
97
|
+
const payload = node.data as { path?: string; type?: string } | undefined;
|
|
98
|
+
if (payload?.type === 'file' && payload.path) {
|
|
99
|
+
setPreviewPath(payload.path);
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
}, { flexGrow: 1 }));
|
|
103
|
+
|
|
104
|
+
const filePicker = useRef(new FilePicker({
|
|
105
|
+
startPath: rootPath,
|
|
106
|
+
onSelect: (selectedPath: string) => {
|
|
107
|
+
setPreviewPath(selectedPath);
|
|
108
|
+
},
|
|
109
|
+
onCancel: () => process.exit(0),
|
|
110
|
+
}));
|
|
111
|
+
|
|
112
|
+
const preview = useRef(new DiffView({
|
|
113
|
+
lines: readPreview(findFirstPreviewPath(rootPath)),
|
|
114
|
+
showLineNumbers: true,
|
|
115
|
+
gutterWidth: 6,
|
|
116
|
+
}, { flexGrow: 1 }));
|
|
117
|
+
|
|
118
|
+
const leftPane = useMemo(() => new Box({
|
|
119
|
+
flexDirection: 'column',
|
|
120
|
+
border: 'single',
|
|
121
|
+
padding: 1,
|
|
122
|
+
flexGrow: 1,
|
|
123
|
+
}), []);
|
|
124
|
+
|
|
125
|
+
const centerPane = useMemo(() => new Box({
|
|
126
|
+
flexDirection: 'column',
|
|
127
|
+
border: 'single',
|
|
128
|
+
padding: 1,
|
|
129
|
+
flexGrow: 1,
|
|
130
|
+
}), []);
|
|
131
|
+
|
|
132
|
+
const rightPane = useMemo(() => new Box({
|
|
133
|
+
flexDirection: 'column',
|
|
134
|
+
border: 'single',
|
|
135
|
+
padding: 1,
|
|
136
|
+
flexGrow: 1,
|
|
137
|
+
}), []);
|
|
138
|
+
|
|
139
|
+
const mainArea = useMemo(() => new Box({
|
|
140
|
+
flexDirection: 'row',
|
|
141
|
+
gap: 1,
|
|
142
|
+
flexGrow: 1,
|
|
143
|
+
}), []);
|
|
144
|
+
|
|
145
|
+
const header = useMemo(() => new Box({
|
|
146
|
+
flexDirection: 'row',
|
|
147
|
+
border: 'single',
|
|
148
|
+
padding: 1,
|
|
149
|
+
gap: 2,
|
|
150
|
+
}), []);
|
|
151
|
+
|
|
152
|
+
const footer = useMemo(() => new Box({
|
|
153
|
+
flexDirection: 'row',
|
|
154
|
+
border: 'single',
|
|
155
|
+
padding: 1,
|
|
156
|
+
gap: 2,
|
|
157
|
+
}), []);
|
|
158
|
+
|
|
159
|
+
const shell = useMemo(() => new AppShell({
|
|
160
|
+
header,
|
|
161
|
+
footer,
|
|
162
|
+
sidebar: leftPane,
|
|
163
|
+
main: mainArea,
|
|
164
|
+
sidebarWidth: 32,
|
|
165
|
+
}), [footer, header, leftPane, mainArea]);
|
|
166
|
+
|
|
167
|
+
useEffect(() => {
|
|
168
|
+
tree.current.setNodes(buildTree(cwd));
|
|
169
|
+
}, [cwd]);
|
|
170
|
+
|
|
171
|
+
useEffect(() => {
|
|
172
|
+
preview.current.setLines(readPreview(previewPath));
|
|
173
|
+
}, [previewPath]);
|
|
174
|
+
|
|
175
|
+
useEffect(() => {
|
|
176
|
+
header.clearChildren();
|
|
177
|
+
header.addChild(new Text('Path: ' + cwd));
|
|
178
|
+
header.addChild(new Text('Focus: ' + focusedPane));
|
|
179
|
+
}, [cwd, focusedPane, header]);
|
|
180
|
+
|
|
181
|
+
useEffect(() => {
|
|
182
|
+
footer.clearChildren();
|
|
183
|
+
footer.addChild(new Text('Tab / Shift+Tab: switch panes'));
|
|
184
|
+
footer.addChild(new Text('Enter: open item | q: quit'));
|
|
185
|
+
}, [footer]);
|
|
186
|
+
|
|
187
|
+
useEffect(() => {
|
|
188
|
+
leftPane.clearChildren();
|
|
189
|
+
leftPane.addChild(tree.current);
|
|
190
|
+
|
|
191
|
+
centerPane.clearChildren();
|
|
192
|
+
centerPane.addChild(filePicker.current);
|
|
193
|
+
|
|
194
|
+
rightPane.clearChildren();
|
|
195
|
+
rightPane.addChild(preview.current);
|
|
196
|
+
|
|
197
|
+
mainArea.clearChildren();
|
|
198
|
+
mainArea.addChild(centerPane);
|
|
199
|
+
mainArea.addChild(rightPane);
|
|
200
|
+
|
|
201
|
+
setPaneFocus(leftPane, focusedPane === 'tree');
|
|
202
|
+
setPaneFocus(centerPane, focusedPane === 'picker');
|
|
203
|
+
setPaneFocus(rightPane, focusedPane === 'preview');
|
|
204
|
+
}, [centerPane, focusedPane, leftPane, mainArea, rightPane]);
|
|
205
|
+
|
|
206
|
+
const cyclePane = (direction: 1 | -1): void => {
|
|
207
|
+
const order: Pane[] = ['tree', 'picker', 'preview'];
|
|
208
|
+
const index = order.indexOf(focusedPane);
|
|
209
|
+
setFocusedPane(order[(index + direction + order.length) % order.length]);
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
const syncPickerPath = (): void => {
|
|
213
|
+
setCwd(filePicker.current.currentPath);
|
|
214
|
+
const selected = filePicker.current.selectedEntry;
|
|
215
|
+
setPreviewPath(
|
|
216
|
+
selected && !selected.isDir
|
|
217
|
+
? selected.fullPath
|
|
218
|
+
: findFirstPreviewPath(filePicker.current.currentPath),
|
|
219
|
+
);
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
useKeymap([
|
|
223
|
+
{ key: 'tab', action: () => cyclePane(1), description: 'Next pane' },
|
|
224
|
+
{ key: 'tab', shift: true, action: () => cyclePane(-1), description: 'Previous pane' },
|
|
225
|
+
{ key: 'enter', action: () => {
|
|
226
|
+
if (focusedPane === 'tree') {
|
|
227
|
+
tree.current.handleKey('Enter');
|
|
228
|
+
const selected = tree.current.selectedNode;
|
|
229
|
+
const payload = selected?.data as { path?: string; type?: string } | undefined;
|
|
230
|
+
if (payload?.type === 'file' && payload.path) {
|
|
231
|
+
setPreviewPath(payload.path);
|
|
232
|
+
}
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (focusedPane === 'picker') {
|
|
237
|
+
filePicker.current.confirm();
|
|
238
|
+
syncPickerPath();
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
preview.current.handleKey('Enter');
|
|
243
|
+
}, description: 'Open item' },
|
|
244
|
+
{ key: 'up', action: () => {
|
|
245
|
+
if (focusedPane === 'tree') tree.current.handleKey('ArrowUp');
|
|
246
|
+
else if (focusedPane === 'picker') filePicker.current.selectPrev();
|
|
247
|
+
else preview.current.handleKey('ArrowUp');
|
|
248
|
+
}, description: 'Move up' },
|
|
249
|
+
{ key: 'down', action: () => {
|
|
250
|
+
if (focusedPane === 'tree') tree.current.handleKey('ArrowDown');
|
|
251
|
+
else if (focusedPane === 'picker') filePicker.current.selectNext();
|
|
252
|
+
else preview.current.handleKey('ArrowDown');
|
|
253
|
+
}, description: 'Move down' },
|
|
254
|
+
{ key: 'left', action: () => {
|
|
255
|
+
if (focusedPane === 'tree') tree.current.handleKey('ArrowLeft');
|
|
256
|
+
else if (focusedPane === 'picker') {
|
|
257
|
+
filePicker.current.goUp();
|
|
258
|
+
syncPickerPath();
|
|
259
|
+
}
|
|
260
|
+
}, description: 'Collapse or go up' },
|
|
261
|
+
{ key: 'right', action: () => {
|
|
262
|
+
if (focusedPane === 'tree') tree.current.handleKey('ArrowRight');
|
|
263
|
+
else if (focusedPane === 'picker') {
|
|
264
|
+
filePicker.current.confirm();
|
|
265
|
+
syncPickerPath();
|
|
266
|
+
}
|
|
267
|
+
}, description: 'Expand or open' },
|
|
268
|
+
{ key: 'backspace', action: () => {
|
|
269
|
+
if (focusedPane === 'picker') {
|
|
270
|
+
filePicker.current.goUp();
|
|
271
|
+
syncPickerPath();
|
|
272
|
+
}
|
|
273
|
+
}, description: 'Parent directory' },
|
|
274
|
+
{ key: 'q', action: () => process.exit(0), description: 'Quit' },
|
|
275
|
+
{ key: 'c', ctrl: true, action: () => process.exit(0), description: 'Quit' },
|
|
276
|
+
]);
|
|
277
|
+
|
|
278
|
+
return shell;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function App() {
|
|
282
|
+
return (
|
|
283
|
+
<AutoThemeProvider>
|
|
284
|
+
<ErrorBoundary fallback={(err) => (
|
|
285
|
+
<box border="single" borderColor="red" padding={1}>
|
|
286
|
+
<text color="red" bold>File Manager Error</text>
|
|
287
|
+
<text>{err.message}</text>
|
|
288
|
+
</box>
|
|
289
|
+
)}>
|
|
290
|
+
<FileManager />
|
|
291
|
+
</ErrorBoundary>
|
|
292
|
+
</AutoThemeProvider>
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
render(<App />, { title: 'file-manager' });
|
package/LICENSE
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
MIT License
|
|
2
|
-
|
|
3
|
-
Copyright (c) 2026 Karanjot Singh
|
|
4
|
-
|
|
5
|
-
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
-
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
-
in the Software without restriction, including without limitation the rights
|
|
8
|
-
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
-
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
-
furnished to do so, subject to the following conditions:
|
|
11
|
-
|
|
12
|
-
The above copyright notice and this permission notice shall be included in all
|
|
13
|
-
copies or substantial portions of the Software.
|
|
14
|
-
|
|
15
|
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
-
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
-
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
-
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
-
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
-
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
-
SOFTWARE.
|