@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.
@@ -0,0 +1,742 @@
1
+ import './explorer.css';
2
+ import { lazy, Suspense, useEffect, useMemo, useRef, useState } from 'react';
3
+ import { editorSettingsOf, type ExplorerSettings, type ExplorerViewSettings as Settings, generalSettingsOf, windowSettingsOf } from './settings';
4
+ import { gitSettingsOf, setZen, useZen } from '@deckbuilderkit/ui';
5
+ import {
6
+ activate,
7
+ activeTab,
8
+ addTab,
9
+ focusedPane,
10
+ pinTab,
11
+ closeTab,
12
+ type ContextMenuItem,
13
+ dialogs,
14
+ Empty,
15
+ errorOf,
16
+ ErrorBanner,
17
+ Explorer,
18
+ type ApiError,
19
+ type FieldSpec,
20
+ Modal,
21
+ navigate,
22
+ newTab,
23
+ openBeside,
24
+ PageHead,
25
+ PanelLoading,
26
+ ResourceForm,
27
+ restore,
28
+ serialize,
29
+ setPreview,
30
+ singlePane,
31
+ Spinner,
32
+ SplitView,
33
+ TabWorkspace,
34
+ useAsync,
35
+ useCommand,
36
+ useDebouncedSave,
37
+ useKeybindings,
38
+ useViewRequest,
39
+ type Layout,
40
+ type QuickOpenFile,
41
+ useProvideQuickOpen,
42
+ fileIconOf,
43
+ fileSettingsOf,
44
+ relPath,
45
+ type SortState,
46
+ ProjectPicker,
47
+ } from '@deckbuilderkit/ui';
48
+ import { closeSession } from '@deckbuilderkit/terminal/session';
49
+ import { explorerApi as api, type LogSettings, type Project } from './client';
50
+ import { terminalAppKeys } from '@deckbuilderkit/terminal';
51
+ import { explorerCommandById as commandById } from './commands';
52
+ import { fullPath, nodeId } from './explorer-paths';
53
+ import { t } from './i18n';
54
+ import { useCopyPath } from './explorer/copy-path';
55
+ import { useFileUndo } from './explorer/file-undo';
56
+ import { useNameIndex } from './explorer/name-index';
57
+ import type { ProjectTreeApi } from './explorer/ProjectTree';
58
+ import type { ExplorerSqlProps } from './explorer/SqlPanel';
59
+ import { useChordOf } from './explorer/tab-actions';
60
+ import { TreePane } from './explorer/TreePane';
61
+
62
+ // The editor and viewers are large; they load when the first file opens.
63
+ const FileTab = lazy(() => import('./explorer/FileTab').then((m) => ({ default: m.FileTab })));
64
+ const TerminalPane = lazy(() => import('@deckbuilderkit/terminal').then((m) => ({ default: m.TerminalPane })));
65
+ const CompareTab = lazy(() => import('./explorer/CompareTab').then((m) => ({ default: m.CompareTab })));
66
+ const DetailsTab = lazy(() => import('./explorer/DetailsTab').then((m) => ({ default: m.DetailsTab })));
67
+
68
+ type FileRef = { project: string; path: string };
69
+ type Route =
70
+ | { kind: 'welcome' }
71
+ | { kind: 'file'; project: string; path: string }
72
+ | { kind: 'terminal'; project: string; path: string; session?: string }
73
+ | { kind: 'diff'; left: FileRef; right: FileRef }
74
+ | { kind: 'details'; project: string; path: string; sort?: SortState | null };
75
+
76
+ const isFileRef = (x: unknown): x is FileRef => {
77
+ const r = x as { project?: unknown; path?: unknown } | null;
78
+ return !!r && typeof r === 'object' && typeof r.project === 'string' && typeof r.path === 'string';
79
+ };
80
+ /** `ref` after a rename or move of `from` to `to` in `project`. */
81
+ const movedRef = (ref: FileRef, project: string, from: string, to: string): FileRef =>
82
+ ref.project === project && relPath.isUnder(ref.path, from) ? { ...ref, path: relPath.moved(ref.path, from, to) } : ref;
83
+ const refUnder = (ref: FileRef, project: string, path: string) => ref.project === project && relPath.isUnder(ref.path, path);
84
+
85
+ const WELCOME: Route = { kind: 'welcome' };
86
+ const MAX_EXPANDED = 2000;
87
+ const MAX_RECENT = 50;
88
+ const RECENT_KEY = 'deckbuilder.recentFiles';
89
+
90
+ function loadRecent(): string[] {
91
+ try {
92
+ const raw = JSON.parse(localStorage.getItem(RECENT_KEY) ?? '[]') as unknown;
93
+ return Array.isArray(raw) ? raw.filter((x): x is string => typeof x === 'string').slice(0, MAX_RECENT) : [];
94
+ } catch {
95
+ return [];
96
+ }
97
+ }
98
+
99
+ function storeRecent(ids: string[]) {
100
+ try {
101
+ localStorage.setItem(RECENT_KEY, JSON.stringify(ids));
102
+ } catch { /* recent files are a convenience */ }
103
+ }
104
+
105
+ const isRoute = (x: unknown): x is Route => {
106
+ const r = x as { kind?: unknown; project?: unknown; path?: unknown; session?: unknown } | null;
107
+ if (!r || typeof r !== 'object') return false;
108
+ if (r.kind === 'welcome') return true;
109
+ if (r.kind === 'diff') return isFileRef((x as { left?: unknown }).left) && isFileRef((x as { right?: unknown }).right);
110
+ if (typeof r.project !== 'string' || typeof r.path !== 'string') return false;
111
+ if (r.kind === 'details') {
112
+ const sort = (x as { sort?: { column?: unknown; dir?: unknown } | null }).sort;
113
+ return sort === undefined || sort === null || (typeof sort === 'object' && typeof sort.column === 'string' && (sort.dir === 'asc' || sort.dir === 'desc'));
114
+ }
115
+ return r.kind === 'file' || (r.kind === 'terminal' && (r.session === undefined || typeof r.session === 'string'));
116
+ };
117
+
118
+ type MarkdownMode = NonNullable<ExplorerSettings['markdownMode']>;
119
+ const MARKDOWN_MODES: readonly string[] = ['live', 'edit', 'split', 'preview'];
120
+
121
+ interface State {
122
+ sizes: [number, number];
123
+ collapsed: boolean;
124
+ expanded: Record<string, string[]>;
125
+ /** Saved project ids shown in the sidebar; `shownIds` resolves them against the project list. */
126
+ trees: string[];
127
+ treeSizes?: [number, number];
128
+ markdownMode: MarkdownMode;
129
+ wrapLines: boolean;
130
+ sqlSizes?: [number, number];
131
+ connections?: Record<string, string>;
132
+ layout: Layout<Route>;
133
+ }
134
+
135
+ function initialState(saved: ExplorerSettings | undefined, defaultWrap: boolean, restoreTabs: boolean): State {
136
+ const expanded: Record<string, string[]> = {};
137
+ for (const [id, ids] of Object.entries(saved?.expanded ?? {})) {
138
+ if (Array.isArray(ids)) expanded[id] = ids.filter((x): x is string => typeof x === 'string');
139
+ }
140
+ const sizes = saved?.sizes;
141
+ return {
142
+ sizes: Array.isArray(sizes) && sizes.length === 2 && sizes.every((n) => typeof n === 'number') ? [sizes[0], sizes[1]] : [0.25, 0.75],
143
+ collapsed: saved?.collapsed === true,
144
+ expanded,
145
+ trees: Array.isArray(saved?.trees) ? saved.trees.filter((x): x is string => typeof x === 'string').slice(0, 2) : [],
146
+ treeSizes: saved?.treeSizes,
147
+ markdownMode: MARKDOWN_MODES.includes(saved?.markdownMode as MarkdownMode) ? saved!.markdownMode! : 'live',
148
+ wrapLines: saved?.wrapLines ?? defaultWrap,
149
+ sqlSizes: saved?.sqlSizes,
150
+ connections: saved?.connections,
151
+ layout: (restoreTabs ? restore(saved?.tabs, isRoute) : undefined) ?? singlePane(newTab<Route>(WELCOME)),
152
+ };
153
+ }
154
+
155
+ /** Id of the project the sidebar shows: the saved one, else the first with expanded folders, else the first project. */
156
+ function shownId(state: State, list: Project[]): string | undefined {
157
+ const saved = list.find((p) => p.id === state.trees[0]);
158
+ return (saved ?? list.find((p) => state.expanded[p.id]) ?? list[0])?.id;
159
+ }
160
+
161
+ /** Projects shown in the sidebar, 1 or 2: the first as `shownId`, the second when saved and still listed. */
162
+ function shownIds(state: State, list: Project[]): string[] {
163
+ const first = shownId(state, list);
164
+ if (!first) return [];
165
+ const second = list.find((p) => p.id === state.trees[1]);
166
+ return second ? [first, second.id] : [first];
167
+ }
168
+
169
+ const sessionIds = (layout: Layout<Route>) =>
170
+ new Set(layout.panes.flatMap((p) => p.tabs.flatMap((tab) => (tab.route.kind === 'terminal' && tab.route.session ? [tab.route.session] : []))));
171
+
172
+ const fresh = () => newTab<Route>(WELCOME);
173
+
174
+ /** `fn` applied to the route of every tab and to its history. */
175
+ function mapRoutes(layout: Layout<Route>, fn: (route: Route) => Route): Layout<Route> {
176
+ return {
177
+ ...layout,
178
+ panes: layout.panes.map((pane) => ({
179
+ ...pane,
180
+ tabs: pane.tabs.map((tab) => ({ ...tab, route: fn(tab.route), back: tab.back.map(fn), forward: tab.forward.map(fn) })),
181
+ })),
182
+ };
183
+ }
184
+
185
+ function closeTabsWhere(layout: Layout<Route>, matches: (route: Route) => boolean): Layout<Route> {
186
+ let next = layout;
187
+ for (const pane of layout.panes) {
188
+ for (const tab of pane.tabs) if (matches(tab.route)) next = closeTab(next, tab.id, fresh);
189
+ }
190
+ return next;
191
+ }
192
+
193
+ /** Ids of `project` under `path`, the folder itself included. */
194
+ const idsUnder = (project: string, path: string) => (id: string) => id.startsWith(`${project}:`) && relPath.isUnder(id.slice(project.length + 1), path);
195
+
196
+ /** A file another view wants the Explorer to show: revealed in the tree, and opened when `open` (sent with `useViewLink('explorer')`). */
197
+ export interface ExplorerRequest {
198
+ project: string;
199
+ path: string;
200
+ open: boolean;
201
+ }
202
+
203
+ export function ExplorerScreen({ settings, onSave }: { settings: Settings; onSave: (patch: Partial<Settings>) => void }) {
204
+ const projects = useAsync(() => api.projects(), []);
205
+ const keys = useKeybindings();
206
+ const list = projects.data ?? [];
207
+ const [state, setState] = useState(() => initialState(settings.explorer, editorSettingsOf(settings).wordWrap, fileSettingsOf(settings.files).restoreTabs));
208
+ const latest = useRef(state);
209
+ const [form, setForm] = useState<{ project?: Project }>();
210
+ const [formError, setFormError] = useState<ApiError>();
211
+ const [actionError, setActionError] = useState<ApiError>();
212
+ const [focusedTree, setFocusedTree] = useState<0 | 1>(0);
213
+ const zen = useZen();
214
+ const [maximized, setMaximized] = useState(false);
215
+ useCommand(commandById('explorer.zen'), () => setZen(!zen));
216
+ useEffect(() => () => setZen(false), []);
217
+ // Esc twice leaves Zen, as in VS Code; the first Esc still reaches whatever has the focus.
218
+ useEffect(() => {
219
+ if (!zen) return;
220
+ let last = -Infinity;
221
+ const onKey = (e: KeyboardEvent) => {
222
+ if (e.key !== 'Escape') return;
223
+ if (e.timeStamp - last < 800) setZen(false);
224
+ last = e.timeStamp;
225
+ };
226
+ window.addEventListener('keydown', onKey, true);
227
+ return () => window.removeEventListener('keydown', onKey, true);
228
+ }, [zen]);
229
+ const copyPath = useCopyPath();
230
+ const undo = useFileUndo();
231
+
232
+ const persist = useDebouncedSave((s: State) =>
233
+ onSave({
234
+ explorer: {
235
+ sizes: s.sizes,
236
+ collapsed: s.collapsed,
237
+ expanded: s.expanded,
238
+ ...(s.trees.length > 0 && { trees: s.trees }),
239
+ ...(s.treeSizes && { treeSizes: s.treeSizes }),
240
+ markdownMode: s.markdownMode,
241
+ wrapLines: s.wrapLines,
242
+ ...(s.sqlSizes && { sqlSizes: s.sqlSizes }),
243
+ ...(s.connections && { connections: s.connections }),
244
+ tabs: serialize(s.layout),
245
+ },
246
+ }),
247
+ );
248
+ const files = fileSettingsOf(settings.files);
249
+ const sql: ExplorerSqlProps = {
250
+ workbench: settings.workbench,
251
+ onWorkbenchChange: (workbench) => onSave({ workbench }),
252
+ connections: state.connections,
253
+ onConnectionsChange: (connections) => update((s) => ({ ...s, connections })),
254
+ sizes: state.sqlSizes,
255
+ onSizesChange: (sqlSizes) => update((s) => ({ ...s, sqlSizes })),
256
+ restEnvironment: settings.rest?.environment ?? '',
257
+ };
258
+ const [logs, setLogs] = useState<LogSettings | undefined>(settings.logs);
259
+ const saveLogs = useDebouncedSave((l: LogSettings) => onSave({ logs: l }));
260
+ const changeLogs = (next: LogSettings) => {
261
+ setLogs(next);
262
+ saveLogs(next);
263
+ };
264
+ const update = (fn: (s: State) => State) => {
265
+ const next = fn(latest.current);
266
+ const kept = sessionIds(next.layout);
267
+ for (const id of sessionIds(latest.current.layout)) if (!kept.has(id)) void closeSession(id).catch(() => {});
268
+ latest.current = next;
269
+ setState(next);
270
+ persist(next);
271
+ };
272
+ const setExpanded = (project: string, ids: string[]) =>
273
+ update((s) => ({ ...s, expanded: { ...s.expanded, [project]: ids.slice(-MAX_EXPANDED) } }));
274
+
275
+ /** Shows `id` in tree `at`, expanding its root the first time. */
276
+ const pickProject = (id: string, at = 0) =>
277
+ update((s) => {
278
+ const shown = shownIds(s, list);
279
+ return {
280
+ ...s,
281
+ trees: at === 0 ? [id, ...shown.slice(1)] : [shown[0] ?? id, id],
282
+ expanded: s.expanded[id] ? s.expanded : { ...s.expanded, [id]: [nodeId(id, '')] },
283
+ };
284
+ });
285
+
286
+ /** Shows a second tree below, on the project after the first (the same one when it is the only one). */
287
+ const splitTree = () =>
288
+ update((s) => {
289
+ const shown = shownIds(s, list);
290
+ if (shown.length !== 1) return s;
291
+ const next = list[(list.findIndex((p) => p.id === shown[0]) + 1) % list.length];
292
+ return { ...s, trees: [shown[0], next.id], expanded: s.expanded[next.id] ? s.expanded : { ...s.expanded, [next.id]: [nodeId(next.id, '')] } };
293
+ });
294
+
295
+ const closeSecondTree = () => {
296
+ setFocusedTree(0);
297
+ update((s) => ({ ...s, trees: shownIds(s, list).slice(0, 1) }));
298
+ };
299
+
300
+ /** Opens a file; `preview` opens it in the pane's preview tab, which a later preview replaces. An already open file is only shown (and kept open unless `preview`). */
301
+ const openFile = (project: string, path: string, preview = false) =>
302
+ update((s) => {
303
+ const target: Route = { kind: 'file', project, path };
304
+ for (const pane of s.layout.panes) {
305
+ const same = pane.tabs.find((x) => x.route.kind === 'file' && x.route.project === project && x.route.path === path);
306
+ if (same) {
307
+ const shown = activate(s.layout, pane.id, same.id);
308
+ return { ...s, layout: preview ? shown : pinTab(shown, same.id) };
309
+ }
310
+ }
311
+ const pane = focusedPane(s.layout);
312
+ const slot = preview ? pane.tabs.find((x) => x.preview) : undefined;
313
+ if (slot) return { ...s, layout: activate(navigate(s.layout, slot.id, target, { replace: true }), pane.id, slot.id) };
314
+ const current = activeTab(s.layout);
315
+ if (current.route.kind === 'welcome') {
316
+ const layout = navigate(s.layout, current.id, target, { replace: true });
317
+ return { ...s, layout: preview ? setPreview(layout, current.id, true) : layout };
318
+ }
319
+ const tab = newTab(target);
320
+ return { ...s, layout: addTab(s.layout, preview ? { ...tab, preview: true } : tab) };
321
+ });
322
+
323
+ const openFileToSide = (project: string, path: string) =>
324
+ update((s) => ({ ...s, layout: openBeside(s.layout, newTab<Route>({ kind: 'file', project, path })) }));
325
+
326
+ const openTerminal = (project: string, path: string) =>
327
+ update((s) => {
328
+ const target: Route = { kind: 'terminal', project, path };
329
+ const current = activeTab(s.layout);
330
+ const layout = current.route.kind === 'welcome' ? navigate(s.layout, current.id, target, { replace: true }) : addTab(s.layout, newTab(target));
331
+ return { ...s, layout };
332
+ });
333
+
334
+ const [compareWith, setCompareWith] = useState<FileRef>();
335
+ const openCompare = (left: FileRef, right: FileRef) =>
336
+ update((s) => {
337
+ const target: Route = { kind: 'diff', left, right };
338
+ const current = activeTab(s.layout);
339
+ const layout = current.route.kind === 'welcome' ? navigate(s.layout, current.id, target, { replace: true }) : addTab(s.layout, newTab(target));
340
+ return { ...s, layout };
341
+ });
342
+
343
+ const openDetails = (project: string, path: string) =>
344
+ update((s) => {
345
+ const target: Route = { kind: 'details', project, path };
346
+ const current = activeTab(s.layout);
347
+ const layout = current.route.kind === 'welcome' ? navigate(s.layout, current.id, target, { replace: true }) : addTab(s.layout, newTab(target));
348
+ return { ...s, layout };
349
+ });
350
+
351
+ const onSession = (tabId: string, session: string) => {
352
+ const tab = latest.current.layout.panes.flatMap((p) => p.tabs).find((x) => x.id === tabId);
353
+ if (!tab || tab.route.kind !== 'terminal' || tab.route.session === session) return;
354
+ const route = tab.route;
355
+ update((s) => ({ ...s, layout: navigate(s.layout, tabId, { ...route, session }, { replace: true }) }));
356
+ };
357
+
358
+ // Open tabs and expanded folders follow a rename or move.
359
+ const moved = (project: string, from: string, to: string) => {
360
+ setCompareWith((c) => c && movedRef(c, project, from, to));
361
+ update((s) => ({
362
+ ...s,
363
+ layout: mapRoutes(s.layout, (r) =>
364
+ (r.kind === 'file' || r.kind === 'details') && r.project === project && relPath.isUnder(r.path, from)
365
+ ? { ...r, path: relPath.moved(r.path, from, to) }
366
+ : r.kind === 'diff'
367
+ ? { ...r, left: movedRef(r.left, project, from, to), right: movedRef(r.right, project, from, to) }
368
+ : r,
369
+ ),
370
+ expanded: s.expanded[project]
371
+ ? {
372
+ ...s.expanded,
373
+ [project]: s.expanded[project].map((id) => (idsUnder(project, from)(id) ? nodeId(project, relPath.moved(id.slice(project.length + 1), from, to)) : id)),
374
+ }
375
+ : s.expanded,
376
+ }));
377
+ };
378
+
379
+ const dirtyCount = (project: string, paths: string[]) =>
380
+ latest.current.layout.panes
381
+ .flatMap((p) => p.tabs)
382
+ .filter((x) => x.dirty && x.route.kind === 'file' && x.route.project === project && paths.some((path) => relPath.isUnder(x.route.kind === 'file' ? x.route.path : '', path))).length;
383
+
384
+ const deleted = (project: string, path: string) => {
385
+ setCompareWith((c) => (c && refUnder(c, project, path) ? undefined : c));
386
+ update((s) => ({
387
+ ...s,
388
+ layout: closeTabsWhere(s.layout, (r) =>
389
+ r.kind === 'file' || r.kind === 'details' ? r.project === project && relPath.isUnder(r.path, path) : r.kind === 'diff' && (refUnder(r.left, project, path) || refUnder(r.right, project, path)),
390
+ ),
391
+ expanded: s.expanded[project] ? { ...s.expanded, [project]: s.expanded[project].filter((id) => !idsUnder(project, path)(id)) } : s.expanded,
392
+ }));
393
+ };
394
+
395
+ const removeProject = async (project: Project) => {
396
+ setActionError(undefined);
397
+ try {
398
+ await api.deleteProject(project.id);
399
+ } catch (err) {
400
+ setActionError(errorOf(err));
401
+ return;
402
+ }
403
+ const at = list.findIndex((p) => p.id === project.id);
404
+ const next = list[at + 1] ?? list[at - 1];
405
+ update((s) => {
406
+ const { [project.id]: _removed, ...expanded } = s.expanded;
407
+ const rest = s.trees.filter((x) => x !== project.id);
408
+ const shownRemoved = s.trees[0] === project.id || (s.trees.length === 0 && shownId(s, list) === project.id);
409
+ const trees = shownRemoved && next ? [next.id, ...rest] : rest;
410
+ if (shownRemoved && next && !expanded[next.id]) expanded[next.id] = [nodeId(next.id, '')];
411
+ return { ...s, expanded, trees, layout: closeTabsWhere(s.layout, (r) => (r.kind === 'diff' ? r.left.project === project.id || r.right.project === project.id : (r.kind === 'file' || r.kind === 'details') && r.project === project.id)) };
412
+ });
413
+ projects.reload();
414
+ };
415
+
416
+ const submitForm = async (values: Record<string, unknown>) => {
417
+ if (!form) return;
418
+ setFormError(undefined);
419
+ try {
420
+ const saved = form.project ? await api.updateProject(form.project.id, values) : await api.createProject(values);
421
+ setForm(undefined);
422
+ projects.reload();
423
+ if (!form.project) pickProject(saved.id, focus);
424
+ } catch (err) {
425
+ setFormError(errorOf(err));
426
+ }
427
+ };
428
+
429
+ const fields: FieldSpec[] = [
430
+ { key: 'name', label: t('explorer.projectName'), kind: 'text', required: true },
431
+ { key: 'path', label: t('explorer.projectPath'), kind: 'path', required: true },
432
+ ];
433
+
434
+ const nameOf = (id: string) => list.find((p) => p.id === id)?.name ?? id;
435
+ const titleOf = (route: Route) =>
436
+ route.kind === 'welcome'
437
+ ? { title: t('explorer.welcome') }
438
+ : route.kind === 'terminal'
439
+ ? { title: `${t('explorer.shared.terminalTab')} · ${relPath.basename(route.path) || nameOf(route.project)}`, icon: 'terminal' as const }
440
+ : route.kind === 'diff'
441
+ ? {
442
+ title: `${relPath.basename(route.left.path)} ↔ ${relPath.basename(route.right.path)}`,
443
+ tooltip: `${nameOf(route.left.project)}/${route.left.path} ↔ ${nameOf(route.right.project)}/${route.right.path}`,
444
+ ...fileIconOf(relPath.basename(route.right.path)),
445
+ }
446
+ : route.kind === 'details'
447
+ ? { title: relPath.basename(route.path) || nameOf(route.project), tooltip: `${nameOf(route.project)}/${route.path}`, icon: 'table' as const }
448
+ : {
449
+ title: relPath.basename(route.path),
450
+ tooltip: `${nameOf(route.project)}/${route.path}`,
451
+ location: [nameOf(route.project), relPath.dirname(route.path)].filter(Boolean).join('/'),
452
+ ...fileIconOf(relPath.basename(route.path)),
453
+ };
454
+
455
+ const menuOf = (route: Route): ContextMenuItem[] => {
456
+ const project = route.kind === 'file' ? list.find((p) => p.id === route.project) : undefined;
457
+ if (route.kind !== 'file' || !project) return [];
458
+ return [
459
+ { id: 'revealInTree', label: t('explorer.revealInTree'), icon: 'locate', run: () => revealFile(route.project, route.path) },
460
+ { id: 'copyFullPath', label: t('explorer.copyFullPath'), icon: 'copy', run: () => copyPath(fullPath(project.path, route.path)) },
461
+ { id: 'copyPath', label: t('explorer.copyPath'), icon: 'copy', run: () => copyPath(route.path) },
462
+ ];
463
+ };
464
+
465
+ const shown = shownIds(state, list).flatMap((id) => list.find((p) => p.id === id) ?? []);
466
+ const focus = shown.length > 1 ? focusedTree : 0;
467
+
468
+ const names = useNameIndex(list, files.exclude);
469
+ const [recent, setRecent] = useState(loadRecent);
470
+ const quickFiles = useMemo<QuickOpenFile[]>(
471
+ () => shown.flatMap((p) => (names.entries[p.id] ?? []).filter((h) => h.kind === 'file').map((h) => ({ id: `${p.id}:${h.path}`, path: h.path, project: shown.length > 1 ? p.name : undefined }))),
472
+ [shown.map((p) => p.id).join(','), names.entries],
473
+ );
474
+ useProvideQuickOpen(
475
+ useMemo(
476
+ () => ({
477
+ files: quickFiles,
478
+ loading: names.loading,
479
+ recent,
480
+ onOpen: () => names.ensure(shown.map((p) => p.id)),
481
+ open: (file, side) => {
482
+ const project = file.id.slice(0, file.id.length - file.path.length - 1);
483
+ if (side) openFileToSide(project, file.path);
484
+ else openFile(project, file.path, files.previewTabs);
485
+ },
486
+ }),
487
+ [quickFiles, names.loading, names.ensure, recent, files.previewTabs],
488
+ ),
489
+ );
490
+
491
+ const trees = useRef<Array<ProjectTreeApi | null>>([]);
492
+ const revealRequest = useRef<{ project: string; path: string }>();
493
+ const [revealTick, setRevealTick] = useState(0);
494
+ const active = activeTab(state.layout).route;
495
+ const activeFile = active.kind === 'file' ? active : undefined;
496
+ const shownKey = shown.map((p) => p.id).join(',');
497
+ const revealIn = (project: string, path: string, autoReveal = false) => {
498
+ const at = shown.findIndex((p) => p.id === project);
499
+ if (at < 0) return false;
500
+ const tree = trees.current[at];
501
+ if (autoReveal && tree?.finding()) tree.scrollTo(path);
502
+ else tree?.reveal(path);
503
+ return true;
504
+ };
505
+ const revealFile = (project: string, path: string) => {
506
+ revealRequest.current = { project, path };
507
+ if (!shown.some((p) => p.id === project)) pickProject(project, focus);
508
+ setRevealTick((n) => n + 1);
509
+ };
510
+ // A file sent by another screen (Git): reveal it, and open it when asked.
511
+ const { request: pendingRequest, take: takeRequest } = useViewRequest<ExplorerRequest>('explorer');
512
+ useEffect(() => {
513
+ if (!projects.data || !pendingRequest) return;
514
+ const request = takeRequest();
515
+ if (!request) return;
516
+ if (request.open) openFile(request.project, request.path);
517
+ revealFile(request.project, request.path);
518
+ }, [projects.data, pendingRequest]);
519
+ useCommand(commandById('explorer.revealInTree'), () => activeFile && revealFile(activeFile.project, activeFile.path), { enabled: !!activeFile });
520
+
521
+ useEffect(() => {
522
+ if (!activeFile) return;
523
+ const id = `${activeFile.project}:${activeFile.path}`;
524
+ setRecent((r) => {
525
+ const next = [id, ...r.filter((x) => x !== id)].slice(0, MAX_RECENT);
526
+ storeRecent(next);
527
+ return next;
528
+ });
529
+ }, [activeFile?.project, activeFile?.path]);
530
+
531
+ useEffect(() => {
532
+ const request = revealRequest.current;
533
+ if (request) {
534
+ if (revealIn(request.project, request.path)) revealRequest.current = undefined;
535
+ return;
536
+ }
537
+ if (!files.autoReveal || !activeFile || /(^|\/)(node_modules|\.git)(\/|$)/.test(activeFile.path)) return;
538
+ revealIn(activeFile.project, activeFile.path, true);
539
+ }, [activeFile?.project, activeFile?.path, shownKey, revealTick, files.autoReveal]);
540
+
541
+ const pickerHandlers = {
542
+ onAdd: () => {
543
+ setFormError(undefined);
544
+ setForm({});
545
+ },
546
+ onEdit: (p: Project) => {
547
+ setFormError(undefined);
548
+ setForm({ project: p });
549
+ },
550
+ onRemove: (p: Project) => void removeProject(p),
551
+ };
552
+
553
+ const sidebar = (
554
+ <>
555
+ {actionError && <ErrorBanner error={actionError} compact />}
556
+ {projects.error && <ErrorBanner error={projects.error} onRetry={projects.reload} />}
557
+ {projects.loading && !projects.data ? (
558
+ <div className="xp-loading"><Spinner small /></div>
559
+ ) : (
560
+ <>
561
+ {shown.length === 0 ? (
562
+ <ProjectPicker projects={list} current={undefined} {...pickerHandlers} onPick={(p) => pickProject(p.id)} />
563
+ ) : (
564
+ <SplitView
565
+ direction="column"
566
+ sizes={shown.length > 1 ? (state.treeSizes ?? [0.5, 0.5]) : [1]}
567
+ defaultSizes={[0.5, 0.5]}
568
+ min={[120, 120]}
569
+ onResize={(sizes) => shown.length > 1 && update((s) => ({ ...s, treeSizes: [sizes[0], sizes[1]] }))}
570
+ >
571
+ {shown.map((project, i) => (
572
+ <TreePane
573
+ key={`${i}:${project.id}`}
574
+ project={project}
575
+ projects={list}
576
+ focused={focus === i}
577
+ exclude={files.exclude}
578
+ compact={files.compactFolders}
579
+ confirmDelete={files.confirmDelete}
580
+ confirmDragDrop={files.confirmDragDrop}
581
+ gitDecorations={gitSettingsOf(settings).explorerDecorations}
582
+ showHidden={files.showHidden}
583
+ nesting={files.fileNesting ? files.nestingPatterns : undefined}
584
+ sort={files.sortOrder}
585
+ names={names.entries[project.id]}
586
+ ensureNames={() => names.ensure([project.id], 30_000)}
587
+ onFocus={() => setFocusedTree(i as 0 | 1)}
588
+ onPick={(p) => pickProject(p.id, i)}
589
+ {...pickerHandlers}
590
+ expanded={state.expanded[project.id] ?? [nodeId(project.id, '')]}
591
+ onExpandedChange={(ids) => setExpanded(project.id, ids)}
592
+ onOpenFile={(p, path) => openFile(p, path)}
593
+ apiRef={(h) => void (trees.current[i] = h)}
594
+ onOpenFileToSide={openFileToSide}
595
+ onPreviewFile={files.previewTabs ? (p, path) => openFile(p, path, true) : undefined}
596
+ onOpenTerminal={openTerminal}
597
+ compareWith={compareWith}
598
+ onSelectForCompare={(p, path) => setCompareWith({ project: p, path })}
599
+ onCompare={openCompare}
600
+ onOpenDetails={openDetails}
601
+ onMoved={moved}
602
+ onDeleted={deleted}
603
+ undo={undo}
604
+ dirtyCount={dirtyCount}
605
+ onGoToFile={activeFile ? () => revealFile(activeFile.project, activeFile.path) : undefined}
606
+ onSplit={i === 0 && shown.length === 1 ? splitTree : undefined}
607
+ onCloseTree={i === 1 ? closeSecondTree : undefined}
608
+ />
609
+ ))}
610
+ </SplitView>
611
+ )}
612
+ </>
613
+ )}
614
+ </>
615
+ );
616
+
617
+ return (
618
+ <>
619
+ <Explorer
620
+ hidden={zen || maximized}
621
+ side={windowSettingsOf(settings).sidebarSide}
622
+ sidebar={sidebar}
623
+ sizes={state.sizes}
624
+ onResize={(sizes) => update((s) => ({ ...s, sizes }))}
625
+ collapsed={state.collapsed}
626
+ onCollapsedChange={(collapsed) => update((s) => ({ ...s, collapsed }))}
627
+ scope="explorer"
628
+ >
629
+ <TabWorkspace
630
+ confirmCloseDirty={generalSettingsOf(settings).confirmCloseDirty}
631
+ layout={state.layout}
632
+ onChange={(layout) => update((s) => ({ ...s, layout }))}
633
+ titleOf={titleOf}
634
+ menuOf={menuOf}
635
+ newRoute={() => WELCOME}
636
+ scope="explorer"
637
+ onMaximizedChange={setMaximized}
638
+ renderTab={(tab, ctx) =>
639
+ tab.route.kind === 'file' ? (
640
+ <Suspense fallback={<PanelLoading />}>
641
+ <FileTab project={tab.route.project} projectName={nameOf(tab.route.project)} path={tab.route.path} focused={ctx.focused} preview={tab.preview} logs={logs} onLogsChange={changeLogs} onDirty={ctx.setDirty} onSaver={ctx.setSaver} onActions={ctx.setActions} sql={sql} markdownMode={state.markdownMode} onMarkdownModeChange={(markdownMode) => update((s) => ({ ...s, markdownMode }))} wrapLines={state.wrapLines} onWrapLinesChange={(wrapLines) => update((s) => ({ ...s, wrapLines }))} files={files} onOpenPath={(p) => tab.route.kind === 'file' && openFile(tab.route.project, p, files.previewTabs)} />
642
+ </Suspense>
643
+ ) : tab.route.kind === 'terminal' ? (
644
+ <Suspense fallback={<PanelLoading />}>
645
+ <TerminalPane
646
+ session={tab.route.session}
647
+ cwd={{ root: tab.route.project, path: tab.route.path }}
648
+ onSession={(id) => onSession(tab.id, id)}
649
+ appKeys={terminalAppKeys('explorer', keys?.defs ?? [], ['explorer.zen'])}
650
+ />
651
+ </Suspense>
652
+ ) : tab.route.kind === 'details' ? (
653
+ <Suspense fallback={<PanelLoading />}>
654
+ {(() => {
655
+ const route = tab.route;
656
+ const project = list.find((p) => p.id === route.project);
657
+ return project ? (
658
+ <DetailsTab
659
+ project={project}
660
+ path={route.path}
661
+ exclude={files.exclude}
662
+ showHidden={files.showHidden}
663
+ sort={route.sort ?? null}
664
+ onSortChange={(sort) => update((s) => ({ ...s, layout: navigate(s.layout, tab.id, { ...route, sort }, { replace: true }) }))}
665
+ onNavigate={(path) => update((s) => ({ ...s, layout: navigate(s.layout, tab.id, { ...route, path }) }))}
666
+ onOpenFile={(path) => openFile(route.project, path, files.previewTabs)}
667
+ onDeleted={deleted}
668
+ dirtyCount={dirtyCount}
669
+ confirmDelete={files.confirmDelete}
670
+ />
671
+ ) : <PanelLoading />;
672
+ })()}
673
+ </Suspense>
674
+ ) : tab.route.kind === 'diff' ? (
675
+ <Suspense fallback={<PanelLoading />}>
676
+ <CompareTab left={tab.route.left} right={tab.route.right} mode={gitSettingsOf(settings).diffMode} />
677
+ </Suspense>
678
+ ) : (
679
+ <Welcome projects={list} onOpen={pickProject} />
680
+ )
681
+ }
682
+ />
683
+ </Explorer>
684
+ {form && (
685
+ <Modal title={t(form.project ? 'explorer.projectEdit' : 'explorer.projectAdd')} onClose={() => setForm(undefined)}>
686
+ <ResourceForm
687
+ fields={fields}
688
+ value={{ ...form.project }}
689
+ error={formError}
690
+ onCancel={() => setForm(undefined)}
691
+ onBrowse={() => dialogs.pickFolder({ title: t('explorer.pickFolder') })}
692
+ onSubmit={(values) => void submitForm(values)}
693
+ />
694
+ </Modal>
695
+ )}
696
+ </>
697
+ );
698
+ }
699
+
700
+ /** The commands an empty editor area lists, like VS Code's watermark; keys follow the user's keymap. */
701
+ const WATERMARK = [
702
+ { id: 'palette.quickOpen', label: 'explorer.watermark.quickOpen' },
703
+ { id: 'explorer.search', label: 'explorer.watermark.search' },
704
+ { id: 'explorer.toggleSidebar', label: 'explorer.watermark.tree' },
705
+ { id: 'palette.toggle', label: 'explorer.watermark.palette' },
706
+ { id: 'explorer.zen', label: 'explorer.watermark.zen' },
707
+ ] as const;
708
+
709
+ function Welcome({ projects, onOpen }: { projects: Project[]; onOpen: (id: string) => void }) {
710
+ const chordOf = useChordOf();
711
+ const hints = WATERMARK.flatMap((w) => {
712
+ const key = chordOf(w.id);
713
+ return key ? [{ ...w, key }] : [];
714
+ });
715
+ return (
716
+ <div className="content">
717
+ <PageHead title={t('explorer.welcome')} />
718
+ <div className="scroll">
719
+ <Empty title={t('explorer.welcomeTitle')}>
720
+ <p className="muted">{t('explorer.welcomeHint')}</p>
721
+ </Empty>
722
+ {projects.length > 0 && (
723
+ <div className="chips">
724
+ {projects.map((p) => (
725
+ <button key={p.id} className="chip" onClick={() => onOpen(p.id)}>{p.name}</button>
726
+ ))}
727
+ </div>
728
+ )}
729
+ {hints.length > 0 && (
730
+ <dl className="xp-watermark">
731
+ {hints.map((h) => (
732
+ <div key={h.id}>
733
+ <dt>{t(h.label)}</dt>
734
+ <dd><kbd>{h.key}</kbd></dd>
735
+ </div>
736
+ ))}
737
+ </dl>
738
+ )}
739
+ </div>
740
+ </div>
741
+ );
742
+ }