@deckbuilderkit/view-explorer 0.0.0-stage → 0.1.0
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 +12 -2
- package/package.json +40 -4
- package/src/ExplorerScreen.tsx +742 -0
- package/src/client.ts +104 -0
- package/src/commands.ts +29 -0
- package/src/explorer/CompareTab.tsx +52 -0
- package/src/explorer/CurlPanel.tsx +144 -0
- package/src/explorer/DetailsTab.tsx +284 -0
- package/src/explorer/FileTab.tsx +466 -0
- package/src/explorer/LogTab.tsx +130 -0
- package/src/explorer/ProjectSearch.tsx +117 -0
- package/src/explorer/ProjectTree.tsx +824 -0
- package/src/explorer/PropertiesDialog.tsx +144 -0
- package/src/explorer/SqlPanel.tsx +221 -0
- package/src/explorer/TreePane.tsx +216 -0
- package/src/explorer/copy-path.ts +9 -0
- package/src/explorer/file-undo.ts +36 -0
- package/src/explorer/fs-actions.ts +34 -0
- package/src/explorer/name-index.ts +58 -0
- package/src/explorer/os-drop.ts +56 -0
- package/src/explorer/resolve-image-path.ts +19 -0
- package/src/explorer/tab-actions.ts +17 -0
- package/src/explorer/tree-find.ts +37 -0
- package/src/explorer/useLocalImages.ts +49 -0
- package/src/explorer-paths.ts +9 -0
- package/src/explorer-state.ts +21 -0
- package/src/explorer.css +61 -0
- package/src/i18n.ts +526 -0
- package/src/options.ts +8 -0
- package/src/server.ts +156 -0
- package/src/settings/cards.tsx +99 -0
- package/src/settings.ts +107 -0
- package/src/view.tsx +38 -0
- package/src/web.ts +4 -0
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
applyEol, parseXmlQueries, applyCombinedXml, combineXmlStatements, mergeXmlStatements, CodeEditor, detectEol, type Eol, FilePreview, HEX_LIMIT, type ImageInfo, langLabel, langOfFile, MARKDOWN_LAYOUTS, MARKDOWN_MODES, MarkdownEditor,
|
|
4
|
+
type MarkdownMode, normalizeEol, prepareForSave, previewKind, type PreviewContent, type SelectionInfo, useMarkdownLayout,
|
|
5
|
+
SqlSteps, type SqlStepsTab, useEditorOptions,
|
|
6
|
+
} from '@deckbuilderkit/editor';
|
|
7
|
+
import {
|
|
8
|
+
type ApiError, type BreadcrumbEntry, Breadcrumbs, errorOf, ErrorBanner, FILE_DEFAULTS, type FileSettings, formatBytes, Icon, type IconName, PanelBanner, PanelLoading,
|
|
9
|
+
relPath, requestBlob, StatusBar, type StatusItem, type TabAction, useCommand, useDialogs, useViewLink,
|
|
10
|
+
} from '@deckbuilderkit/ui';
|
|
11
|
+
import { explorerApi as api, type LogSettings, type TextEncoding } from '../client';
|
|
12
|
+
import { explorerCommandById as commandById } from '../commands';
|
|
13
|
+
import { t } from '../i18n';
|
|
14
|
+
import { LogTab } from './LogTab';
|
|
15
|
+
import { useExplorerCurl } from './CurlPanel';
|
|
16
|
+
import { type ExplorerSqlProps, useExplorerSql } from './SqlPanel';
|
|
17
|
+
import { useChordOf, useTabActions } from './tab-actions';
|
|
18
|
+
import { useLocalImages } from './useLocalImages';
|
|
19
|
+
|
|
20
|
+
type State =
|
|
21
|
+
| { status: 'loading' }
|
|
22
|
+
| { status: 'error'; error: ApiError }
|
|
23
|
+
| { status: 'ready'; content: PreviewContent; mtime: number };
|
|
24
|
+
|
|
25
|
+
export interface FileTabProps {
|
|
26
|
+
project: string;
|
|
27
|
+
projectName: string;
|
|
28
|
+
path: string;
|
|
29
|
+
/** The tab is selected and its pane has the focus. */
|
|
30
|
+
focused: boolean;
|
|
31
|
+
/** A preview tab keeps the focus where it was (the tree), so Delete, F2 and arrows still act on the tree. */
|
|
32
|
+
preview?: boolean;
|
|
33
|
+
/** Log viewer state (highlights, bookmarks) and its change handler; only log files use them. */
|
|
34
|
+
logs?: LogSettings;
|
|
35
|
+
onLogsChange?: (logs: LogSettings) => void;
|
|
36
|
+
/** Mode of the markdown editor and its change handler. */
|
|
37
|
+
markdownMode?: MarkdownMode;
|
|
38
|
+
onMarkdownModeChange?: (mode: MarkdownMode) => void;
|
|
39
|
+
/** Wrap long lines in the editors (default on) and its change handler. */
|
|
40
|
+
wrapLines?: boolean;
|
|
41
|
+
onWrapLinesChange?: (wrap: boolean) => void;
|
|
42
|
+
/** Save behaviour (auto-save, trim, final newline). */
|
|
43
|
+
files?: Required<FileSettings>;
|
|
44
|
+
/** Reports unsaved changes to the tab bar, and registers what saves the tab (see `TabContext`). */
|
|
45
|
+
onDirty?: (dirty: boolean) => void;
|
|
46
|
+
onSaver?: (save: (() => Promise<boolean>) | undefined) => void;
|
|
47
|
+
/** Opens another file of the project (picked in the breadcrumbs). */
|
|
48
|
+
onOpenPath?: (path: string) => void;
|
|
49
|
+
/** Puts the tab's actions in the tab bar (see `TabContext.setActions`). */
|
|
50
|
+
onActions?: (actions: TabAction[]) => void;
|
|
51
|
+
/** Running SQL from `.sql` files of allowed projects (see `useExplorerSql`). */
|
|
52
|
+
sql?: ExplorerSqlProps;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const MODE_ICONS: Record<MarkdownMode, IconName> = { live: 'wysiwyg', edit: 'code', split: 'split', preview: 'preview' };
|
|
56
|
+
|
|
57
|
+
/** Line breaks of new files and of files without any: the OS's, like VS Code's `files.eol: auto`. */
|
|
58
|
+
const OS_EOL: Eol = typeof navigator !== 'undefined' && /^Win/.test(navigator.platform) ? '\r\n' : '\n';
|
|
59
|
+
const AUTO_SAVE_DELAY = 1000;
|
|
60
|
+
|
|
61
|
+
/** Text kinds whose tab opens in the preview (the toggle switches to the editor). */
|
|
62
|
+
const PREVIEW_FIRST = new Set(['table', 'notebook', 'har']);
|
|
63
|
+
/** Text kinds with a preview the toggle can switch to. */
|
|
64
|
+
const TOGGLES = new Set(['json', 'html', 'env', 'table', 'notebook', 'har']);
|
|
65
|
+
const BLOB_KINDS = new Set(['image', 'svg', 'audio', 'video']);
|
|
66
|
+
const ENCODING_LABELS: Record<TextEncoding, string> = {
|
|
67
|
+
utf8: 'UTF-8', utf8bom: 'UTF-8 with BOM', utf16le: 'UTF-16 LE', utf16be: 'UTF-16 BE', windows1252: 'Windows 1252',
|
|
68
|
+
};
|
|
69
|
+
const eolLabel = (eol: Eol) => (eol === '\n' ? 'LF' : 'CRLF');
|
|
70
|
+
|
|
71
|
+
/** VS Code's selection item: `Ln 12, Col 4 (3 selected)`, or the count of several selections. */
|
|
72
|
+
function positionText(s: SelectionInfo): string {
|
|
73
|
+
if (s.selections > 1) {
|
|
74
|
+
return s.selected
|
|
75
|
+
? t('explorer.status.selectionsChars', { count: s.selections, chars: s.selected })
|
|
76
|
+
: t('explorer.status.selections', { count: s.selections });
|
|
77
|
+
}
|
|
78
|
+
const at = t('explorer.status.position', { line: s.line, col: s.col });
|
|
79
|
+
return s.selected ? `${at} ${t('explorer.status.selected', { count: s.selected })}` : at;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** One open file: log files get the log viewer, the rest `TextFileTab`. */
|
|
83
|
+
export function FileTab(props: FileTabProps) {
|
|
84
|
+
return previewKind(relPath.basename(props.path)) === 'log' ? <LogTab {...props} /> : <TextFileTab {...props} />;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** A preview by extension, and for text files an editor with Save (Ctrl+S). */
|
|
88
|
+
function TextFileTab({ project, projectName, path, focused, preview, markdownMode = 'live', onMarkdownModeChange, wrapLines: wrapLinesProp, onWrapLinesChange, files = FILE_DEFAULTS, onDirty, onSaver, onOpenPath, onActions, sql }: FileTabProps) {
|
|
89
|
+
const wrapLines = wrapLinesProp ?? useEditorOptions()?.wordWrap ?? true;
|
|
90
|
+
const name = relPath.basename(path);
|
|
91
|
+
const markdown = previewKind(name) === 'markdown';
|
|
92
|
+
const resolveImage = useLocalImages(project, path);
|
|
93
|
+
const viewHtml = useCallback(async () => (await api.fsViewTicket(project, path)).url, [project, path]);
|
|
94
|
+
const [state, setState] = useState<State>({ status: 'loading' });
|
|
95
|
+
const [previewing, setPreviewing] = useState(false);
|
|
96
|
+
const [draft, setDraft] = useState('');
|
|
97
|
+
const [saving, setSaving] = useState(false);
|
|
98
|
+
const [saveError, setSaveError] = useState<ApiError>();
|
|
99
|
+
/** How the file is stored on disk; the editor always holds `\n` breaks. */
|
|
100
|
+
const format = useRef<{ encoding: TextEncoding; eol: Eol }>({ encoding: 'utf8', eol: OS_EOL });
|
|
101
|
+
/** The line breaks the next save writes; differing from the file's makes the tab dirty. */
|
|
102
|
+
const [eol, setEol] = useState<Eol>(OS_EOL);
|
|
103
|
+
const [disk, setDisk] = useState<{ size?: number; readOnly?: boolean }>({});
|
|
104
|
+
const [selection, setSelection] = useState<SelectionInfo>();
|
|
105
|
+
const [stepsOpen, setStepsOpen] = useState(false);
|
|
106
|
+
const [stepsTab, setStepsTab] = useState<SqlStepsTab>('steps');
|
|
107
|
+
const [image, setImage] = useState<ImageInfo>();
|
|
108
|
+
const generation = useRef(0);
|
|
109
|
+
const dirtyRef = useRef(false);
|
|
110
|
+
const blobUrl = useRef<string>();
|
|
111
|
+
const { confirm, element } = useDialogs();
|
|
112
|
+
|
|
113
|
+
const revoke = () => {
|
|
114
|
+
if (blobUrl.current) URL.revokeObjectURL(blobUrl.current);
|
|
115
|
+
blobUrl.current = undefined;
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const load = useCallback(async (silent = false) => {
|
|
119
|
+
const gen = ++generation.current;
|
|
120
|
+
if (!silent) {
|
|
121
|
+
setState({ status: 'loading' });
|
|
122
|
+
setPreviewing(PREVIEW_FIRST.has(previewKind(name)));
|
|
123
|
+
setSaveError(undefined);
|
|
124
|
+
}
|
|
125
|
+
try {
|
|
126
|
+
const kind = previewKind(name);
|
|
127
|
+
if (BLOB_KINDS.has(kind)) {
|
|
128
|
+
const blob = await requestBlob('/api/fs/raw', { root: project, path });
|
|
129
|
+
const source = kind === 'svg' ? await blob.text() : undefined;
|
|
130
|
+
const url = URL.createObjectURL(blob);
|
|
131
|
+
if (gen !== generation.current) {
|
|
132
|
+
URL.revokeObjectURL(url);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
revoke();
|
|
136
|
+
blobUrl.current = url;
|
|
137
|
+
setDisk({ size: blob.size });
|
|
138
|
+
setState({ status: 'ready', content: { url, source }, mtime: 0 });
|
|
139
|
+
} else if (kind === 'archive') {
|
|
140
|
+
const archive = await api.fsArchive(project, path);
|
|
141
|
+
if (gen === generation.current) setState({ status: 'ready', content: { archive }, mtime: 0 });
|
|
142
|
+
} else if (kind === 'pdf') {
|
|
143
|
+
const pdf = new Uint8Array(await (await requestBlob('/api/fs/raw', { root: project, path })).arrayBuffer());
|
|
144
|
+
if (gen === generation.current) setState({ status: 'ready', content: { pdf }, mtime: 0 });
|
|
145
|
+
} else if (kind === 'font' || kind === 'hex') {
|
|
146
|
+
const limit = kind === 'hex' ? HEX_LIMIT : undefined;
|
|
147
|
+
const bytes = new Uint8Array(await (await requestBlob('/api/fs/raw', { root: project, path, limit })).arrayBuffer());
|
|
148
|
+
if (gen === generation.current) setState({ status: 'ready', content: { bytes }, mtime: 0 });
|
|
149
|
+
} else if (kind === 'sqlite') {
|
|
150
|
+
const sqlite = await api.fsSqlite(project, path);
|
|
151
|
+
const load = (table: string, offset: number) => api.fsSqlite(project, path, table, offset);
|
|
152
|
+
if (gen === generation.current) setState({ status: 'ready', content: { sqlite, load }, mtime: 0 });
|
|
153
|
+
} else {
|
|
154
|
+
const read = await api.fsRead(project, path);
|
|
155
|
+
let content: PreviewContent = read;
|
|
156
|
+
if ('text' in read) {
|
|
157
|
+
format.current = { encoding: read.encoding, eol: detectEol(read.text, OS_EOL) };
|
|
158
|
+
content = { ...read, text: normalizeEol(read.text) };
|
|
159
|
+
}
|
|
160
|
+
const readOnly = 'text' in read && read.readOnly === true;
|
|
161
|
+
if ('binary' in read) {
|
|
162
|
+
try {
|
|
163
|
+
const bytes = new Uint8Array(await (await requestBlob('/api/fs/raw', { root: project, path, limit: HEX_LIMIT })).arrayBuffer());
|
|
164
|
+
content = { bytes };
|
|
165
|
+
} catch {
|
|
166
|
+
content = read;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
if (gen === generation.current) {
|
|
170
|
+
if ('text' in content) setDraft(content.text);
|
|
171
|
+
setEol(format.current.eol);
|
|
172
|
+
setDisk({ size: read.size, readOnly });
|
|
173
|
+
setState({ status: 'ready', content, mtime: read.mtime });
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
} catch (err) {
|
|
177
|
+
if (gen === generation.current) setState({ status: 'error', error: errorOf(err) });
|
|
178
|
+
}
|
|
179
|
+
}, [project, path, name]);
|
|
180
|
+
|
|
181
|
+
useEffect(() => {
|
|
182
|
+
if (!dirtyRef.current) void load();
|
|
183
|
+
}, [load]);
|
|
184
|
+
const known = useRef<string>();
|
|
185
|
+
useEffect(() => {
|
|
186
|
+
known.current = undefined;
|
|
187
|
+
const check = async () => {
|
|
188
|
+
if (document.visibilityState !== 'visible') return;
|
|
189
|
+
try {
|
|
190
|
+
const st = await api.fsStat(project, path);
|
|
191
|
+
const stamp = `${st.mtime}:${st.size}`;
|
|
192
|
+
const prev = known.current;
|
|
193
|
+
if (prev === stamp) return;
|
|
194
|
+
if (prev !== undefined && dirtyRef.current) return;
|
|
195
|
+
known.current = stamp;
|
|
196
|
+
if (prev !== undefined) void load(true);
|
|
197
|
+
} catch {
|
|
198
|
+
// the file may be mid-write or gone; the next tick retries
|
|
199
|
+
}
|
|
200
|
+
};
|
|
201
|
+
void check();
|
|
202
|
+
const timer = window.setInterval(() => void check(), 2000);
|
|
203
|
+
window.addEventListener('focus', check);
|
|
204
|
+
return () => {
|
|
205
|
+
window.clearInterval(timer);
|
|
206
|
+
window.removeEventListener('focus', check);
|
|
207
|
+
};
|
|
208
|
+
}, [project, path, load]);
|
|
209
|
+
useEffect(() => () => {
|
|
210
|
+
generation.current++;
|
|
211
|
+
revoke();
|
|
212
|
+
}, []);
|
|
213
|
+
|
|
214
|
+
const content = state.status === 'ready' ? state.content : undefined;
|
|
215
|
+
const text = content && 'text' in content ? content.text : undefined;
|
|
216
|
+
const editable = text !== undefined && !(content && 'truncated' in content && content.truncated);
|
|
217
|
+
// Editable files open straight in the editor; only JSON can switch to its tree preview.
|
|
218
|
+
const active = editable && !previewing;
|
|
219
|
+
const mdEditor = markdown && active;
|
|
220
|
+
const dirty = active && text !== undefined && (draft !== text || eol !== format.current.eol);
|
|
221
|
+
dirtyRef.current = dirty;
|
|
222
|
+
|
|
223
|
+
const confirmDiscard = async () =>
|
|
224
|
+
!dirty || confirm({ title: t('explorer.discardTitle'), body: t('explorer.discardBody', { name }), okLabel: t('explorer.discard'), danger: true });
|
|
225
|
+
|
|
226
|
+
const toggleEdit = async () => {
|
|
227
|
+
if (previewing) {
|
|
228
|
+
setDraft(text ?? '');
|
|
229
|
+
setSaveError(undefined);
|
|
230
|
+
setPreviewing(false);
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
if (!(await confirmDiscard())) return;
|
|
234
|
+
setSaveError(undefined);
|
|
235
|
+
setPreviewing(true);
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
const reload = async () => {
|
|
239
|
+
if (await confirmDiscard()) void load();
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
/** `overwrite` skips the changed-on-disk check; `unlock` clears the file's read-only flag first. Resolves true when the file is saved or had nothing to save. */
|
|
243
|
+
const save = async (opts: { overwrite?: boolean; unlock?: boolean } = {}): Promise<boolean> => {
|
|
244
|
+
if (!dirty) return true;
|
|
245
|
+
if (saving || state.status !== 'ready') return false;
|
|
246
|
+
setSaving(true);
|
|
247
|
+
setSaveError(undefined);
|
|
248
|
+
const typed = draft;
|
|
249
|
+
const text = prepareForSave(typed, { trim: files.trimTrailingWhitespace, finalNewline: files.insertFinalNewline, markdown });
|
|
250
|
+
const breaks = eol;
|
|
251
|
+
try {
|
|
252
|
+
const saved = await api.fsWrite(project, path, applyEol(text, breaks), opts.overwrite ? undefined : state.mtime, format.current.encoding, opts.unlock);
|
|
253
|
+
// Our own write is no change on disk for the poll below.
|
|
254
|
+
known.current = `${saved.mtime}:${saved.size}`;
|
|
255
|
+
format.current = { ...format.current, eol: breaks };
|
|
256
|
+
setDisk((d) => ({ size: saved.size, readOnly: opts.unlock ? false : d.readOnly }));
|
|
257
|
+
setDraft((d) => (d === typed ? text : d));
|
|
258
|
+
setState({ status: 'ready', content: { text }, mtime: saved.mtime });
|
|
259
|
+
return true;
|
|
260
|
+
} catch (err) {
|
|
261
|
+
setSaveError(errorOf(err));
|
|
262
|
+
return false;
|
|
263
|
+
} finally {
|
|
264
|
+
setSaving(false);
|
|
265
|
+
}
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
useCommand(commandById('explorer.file.save'), () => void save(), { enabled: focused && active });
|
|
269
|
+
const toggleWrap = () => onWrapLinesChange?.(!wrapLines);
|
|
270
|
+
useCommand(commandById('explorer.file.wrap'), toggleWrap, { enabled: focused && active && Boolean(onWrapLinesChange) });
|
|
271
|
+
const [mdLayout, setMdLayout] = useMarkdownLayout();
|
|
272
|
+
/** The mode Ctrl+Shift+V returns to from Preview. */
|
|
273
|
+
const beforePreview = useRef<MarkdownMode>('live');
|
|
274
|
+
const setMode = (m: MarkdownMode) => {
|
|
275
|
+
if (m === markdownMode) return;
|
|
276
|
+
if (m === 'preview') beforePreview.current = markdownMode;
|
|
277
|
+
onMarkdownModeChange?.(m);
|
|
278
|
+
};
|
|
279
|
+
const modeRun = (m: MarkdownMode) => () => (m === 'preview' && markdownMode === 'preview' ? setMode(beforePreview.current) : setMode(m));
|
|
280
|
+
// Fixed order, so the hooks run the same way every render.
|
|
281
|
+
for (const m of MARKDOWN_MODES) useCommand(commandById(`explorer.markdown.${m}`), modeRun(m), { enabled: focused && mdEditor });
|
|
282
|
+
const [crumbSignal, setCrumbSignal] = useState(0);
|
|
283
|
+
useCommand(commandById('explorer.breadcrumbs'), () => setCrumbSignal((n) => n + 1), { enabled: focused && files.breadcrumbs });
|
|
284
|
+
const listDir = useCallback(
|
|
285
|
+
async (dir: string): Promise<BreadcrumbEntry[]> =>
|
|
286
|
+
(await api.fsList(project, dir)).map((e) => ({
|
|
287
|
+
name: e.name,
|
|
288
|
+
kind: e.kind === 'dir' || e.target === 'dir' ? 'dir' : 'file',
|
|
289
|
+
path: relPath.join(dir, e.name),
|
|
290
|
+
})),
|
|
291
|
+
[project],
|
|
292
|
+
);
|
|
293
|
+
|
|
294
|
+
const saveRef = useRef(save);
|
|
295
|
+
saveRef.current = save;
|
|
296
|
+
const onDirtyRef = useRef(onDirty);
|
|
297
|
+
onDirtyRef.current = onDirty;
|
|
298
|
+
const onSaverRef = useRef(onSaver);
|
|
299
|
+
onSaverRef.current = onSaver;
|
|
300
|
+
useEffect(() => {
|
|
301
|
+
onDirtyRef.current?.(dirty);
|
|
302
|
+
}, [dirty]);
|
|
303
|
+
useEffect(() => {
|
|
304
|
+
onSaverRef.current?.(() => saveRef.current());
|
|
305
|
+
return () => {
|
|
306
|
+
onSaverRef.current?.(undefined);
|
|
307
|
+
onDirtyRef.current?.(false);
|
|
308
|
+
};
|
|
309
|
+
}, []);
|
|
310
|
+
const conflict = saveError?.code === 'fileChanged';
|
|
311
|
+
const lockedFile = saveError?.code === 'fileReadOnly';
|
|
312
|
+
// Both wait for the user's choice; auto-save would only repeat the error.
|
|
313
|
+
const blocked = conflict || lockedFile;
|
|
314
|
+
useEffect(() => {
|
|
315
|
+
if (files.autoSave !== 'afterDelay' || !dirty || blocked) return;
|
|
316
|
+
const timer = window.setTimeout(() => void saveRef.current(), AUTO_SAVE_DELAY);
|
|
317
|
+
return () => window.clearTimeout(timer);
|
|
318
|
+
}, [draft, eol, dirty, blocked, files.autoSave]);
|
|
319
|
+
useEffect(() => {
|
|
320
|
+
if (files.autoSave !== 'onFocusChange') return;
|
|
321
|
+
if (!focused) void saveRef.current();
|
|
322
|
+
const onBlur = () => void saveRef.current();
|
|
323
|
+
window.addEventListener('blur', onBlur);
|
|
324
|
+
return () => window.removeEventListener('blur', onBlur);
|
|
325
|
+
}, [focused, files.autoSave]);
|
|
326
|
+
|
|
327
|
+
const overwrite = async () => {
|
|
328
|
+
const ok = await confirm({ title: t('explorer.overwriteTitle'), body: t('explorer.overwriteBody', { name }), okLabel: t('explorer.overwrite'), danger: true });
|
|
329
|
+
if (ok) void save({ overwrite: true });
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
const xml = useMemo(() => (/\.xml$/i.test(name) ? parseXmlQueries(draft) : null), [name, draft]);
|
|
333
|
+
const [xmlRaw, setXmlRaw] = useState(false);
|
|
334
|
+
const xmlStmts = xml && !xmlRaw ? xml.statements : undefined;
|
|
335
|
+
const [merged, setMerged] = useState(false);
|
|
336
|
+
const mergeOn = merged && !!xmlStmts && xmlStmts.length > 1;
|
|
337
|
+
const editorValue = useMemo(() => (xmlStmts ? (mergeOn ? mergeXmlStatements(xmlStmts) : combineXmlStatements(xmlStmts)) : draft), [xmlStmts, draft, mergeOn]);
|
|
338
|
+
const onEditorChange = (v: string) => {
|
|
339
|
+
if (mergeOn) return;
|
|
340
|
+
setDraft(xmlStmts ? applyCombinedXml(draft, xmlStmts, v) : v);
|
|
341
|
+
};
|
|
342
|
+
const sqlUi = useExplorerSql({ project, name, asSql: !!xmlStmts, active, focused, draft: editorValue, sql, confirm, onRunStart: () => setStepsOpen(false) });
|
|
343
|
+
const curlUi = useExplorerCurl({ name, active, draft, sql });
|
|
344
|
+
const chordOf = useChordOf();
|
|
345
|
+
const stepsPanel = (): ReactNode => isSql && stepsOpen ? (
|
|
346
|
+
<SqlSteps
|
|
347
|
+
bottom
|
|
348
|
+
text={editorValue}
|
|
349
|
+
cursor={selection?.pos}
|
|
350
|
+
onJump={(from, to) => sqlUi.editor.current?.reveal(from, to)}
|
|
351
|
+
onClose={() => setStepsOpen(false)}
|
|
352
|
+
tab={stepsTab}
|
|
353
|
+
onTab={setStepsTab}
|
|
354
|
+
paramValues={sqlUi.paramValues}
|
|
355
|
+
onParamValue={sqlUi.setParamValue}
|
|
356
|
+
/>
|
|
357
|
+
) : undefined;
|
|
358
|
+
const openSteps = () => {
|
|
359
|
+
sqlUi.closeResults();
|
|
360
|
+
setStepsOpen(true);
|
|
361
|
+
};
|
|
362
|
+
const toWorkbench = useViewLink('workbench');
|
|
363
|
+
const openInWorkbench = async () => {
|
|
364
|
+
if (!sql || !toWorkbench) return;
|
|
365
|
+
if (dirty && !(await save())) return;
|
|
366
|
+
sql.onWorkbenchChange({ ...sql.workbench, doc: { kind: 'file', project, path } });
|
|
367
|
+
toWorkbench();
|
|
368
|
+
};
|
|
369
|
+
const actions: TabAction[] = [...sqlUi.actions, ...curlUi.actions];
|
|
370
|
+
const isSql = langOfFile(name) === 'sql' || !!xmlStmts;
|
|
371
|
+
if (xml && active) {
|
|
372
|
+
actions.push({ id: 'xml.sql', group: 'primary', icon: 'database', label: t('explorer.sql.xmlView'), checked: !xmlRaw, run: () => setXmlRaw(!xmlRaw) });
|
|
373
|
+
}
|
|
374
|
+
if (active && xmlStmts && xmlStmts.length > 1) {
|
|
375
|
+
actions.push({ id: 'sql.merge', group: 'primary', icon: 'copy', label: t('explorer.sql.merge'), checked: mergeOn, run: () => setMerged(!mergeOn) });
|
|
376
|
+
}
|
|
377
|
+
if (isSql && editable) {
|
|
378
|
+
actions.push({ id: 'sql.steps', group: 'primary', icon: 'list-flat', label: t('explorer.sql.steps'), checked: stepsOpen, run: () => (stepsOpen ? setStepsOpen(false) : openSteps()) });
|
|
379
|
+
}
|
|
380
|
+
const showProblems = () => {
|
|
381
|
+
setStepsTab('problems');
|
|
382
|
+
openSteps();
|
|
383
|
+
};
|
|
384
|
+
useCommand(commandById('explorer.sql.problems'), showProblems, { enabled: focused && active && isSql && editable });
|
|
385
|
+
if (editable && TOGGLES.has(previewKind(name))) {
|
|
386
|
+
actions.push({ id: 'preview', group: 'primary', icon: 'preview', label: t('explorer.preview'), checked: previewing, run: () => void toggleEdit() });
|
|
387
|
+
}
|
|
388
|
+
if (mdEditor) {
|
|
389
|
+
for (const m of MARKDOWN_MODES) {
|
|
390
|
+
actions.push({ id: `mode.${m}`, group: 'primary', icon: MODE_ICONS[m], label: t(`preview.mode.${m}`), hint: chordOf(`explorer.markdown.${m}`), checked: markdownMode === m, radio: true, run: () => setMode(m) });
|
|
391
|
+
}
|
|
392
|
+
if (markdownMode !== 'edit') {
|
|
393
|
+
for (const l of MARKDOWN_LAYOUTS) {
|
|
394
|
+
actions.push({ id: `layout.${l}`, group: 'menu', section: 'layout', label: `${t('explorer.layout')}: ${t(`preview.layout.${l}`)}`, checked: mdLayout === l, radio: true, run: () => setMdLayout(l) });
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
if (active && onWrapLinesChange) {
|
|
399
|
+
actions.push({ id: 'wrap', group: 'menu', section: 'view', label: t('explorer.wrap'), hint: chordOf('explorer.file.wrap'), checked: wrapLines, run: toggleWrap });
|
|
400
|
+
}
|
|
401
|
+
if (active) {
|
|
402
|
+
actions.push({ id: 'save', group: 'menu', section: 'file', label: t('explorer.save'), hint: chordOf('explorer.file.save'), disabled: !dirty || saving, run: () => void save() });
|
|
403
|
+
}
|
|
404
|
+
actions.push({ id: 'reload', group: 'menu', section: 'file', icon: 'refresh', label: t('explorer.reload'), run: () => void reload() });
|
|
405
|
+
if (sql && sqlUi.enabled && toWorkbench) {
|
|
406
|
+
actions.push({ id: 'sql.openWorkbench', group: 'menu', section: 'file', label: t('explorer.sql.openWorkbench'), run: () => void openInWorkbench() });
|
|
407
|
+
}
|
|
408
|
+
useTabActions(actions, onActions);
|
|
409
|
+
|
|
410
|
+
const status: StatusItem[] = [];
|
|
411
|
+
if (content) {
|
|
412
|
+
if (active && selection) status.push({ id: 'position', text: positionText(selection) });
|
|
413
|
+
if (image && image.width > 0) {
|
|
414
|
+
status.push({ id: 'image', text: `${image.width} × ${image.height}`, title: t('explorer.status.imageSize') });
|
|
415
|
+
status.push({ id: 'zoom', text: `${Math.round(image.zoom * 100)}%`, title: t('explorer.status.zoom') });
|
|
416
|
+
}
|
|
417
|
+
if (text !== undefined) {
|
|
418
|
+
status.push({ id: 'encoding', text: ENCODING_LABELS[format.current.encoding], title: t('explorer.status.encoding') });
|
|
419
|
+
const other: Eol = eol === '\n' ? '\r\n' : '\n';
|
|
420
|
+
status.push(active
|
|
421
|
+
? { id: 'eol', text: eolLabel(eol), title: t('explorer.status.eol', { eol: eolLabel(other) }), onClick: () => setEol(other) }
|
|
422
|
+
: { id: 'eol', text: eolLabel(eol) });
|
|
423
|
+
status.push({ id: 'language', text: langLabel(langOfFile(name)), title: t('explorer.status.language') });
|
|
424
|
+
}
|
|
425
|
+
if (disk.size !== undefined) status.push({ id: 'size', text: formatBytes(disk.size), title: t('explorer.status.size') });
|
|
426
|
+
if (disk.readOnly) status.push({ id: 'readOnly', text: <><Icon name="lock" size={12} /> {t('explorer.status.readOnly')}</> });
|
|
427
|
+
if (text !== undefined && !editable) status.push({ id: 'truncated', text: t('explorer.status.truncated') });
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
return (
|
|
431
|
+
<div className="xp-file">
|
|
432
|
+
{element}
|
|
433
|
+
{files.breadcrumbs && (
|
|
434
|
+
<div className="xp-bar">
|
|
435
|
+
<Breadcrumbs root={projectName} path={path} list={listDir} onPick={(e) => onOpenPath?.(e.path)} focusSignal={crumbSignal} title={`${projectName}/${path}`} />
|
|
436
|
+
</div>
|
|
437
|
+
)}
|
|
438
|
+
{conflict ? (
|
|
439
|
+
<PanelBanner tone="err" icon="alert" action={
|
|
440
|
+
<>
|
|
441
|
+
<button className="btn btn-sm" onClick={reload}>{t('explorer.reload')}</button>
|
|
442
|
+
<button className="btn btn-sm" onClick={overwrite}>{t('explorer.overwrite')}</button>
|
|
443
|
+
</>
|
|
444
|
+
}>
|
|
445
|
+
{t('explorer.staleSave', { name })}
|
|
446
|
+
</PanelBanner>
|
|
447
|
+
) : lockedFile ? (
|
|
448
|
+
<PanelBanner tone="err" icon="lock" action={<button className="btn btn-sm" onClick={() => void save({ unlock: true })}>{t('explorer.makeWritable')}</button>}>
|
|
449
|
+
{t('explorer.readOnlySave', { name })}
|
|
450
|
+
</PanelBanner>
|
|
451
|
+
) : saveError && <ErrorBanner error={saveError} compact />}
|
|
452
|
+
<div className="xp-body">
|
|
453
|
+
{state.status === 'loading' && <PanelLoading />}
|
|
454
|
+
{state.status === 'error' && <ErrorBanner error={state.error} onRetry={() => void load()} />}
|
|
455
|
+
{content && (mdEditor
|
|
456
|
+
? <MarkdownEditor value={draft} onChange={setDraft} mode={markdownMode} layout={mdLayout} wrap={wrapLines} autoFocus={!preview} onSelection={setSelection} toc={files.markdownToc} resolveImage={resolveImage} />
|
|
457
|
+
: active
|
|
458
|
+
? curlUi.enabled
|
|
459
|
+
? curlUi.layout(<CodeEditor ref={curlUi.editor} value={draft} lang={langOfFile(name)} onChange={setDraft} height="100%" wrap={wrapLines} autoFocus={!preview} onSelection={setSelection} {...curlUi.editorProps} />)
|
|
460
|
+
: sqlUi.layout(<CodeEditor ref={sqlUi.editor} value={editorValue} lang={xmlStmts ? 'sql' : langOfFile(name)} onChange={onEditorChange} readOnly={mergeOn} height="100%" wrap={wrapLines} autoFocus={!preview} onSelection={setSelection} sqlLint={isSql} {...sqlUi.editorProps} />, stepsPanel())
|
|
461
|
+
: <FilePreview name={name} content={content} onImageInfo={setImage} toc={files.markdownToc} resolveImage={resolveImage} viewHtml={viewHtml} />)}
|
|
462
|
+
</div>
|
|
463
|
+
{status.length > 0 && <StatusBar items={status} />}
|
|
464
|
+
</div>
|
|
465
|
+
);
|
|
466
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
type ApiError,
|
|
4
|
+
copyText,
|
|
5
|
+
errorOf,
|
|
6
|
+
ErrorBanner,
|
|
7
|
+
type HighlightTerm,
|
|
8
|
+
type LogBookmark,
|
|
9
|
+
LogView,
|
|
10
|
+
MAX_LOG_KEY_CHARS,
|
|
11
|
+
PanelBanner,
|
|
12
|
+
PanelLoading,
|
|
13
|
+
type TabAction,
|
|
14
|
+
useLogTail,
|
|
15
|
+
validLogSettings,
|
|
16
|
+
} from '@deckbuilderkit/ui';
|
|
17
|
+
import { explorerApi as api, type LogSettings } from '../client';
|
|
18
|
+
import { t } from '../i18n';
|
|
19
|
+
import { useTabActions } from './tab-actions';
|
|
20
|
+
|
|
21
|
+
const COPY_CHUNK_BYTES = 20_000_000;
|
|
22
|
+
const COPY_MAX_BYTES = 50_000_000;
|
|
23
|
+
|
|
24
|
+
class RangeStale extends Error {}
|
|
25
|
+
class RangeTooLarge extends Error {}
|
|
26
|
+
|
|
27
|
+
interface LogTabProps {
|
|
28
|
+
project: string;
|
|
29
|
+
projectName: string;
|
|
30
|
+
path: string;
|
|
31
|
+
/** The tab is selected and its pane has the focus. */
|
|
32
|
+
focused: boolean;
|
|
33
|
+
logs?: LogSettings;
|
|
34
|
+
onLogsChange?: (logs: LogSettings) => void;
|
|
35
|
+
onActions?: (actions: TabAction[]) => void;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Text from byte `from` to the end of the file as the first answer saw it. */
|
|
39
|
+
async function textFrom(project: string, path: string, from: number): Promise<string> {
|
|
40
|
+
let text = '';
|
|
41
|
+
let at = from;
|
|
42
|
+
let total = 0;
|
|
43
|
+
let size: number | undefined;
|
|
44
|
+
for (;;) {
|
|
45
|
+
const chunk = await api.fsTail(project, path, at, COPY_CHUNK_BYTES);
|
|
46
|
+
if (chunk.reset) throw new RangeStale();
|
|
47
|
+
size ??= chunk.size;
|
|
48
|
+
text += chunk.text;
|
|
49
|
+
total += chunk.to - at;
|
|
50
|
+
if (total > COPY_MAX_BYTES) throw new RangeTooLarge();
|
|
51
|
+
if (chunk.to >= size || chunk.to <= at) return text + (chunk.partial ?? '');
|
|
52
|
+
at = chunk.to;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const lineCount = (text: string) => (text.match(/\n/g)?.length ?? 0) + (text === '' || text.endsWith('\n') ? 0 : 1);
|
|
57
|
+
|
|
58
|
+
/** One open log file: followed live, with highlights and byte-offset bookmarks persisted in settings. */
|
|
59
|
+
export function LogTab({ project, path, focused, logs, onLogsChange, onActions }: LogTabProps) {
|
|
60
|
+
const key = `${project}:${path}`;
|
|
61
|
+
const [local, setLocal] = useState<LogSettings>({ highlights: [], bookmarks: {} });
|
|
62
|
+
const state = logs ?? local;
|
|
63
|
+
const [follow, setFollow] = useState(true);
|
|
64
|
+
const [note, setNote] = useState<string>();
|
|
65
|
+
const [actionError, setActionError] = useState<ApiError>();
|
|
66
|
+
const tail = useLogTail((from) => api.fsTail(project, path, from), { key, paused: !focused });
|
|
67
|
+
|
|
68
|
+
const change = (next: LogSettings) => (onLogsChange ?? setLocal)(validLogSettings(next) ?? next);
|
|
69
|
+
const bookmarks = state.bookmarks[key] ?? [];
|
|
70
|
+
|
|
71
|
+
const setBookmarks = (list: LogBookmark[]) => {
|
|
72
|
+
const { [key]: _old, ...rest } = state.bookmarks;
|
|
73
|
+
change({ ...state, bookmarks: list.length ? { ...rest, [key]: list } : rest });
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const run = async (action: () => Promise<void>) => {
|
|
77
|
+
setNote(undefined);
|
|
78
|
+
setActionError(undefined);
|
|
79
|
+
try {
|
|
80
|
+
await action();
|
|
81
|
+
} catch (err) {
|
|
82
|
+
if (err instanceof RangeStale) setActionError(errorOf({ code: 'rangeStale' }));
|
|
83
|
+
else if (err instanceof RangeTooLarge) setNote(t('explorer.logTooLarge'));
|
|
84
|
+
else setActionError(errorOf(err));
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const copyFrom = (b: LogBookmark) =>
|
|
89
|
+
run(async () => {
|
|
90
|
+
const text = await textFrom(project, path, b.offset);
|
|
91
|
+
await copyText(text);
|
|
92
|
+
setNote(t('explorer.logCopied', { count: lineCount(text) }));
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const saveFrom = (b: LogBookmark) =>
|
|
96
|
+
run(async () => {
|
|
97
|
+
const saved = await api.fsSaveRange(project, path, b.offset);
|
|
98
|
+
if (saved.path) setNote(t('explorer.logSaved', { path: saved.path }));
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
useTabActions([{ id: 'reload', group: 'menu', icon: 'refresh', label: t('explorer.reload'), run: tail.reload }], onActions);
|
|
102
|
+
|
|
103
|
+
return (
|
|
104
|
+
<div className="xp-file">
|
|
105
|
+
{note && <div className="xp-bar muted">{note}</div>}
|
|
106
|
+
{tail.rotated && (
|
|
107
|
+
<PanelBanner tone="warn" icon="alert" action={<button className="btn btn-sm" onClick={tail.dismissRotated}>{t('explorer.logDismiss')}</button>}>
|
|
108
|
+
{t('explorer.logRotated')}
|
|
109
|
+
</PanelBanner>
|
|
110
|
+
)}
|
|
111
|
+
{actionError && <ErrorBanner error={actionError} compact />}
|
|
112
|
+
<div className="xp-body">
|
|
113
|
+
{tail.loading && !tail.lines.length && <PanelLoading />}
|
|
114
|
+
{tail.error && <ErrorBanner error={tail.error} onRetry={tail.reload} />}
|
|
115
|
+
<LogView
|
|
116
|
+
lines={tail.lines}
|
|
117
|
+
highlights={state.highlights}
|
|
118
|
+
onHighlightsChange={(highlights: HighlightTerm[]) => change({ ...state, highlights })}
|
|
119
|
+
follow={follow}
|
|
120
|
+
onFollowChange={setFollow}
|
|
121
|
+
bookmarks={key.length > MAX_LOG_KEY_CHARS ? undefined : bookmarks}
|
|
122
|
+
onBookmarksChange={key.length > MAX_LOG_KEY_CHARS ? undefined : setBookmarks}
|
|
123
|
+
endOffset={tail.end}
|
|
124
|
+
onCopyFrom={(b) => void copyFrom(b)}
|
|
125
|
+
onSaveFrom={(b) => void saveFrom(b)}
|
|
126
|
+
/>
|
|
127
|
+
</div>
|
|
128
|
+
</div>
|
|
129
|
+
);
|
|
130
|
+
}
|