@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/src/client.ts ADDED
@@ -0,0 +1,104 @@
1
+ import type { ArchiveContent, HttpClient, SqliteContent } from '@deckbuilderkit/editor';
2
+ import type { EditorInfo, FsArchiveFormat, FsCompressResult, FsDuProgress, FsExtractResult, FsHash, FsInfo, GitStatus, TailChunk } from '@deckbuilderkit/server-core/protocol';
3
+ import type { SqlClient } from '@deckbuilderkit/sql-ui';
4
+
5
+ export type { LogSettings } from '@deckbuilderkit/ui';
6
+ export type { EditorInfo, FsDuProgress, FsHash, FsInfo, TailChunk };
7
+
8
+ /** A project folder the Explorer shows. */
9
+ export interface Project {
10
+ id: string;
11
+ name: string;
12
+ path: string;
13
+ }
14
+
15
+ export interface FsEntry {
16
+ name: string;
17
+ kind: 'file' | 'dir' | 'link';
18
+ size: number;
19
+ mtime: number;
20
+ /** A link that leads out of the project folder. */
21
+ outside?: true;
22
+ /** What a link inside the project points to. */
23
+ target?: 'file' | 'dir';
24
+ /** Matches an exclude glob; only listed when the tree shows excluded entries. */
25
+ excluded?: true;
26
+ }
27
+
28
+ /** The text encodings the server reads and writes. */
29
+ export type TextEncoding = 'utf8' | 'utf8bom' | 'utf16le' | 'utf16be' | 'windows1252';
30
+
31
+ export type FsRead =
32
+ | { text: string; encoding: TextEncoding; size: number; mtime: number; truncated: boolean; readOnly?: true }
33
+ | { binary: true; size: number; mtime: number };
34
+
35
+ export interface SearchHit {
36
+ path: string;
37
+ kind: 'file' | 'dir';
38
+ line?: number;
39
+ text?: string;
40
+ }
41
+
42
+ type Done = Promise<{ ok: true }>;
43
+ type Existing = 'replace' | 'keepBoth';
44
+
45
+ /** The server calls the Explorer makes; the app builds it from its api and passes it to `explorerView`. */
46
+ export interface ExplorerClient {
47
+ projects: () => Promise<Project[]>;
48
+ createProject: (input: Record<string, unknown>) => Promise<Project>;
49
+ updateProject: (id: string, input: Record<string, unknown>) => Promise<Project>;
50
+ deleteProject: (id: string) => Done;
51
+ fsList: (root: string, path: string, view?: { exclude?: string[]; sort?: string; all?: boolean }) => Promise<FsEntry[]>;
52
+ fsArchive: (root: string, path: string) => Promise<ArchiveContent>;
53
+ fsSqlite: (root: string, path: string, table?: string, offset?: number) => Promise<SqliteContent>;
54
+ fsStat: (root: string, path: string) => Promise<FsEntry>;
55
+ fsRead: (root: string, path: string) => Promise<FsRead>;
56
+ fsInfo: (root: string, path: string) => Promise<FsInfo>;
57
+ fsDu: (root: string, path: string, onProgress: (p: FsDuProgress) => void, signal?: AbortSignal) => Promise<FsDuProgress>;
58
+ fsHash: (root: string, path: string) => Promise<FsHash>;
59
+ fsExtract: (root: string, path: string, to: string) => Promise<FsExtractResult>;
60
+ fsCompress: (root: string, paths: string[], name: string, format: FsArchiveFormat) => Promise<FsCompressResult>;
61
+ fsCreate: (root: string, path: string, kind: 'file' | 'dir') => Promise<FsEntry>;
62
+ fsTail: (root: string, path: string, from?: number, max?: number) => Promise<TailChunk>;
63
+ fsSaveRange: (root: string, path: string, from: number) => Promise<{ path: string | null; bytes?: number }>;
64
+ fsWrite: (root: string, path: string, text: string, expectedMtime: number | undefined, encoding?: TextEncoding, unlock?: boolean) => Promise<FsEntry>;
65
+ fsRename: (root: string, path: string, name: string) => Promise<FsEntry>;
66
+ fsMove: (root: string, from: string, to: string, onExisting?: Existing) => Promise<FsEntry>;
67
+ fsCopy: (root: string, from: string, to: string) => Promise<FsEntry>;
68
+ fsUpload: (root: string, path: string, body: Blob, onExisting?: Existing) => Promise<FsEntry>;
69
+ fsViewTicket: (root: string, path: string) => Promise<{ url: string }>;
70
+ fsDownloadTicket: (root: string, path: string) => Promise<{ ticket: string; name: string }>;
71
+ fsDelete: (root: string, paths: string[]) => Done;
72
+ fsReveal: (root: string, path: string) => Done;
73
+ fsOpen: (root: string, path: string) => Done;
74
+ editors: () => Promise<EditorInfo[]>;
75
+ openInEditor: (editor: EditorInfo['id'], root: string, path?: string) => Done;
76
+ gitStatus: (root: string) => Promise<GitStatus>;
77
+ httpSave: (id: string) => Promise<{ path: string | null; bytes?: number }>;
78
+ }
79
+
80
+ /** Everything the Explorer calls: its own routes plus the SQL and HTTP clients of the capability packages. */
81
+ export interface ExplorerClients {
82
+ explorer: ExplorerClient;
83
+ sql: SqlClient;
84
+ http: HttpClient;
85
+ }
86
+
87
+ let current: ExplorerClients | undefined;
88
+
89
+ /** Set by `explorerView`; the screen and its panels call the server through these. */
90
+ export const bindExplorerClient = (clients: ExplorerClients) => {
91
+ current = clients;
92
+ };
93
+
94
+ const bound = <T extends object>(pick: (c: ExplorerClients) => T, name: string): T =>
95
+ new Proxy({} as T, {
96
+ get(_, key) {
97
+ if (!current) throw new Error(`The ${name} client is not bound`);
98
+ return pick(current)[key as keyof T];
99
+ },
100
+ });
101
+
102
+ export const explorerApi: ExplorerClient = bound((c) => c.explorer, 'Explorer');
103
+ export const sqlClient: SqlClient = bound((c) => c.sql, 'SQL');
104
+ export const httpClient: HttpClient = bound((c) => c.http, 'HTTP');
@@ -0,0 +1,29 @@
1
+ import { type CommandDef, explorerCommands, tabCommands } from '@deckbuilderkit/ui';
2
+ import { t } from './i18n';
3
+
4
+ export function explorerViewCommands(): CommandDef[] {
5
+ return [
6
+ ...tabCommands('explorer'),
7
+ ...explorerCommands('explorer'),
8
+ { id: 'explorer.find', scope: 'explorer', label: t('keys.explorer.find'), keys: ['Ctrl+F'], inInput: true },
9
+ { id: 'explorer.search', scope: 'explorer', label: t('keys.explorer.search'), keys: ['Ctrl+Shift+F'], inInput: true },
10
+ { id: 'explorer.revealInTree', scope: 'explorer', label: t('keys.explorer.revealInTree'), keys: ['Ctrl+K Ctrl+E'] },
11
+ { id: 'explorer.collapseAll', scope: 'explorer', label: t('keys.explorer.collapseAll'), keys: ['Ctrl+K Ctrl+Shift+0'] },
12
+ { id: 'explorer.breadcrumbs', scope: 'explorer', label: t('keys.explorer.breadcrumbs'), keys: ['Ctrl+Shift+.', 'Ctrl+Shift+>'], inInput: true },
13
+ { id: 'explorer.zen', scope: 'explorer', label: t('keys.explorer.zen'), keys: ['Ctrl+K Z'], inInput: true },
14
+ { id: 'explorer.sql.problems', scope: 'explorer', label: t('keys.explorer.sqlProblems'), keys: ['Ctrl+Shift+M'], inInput: true },
15
+ { id: 'explorer.file.save', scope: 'explorer', label: t('keys.file.save'), keys: ['Ctrl+S'], inInput: true },
16
+ { id: 'explorer.file.wrap', scope: 'explorer', label: t('keys.file.wrap'), keys: ['Alt+Z'], inInput: true },
17
+ { id: 'explorer.sql.format', scope: 'explorer', label: t('keys.explorer.sqlFormat'), keys: ['Alt+Shift+F'], inInput: true },
18
+ ...(['live', 'edit', 'split', 'preview'] as const).map((mode): CommandDef => ({
19
+ id: `explorer.markdown.${mode}`, scope: 'explorer', label: t(`keys.markdown.${mode}`), inInput: true,
20
+ keys: mode === 'preview' ? ['Ctrl+Shift+V'] : mode === 'split' ? ['Ctrl+Alt+V'] : [],
21
+ })),
22
+ ];
23
+ }
24
+
25
+ export function explorerCommandById(id: string): CommandDef {
26
+ const def = explorerViewCommands().find((d) => d.id === id);
27
+ if (!def) throw new Error(`Unknown command ${id}`);
28
+ return def;
29
+ }
@@ -0,0 +1,52 @@
1
+ import { lazy, Suspense, useState } from 'react';
2
+ import { type DiffMode, langOfFile } from '@deckbuilderkit/editor';
3
+ import { ErrorBanner, PanelBanner, PanelLoading, PanelNotice, relPath, useAsync } from '@deckbuilderkit/ui';
4
+ import { explorerApi as api } from '../client';
5
+ import { t } from '../i18n';
6
+
7
+ const DiffView = lazy(() => import('@deckbuilderkit/editor').then((m) => ({ default: m.DiffView })));
8
+
9
+ export interface CompareSide {
10
+ project: string;
11
+ path: string;
12
+ }
13
+
14
+ export interface CompareTabProps {
15
+ left: CompareSide;
16
+ right: CompareSide;
17
+ /** Initial layout, from the git.diffMode setting. */
18
+ mode: DiffMode;
19
+ }
20
+
21
+ /** Two text files side by side (or inline); binary files get a message, truncated ones a note. */
22
+ export function CompareTab({ left, right, mode: initialMode }: CompareTabProps) {
23
+ const [mode, setMode] = useState<DiffMode>(initialMode);
24
+ const sides = useAsync(() => Promise.all([api.fsRead(left.project, left.path), api.fsRead(right.project, right.path)]), [left.project, left.path, right.project, right.path]);
25
+ if (sides.error) return <ErrorBanner error={sides.error} onRetry={sides.reload} />;
26
+ if (!sides.data) return <PanelLoading />;
27
+ const [a, b] = sides.data;
28
+ if (!('text' in a) || !('text' in b)) {
29
+ const path = !('text' in a) ? left.path : right.path;
30
+ return <PanelNotice icon="file" title={path} message={t('explorer.compareBinary')} />;
31
+ }
32
+ const lang = langOfFile(relPath.basename(right.path)) ?? langOfFile(relPath.basename(left.path));
33
+ const diff = (
34
+ <Suspense fallback={<PanelLoading />}>
35
+ <DiffView
36
+ original={a.text}
37
+ modified={b.text}
38
+ lang={lang}
39
+ mode={mode}
40
+ onModeChange={setMode}
41
+ modeLabels={{ split: t('git.split'), inline: t('git.inline') }}
42
+ />
43
+ </Suspense>
44
+ );
45
+ if (!a.truncated && !b.truncated) return diff;
46
+ return (
47
+ <div className="git-conflict">
48
+ <PanelBanner tone="warn">{t('explorer.compareTruncated')}</PanelBanner>
49
+ <div className="git-conflict-body">{diff}</div>
50
+ </div>
51
+ );
52
+ }
@@ -0,0 +1,144 @@
1
+ import { type ReactNode, useEffect, useRef, useState } from 'react';
2
+ import { type CodeEditorHandle, type CodeEditorProps, curlAt, draftToSpec, type HttpHeaderDraft, type ImportedRequest, parseCurl, ResponseView, warningText } from '@deckbuilderkit/editor';
3
+ import type { HttpResponseInfo } from '@deckbuilderkit/server-core/protocol';
4
+ import { type ApiError, errorOf, Icon, PanelBanner, SplitView, type TabAction, useViewLink } from '@deckbuilderkit/ui';
5
+ import { explorerApi as api } from '../client';
6
+ import { httpClient } from '../client';
7
+ import { t } from '../i18n';
8
+ import type { ExplorerSqlProps } from './SqlPanel';
9
+
10
+ const DEFAULT_SPLIT: [number, number] = [0.6, 0.4];
11
+
12
+ interface Options {
13
+ name: string;
14
+ /** The text editor shows (not a preview). */
15
+ active: boolean;
16
+ draft: string;
17
+ /** Shares the SQL props: split size and `go`. */
18
+ sql?: ExplorerSqlProps;
19
+ }
20
+
21
+ const withAuth = (request: ImportedRequest): ImportedRequest['draft'] => {
22
+ const { draft, auth } = request;
23
+ if (!auth || draft.headers.some((h) => h.enabled && h.name.trim().toLowerCase() === 'authorization')) return draft;
24
+ const header: HttpHeaderDraft = { name: 'Authorization', value: auth, enabled: true };
25
+ return { ...draft, headers: [...draft.headers, header] };
26
+ };
27
+
28
+ /**
29
+ * Curl commands of an Explorer `.curl` file: Run (selection or the command at the cursor) with the response in a panel below the editor,
30
+ * and Open in REST. Only `.curl` files; the rest get nothing.
31
+ */
32
+ export function useExplorerCurl({ name, active, draft, sql }: Options) {
33
+ const enabled = !!sql && active && /\.curl$/i.test(name);
34
+ const toRest = useViewLink<ImportedRequest>('rest');
35
+ const editor = useRef<CodeEditorHandle>(null);
36
+ const abort = useRef<AbortController>();
37
+ const [open, setOpen] = useState(false);
38
+ const [sending, setSending] = useState(false);
39
+ const [response, setResponse] = useState<HttpResponseInfo>();
40
+ const [error, setError] = useState<ApiError>();
41
+ const [notice, setNotice] = useState<{ tone: 'warn' | 'err'; text: string }>();
42
+
43
+ useEffect(() => () => abort.current?.abort(), []);
44
+
45
+ const resolve = (): ImportedRequest | undefined => {
46
+ const handle = editor.current;
47
+ const selected = handle?.getSelectionText() ?? '';
48
+ const source = selected.trim() !== '' ? selected : curlAt(draft, handle?.view?.state.selection.main.head ?? 0)?.text;
49
+ try {
50
+ if (source === undefined) throw new Error('notCurl');
51
+ const request = parseCurl(source);
52
+ setNotice(request.warnings.length > 0 ? { tone: 'warn', text: request.warnings.map(warningText).join(' ') } : undefined);
53
+ return request;
54
+ } catch (err) {
55
+ setNotice({ tone: 'err', text: err instanceof Error && err.message === 'notCurl' ? t('explorer.curl.notCurl') : errorOf(err).message });
56
+ setOpen(true);
57
+ return undefined;
58
+ }
59
+ };
60
+
61
+ const run = async () => {
62
+ if (sending) return;
63
+ const request = resolve();
64
+ if (!request) return;
65
+ const controller = new AbortController();
66
+ abort.current = controller;
67
+ setOpen(true);
68
+ setSending(true);
69
+ setError(undefined);
70
+ setResponse(undefined);
71
+ try {
72
+ const result = await httpClient.send(draftToSpec(withAuth(request)), undefined, controller.signal, { environment: sql?.restEnvironment || undefined });
73
+ if (!controller.signal.aborted) setResponse(result);
74
+ } catch (err) {
75
+ if (!controller.signal.aborted) setError(errorOf(err));
76
+ } finally {
77
+ if (abort.current === controller) {
78
+ abort.current = undefined;
79
+ setSending(false);
80
+ }
81
+ }
82
+ };
83
+
84
+ const cancel = () => {
85
+ abort.current?.abort();
86
+ abort.current = undefined;
87
+ setSending(false);
88
+ };
89
+
90
+ const saveResponse = async (info: HttpResponseInfo) => {
91
+ try {
92
+ await api.httpSave(info.id);
93
+ } catch (err) {
94
+ setNotice({ tone: 'err', text: errorOf(err).message });
95
+ }
96
+ };
97
+
98
+ const openInRest = () => {
99
+ if (!sql || !toRest) return;
100
+ const request = resolve();
101
+ if (request) toRest(request);
102
+ };
103
+
104
+ const actions: TabAction[] = enabled ? [
105
+ { id: 'curl.run', group: 'primary', icon: 'play', label: t('explorer.shared.run'), hint: 'Ctrl+Enter', disabled: sending, run: () => void run() },
106
+ ...(toRest ? [{ id: 'curl.openRest', group: 'primary' as const, icon: 'send' as const, label: t('explorer.curl.openRest'), run: openInRest }] : []),
107
+ ] : [];
108
+
109
+ const editorProps: Pick<CodeEditorProps, 'onRun'> = enabled ? { onRun: () => void run() } : {};
110
+
111
+ /** `editorNode` alone, or for `.curl` files with the response panel below. */
112
+ const layout = (editorNode: ReactNode): ReactNode => {
113
+ if (!enabled || !sql) return editorNode;
114
+ const sizes = open ? (sql.sizes ?? DEFAULT_SPLIT) : [1];
115
+ return (
116
+ <SplitView
117
+ direction="column"
118
+ sizes={sizes}
119
+ defaultSizes={open ? DEFAULT_SPLIT : undefined}
120
+ onResize={(s) => s.length === 2 && sql.onSizesChange([s[0], s[1]])}
121
+ >
122
+ <div key="editor" className="ex-pane">
123
+ <div className="xp-sql-editor">{editorNode}</div>
124
+ </div>
125
+ {open && (
126
+ <div key="response" className="ex-pane">
127
+ <div className="xp-bar">
128
+ <Icon name="send" size={12} />
129
+ <span className="spacer" />
130
+ {sending && <button className="btn btn-sm" onClick={cancel}>{t('explorer.shared.cancel')}</button>}
131
+ <button className="btn btn-ghost btn-sm btn-icon" onClick={() => setOpen(false)} title={t('explorer.curl.close')} aria-label={t('explorer.curl.close')}>
132
+ <Icon name="close" />
133
+ </button>
134
+ </div>
135
+ {notice && <PanelBanner tone={notice.tone}>{notice.text}</PanelBanner>}
136
+ <ResponseView response={response} error={error} sending={sending} onSave={response ? () => void saveResponse(response) : undefined} />
137
+ </div>
138
+ )}
139
+ </SplitView>
140
+ );
141
+ };
142
+
143
+ return { enabled, editor, editorProps, actions, layout };
144
+ }
@@ -0,0 +1,284 @@
1
+ import { useCallback, useEffect, useMemo, useState, type KeyboardEvent } from 'react';
2
+ import {
3
+ type BreadcrumbEntry,
4
+ Breadcrumbs,
5
+ ContextMenu,
6
+ type ContextMenuItem,
7
+ DataTable,
8
+ type DataTableColumn,
9
+ ErrorBanner,
10
+ errorMessage,
11
+ errorOf,
12
+ fileIconOf,
13
+ folderIconOf,
14
+ formatBytes,
15
+ Icon,
16
+ relPath,
17
+ type SortState,
18
+ useDialogs,
19
+ useToast,
20
+ type ApiError,
21
+ } from '@deckbuilderkit/ui';
22
+ import type { FsArchiveFormat } from '@deckbuilderkit/server-core/protocol';
23
+ import { multiDeleteEnabled } from '../options';
24
+ import { explorerApi as api, type FsEntry, type Project } from '../client';
25
+ import { fullPath } from '../explorer-paths';
26
+ import { t } from '../i18n';
27
+ import { useCopyPath } from './copy-path';
28
+ import { commonParent, compressPaths } from './fs-actions';
29
+
30
+ interface Row {
31
+ name: string;
32
+ path: string;
33
+ kind: FsEntry['kind'];
34
+ dir: boolean;
35
+ size: number;
36
+ mtime: number;
37
+ outside?: true;
38
+ excluded?: true;
39
+ }
40
+
41
+ const MAX_LISTED = 10;
42
+ const keyOf = (r: Row) => r.path;
43
+ const foldersFirst = (r: Row) => (r.dir ? 0 : 1);
44
+
45
+ export interface DetailsTabProps {
46
+ project: Project;
47
+ /** The folder shown, relative to the project ('' = the root). */
48
+ path: string;
49
+ /** The tree's exclude globs; `showHidden` lists the excluded entries too (dimmed). */
50
+ exclude: string[];
51
+ showHidden?: boolean;
52
+ sort: SortState | null;
53
+ onSortChange(sort: SortState | null): void;
54
+ onNavigate(path: string): void;
55
+ onOpenFile(path: string): void;
56
+ onDeleted(project: string, path: string): void;
57
+ dirtyCount?(project: string, paths: string[]): number;
58
+ confirmDelete?: boolean;
59
+ }
60
+
61
+ const typeOf = (row: Row) => {
62
+ if (row.dir) return t('explorer.details.folder');
63
+ if (row.kind === 'link') return t('explorer.details.link');
64
+ const dot = row.name.lastIndexOf('.');
65
+ return dot > 0 ? t('explorer.details.fileType', { ext: row.name.slice(dot + 1).toUpperCase() }) : t('explorer.details.file');
66
+ };
67
+
68
+ /** A folder as a sortable table: folders first, Enter opens, Backspace or Alt+Up goes to the parent. */
69
+ export function DetailsTab({ project, path, exclude, showHidden = false, sort, onSortChange, onNavigate, onOpenFile, onDeleted, dirtyCount, confirmDelete = true }: DetailsTabProps) {
70
+ const [rows, setRows] = useState<Row[]>();
71
+ const [error, setError] = useState<ApiError>();
72
+ const [tick, setTick] = useState(0);
73
+ const [selection, setSelection] = useState<string[]>([]);
74
+ const [menu, setMenu] = useState<{ at: { x: number; y: number }; rows: Row[] }>();
75
+ const { confirm, element } = useDialogs();
76
+ const toast = useToast();
77
+ const copyPath = useCopyPath();
78
+ const reload = () => setTick((n) => n + 1);
79
+
80
+ useEffect(() => {
81
+ let live = true;
82
+ setError(undefined);
83
+ api.fsList(project.id, path, { exclude, all: showHidden }).then(
84
+ (entries) => {
85
+ if (!live) return;
86
+ setRows(entries.map((e) => ({
87
+ name: e.name,
88
+ path: relPath.join(path, e.name),
89
+ kind: e.kind,
90
+ dir: e.kind === 'dir' || e.target === 'dir',
91
+ size: e.size,
92
+ mtime: e.mtime,
93
+ outside: e.outside,
94
+ excluded: e.excluded,
95
+ })));
96
+ },
97
+ (err) => live && setError(errorOf(err)),
98
+ );
99
+ return () => {
100
+ live = false;
101
+ };
102
+ }, [project.id, path, exclude.join('\n'), showHidden, tick]);
103
+
104
+ useEffect(() => setSelection([]), [project.id, path]);
105
+
106
+ const run = async (job: () => Promise<void>) => {
107
+ try {
108
+ await job();
109
+ } catch (err) {
110
+ toast({ message: errorMessage(errorOf(err)), kind: 'error' });
111
+ }
112
+ };
113
+
114
+ const columns = useMemo<DataTableColumn<Row>[]>(
115
+ () => [
116
+ {
117
+ id: 'name',
118
+ label: t('explorer.details.name'),
119
+ width: 320,
120
+ value: (r) => r.name,
121
+ render: (r) => {
122
+ const file = fileIconOf(r.name);
123
+ const icon = r.dir ? (r.kind === 'link' ? 'folder-link' : folderIconOf(r.name)) : file.icon;
124
+ return (
125
+ <span className={`xp-details-name${r.excluded ? ' dim' : ''}`}>
126
+ <span className={!r.dir && file.tone ? `tone-${file.tone}` : undefined}><Icon name={icon} size={14} /></span>
127
+ <span className="xp-details-label">{r.name}</span>
128
+ </span>
129
+ );
130
+ },
131
+ },
132
+ { id: 'size', label: t('explorer.details.size'), width: 100, align: 'right', value: (r) => (r.dir ? null : r.size), render: (r) => (r.dir ? '' : formatBytes(r.size)) },
133
+ { id: 'mtime', label: t('explorer.details.modified'), width: 180, value: (r) => r.mtime, render: (r) => new Date(r.mtime).toLocaleString(), copy: (r) => new Date(r.mtime).toLocaleString() },
134
+ { id: 'type', label: t('explorer.details.type'), width: 140, value: typeOf },
135
+ ],
136
+ [],
137
+ );
138
+
139
+ const open = (row: Row) => (row.dir ? (!row.outside && onNavigate(row.path)) : onOpenFile(row.path));
140
+ const up = () => path !== '' && onNavigate(relPath.dirname(path));
141
+
142
+ const onKeyDown = (e: KeyboardEvent) => {
143
+ if (e.key === 'Backspace' || (e.altKey && e.key === 'ArrowUp')) {
144
+ e.preventDefault();
145
+ up();
146
+ }
147
+ };
148
+
149
+ const compress = (targets: Row[], format: FsArchiveFormat) =>
150
+ run(async () => {
151
+ const done = await compressPaths(project, targets.map((r) => r.path), format);
152
+ if (!done) return;
153
+ reload();
154
+ toast({ message: done.message });
155
+ });
156
+
157
+ const remove = (targets: Row[]) =>
158
+ run(async () => {
159
+ if (!targets.length) return;
160
+ if (targets.length > 1 && !multiDeleteEnabled()) {
161
+ toast({ message: t('explorer.deleteOne') });
162
+ return;
163
+ }
164
+ const dirty = dirtyCount?.(project.id, targets.map((r) => r.path)) ?? 0;
165
+ const ok = !confirmDelete && dirty === 0 ? true : await confirm({
166
+ title: t('explorer.deleteTitle'),
167
+ body: (
168
+ <>
169
+ <p>{targets.length === 1 ? t('explorer.deleteBody', { name: targets[0]!.name }) : t('explorer.deleteMany', { count: targets.length })}</p>
170
+ {targets.length > 1 && (
171
+ <ul>
172
+ {targets.slice(0, MAX_LISTED).map((r) => <li key={r.path}>{r.name}</li>)}
173
+ {targets.length > MAX_LISTED && <li>{t('explorer.andMore', { count: targets.length - MAX_LISTED })}</li>}
174
+ </ul>
175
+ )}
176
+ {dirty > 0 && <p>{t('explorer.dirtyDelete', { count: dirty })}</p>}
177
+ </>
178
+ ),
179
+ okLabel: t('explorer.delete'),
180
+ danger: true,
181
+ });
182
+ if (!ok) return;
183
+ try {
184
+ for (const r of targets) {
185
+ await api.fsDelete(project.id, [r.path]);
186
+ onDeleted(project.id, r.path);
187
+ }
188
+ } finally {
189
+ setSelection([]);
190
+ reload();
191
+ }
192
+ });
193
+
194
+ const menuItems = (picked: Row[]): Array<ContextMenuItem | 'sep'> => {
195
+ const one = picked.length === 1 ? picked[0]! : undefined;
196
+ const actionable = picked.filter((r) => !r.outside);
197
+ const canCompress = actionable.length === picked.length && commonParent(picked) !== undefined;
198
+ return [
199
+ ...(one ? [{ id: 'open', label: t('explorer.open'), icon: one.dir ? ('folder' as const) : ('file' as const), run: () => open(one), disabled: one.dir && !!one.outside }, 'sep' as const] : []),
200
+ ...(canCompress
201
+ ? [
202
+ { id: 'compressZip', label: t('explorer.compressZip'), icon: 'archive' as const, run: () => void compress(picked, 'zip') },
203
+ { id: 'compress7z', label: t('explorer.compress7z'), run: () => void compress(picked, '7z') },
204
+ ]
205
+ : []),
206
+ {
207
+ id: 'delete',
208
+ label: t('explorer.delete'),
209
+ icon: 'trash',
210
+ hint: picked.length > 1 && !multiDeleteEnabled() ? t('explorer.deleteOne') : 'Del',
211
+ danger: true,
212
+ disabled: actionable.length !== picked.length || (picked.length > 1 && !multiDeleteEnabled()),
213
+ run: () => void remove(picked),
214
+ },
215
+ 'sep',
216
+ ...(one
217
+ ? [
218
+ { id: 'copyFullPath', label: t('explorer.copyFullPath'), icon: 'copy' as const, run: () => copyPath(fullPath(project.path, one.path)) },
219
+ { id: 'copyPath', label: t('explorer.copyPath'), icon: 'copy' as const, run: () => copyPath(one.path) },
220
+ ]
221
+ : []),
222
+ ];
223
+ };
224
+
225
+ const listDir = useCallback(
226
+ async (dir: string): Promise<BreadcrumbEntry[]> =>
227
+ (await api.fsList(project.id, dir, { exclude })).map((e) => ({
228
+ name: e.name,
229
+ kind: e.kind === 'dir' || e.target === 'dir' ? 'dir' : 'file',
230
+ path: relPath.join(dir, e.name),
231
+ })),
232
+ [project.id, exclude.join('\n')],
233
+ );
234
+
235
+ const picked = (rows ?? []).filter((r) => selection.includes(r.path));
236
+
237
+ return (
238
+ <div
239
+ className="xp-details"
240
+ onKeyDown={(e) => {
241
+ if (e.key === 'Delete' && picked.length && !menu) {
242
+ e.preventDefault();
243
+ void remove(picked);
244
+ }
245
+ }}
246
+ >
247
+ {element}
248
+ <div className="xp-bar">
249
+ <button type="button" className="btn btn-ghost btn-sm btn-icon" onClick={up} disabled={path === ''} title={t('explorer.details.up')} aria-label={t('explorer.details.up')}>
250
+ <Icon name="chevron-up" />
251
+ </button>
252
+ <Breadcrumbs
253
+ root={project.name}
254
+ path={path}
255
+ icon={<Icon name={folderIconOf(relPath.basename(path))} size={14} />}
256
+ list={listDir}
257
+ onPick={(e) => (e.kind === 'dir' ? onNavigate(e.path) : onOpenFile(e.path))}
258
+ title={`${project.name}/${path}`}
259
+ />
260
+ </div>
261
+ {error && <ErrorBanner error={error} onRetry={reload} compact />}
262
+ <div className="xp-details-body">
263
+ <DataTable
264
+ columns={columns}
265
+ rows={rows ?? []}
266
+ rowKey={keyOf}
267
+ sort={sort}
268
+ onSortChange={onSortChange}
269
+ selection={selection}
270
+ onSelectionChange={setSelection}
271
+ groupOf={foldersFirst}
272
+ onRowOpen={open}
273
+ onKeyDown={onKeyDown}
274
+ onRowContextMenu={(row, e) => {
275
+ const keys = selection.includes(row.path) ? selection : [row.path];
276
+ setMenu({ at: { x: e.clientX, y: e.clientY }, rows: (rows ?? []).filter((r) => keys.includes(r.path)) });
277
+ }}
278
+ empty={rows ? t('explorer.details.empty') : ''}
279
+ />
280
+ </div>
281
+ {menu && <ContextMenu at={menu.at} items={menuItems(menu.rows)} onClose={() => setMenu(undefined)} />}
282
+ </div>
283
+ );
284
+ }