@bod.ee/db 0.12.8 → 0.13.1
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/CLAUDE.md +2 -1
- package/admin/admin.ts +23 -3
- package/admin/bun.lock +248 -0
- package/admin/index.html +12 -0
- package/admin/package.json +22 -0
- package/admin/src/App.tsx +23 -0
- package/admin/src/client/ZuzClient.ts +183 -0
- package/admin/src/client/types.ts +28 -0
- package/admin/src/components/MetricsBar.tsx +167 -0
- package/admin/src/components/Sparkline.tsx +72 -0
- package/admin/src/components/TreePane.tsx +287 -0
- package/admin/src/components/tabs/Advanced.tsx +222 -0
- package/admin/src/components/tabs/AuthRules.tsx +104 -0
- package/admin/src/components/tabs/Cache.tsx +113 -0
- package/admin/src/components/tabs/KeyAuth.tsx +462 -0
- package/admin/src/components/tabs/MessageQueue.tsx +237 -0
- package/admin/src/components/tabs/Query.tsx +75 -0
- package/admin/src/components/tabs/ReadWrite.tsx +177 -0
- package/admin/src/components/tabs/Replication.tsx +94 -0
- package/admin/src/components/tabs/Streams.tsx +329 -0
- package/admin/src/components/tabs/StressTests.tsx +209 -0
- package/admin/src/components/tabs/Subscriptions.tsx +69 -0
- package/admin/src/components/tabs/TabPane.tsx +151 -0
- package/admin/src/components/tabs/VFS.tsx +435 -0
- package/admin/src/components/tabs/View.tsx +14 -0
- package/admin/src/components/tabs/utils.ts +25 -0
- package/admin/src/context/DbContext.tsx +33 -0
- package/admin/src/context/StatsContext.tsx +56 -0
- package/admin/src/main.tsx +10 -0
- package/admin/src/styles.css +96 -0
- package/admin/tsconfig.app.json +21 -0
- package/admin/tsconfig.json +7 -0
- package/admin/tsconfig.node.json +15 -0
- package/admin/vite.config.ts +42 -0
- package/deploy/base.yaml +1 -1
- package/deploy/prod-il.config.ts +5 -2
- package/deploy/prod.config.ts +5 -2
- package/package.json +4 -1
- package/src/server/BodDB.ts +62 -5
- package/src/server/ReplicationEngine.ts +148 -35
- package/src/server/StreamEngine.ts +2 -2
- package/src/server/Transport.ts +17 -0
- package/tests/replication.test.ts +162 -1
- package/admin/ui.html +0 -3562
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { useState, useEffect } from 'react';
|
|
2
|
+
import ReadWrite from './ReadWrite';
|
|
3
|
+
import Query from './Query';
|
|
4
|
+
import Subscriptions from './Subscriptions';
|
|
5
|
+
import AuthRules from './AuthRules';
|
|
6
|
+
import Advanced from './Advanced';
|
|
7
|
+
import Streams from './Streams';
|
|
8
|
+
import MessageQueue from './MessageQueue';
|
|
9
|
+
import Replication from './Replication';
|
|
10
|
+
import VFS from './VFS';
|
|
11
|
+
import Cache from './Cache';
|
|
12
|
+
import KeyAuth from './KeyAuth';
|
|
13
|
+
import StressTests from './StressTests';
|
|
14
|
+
import View from './View';
|
|
15
|
+
|
|
16
|
+
const TABS = [
|
|
17
|
+
{ id: 'rw', label: 'Read / Write' },
|
|
18
|
+
{ id: 'query', label: 'Query Builder' },
|
|
19
|
+
{ id: 'subs', label: 'Live Subscriptions' },
|
|
20
|
+
{ id: 'auth', label: 'Auth & Rules' },
|
|
21
|
+
{ id: 'advanced', label: 'Advanced' },
|
|
22
|
+
{ id: 'streams', label: 'Streams' },
|
|
23
|
+
{ id: 'mq', label: 'MQ' },
|
|
24
|
+
{ id: 'repl', label: 'Replication' },
|
|
25
|
+
{ id: 'vfs', label: 'VFS' },
|
|
26
|
+
{ id: 'cache', label: 'Cache' },
|
|
27
|
+
{ id: 'keyauth', label: 'KeyAuth' },
|
|
28
|
+
{ id: 'stress', label: 'Stress Tests' },
|
|
29
|
+
{ id: 'view', label: 'View' },
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
const PERSIST_FIELDS = ['rw-path','rw-value','upd-value','q-path','sub-path','auth-test-path','rw-auth-token','auth-token','push-path','push-value','batch-value','tf-path','tf-value','ttl-path','ttl-value','fts-path','fts-content','fts-query','fts-prefix','vec-path','vec-embedding','vec-query','vec-prefix'];
|
|
33
|
+
const DEFAULT_VALUES: Record<string, string> = {
|
|
34
|
+
'rw-path': 'users/alice',
|
|
35
|
+
'rw-value': '{ "name": "Alice", "age": 30, "role": "admin" }',
|
|
36
|
+
'upd-value': '{ "users/alice/age": 31, "users/bob/role": "admin" }',
|
|
37
|
+
'q-path': 'users',
|
|
38
|
+
'sub-path': 'users',
|
|
39
|
+
'auth-test-path': 'users/alice',
|
|
40
|
+
'push-path': 'logs',
|
|
41
|
+
'push-value': `{ "level": "info", "msg": "hello from admin", "ts": ${Date.now()} }`,
|
|
42
|
+
'batch-value': '[\n { "op": "set", "path": "counters/views", "value": 42 },\n { "op": "push", "path": "logs", "value": { "msg": "batch test" } }\n]',
|
|
43
|
+
'tf-path': 'counters/likes',
|
|
44
|
+
'tf-value': '1',
|
|
45
|
+
'ttl-path': 'sessions/demo',
|
|
46
|
+
'ttl-value': '{ "token": "abc123", "user": "alice" }',
|
|
47
|
+
'fts-path': 'users/alice',
|
|
48
|
+
'fts-content': 'Alice is an admin user who manages the system',
|
|
49
|
+
'fts-query': 'admin',
|
|
50
|
+
'fts-prefix': 'users',
|
|
51
|
+
'vec-path': 'users/alice',
|
|
52
|
+
'vec-embedding': JSON.stringify(Array.from({ length: 384 }, (_, i) => +(Math.sin(i * 0.1) * 0.5).toFixed(4))),
|
|
53
|
+
'vec-query': JSON.stringify(Array.from({ length: 384 }, (_, i) => +(Math.sin(i * 0.1) * 0.5).toFixed(4))),
|
|
54
|
+
'vec-prefix': 'users',
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
function persistFields() {
|
|
58
|
+
const state: Record<string, string> = {};
|
|
59
|
+
for (const id of PERSIST_FIELDS) {
|
|
60
|
+
const el = document.getElementById(id) as HTMLInputElement;
|
|
61
|
+
if (el) state[id] = el.value;
|
|
62
|
+
}
|
|
63
|
+
localStorage.setItem('zuzdb:fields', JSON.stringify(state));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function restoreFields() {
|
|
67
|
+
const saved = localStorage.getItem('zuzdb:fields');
|
|
68
|
+
try {
|
|
69
|
+
const state = JSON.parse(saved ?? '{}');
|
|
70
|
+
const hasState = saved && Object.keys(state).some((k) => state[k]);
|
|
71
|
+
for (const id of PERSIST_FIELDS) {
|
|
72
|
+
const el = document.getElementById(id) as HTMLInputElement;
|
|
73
|
+
if (!el) continue;
|
|
74
|
+
if (hasState && state[id]) el.value = state[id];
|
|
75
|
+
else if (DEFAULT_VALUES[id]) el.value = DEFAULT_VALUES[id];
|
|
76
|
+
}
|
|
77
|
+
} catch {}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export default function TabPane() {
|
|
81
|
+
const [activeTab, setActiveTab] = useState(() => {
|
|
82
|
+
return new URLSearchParams(location.search).get('tab') || localStorage.getItem('zuzdb:tab') || 'rw';
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
function showTab(id: string) {
|
|
86
|
+
setActiveTab(id);
|
|
87
|
+
const url = new URL(location.href);
|
|
88
|
+
url.searchParams.set('tab', id);
|
|
89
|
+
history.replaceState(null, '', url);
|
|
90
|
+
localStorage.setItem('zuzdb:tab', id);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Restore field values after render
|
|
94
|
+
useEffect(() => {
|
|
95
|
+
restoreFields();
|
|
96
|
+
// Attach persist listeners
|
|
97
|
+
for (const id of PERSIST_FIELDS) {
|
|
98
|
+
const el = document.getElementById(id);
|
|
99
|
+
if (el) el.addEventListener('input', persistFields);
|
|
100
|
+
}
|
|
101
|
+
return () => {
|
|
102
|
+
for (const id of PERSIST_FIELDS) {
|
|
103
|
+
const el = document.getElementById(id);
|
|
104
|
+
if (el) el.removeEventListener('input', persistFields);
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
}, [activeTab]);
|
|
108
|
+
|
|
109
|
+
// Listen for programmatic tab switches (from tree selectPath)
|
|
110
|
+
useEffect(() => {
|
|
111
|
+
const handler = (e: Event) => {
|
|
112
|
+
const tab = (e as CustomEvent).detail as string;
|
|
113
|
+
showTab(tab);
|
|
114
|
+
};
|
|
115
|
+
window.addEventListener('zuzdb:tab', handler);
|
|
116
|
+
window.addEventListener('zuzdb:selectPath', () => showTab('view'));
|
|
117
|
+
return () => {
|
|
118
|
+
window.removeEventListener('zuzdb:tab', handler);
|
|
119
|
+
};
|
|
120
|
+
}, []);
|
|
121
|
+
|
|
122
|
+
return (
|
|
123
|
+
<>
|
|
124
|
+
<div className="tabs">
|
|
125
|
+
{TABS.map(t => (
|
|
126
|
+
<div
|
|
127
|
+
key={t.id}
|
|
128
|
+
className={`tab${activeTab === t.id ? ' active' : ''}`}
|
|
129
|
+
onClick={() => showTab(t.id)}
|
|
130
|
+
>
|
|
131
|
+
{t.label}
|
|
132
|
+
</div>
|
|
133
|
+
))}
|
|
134
|
+
</div>
|
|
135
|
+
|
|
136
|
+
<div className={`panel${activeTab === 'rw' ? ' active' : ''}`} id="panel-rw"><ReadWrite /></div>
|
|
137
|
+
<div className={`panel${activeTab === 'query' ? ' active' : ''}`} id="panel-query"><Query /></div>
|
|
138
|
+
<div className={`panel${activeTab === 'subs' ? ' active' : ''}`} id="panel-subs"><Subscriptions /></div>
|
|
139
|
+
<div className={`panel${activeTab === 'auth' ? ' active' : ''}`} id="panel-auth"><AuthRules active={activeTab === 'auth'} /></div>
|
|
140
|
+
<div className={`panel${activeTab === 'advanced' ? ' active' : ''}`} id="panel-advanced"><Advanced /></div>
|
|
141
|
+
<div className={`panel${activeTab === 'streams' ? ' active' : ''}`} id="panel-streams"><Streams /></div>
|
|
142
|
+
<div className={`panel${activeTab === 'mq' ? ' active' : ''}`} id="panel-mq"><MessageQueue /></div>
|
|
143
|
+
<div className={`panel${activeTab === 'repl' ? ' active' : ''}`} id="panel-repl"><Replication active={activeTab === 'repl'} /></div>
|
|
144
|
+
<div className={`panel${activeTab === 'vfs' ? ' active' : ''}`} id="panel-vfs"><VFS active={activeTab === 'vfs'} /></div>
|
|
145
|
+
<div className={`panel${activeTab === 'cache' ? ' active' : ''}`} id="panel-cache"><Cache /></div>
|
|
146
|
+
<div className={`panel${activeTab === 'keyauth' ? ' active' : ''}`} id="panel-keyauth"><KeyAuth active={activeTab === 'keyauth'} /></div>
|
|
147
|
+
<div className={`panel${activeTab === 'stress' ? ' active' : ''}`} id="panel-stress"><StressTests /></div>
|
|
148
|
+
<div className={`panel${activeTab === 'view' ? ' active' : ''}`} id="panel-view"><View /></div>
|
|
149
|
+
</>
|
|
150
|
+
);
|
|
151
|
+
}
|
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
import { useEffect, useRef } from 'react';
|
|
2
|
+
import { showStatus, apiFetch } from './utils';
|
|
3
|
+
|
|
4
|
+
interface Props { active: boolean; }
|
|
5
|
+
|
|
6
|
+
interface VfsItem {
|
|
7
|
+
name: string;
|
|
8
|
+
isDir: boolean;
|
|
9
|
+
size?: number;
|
|
10
|
+
mime?: string;
|
|
11
|
+
mtime?: number | string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function vfsFormatSize(b?: number): string {
|
|
15
|
+
if (b == null) return '—';
|
|
16
|
+
if (b < 1024) return b + ' B';
|
|
17
|
+
if (b < 1024 * 1024) return (b / 1024).toFixed(1) + ' KB';
|
|
18
|
+
return (b / (1024 * 1024)).toFixed(1) + ' MB';
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function vfsFormatDate(ts?: number | string): string {
|
|
22
|
+
if (!ts) return '';
|
|
23
|
+
const d = new Date(typeof ts === 'number' ? ts : ts);
|
|
24
|
+
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export default function VFS({ active }: Props) {
|
|
28
|
+
const vfsPathRef = useRef('');
|
|
29
|
+
const vfsSelectedRef = useRef<(VfsItem & { fullPath: string }) | null>(null);
|
|
30
|
+
const vfsItemsRef = useRef<VfsItem[]>([]);
|
|
31
|
+
|
|
32
|
+
useEffect(() => { if (active) vfsNavigate(''); }, [active]);
|
|
33
|
+
|
|
34
|
+
async function vfsNavigate(path: string) {
|
|
35
|
+
vfsPathRef.current = (path || '').replace(/^\/+|\/+$/g, '');
|
|
36
|
+
vfsSelectedRef.current = null;
|
|
37
|
+
const previewEl = document.getElementById('vfs-preview') as HTMLElement;
|
|
38
|
+
if (previewEl) previewEl.style.display = 'none';
|
|
39
|
+
|
|
40
|
+
// Breadcrumb
|
|
41
|
+
const bc = document.getElementById('vfs-breadcrumb') as HTMLElement;
|
|
42
|
+
const segs = vfsPathRef.current ? vfsPathRef.current.split('/') : [];
|
|
43
|
+
let bcHtml = `<span style="cursor:pointer;color:#569cd6" data-path="">/</span>`;
|
|
44
|
+
let built = '';
|
|
45
|
+
for (let i = 0; i < segs.length; i++) {
|
|
46
|
+
built += (i ? '/' : '') + segs[i];
|
|
47
|
+
const p = built;
|
|
48
|
+
bcHtml += ` > <span style="cursor:pointer;color:#569cd6" data-path="${p}">${segs[i]}</span>`;
|
|
49
|
+
}
|
|
50
|
+
bc.innerHTML = bcHtml;
|
|
51
|
+
bc.querySelectorAll('span[data-path]').forEach(s => {
|
|
52
|
+
s.addEventListener('click', () => vfsNavigate((s as HTMLElement).dataset.path || ''));
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const tbl = document.getElementById('vfs-table') as HTMLElement;
|
|
56
|
+
const t0 = performance.now();
|
|
57
|
+
try {
|
|
58
|
+
const json = await apiFetch('/files/' + (vfsPathRef.current || '') + '?list=1');
|
|
59
|
+
const ms = performance.now() - t0;
|
|
60
|
+
if (!json.ok) { tbl.innerHTML = `<div style="padding:12px;color:#f44">Error: ${json.error || 'Failed'}</div>`; return; }
|
|
61
|
+
const items = (json.data as VfsItem[]) || [];
|
|
62
|
+
items.sort((a, b) => {
|
|
63
|
+
if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
|
|
64
|
+
return (a.name || '').localeCompare(b.name || '');
|
|
65
|
+
});
|
|
66
|
+
vfsItemsRef.current = items;
|
|
67
|
+
showStatus('vfs-explorer-status', items.length + ' items', true, ms);
|
|
68
|
+
if (!items.length) {
|
|
69
|
+
tbl.innerHTML = '<div style="padding:20px;text-align:center;color:#555">Empty directory</div>';
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
const svgDl = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>';
|
|
73
|
+
const svgRn = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 3a2.83 2.83 0 114 4L7.5 20.5 2 22l1.5-5.5L17 3z"/></svg>';
|
|
74
|
+
const svgDel = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 011-1h4a1 1 0 011 1v2"/></svg>';
|
|
75
|
+
const btnBase = 'display:inline-flex;align-items:center;justify-content:center;width:26px;height:26px;padding:0';
|
|
76
|
+
let rows = '<table style="width:100%;border-collapse:collapse"><tbody>';
|
|
77
|
+
for (let idx = 0; idx < items.length; idx++) {
|
|
78
|
+
const item = items[idx];
|
|
79
|
+
const icon = item.isDir ? '📁' : '📄';
|
|
80
|
+
const fp = (vfsPathRef.current ? vfsPathRef.current + '/' + item.name : item.name);
|
|
81
|
+
const dlStyle = item.isDir ? btnBase + ';visibility:hidden' : btnBase;
|
|
82
|
+
const actions = `<span class="vfs-row-actions" style="display:flex;gap:3px;align-items:center;visibility:hidden">
|
|
83
|
+
<button class="sm" style="${dlStyle}" data-action="download" data-idx="${idx}" data-fp="${fp}" title="Download">${svgDl}</button>
|
|
84
|
+
<button class="sm" style="${btnBase}" data-action="rename" data-idx="${idx}" data-fp="${fp}" title="Rename">${svgRn}</button>
|
|
85
|
+
<button class="danger sm" style="${btnBase}" data-action="delete" data-idx="${idx}" data-fp="${fp}" title="Delete">${svgDel}</button>
|
|
86
|
+
</span>`;
|
|
87
|
+
rows += `<tr style="cursor:pointer;padding:0;border-bottom:1px solid #1a1a1a;height:34px" data-idx="${idx}" data-fp="${fp}" data-isdir="${item.isDir}">
|
|
88
|
+
<td style="width:28px;padding:6px 4px 6px 10px">${icon}</td>
|
|
89
|
+
<td style="color:#ccc;padding:6px 4px">${item.name || ''}</td>
|
|
90
|
+
<td style="width:70px;text-align:right;color:#888;padding:6px 4px">${item.isDir ? '' : vfsFormatSize(item.size)}</td>
|
|
91
|
+
<td style="width:70px;text-align:right;color:#666;padding:6px 4px">${vfsFormatDate(item.mtime)}</td>
|
|
92
|
+
<td style="width:90px;text-align:right;padding:6px 10px 6px 4px">${actions}</td>
|
|
93
|
+
</tr>`;
|
|
94
|
+
}
|
|
95
|
+
rows += '</tbody></table>';
|
|
96
|
+
tbl.innerHTML = rows;
|
|
97
|
+
|
|
98
|
+
// Attach events
|
|
99
|
+
tbl.querySelectorAll('tr[data-idx]').forEach(tr => {
|
|
100
|
+
const el = tr as HTMLElement;
|
|
101
|
+
el.addEventListener('mouseover', () => {
|
|
102
|
+
el.style.background = '#1e1e1e';
|
|
103
|
+
(el.querySelector('.vfs-row-actions') as HTMLElement)?.style.setProperty('visibility', 'visible');
|
|
104
|
+
});
|
|
105
|
+
el.addEventListener('mouseout', () => {
|
|
106
|
+
el.style.background = '';
|
|
107
|
+
(el.querySelector('.vfs-row-actions') as HTMLElement)?.style.setProperty('visibility', 'hidden');
|
|
108
|
+
});
|
|
109
|
+
el.addEventListener('click', (e) => {
|
|
110
|
+
const actionBtn = (e.target as HTMLElement).closest('[data-action]') as HTMLElement;
|
|
111
|
+
if (actionBtn) {
|
|
112
|
+
e.stopPropagation();
|
|
113
|
+
const idx = Number(actionBtn.dataset.idx);
|
|
114
|
+
const fp = actionBtn.dataset.fp!;
|
|
115
|
+
const action = actionBtn.dataset.action!;
|
|
116
|
+
vfsItemsRef.current[idx] && (vfsSelectedRef.current = { ...vfsItemsRef.current[idx], fullPath: fp });
|
|
117
|
+
if (action === 'download') vfsDownload();
|
|
118
|
+
else if (action === 'rename') vfsRename();
|
|
119
|
+
else if (action === 'delete' && confirm('Delete ' + vfsItemsRef.current[idx].name + '?')) vfsDeleteSel();
|
|
120
|
+
} else {
|
|
121
|
+
const idx = Number(el.dataset.idx);
|
|
122
|
+
const fp = el.dataset.fp!;
|
|
123
|
+
if (el.dataset.isdir === 'true') vfsNavigate(fp);
|
|
124
|
+
else vfsPreview(idx, fp);
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
} catch (e: unknown) {
|
|
129
|
+
tbl.innerHTML = `<div style="padding:12px;color:#f44">${(e as Error).message}</div>`;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function vfsPreview(idx: number, fullPath: string) {
|
|
134
|
+
vfsSelectedRef.current = { ...vfsItemsRef.current[idx], fullPath };
|
|
135
|
+
const item = vfsSelectedRef.current;
|
|
136
|
+
const panel = document.getElementById('vfs-preview') as HTMLElement;
|
|
137
|
+
const body = document.getElementById('vfs-preview-body') as HTMLElement;
|
|
138
|
+
(document.getElementById('vfs-preview-name') as HTMLElement).textContent = item.name;
|
|
139
|
+
(document.getElementById('vfs-preview-info') as HTMLElement).textContent = vfsFormatSize(item.size) + (item.mime ? ' · ' + item.mime : '');
|
|
140
|
+
panel.style.display = 'block';
|
|
141
|
+
body.textContent = 'Loading…';
|
|
142
|
+
const textTypes = ['text/', 'application/json', 'application/xml', 'application/javascript', 'application/typescript', 'application/yaml', 'application/toml'];
|
|
143
|
+
const isText = !item.mime || textTypes.some(t => (item.mime || '').startsWith(t)) || (item.size || 0) === 0;
|
|
144
|
+
if (isText && (item.size || 0) > 0) {
|
|
145
|
+
try {
|
|
146
|
+
const res = await fetch('/files/' + fullPath);
|
|
147
|
+
if (!res.ok) { body.textContent = 'Failed to load'; return; }
|
|
148
|
+
const text = await res.text();
|
|
149
|
+
body.textContent = text.length > 50000 ? text.slice(0, 50000) + '\n… (truncated)' : text;
|
|
150
|
+
} catch (e: unknown) { body.textContent = (e as Error).message; }
|
|
151
|
+
} else if ((item.size || 0) === 0) {
|
|
152
|
+
body.textContent = '(empty file)';
|
|
153
|
+
} else {
|
|
154
|
+
body.textContent = `Binary file · ${vfsFormatSize(item.size)} · ${item.mime || 'unknown type'}\n\nUse Download to save.`;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function vfsUploadHere() {
|
|
159
|
+
const fileInput = document.getElementById('vfs-upload-file') as HTMLInputElement;
|
|
160
|
+
if (!fileInput.files?.length) return;
|
|
161
|
+
const file = fileInput.files[0];
|
|
162
|
+
const path = (vfsPathRef.current ? vfsPathRef.current + '/' : '') + file.name;
|
|
163
|
+
const t0 = performance.now();
|
|
164
|
+
try {
|
|
165
|
+
const json = await apiFetch('/files/' + path, { method: 'POST', body: file, headers: { 'Content-Type': file.type || 'application/octet-stream' } });
|
|
166
|
+
showStatus('vfs-explorer-status', json.ok ? 'Uploaded ' + file.name : (json.error as string || 'Failed'), !!json.ok, performance.now() - t0);
|
|
167
|
+
} catch (e: unknown) { showStatus('vfs-explorer-status', (e as Error).message, false, performance.now() - t0); }
|
|
168
|
+
fileInput.value = '';
|
|
169
|
+
vfsNavigate(vfsPathRef.current);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function vfsSyncFolder() {
|
|
173
|
+
if (!(window as unknown as { showDirectoryPicker?: () => Promise<FileSystemDirectoryHandle> }).showDirectoryPicker) { alert('Browser does not support directory picker (use Chrome/Edge)'); return; }
|
|
174
|
+
let dirHandle: FileSystemDirectoryHandle;
|
|
175
|
+
try { dirHandle = await (window as unknown as { showDirectoryPicker: () => Promise<FileSystemDirectoryHandle> }).showDirectoryPicker(); }
|
|
176
|
+
catch { return; }
|
|
177
|
+
const basePath = vfsPathRef.current ? vfsPathRef.current + '/' + dirHandle.name : dirHandle.name;
|
|
178
|
+
async function collectFiles(handle: FileSystemDirectoryHandle, prefix: string): Promise<Array<{ path: string; handle: FileSystemFileHandle }>> {
|
|
179
|
+
const files: Array<{ path: string; handle: FileSystemFileHandle }> = [];
|
|
180
|
+
for await (const [name, entry] of (handle as unknown as Iterable<[string, FileSystemHandle]>)) {
|
|
181
|
+
const path = prefix ? prefix + '/' + name : name;
|
|
182
|
+
if (entry.kind === 'file') files.push({ path, handle: entry as FileSystemFileHandle });
|
|
183
|
+
else files.push(...await collectFiles(entry as FileSystemDirectoryHandle, path));
|
|
184
|
+
}
|
|
185
|
+
return files;
|
|
186
|
+
}
|
|
187
|
+
showStatus('vfs-explorer-status', 'Scanning folder…', true);
|
|
188
|
+
const files = await collectFiles(dirHandle, '');
|
|
189
|
+
if (!files.length) { showStatus('vfs-explorer-status', 'Folder is empty', false); return; }
|
|
190
|
+
const t0 = performance.now();
|
|
191
|
+
let ok = 0, fail = 0;
|
|
192
|
+
showStatus('vfs-explorer-status', 'Uploading 0/' + files.length + '…', true);
|
|
193
|
+
for (let i = 0; i < files.length; i += 5) {
|
|
194
|
+
const batch = files.slice(i, i + 5);
|
|
195
|
+
const results = await Promise.allSettled(batch.map(async (f) => {
|
|
196
|
+
const file = await f.handle.getFile();
|
|
197
|
+
const json = await apiFetch('/files/' + basePath + '/' + f.path, { method: 'POST', body: file, headers: { 'Content-Type': file.type || 'application/octet-stream' } });
|
|
198
|
+
if (!json.ok) throw new Error(json.error as string);
|
|
199
|
+
}));
|
|
200
|
+
for (const r of results) r.status === 'fulfilled' ? ok++ : fail++;
|
|
201
|
+
showStatus('vfs-explorer-status', 'Uploading ' + (ok + fail) + '/' + files.length + '…', true);
|
|
202
|
+
}
|
|
203
|
+
showStatus('vfs-explorer-status', 'Synced ' + ok + ' files' + (fail ? ', ' + fail + ' failed' : ''), !fail, performance.now() - t0);
|
|
204
|
+
vfsNavigate(vfsPathRef.current);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async function vfsMkdirHere() {
|
|
208
|
+
const name = prompt('Folder name:');
|
|
209
|
+
if (!name) return;
|
|
210
|
+
const path = (vfsPathRef.current ? vfsPathRef.current + '/' : '') + name;
|
|
211
|
+
const t0 = performance.now();
|
|
212
|
+
try {
|
|
213
|
+
const json = await apiFetch('/files/' + path + '?mkdir=1', { method: 'POST' });
|
|
214
|
+
showStatus('vfs-explorer-status', json.ok ? 'Created ' + name : (json.error as string || 'Failed'), !!json.ok, performance.now() - t0);
|
|
215
|
+
} catch (e: unknown) { showStatus('vfs-explorer-status', (e as Error).message, false, performance.now() - t0); }
|
|
216
|
+
vfsNavigate(vfsPathRef.current);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async function vfsDownload() {
|
|
220
|
+
if (!vfsSelectedRef.current) return;
|
|
221
|
+
const t0 = performance.now();
|
|
222
|
+
try {
|
|
223
|
+
const res = await fetch('/files/' + vfsSelectedRef.current.fullPath);
|
|
224
|
+
if (!res.ok) return showStatus('vfs-explorer-status', 'Not found', false, performance.now() - t0);
|
|
225
|
+
const blob = await res.blob();
|
|
226
|
+
const url = URL.createObjectURL(blob);
|
|
227
|
+
const a = document.createElement('a'); a.href = url; a.download = vfsSelectedRef.current.name;
|
|
228
|
+
document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url);
|
|
229
|
+
showStatus('vfs-explorer-status', 'Downloaded ' + vfsSelectedRef.current.name + ' (' + vfsFormatSize(blob.size) + ')', true, performance.now() - t0);
|
|
230
|
+
} catch (e: unknown) { showStatus('vfs-explorer-status', (e as Error).message, false, performance.now() - t0); }
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async function vfsRename() {
|
|
234
|
+
if (!vfsSelectedRef.current) return;
|
|
235
|
+
const newName = prompt('New name:', vfsSelectedRef.current.name);
|
|
236
|
+
if (!newName || newName === vfsSelectedRef.current.name) return;
|
|
237
|
+
const dst = (vfsPathRef.current ? vfsPathRef.current + '/' : '') + newName;
|
|
238
|
+
const t0 = performance.now();
|
|
239
|
+
try {
|
|
240
|
+
const json = await apiFetch('/files/' + vfsSelectedRef.current.fullPath + '?move=' + encodeURIComponent(dst), { method: 'PUT' });
|
|
241
|
+
showStatus('vfs-explorer-status', json.ok ? 'Renamed → ' + newName : (json.error as string || 'Failed'), !!json.ok, performance.now() - t0);
|
|
242
|
+
} catch (e: unknown) { showStatus('vfs-explorer-status', (e as Error).message, false, performance.now() - t0); }
|
|
243
|
+
vfsNavigate(vfsPathRef.current);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async function vfsDeleteSel() {
|
|
247
|
+
if (!vfsSelectedRef.current) return;
|
|
248
|
+
const t0 = performance.now();
|
|
249
|
+
try {
|
|
250
|
+
const json = await apiFetch('/files/' + vfsSelectedRef.current.fullPath, { method: 'DELETE' });
|
|
251
|
+
showStatus('vfs-explorer-status', json.ok ? 'Deleted ' + vfsSelectedRef.current.name : (json.error as string || 'Failed'), !!json.ok, performance.now() - t0);
|
|
252
|
+
} catch (e: unknown) { showStatus('vfs-explorer-status', (e as Error).message, false, performance.now() - t0); }
|
|
253
|
+
vfsSelectedRef.current = null;
|
|
254
|
+
(document.getElementById('vfs-preview') as HTMLElement).style.display = 'none';
|
|
255
|
+
vfsNavigate(vfsPathRef.current);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Advanced API ops
|
|
259
|
+
async function doVfsUpload() {
|
|
260
|
+
const path = (document.getElementById('vfs-upload-path') as HTMLInputElement).value.trim();
|
|
261
|
+
const fileInput = document.getElementById('vfs-adv-upload-file') as HTMLInputElement;
|
|
262
|
+
if (!path) return showStatus('vfs-upload-status', 'Path required', false);
|
|
263
|
+
if (!fileInput.files?.length) return showStatus('vfs-upload-status', 'Select a file', false);
|
|
264
|
+
const file = fileInput.files[0];
|
|
265
|
+
const t0 = performance.now();
|
|
266
|
+
try {
|
|
267
|
+
const json = await apiFetch('/files/' + path, { method: 'POST', body: file, headers: { 'Content-Type': file.type || 'application/octet-stream' } });
|
|
268
|
+
const ms = performance.now() - t0;
|
|
269
|
+
if (json.ok) {
|
|
270
|
+
for (const id of ['vfs-download-path','vfs-stat-path','vfs-delete-path','vfs-move-src']) (document.getElementById(id) as HTMLInputElement).value = path;
|
|
271
|
+
const dir = path.includes('/') ? path.slice(0, path.lastIndexOf('/')) : '';
|
|
272
|
+
(document.getElementById('vfs-list-path') as HTMLInputElement).value = dir || '/';
|
|
273
|
+
}
|
|
274
|
+
const d = json.data as { size: number; mime: string } | undefined;
|
|
275
|
+
showStatus('vfs-upload-status', json.ok ? `Uploaded ${d?.size} bytes (${d?.mime})` : (json.error as string || 'Failed'), !!json.ok, ms);
|
|
276
|
+
} catch (e: unknown) { showStatus('vfs-upload-status', (e as Error).message, false, performance.now() - t0); }
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async function doVfsDownload() {
|
|
280
|
+
const path = (document.getElementById('vfs-download-path') as HTMLInputElement).value.trim();
|
|
281
|
+
if (!path) return showStatus('vfs-download-status', 'Path required', false);
|
|
282
|
+
const t0 = performance.now();
|
|
283
|
+
try {
|
|
284
|
+
const res = await fetch('/files/' + path);
|
|
285
|
+
if (!res.ok) { showStatus('vfs-download-status', 'Not found', false, performance.now() - t0); return; }
|
|
286
|
+
const blob = await res.blob();
|
|
287
|
+
const url = URL.createObjectURL(blob);
|
|
288
|
+
const a = document.createElement('a'); a.href = url; a.download = path.split('/').pop()!;
|
|
289
|
+
document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url);
|
|
290
|
+
showStatus('vfs-download-status', `Downloaded ${blob.size} bytes`, true, performance.now() - t0);
|
|
291
|
+
} catch (e: unknown) { showStatus('vfs-download-status', (e as Error).message, false, performance.now() - t0); }
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
async function doVfsStat() {
|
|
295
|
+
const path = (document.getElementById('vfs-stat-path') as HTMLInputElement).value.trim();
|
|
296
|
+
if (!path) return showStatus('vfs-stat-status', 'Path required', false);
|
|
297
|
+
const t0 = performance.now();
|
|
298
|
+
try {
|
|
299
|
+
const json = await apiFetch('/files/' + path + '?stat=1');
|
|
300
|
+
const ms = performance.now() - t0;
|
|
301
|
+
const el = document.getElementById('vfs-stat-result') as HTMLElement;
|
|
302
|
+
if (json.ok) { el.style.display = 'block'; el.textContent = JSON.stringify(json.data, null, 2); }
|
|
303
|
+
else { el.style.display = 'none'; }
|
|
304
|
+
const d = json.data as { isDir: boolean; name: string; size: number } | undefined;
|
|
305
|
+
showStatus('vfs-stat-status', json.ok ? `${d?.isDir ? 'Dir' : 'File'}: ${d?.name} (${d?.size}b)` : (json.error as string || 'Not found'), !!json.ok, ms);
|
|
306
|
+
} catch (e: unknown) { showStatus('vfs-stat-status', (e as Error).message, false, performance.now() - t0); }
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
async function doVfsList() {
|
|
310
|
+
const path = (document.getElementById('vfs-list-path') as HTMLInputElement).value.trim();
|
|
311
|
+
if (!path) return showStatus('vfs-list-status', 'Path required', false);
|
|
312
|
+
const t0 = performance.now();
|
|
313
|
+
try {
|
|
314
|
+
const json = await apiFetch('/files/' + path + '?list=1');
|
|
315
|
+
const ms = performance.now() - t0;
|
|
316
|
+
const el = document.getElementById('vfs-list-result') as HTMLElement;
|
|
317
|
+
if (json.ok) { el.style.display = 'block'; el.textContent = JSON.stringify(json.data, null, 2); }
|
|
318
|
+
else { el.style.display = 'none'; }
|
|
319
|
+
showStatus('vfs-list-status', json.ok ? `${(json.data as unknown[]).length} entries` : (json.error as string || 'Failed'), !!json.ok, ms);
|
|
320
|
+
} catch (e: unknown) { showStatus('vfs-list-status', (e as Error).message, false, performance.now() - t0); }
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
async function doVfsMkdir() {
|
|
324
|
+
const path = (document.getElementById('vfs-mkdir-path') as HTMLInputElement).value.trim();
|
|
325
|
+
if (!path) return showStatus('vfs-mkdir-status', 'Path required', false);
|
|
326
|
+
const t0 = performance.now();
|
|
327
|
+
try {
|
|
328
|
+
const json = await apiFetch('/files/' + path + '?mkdir=1', { method: 'POST' });
|
|
329
|
+
showStatus('vfs-mkdir-status', json.ok ? `Created: ${path}` : (json.error as string || 'Failed'), !!json.ok, performance.now() - t0);
|
|
330
|
+
} catch (e: unknown) { showStatus('vfs-mkdir-status', (e as Error).message, false, performance.now() - t0); }
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
async function doVfsMove() {
|
|
334
|
+
const src = (document.getElementById('vfs-move-src') as HTMLInputElement).value.trim();
|
|
335
|
+
const dst = (document.getElementById('vfs-move-dst') as HTMLInputElement).value.trim();
|
|
336
|
+
if (!src || !dst) return showStatus('vfs-move-status', 'Both paths required', false);
|
|
337
|
+
const t0 = performance.now();
|
|
338
|
+
try {
|
|
339
|
+
const json = await apiFetch('/files/' + src + '?move=' + encodeURIComponent(dst), { method: 'PUT' });
|
|
340
|
+
showStatus('vfs-move-status', json.ok ? `Moved → ${(json.data as { path: string }).path}` : (json.error as string || 'Failed'), !!json.ok, performance.now() - t0);
|
|
341
|
+
} catch (e: unknown) { showStatus('vfs-move-status', (e as Error).message, false, performance.now() - t0); }
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
async function doVfsDelete() {
|
|
345
|
+
const path = (document.getElementById('vfs-delete-path') as HTMLInputElement).value.trim();
|
|
346
|
+
if (!path) return showStatus('vfs-delete-status', 'Path required', false);
|
|
347
|
+
const t0 = performance.now();
|
|
348
|
+
try {
|
|
349
|
+
const json = await apiFetch('/files/' + path, { method: 'DELETE' });
|
|
350
|
+
showStatus('vfs-delete-status', json.ok ? `Deleted: ${path}` : (json.error as string || 'Failed'), !!json.ok, performance.now() - t0);
|
|
351
|
+
} catch (e: unknown) { showStatus('vfs-delete-status', (e as Error).message, false, performance.now() - t0); }
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
return (
|
|
355
|
+
<>
|
|
356
|
+
<div id="vfs-toolbar" style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8, flexWrap: 'wrap' }}>
|
|
357
|
+
<div id="vfs-breadcrumb" style={{ flex: 1, fontSize: 13, color: '#ccc', minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}></div>
|
|
358
|
+
<input id="vfs-upload-file" type="file" style={{ display: 'none' }} onChange={vfsUploadHere} />
|
|
359
|
+
<button className="success sm" onClick={() => (document.getElementById('vfs-upload-file') as HTMLInputElement).click()}>Upload</button>
|
|
360
|
+
<button className="sm" onClick={vfsSyncFolder}>Sync Folder</button>
|
|
361
|
+
<button className="sm" onClick={vfsMkdirHere}>New Folder</button>
|
|
362
|
+
<button className="sm icon" onClick={() => vfsNavigate(vfsPathRef.current)} title="Reload">
|
|
363
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 11-2.12-9.36L23 10"/></svg>
|
|
364
|
+
</button>
|
|
365
|
+
</div>
|
|
366
|
+
<div id="vfs-table" style={{ border: '1px solid #222', borderRadius: 3, overflow: 'auto', maxHeight: 320, fontSize: 12 }}>
|
|
367
|
+
<div style={{ padding: 20, textAlign: 'center', color: '#555' }}>Loading…</div>
|
|
368
|
+
</div>
|
|
369
|
+
<div id="vfs-explorer-status" style={{ marginTop: 4 }}></div>
|
|
370
|
+
<div id="vfs-preview" style={{ display: 'none', marginTop: 6, border: '1px solid #222', borderRadius: 3, overflow: 'hidden' }}>
|
|
371
|
+
<div id="vfs-preview-header" style={{ padding: '6px 10px', borderBottom: '1px solid #222', display: 'flex', alignItems: 'center', gap: 8, fontSize: 12 }}>
|
|
372
|
+
<span id="vfs-preview-name" style={{ color: '#569cd6', fontWeight: 'bold' }}></span>
|
|
373
|
+
<span id="vfs-preview-info" style={{ color: '#888' }}></span>
|
|
374
|
+
<span style={{ flex: 1 }}></span>
|
|
375
|
+
<button className="sm" onClick={vfsDownload}>Download</button>
|
|
376
|
+
<button className="sm" onClick={vfsRename}>Rename</button>
|
|
377
|
+
<button className="danger sm" onClick={() => { if (confirm('Delete?')) vfsDeleteSel(); }}>Delete</button>
|
|
378
|
+
<button className="sm" onClick={() => { (document.getElementById('vfs-preview') as HTMLElement).style.display = 'none'; }}>✕</button>
|
|
379
|
+
</div>
|
|
380
|
+
<div id="vfs-preview-body" style={{ padding: 10, maxHeight: 250, overflow: 'auto', fontSize: 12, color: '#ccc', whiteSpace: 'pre-wrap', fontFamily: 'monospace' }}></div>
|
|
381
|
+
</div>
|
|
382
|
+
<details style={{ marginTop: 12 }}>
|
|
383
|
+
<summary style={{ cursor: 'pointer', color: '#888', fontSize: 11 }}>Advanced (raw API)</summary>
|
|
384
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, marginTop: 8 }}>
|
|
385
|
+
<div style={{ border: '1px solid #222', borderRadius: 3, padding: 10 }}>
|
|
386
|
+
<label style={{ fontSize: 11, color: '#888', marginBottom: 6 }}>Upload File</label>
|
|
387
|
+
<div className="row">
|
|
388
|
+
<input id="vfs-upload-path" type="text" placeholder="docs/readme.txt" style={{ flex: 2 }} />
|
|
389
|
+
<input id="vfs-adv-upload-file" type="file" style={{ flex: 2 }} onChange={(e) => { if (e.target.files?.[0]) (document.getElementById('vfs-upload-path') as HTMLInputElement).value = e.target.files[0].name; }} />
|
|
390
|
+
<button className="success" onClick={doVfsUpload}>UPLOAD</button>
|
|
391
|
+
</div>
|
|
392
|
+
<div id="vfs-upload-status" style={{ marginTop: 4 }}></div>
|
|
393
|
+
</div>
|
|
394
|
+
<div style={{ border: '1px solid #222', borderRadius: 3, padding: 10 }}>
|
|
395
|
+
<label style={{ fontSize: 11, color: '#888', marginBottom: 6 }}>Download File</label>
|
|
396
|
+
<div className="row"><input id="vfs-download-path" type="text" placeholder="docs/readme.txt" style={{ flex: 2 }} /><button onClick={doVfsDownload}>DOWNLOAD</button></div>
|
|
397
|
+
<div id="vfs-download-status" style={{ marginTop: 4 }}></div>
|
|
398
|
+
</div>
|
|
399
|
+
<div style={{ border: '1px solid #222', borderRadius: 3, padding: 10 }}>
|
|
400
|
+
<label style={{ fontSize: 11, color: '#888', marginBottom: 6 }}>Stat</label>
|
|
401
|
+
<div className="row"><input id="vfs-stat-path" type="text" placeholder="docs/readme.txt" style={{ flex: 2 }} /><button onClick={doVfsStat}>STAT</button></div>
|
|
402
|
+
<div id="vfs-stat-status" style={{ marginTop: 4 }}></div>
|
|
403
|
+
<div className="result" id="vfs-stat-result" style={{ marginTop: 4, display: 'none', maxHeight: 200 }}>—</div>
|
|
404
|
+
</div>
|
|
405
|
+
<div style={{ border: '1px solid #222', borderRadius: 3, padding: 10 }}>
|
|
406
|
+
<label style={{ fontSize: 11, color: '#888', marginBottom: 6 }}>List Directory</label>
|
|
407
|
+
<div className="row"><input id="vfs-list-path" type="text" placeholder="docs" style={{ flex: 2 }} /><button onClick={doVfsList}>LIST</button></div>
|
|
408
|
+
<div id="vfs-list-status" style={{ marginTop: 4 }}></div>
|
|
409
|
+
<div className="result" id="vfs-list-result" style={{ marginTop: 4, display: 'none', maxHeight: 300 }}>—</div>
|
|
410
|
+
</div>
|
|
411
|
+
<div style={{ border: '1px solid #222', borderRadius: 3, padding: 10 }}>
|
|
412
|
+
<label style={{ fontSize: 11, color: '#888', marginBottom: 6 }}>Mkdir</label>
|
|
413
|
+
<div className="row"><input id="vfs-mkdir-path" type="text" placeholder="docs/drafts" style={{ flex: 2 }} /><button className="success" onClick={doVfsMkdir}>MKDIR</button></div>
|
|
414
|
+
<div id="vfs-mkdir-status" style={{ marginTop: 4 }}></div>
|
|
415
|
+
</div>
|
|
416
|
+
<div style={{ border: '1px solid #222', borderRadius: 3, padding: 10 }}>
|
|
417
|
+
<label style={{ fontSize: 11, color: '#888', marginBottom: 6 }}>Move / Rename</label>
|
|
418
|
+
<div className="row">
|
|
419
|
+
<input id="vfs-move-src" type="text" placeholder="docs/old.txt" style={{ flex: 2 }} />
|
|
420
|
+
<span style={{ color: '#555' }}>→</span>
|
|
421
|
+
<input id="vfs-move-dst" type="text" placeholder="docs/new.txt" style={{ flex: 2 }} />
|
|
422
|
+
<button onClick={doVfsMove}>MOVE</button>
|
|
423
|
+
</div>
|
|
424
|
+
<div id="vfs-move-status" style={{ marginTop: 4 }}></div>
|
|
425
|
+
</div>
|
|
426
|
+
<div style={{ border: '1px solid #222', borderRadius: 3, padding: 10 }}>
|
|
427
|
+
<label style={{ fontSize: 11, color: '#888', marginBottom: 6 }}>Delete</label>
|
|
428
|
+
<div className="row"><input id="vfs-delete-path" type="text" placeholder="docs/readme.txt" style={{ flex: 2 }} /><button className="danger" onClick={() => { if (confirm('Delete this file?')) doVfsDelete(); }}>DELETE</button></div>
|
|
429
|
+
<div id="vfs-delete-status" style={{ marginTop: 4 }}></div>
|
|
430
|
+
</div>
|
|
431
|
+
</div>
|
|
432
|
+
</details>
|
|
433
|
+
</>
|
|
434
|
+
);
|
|
435
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export default function View() {
|
|
2
|
+
return (
|
|
3
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, height: '100%' }}>
|
|
4
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
|
5
|
+
<span style={{ color: '#888', fontSize: 12 }}>Path:</span>
|
|
6
|
+
<span id="view-path" style={{ color: '#4ec9b0', fontFamily: 'monospace', fontSize: 13 }}>—</span>
|
|
7
|
+
<span id="view-time" style={{ color: '#555', fontSize: 11 }}></span>
|
|
8
|
+
</div>
|
|
9
|
+
<pre id="view-result" style={{ flex: 1, margin: 0, padding: 12, background: '#0a0a0a', border: '1px solid #2a2a2a', borderRadius: 4, overflow: 'auto', color: '#d4d4d4', fontSize: 13, whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>
|
|
10
|
+
Select a node in the tree to view its data.
|
|
11
|
+
</pre>
|
|
12
|
+
</div>
|
|
13
|
+
);
|
|
14
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export function showStatus(id: string, msg: string, ok: boolean, ms?: number) {
|
|
2
|
+
const el = document.getElementById(id);
|
|
3
|
+
if (!el) return;
|
|
4
|
+
el.className = 'status ' + (ok ? 'ok' : 'err');
|
|
5
|
+
el.textContent = ms != null ? `${msg} (${ms.toFixed(1)}ms)` : msg;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function escHtml(s: string) {
|
|
9
|
+
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function apiFetch(url: string, opts?: RequestInit): Promise<Record<string, unknown>> {
|
|
13
|
+
let res: Response;
|
|
14
|
+
try { res = await fetch(url, opts); } catch (e: unknown) { return { ok: false, error: (e as Error).message }; }
|
|
15
|
+
const ct = res.headers.get('content-type') || '';
|
|
16
|
+
if (ct.includes('application/json')) {
|
|
17
|
+
try { return await res.json(); } catch (e: unknown) { return { ok: false, error: 'Invalid JSON: ' + (e as Error).message }; }
|
|
18
|
+
}
|
|
19
|
+
const text = await res.text().catch(() => res.statusText);
|
|
20
|
+
return { ok: false, error: res.status + ' ' + (text || res.statusText) };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function refreshTreePath(path: string) {
|
|
24
|
+
(window as unknown as Record<string, (...args: unknown[]) => void>).__treeRefreshPath?.(path);
|
|
25
|
+
}
|