@deckbuilderkit/view-explorer 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,144 @@
1
+ import { useEffect, useRef, useState, type ReactNode } from 'react';
2
+ import { copyText, errorMessage, errorOf, formatBytes, Modal, useToast } from '@deckbuilderkit/ui';
3
+ import { explorerApi as api, type FsDuProgress, type FsInfo } from '../client';
4
+ import { t } from '../i18n';
5
+
6
+ const GRID = { display: 'grid', gridTemplateColumns: 'max-content 1fr', gap: '6px 16px', alignItems: 'center', margin: 0 } as const;
7
+ const WRAP = { overflowWrap: 'anywhere', margin: 0 } as const;
8
+ const ROW = { display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' } as const;
9
+
10
+ const when = (ms: number | null) => (ms === null ? '—' : new Date(ms).toLocaleString());
11
+ const sized = (bytes: number) => t('explorer.props.bytes', { size: formatBytes(bytes), bytes: bytes.toLocaleString() });
12
+
13
+ /** Properties of one Explorer entry; a folder can count its size, a file can show its SHA-256. */
14
+ export function PropertiesDialog({ root, path, onClose }: { root: string; path: string; onClose: () => void }) {
15
+ const toast = useToast();
16
+ const [info, setInfo] = useState<FsInfo>();
17
+ const [error, setError] = useState<string>();
18
+ const [du, setDu] = useState<FsDuProgress>();
19
+ const [counting, setCounting] = useState(false);
20
+ const [hash, setHash] = useState<string>();
21
+ const [hashing, setHashing] = useState(false);
22
+ const abort = useRef<AbortController | undefined>(undefined);
23
+
24
+ useEffect(() => {
25
+ let live = true;
26
+ api.fsInfo(root, path).then(
27
+ (i) => live && setInfo(i),
28
+ (err) => live && setError(errorMessage(errorOf(err))),
29
+ );
30
+ return () => {
31
+ live = false;
32
+ abort.current?.abort();
33
+ };
34
+ }, [root, path]);
35
+
36
+ const copy = (text: string) =>
37
+ void copyText(text).then((ok) => toast(ok ? { message: t('explorer.props.copied') } : { message: t('explorer.copyFailed'), kind: 'error' }));
38
+
39
+ const count = () => {
40
+ const ac = new AbortController();
41
+ abort.current = ac;
42
+ setCounting(true);
43
+ setDu(undefined);
44
+ api
45
+ .fsDu(root, path, setDu, ac.signal)
46
+ .catch((err) => !ac.signal.aborted && setError(errorMessage(errorOf(err))))
47
+ .finally(() => {
48
+ if (abort.current === ac) setCounting(false);
49
+ });
50
+ };
51
+ const cancel = () => {
52
+ abort.current?.abort();
53
+ abort.current = undefined;
54
+ setCounting(false);
55
+ };
56
+
57
+ const hashIt = () => {
58
+ setHashing(true);
59
+ api
60
+ .fsHash(root, path)
61
+ .then((h) => setHash(h.sha256), (err) => setError(errorMessage(errorOf(err))))
62
+ .finally(() => setHashing(false));
63
+ };
64
+
65
+ const row = (label: string, value: ReactNode) => (
66
+ <>
67
+ <dt>{label}</dt>
68
+ <dd style={WRAP}>{value}</dd>
69
+ </>
70
+ );
71
+
72
+ return (
73
+ <Modal
74
+ title={t('explorer.properties')}
75
+ onClose={onClose}
76
+ footer={
77
+ <button className="btn" onClick={onClose}>
78
+ {t('explorer.props.close')}
79
+ </button>
80
+ }
81
+ >
82
+ {error && <p className="error">{error}</p>}
83
+ {info && (
84
+ <dl style={GRID}>
85
+ {row(t('explorer.props.name'), info.name)}
86
+ {row(
87
+ t('explorer.props.path'),
88
+ <span style={ROW}>
89
+ <span style={WRAP}>{info.fullPath}</span>
90
+ <button className="btn btn-sm" onClick={() => copy(info.fullPath)}>
91
+ {t('explorer.props.copy')}
92
+ </button>
93
+ </span>,
94
+ )}
95
+ {row(t('explorer.props.type'), t(`explorer.props.${info.type}`))}
96
+ {info.type === 'dir'
97
+ ? row(
98
+ t('explorer.props.size'),
99
+ <span style={ROW}>
100
+ {du && (
101
+ <span>
102
+ {sized(du.bytes)} · {t('explorer.props.contains', { files: du.files.toLocaleString(), dirs: du.dirs.toLocaleString() })}
103
+ {counting && ` · ${t('explorer.props.counting')}`}
104
+ </span>
105
+ )}
106
+ {counting ? (
107
+ <button className="btn btn-sm" onClick={cancel}>
108
+ {t('explorer.props.cancel')}
109
+ </button>
110
+ ) : (
111
+ <button className="btn btn-sm" onClick={count}>
112
+ {t('explorer.props.calcSize')}
113
+ </button>
114
+ )}
115
+ </span>,
116
+ )
117
+ : row(t('explorer.props.size'), sized(info.size))}
118
+ {row(t('explorer.props.created'), when(info.created))}
119
+ {row(t('explorer.props.modified'), when(info.modified))}
120
+ {row(t('explorer.props.accessed'), when(info.accessed))}
121
+ {row(t('explorer.props.readOnly'), t(info.readOnly ? 'explorer.props.yes' : 'explorer.props.no'))}
122
+ {info.type === 'file' &&
123
+ row(
124
+ t('explorer.props.sha256'),
125
+ <span style={ROW}>
126
+ {hash ? (
127
+ <>
128
+ <code style={WRAP}>{hash}</code>
129
+ <button className="btn btn-sm" onClick={() => copy(hash)}>
130
+ {t('explorer.props.copy')}
131
+ </button>
132
+ </>
133
+ ) : (
134
+ <button className="btn btn-sm" disabled={hashing} onClick={hashIt}>
135
+ {hashing ? t('explorer.props.hashing') : t('explorer.props.sha256')}
136
+ </button>
137
+ )}
138
+ </span>,
139
+ )}
140
+ </dl>
141
+ )}
142
+ </Modal>
143
+ );
144
+ }
@@ -0,0 +1,221 @@
1
+ import { type ReactNode, useMemo, useRef, useState } from 'react';
2
+ import { type CodeEditorHandle, type CodeEditorProps, langOfFile, resolveMarkers, type RunMode, sqlMarkers, sqlParams, type SqlParam } from '@deckbuilderkit/editor';
3
+ import { Badge, ErrorBanner, Icon, Modal, SplitView, type TabAction, useAsync, useCommand, useViewLink } from '@deckbuilderkit/ui';
4
+ import { DEFAULT_RESULTS, SqlResults, useSqlRunner, useSqlSchema } from '@deckbuilderkit/sql-ui';
5
+ import { type ExplorerWorkbench, sqlRunnerSettings, withWorkbenchDefaults } from '../settings';
6
+ import { explorerCommandById as commandById } from '../commands';
7
+ import { t } from '../i18n';
8
+ import { sqlClient } from '../client';
9
+ import { useChordOf } from './tab-actions';
10
+
11
+ const DEFAULT_SPLIT: [number, number] = [0.6, 0.4];
12
+ const MANAGE = '::manage';
13
+
14
+ /** What the Explorer passes a file tab so `.sql` files of allowed projects can run SQL. */
15
+ export interface ExplorerSqlProps {
16
+ /** The stored workbench settings: allowed projects, last connection per project, result format. */
17
+ workbench?: Partial<ExplorerWorkbench>;
18
+ onWorkbenchChange(workbench: Partial<ExplorerWorkbench>): void;
19
+ /** Last connection id per project id. */
20
+ connections?: Record<string, string>;
21
+ onConnectionsChange(connections: Record<string, string>): void;
22
+ /** Fractions of the editor and the results panel. */
23
+ sizes?: [number, number];
24
+ onSizesChange(sizes: [number, number]): void;
25
+ /** Active REST environment id ('' = none); `.curl` runs substitute its variables. */
26
+ restEnvironment?: string;
27
+ }
28
+
29
+ interface Options {
30
+ project: string;
31
+ name: string;
32
+ /** Treat the file as SQL whatever its extension (SQL view of a query XML). */
33
+ asSql?: boolean;
34
+ /** The text editor shows (not a preview). */
35
+ active: boolean;
36
+ focused: boolean;
37
+ draft: string;
38
+ sql?: ExplorerSqlProps;
39
+ confirm: (opts: { title: string; body?: string; okLabel?: string; danger?: boolean }) => Promise<boolean>;
40
+ /** Called when a run opens the results, so another bottom panel can give way. */
41
+ onRunStart?: () => void;
42
+ }
43
+
44
+ function ParamsDialog({ params, initial, onSubmit, onClose }: { params: SqlParam[]; initial: Record<number, string>; onSubmit: (values: Record<number, string>) => void; onClose: () => void }) {
45
+ const [values, setValues] = useState<Record<number, string>>(initial);
46
+ const submit = () => onSubmit(values);
47
+ return (
48
+ <Modal
49
+ title={t('explorer.sql.params')}
50
+ onClose={onClose}
51
+ footer={(
52
+ <>
53
+ <button className="btn" onClick={onClose}>{t('explorer.shared.cancel')}</button>
54
+ <button className="btn btn-primary" onClick={submit}>{t('explorer.sql.paramsRun')}</button>
55
+ </>
56
+ )}
57
+ >
58
+ <form onSubmit={(e) => { e.preventDefault(); submit(); }} style={{ display: 'grid', gap: 10 }}>
59
+ <p className="muted">{t('explorer.sql.paramsHint')}</p>
60
+ {params.map((p, i) => (
61
+ <label key={p.n} style={{ display: 'grid', gap: 4 }}>
62
+ <span><code>{`{${p.n}}`}</code> <span className="muted">{t(p.unguarded === 0 ? 'explorer.sql.paramOptional' : 'explorer.sql.paramRequired')}</span></span>
63
+ <input className="input" autoFocus={i === 0} onFocus={i === 0 ? (e) => e.target.select() : undefined} value={values[p.n] ?? ''} onChange={(e) => setValues({ ...values, [p.n]: e.target.value })} />
64
+ </label>
65
+ ))}
66
+ <button type="submit" hidden />
67
+ </form>
68
+ </Modal>
69
+ );
70
+ }
71
+
72
+ /**
73
+ * SQL of an Explorer file: connection dropdown and Run / Run all / Format tab actions, the editor's run keys and completions, and a results panel
74
+ * below the editor. Only `.sql` files of projects in `workbench.explorerProjects`; the rest get nothing.
75
+ * The connection's DB session is the Workbench's.
76
+ */
77
+ export function useExplorerSql({ project, name, asSql, active, focused, draft, sql, confirm, onRunStart }: Options) {
78
+ const settings = useMemo(() => withWorkbenchDefaults(sql?.workbench), [sql?.workbench]);
79
+ const enabled = !!sql && active && (asSql || langOfFile(name) === 'sql') && settings.explorerProjects.includes(project);
80
+ const connections = useAsync(() => (enabled ? sqlClient.connections() : Promise.resolve([])), [enabled]);
81
+ const connection = connections.data?.find((c) => c.id === sql?.connections?.[project]);
82
+ const db = enabled ? connection?.id : undefined;
83
+ const runner = useSqlRunner(sqlClient, db, sqlRunnerSettings(settings), confirm);
84
+ const { sqlSchema } = useSqlSchema(sqlClient, db);
85
+ const editor = useRef<CodeEditorHandle>(null);
86
+ const [open, setOpen] = useState(false);
87
+ const [resultSizes, setResultSizes] = useState<[number, number]>(DEFAULT_RESULTS);
88
+ const chordOf = useChordOf();
89
+
90
+ const fileKey = `${project}/${name}`;
91
+ const [remembered, setRemembered] = useState<Record<string, Record<number, string>>>({});
92
+ const [dialog, setDialog] = useState<{ text: string; mode: RunMode } | 'edit' | null>(null);
93
+ const [paramError, setParamError] = useState<string>();
94
+
95
+ const execute = (text: string, mode: RunMode, values: Record<number, string>) => {
96
+ if (!db) return;
97
+ let sqlText = text;
98
+ if (sqlParams(text).length > 0 || sqlMarkers(text).length > 0) {
99
+ const r = resolveMarkers(text, values);
100
+ if (r.error) {
101
+ setParamError(r.error);
102
+ setOpen(true);
103
+ return;
104
+ }
105
+ sqlText = r.sql;
106
+ }
107
+ setParamError(undefined);
108
+ setOpen(true);
109
+ onRunStart?.();
110
+ runner.run(sqlText, mode);
111
+ };
112
+ const run = (text: string, mode: RunMode) => {
113
+ if (!db) return;
114
+ if (sqlParams(text).length > 0) {
115
+ setDialog({ text, mode });
116
+ return;
117
+ }
118
+ execute(text, mode, remembered[fileKey] ?? {});
119
+ };
120
+ const editParams = () => setDialog('edit');
121
+ const submitParams = (values: Record<number, string>) => {
122
+ const pending = dialog;
123
+ setRemembered((r) => ({ ...r, [fileKey]: values }));
124
+ setDialog(null);
125
+ if (pending && pending !== 'edit') execute(pending.text, pending.mode, values);
126
+ };
127
+ const format = () => editor.current?.format(settings.format);
128
+ useCommand(commandById('explorer.sql.format'), format, { enabled: focused && enabled && !asSql });
129
+
130
+ const toWorkbench = useViewLink('workbench');
131
+ const changeConnection = (value: string) => {
132
+ if (!sql) return;
133
+ if (value === MANAGE) {
134
+ toWorkbench?.();
135
+ return;
136
+ }
137
+ runner.reset();
138
+ const byProject = { ...sql.connections };
139
+ if (value) byProject[project] = value;
140
+ else delete byProject[project];
141
+ sql.onConnectionsChange(byProject);
142
+ };
143
+
144
+ const noConnection = db ? undefined : t('explorer.sql.noConnection');
145
+ const connectionOptions = [
146
+ { value: '', label: t('explorer.sql.pickConnection') },
147
+ ...(connections.data ?? []).map((c) => ({ value: c.id, label: c.name })),
148
+ ...(toWorkbench ? [{ value: MANAGE, label: t('explorer.shared.sourceManage') }] : []),
149
+ ];
150
+ const actions: TabAction[] = enabled ? [
151
+ { id: 'sql.connection', group: 'primary', label: t('explorer.shared.source'), options: connectionOptions, value: connection?.id ?? '', run: (value) => changeConnection(value ?? '') },
152
+ { id: 'sql.run', group: 'primary', icon: 'play', label: t('explorer.shared.run'), hint: noConnection ?? 'Ctrl+Enter', disabled: !db || runner.busy, run: () => run(editor.current?.getRunText() ?? draft, 'statement') },
153
+ { id: 'sql.runAll', group: 'primary', icon: 'forward', label: t('explorer.shared.runAll'), hint: noConnection ?? 'Ctrl+Shift+Enter', disabled: !db || runner.busy, run: () => run(draft, 'all') },
154
+ ...(sqlParams(draft).length > 0 ? [{ id: 'sql.params', group: 'primary' as const, icon: 'code' as const, label: t('explorer.sql.params'), run: editParams }] : []),
155
+ ...(asSql ? [] : [{ id: 'sql.format', group: 'primary' as const, icon: 'braces' as const, label: t('explorer.shared.format'), hint: chordOf('explorer.sql.format'), run: format }]),
156
+ ] : [];
157
+
158
+ const editorProps: Pick<CodeEditorProps, 'onRun' | 'sqlDialect' | 'sqlSchema'> = enabled ? { onRun: run, sqlDialect: 'plsql', sqlSchema } : {};
159
+
160
+ const { status, starting } = runner;
161
+ /** `editorNode` alone, or with a bottom panel: `steps` when given, else the results of a SQL file. They compete for the same slot. */
162
+ const layout = (editorNode: ReactNode, steps?: ReactNode): ReactNode => (
163
+ <>
164
+ {baseLayout(editorNode, steps)}
165
+ {dialog && (
166
+ <ParamsDialog
167
+ params={sqlParams(dialog === 'edit' ? draft : dialog.text)}
168
+ initial={remembered[fileKey] ?? {}}
169
+ onSubmit={submitParams}
170
+ onClose={() => setDialog(null)}
171
+ />
172
+ )}
173
+ </>
174
+ );
175
+ const baseLayout = (editorNode: ReactNode, steps?: ReactNode): ReactNode => {
176
+ const showResults = enabled && !!sql && open && !steps;
177
+ if (!steps && !(enabled && sql)) return editorNode;
178
+ const bottom = !!steps || showResults;
179
+ const sizes = bottom ? (sql?.sizes ?? DEFAULT_SPLIT) : [1];
180
+ return (
181
+ <SplitView
182
+ direction="column"
183
+ sizes={sizes}
184
+ defaultSizes={bottom ? DEFAULT_SPLIT : undefined}
185
+ onResize={(s) => s.length === 2 && sql?.onSizesChange([s[0], s[1]])}
186
+ >
187
+ <div key="editor" className="ex-pane">
188
+ <div className="xp-sql-editor">{editorNode}</div>
189
+ </div>
190
+ {steps && <div key="steps" className="ex-pane">{steps}</div>}
191
+ {showResults && (
192
+ <div key="results" className="ex-pane">
193
+ <div className="xp-bar">
194
+ <Icon name="database" size={12} />
195
+ <span>{connection?.name}</span>
196
+ {status?.pending && <Badge tone="warn">{t('explorer.shared.txPending')}</Badge>}
197
+ <span className="spacer" />
198
+ {runner.running && <button className="btn btn-sm" onClick={runner.cancel}>{t('explorer.shared.cancel')}</button>}
199
+ <button className="btn btn-sm" onClick={runner.copy} disabled={runner.tableRows.length === 0}>{t(runner.selection.length > 0 ? 'explorer.shared.copySelected' : 'explorer.shared.copy')}</button>
200
+ <button className="btn btn-sm" onClick={runner.commit} disabled={!status?.connected || starting}>{t('explorer.shared.txCommit')}</button>
201
+ <button className="btn btn-sm" onClick={runner.rollback} disabled={!status?.connected || starting}>{t('explorer.shared.txRollback')}</button>
202
+ <button className="btn btn-ghost btn-sm btn-icon" onClick={() => setOpen(false)} title={t('explorer.sql.close')} aria-label={t('explorer.sql.close')}>
203
+ <Icon name="close" />
204
+ </button>
205
+ </div>
206
+ {paramError && <ErrorBanner error={{ kind: 'client', code: 'raw', message: paramError }} compact />}
207
+ {runner.actionError && <ErrorBanner error={runner.actionError} compact />}
208
+ <SqlResults runner={runner} sizes={resultSizes} onSizes={setResultSizes} />
209
+ </div>
210
+ )}
211
+ </SplitView>
212
+ );
213
+ };
214
+
215
+ const closeResults = () => setOpen(false);
216
+
217
+ const paramValues = remembered[fileKey] ?? {};
218
+ const setParamValue = (n: number, value: string) => setRemembered((r) => ({ ...r, [fileKey]: { ...r[fileKey], [n]: value } }));
219
+
220
+ return { enabled, editor, editorProps, actions, layout, closeResults, paramValues, setParamValue };
221
+ }
@@ -0,0 +1,216 @@
1
+ import { useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';
2
+ import { Icon, ProjectPicker, type ProjectPickerProps, useCommand } from '@deckbuilderkit/ui';
3
+ import type { Project, SearchHit } from '../client';
4
+ import { explorerCommandById as commandById } from '../commands';
5
+ import { nodeId } from '../explorer-paths';
6
+ import { t } from '../i18n';
7
+ import { ProjectSearch } from './ProjectSearch';
8
+ import { ProjectTree, type ProjectTreeApi, type ProjectTreeProps } from './ProjectTree';
9
+ import { findInTree } from './tree-find';
10
+
11
+ export interface TreePaneProps extends ProjectTreeProps, Omit<ProjectPickerProps, 'current' | 'actions'> {
12
+ project: Project;
13
+ /** This pane is the one the user last clicked; Ctrl+Shift+F searches its project. */
14
+ focused: boolean;
15
+ /** Start with the excluded entries shown. */
16
+ showHidden?: boolean;
17
+ onFocus(): void;
18
+ /** The Explorer settings that decide what the tree lists. */
19
+ exclude: string[];
20
+ sort: string;
21
+ /** Every path of the project, for find in the tree; `ensureNames` loads or refreshes it. */
22
+ names?: SearchHit[];
23
+ ensureNames?(): void;
24
+ /** Show a second tree below; listed in the header's more menu. */
25
+ onSplit?(): void;
26
+ /** Close this tree; listed in the header's more menu. */
27
+ onCloseTree?(): void;
28
+ /** Reveal the active file in the tree, switching project if needed; undefined while no file is open. */
29
+ onGoToFile?(): void;
30
+ }
31
+
32
+ /** One sidebar tree: the project picker, and the project's tree or, while searching, its search panel. */
33
+ export function TreePane({ focused, showHidden = false, onFocus, exclude, sort, names, ensureNames, onSplit, onCloseTree, onGoToFile, apiRef, projects, onPick, onAdd, onEdit, onRemove, ...tree }: TreePaneProps) {
34
+ const { project } = tree;
35
+ const own = useRef<ProjectTreeApi>(null);
36
+ useImperativeHandle(apiRef, () => ({
37
+ newFile: () => own.current?.newFile(),
38
+ newFolder: () => own.current?.newFolder(),
39
+ refresh: () => own.current?.refresh(),
40
+ collapseAll: () => own.current?.collapseAll(),
41
+ expandAll: () => own.current?.expandAll(),
42
+ canCollapse: () => own.current?.canCollapse() ?? false,
43
+ reveal: (path) => own.current?.reveal(path),
44
+ scrollTo: (path) => own.current?.scrollTo(path),
45
+ finding: () => own.current?.finding() ?? false,
46
+ selectedFolder: () => own.current?.selectedFolder() ?? '',
47
+ }));
48
+ const [moreOpen, setMoreOpen] = useState(false);
49
+ const moreWrap = useRef<HTMLDivElement>(null);
50
+ const [moreBox, setMoreBox] = useState<{ top: number; right: number; maxHeight: number }>();
51
+ useEffect(() => {
52
+ if (!moreOpen || !moreWrap.current) return setMoreBox(undefined);
53
+ const r = moreWrap.current.getBoundingClientRect();
54
+ setMoreBox({ top: r.bottom + 4, right: window.innerWidth - r.right, maxHeight: Math.max(120, window.innerHeight - r.bottom - 12) });
55
+ }, [moreOpen]);
56
+ useEffect(() => {
57
+ if (!moreOpen) return;
58
+ const away = (e: MouseEvent) => {
59
+ if (!moreWrap.current?.contains(e.target as Node)) setMoreOpen(false);
60
+ };
61
+ document.addEventListener('mousedown', away);
62
+ return () => document.removeEventListener('mousedown', away);
63
+ }, [moreOpen]);
64
+ const item = (icon: string, label: string, act: () => void, checked?: boolean) => (
65
+ <button
66
+ type="button"
67
+ className="menu-item"
68
+ role={checked === undefined ? 'menuitem' : 'menuitemcheckbox'}
69
+ aria-checked={checked}
70
+ onClick={() => {
71
+ setMoreOpen(false);
72
+ act();
73
+ }}
74
+ >
75
+ <span className="menu-icon"><Icon name={icon as never} size={14} /></span>
76
+ <span className="menu-label">{label}</span>
77
+ {checked && <Icon name="check" size={14} />}
78
+ </button>
79
+ );
80
+ const [searching, setSearching] = useState(false);
81
+ const [showExcluded, setShowExcluded] = useState(showHidden);
82
+ const [signal, setSignal] = useState(0);
83
+ const [finding, setFinding] = useState(false);
84
+ const [findQuery, setFindQuery] = useState('');
85
+ const [filterMode, setFilterMode] = useState(true);
86
+ const [matchAt, setMatchAt] = useState(-1);
87
+ const findInput = useRef<HTMLInputElement>(null);
88
+ const [inside, setInside] = useState(false);
89
+
90
+ const result = useMemo(() => (finding && names ? findInTree(findQuery, names) : undefined), [finding, findQuery, names]);
91
+ useEffect(() => setMatchAt(-1), [result]);
92
+
93
+ const openFind = () => {
94
+ setFinding(true);
95
+ ensureNames?.();
96
+ findInput.current?.focus();
97
+ findInput.current?.select();
98
+ };
99
+ const closeFind = () => {
100
+ setFinding(false);
101
+ setFindQuery('');
102
+ };
103
+ const stepMatch = (delta: number) => {
104
+ if (!result?.order.length) return;
105
+ const next = (matchAt + delta + result.order.length) % result.order.length;
106
+ setMatchAt(next);
107
+ own.current?.scrollTo(result.order[next]!);
108
+ };
109
+
110
+ const openSearch = () => {
111
+ setSearching(true);
112
+ setSignal((n) => n + 1);
113
+ };
114
+ useCommand(commandById('explorer.search'), openSearch, { enabled: focused && inside });
115
+ useCommand(commandById('explorer.find'), openFind, { enabled: focused && inside });
116
+ useEffect(() => {
117
+ if (finding) findInput.current?.focus();
118
+ }, [finding]);
119
+ const collapsible = tree.expanded.some((id) => id !== nodeId(project.id, ''));
120
+ const toggleAll = () => (collapsible ? own.current?.collapseAll() : own.current?.expandAll());
121
+ useCommand(commandById('explorer.collapseAll'), toggleAll, { enabled: focused });
122
+
123
+ return (
124
+ <div
125
+ className="xp-pane"
126
+ onMouseDownCapture={() => {
127
+ onFocus();
128
+ setInside(true);
129
+ }}
130
+ onFocusCapture={() => {
131
+ onFocus();
132
+ setInside(true);
133
+ }}
134
+ onBlurCapture={(e) => {
135
+ if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setInside(false);
136
+ }}
137
+ >
138
+ <ProjectPicker
139
+ projects={projects}
140
+ current={project}
141
+ onPick={onPick}
142
+ onAdd={onAdd}
143
+ onEdit={onEdit}
144
+ onRemove={onRemove}
145
+ actions={
146
+ <>
147
+ <button type="button" className="btn btn-ghost btn-sm btn-icon" onClick={() => own.current?.newFile()} title={t('explorer.newFile')} aria-label={t('explorer.newFile')}>
148
+ <Icon name="file-plus" />
149
+ </button>
150
+ <button type="button" className="btn btn-ghost btn-sm btn-icon" onClick={toggleAll} title={t(collapsible ? 'explorer.collapseAll' : 'explorer.expandAll')} aria-label={t(collapsible ? 'explorer.collapseAll' : 'explorer.expandAll')}>
151
+ <Icon name={collapsible ? 'collapse-all' : 'expand-all'} />
152
+ </button>
153
+ <button type="button" className="btn btn-ghost btn-sm btn-icon" onClick={onGoToFile} disabled={!onGoToFile} title={t('explorer.goToFile')} aria-label={t('explorer.goToFile')}>
154
+ <Icon name="locate" />
155
+ </button>
156
+ <div className="menu-wrap" ref={moreWrap} onKeyDown={(e) => e.key === 'Escape' && setMoreOpen(false)}>
157
+ <button type="button" className="btn btn-ghost btn-sm btn-icon" onClick={() => setMoreOpen(!moreOpen)} aria-expanded={moreOpen} aria-haspopup="menu" title={t('explorer.more')} aria-label={t('explorer.more')}>
158
+ <Icon name="more" />
159
+ </button>
160
+ {moreOpen && moreBox && (
161
+ <div className="menu" role="menu" style={{ position: 'fixed', overflowY: 'auto', ...moreBox }}>
162
+ {item('search', t('explorer.search.open'), openSearch)}
163
+ {item('folder-plus', t('explorer.newFolder'), () => own.current?.newFolder())}
164
+ {item('refresh', t('explorer.refresh'), () => own.current?.refresh())}
165
+ {tree.onOpenDetails && item('table', t('explorer.openDetails'), () => tree.onOpenDetails?.(project.id, own.current?.selectedFolder() ?? ''))}
166
+ {item(showExcluded ? 'eye' : 'eye-off', t('explorer.showExcluded'), () => setShowExcluded(!showExcluded), showExcluded)}
167
+ {onSplit && item('split-down', t('explorer.split'), onSplit)}
168
+ {onCloseTree && item('close', t('explorer.closeTree'), onCloseTree)}
169
+ </div>
170
+ )}
171
+ </div>
172
+ </>
173
+ }
174
+ />
175
+ {finding && (
176
+ <div className="xp-find">
177
+ <input
178
+ ref={findInput}
179
+ type="search"
180
+ value={findQuery}
181
+ placeholder={t('explorer.find.placeholder')}
182
+ aria-label={t('explorer.find.placeholder')}
183
+ onChange={(e) => setFindQuery(e.target.value)}
184
+ onKeyDown={(e) => {
185
+ if (e.key === 'Escape') {
186
+ e.preventDefault();
187
+ closeFind();
188
+ } else if (e.key === 'Enter' || e.key === 'ArrowDown') {
189
+ e.preventDefault();
190
+ stepMatch(e.shiftKey ? -1 : 1);
191
+ } else if (e.key === 'ArrowUp') {
192
+ e.preventDefault();
193
+ stepMatch(-1);
194
+ }
195
+ }}
196
+ />
197
+ <span className="xp-find-count muted">
198
+ {findQuery.trim() ? (result?.order.length ? `${matchAt >= 0 ? matchAt + 1 : 0}/${result.order.length}` : names ? t('explorer.find.none') : '…') : ''}
199
+ </span>
200
+ <button type="button" className={`chip${filterMode ? ' chip-on' : ''}`} onClick={() => setFilterMode(!filterMode)} aria-pressed={filterMode} title={t('explorer.find.filterHint')}>
201
+ {t('explorer.find.filter')}
202
+ </button>
203
+ <button type="button" className="btn btn-ghost btn-sm btn-icon" onClick={closeFind} title={t('explorer.search.close')} aria-label={t('explorer.search.close')}>
204
+ <Icon name="close" />
205
+ </button>
206
+ </div>
207
+ )}
208
+ <div className="xp-pane-body">
209
+ {searching && <ProjectSearch project={project} exclude={exclude} onOpenFile={tree.onPreviewFile ?? tree.onOpenFile} onClose={() => setSearching(false)} focusSignal={signal} />}
210
+ <div className="xp-tree-wrap" hidden={searching}>
211
+ <ProjectTree {...tree} view={{ exclude, sort, showExcluded }} find={result && { mode: filterMode ? 'filter' : 'highlight', result }} apiRef={own} />
212
+ </div>
213
+ </div>
214
+ </div>
215
+ );
216
+ }
@@ -0,0 +1,9 @@
1
+ import { copyText, useToast } from '@deckbuilderkit/ui';
2
+ import { t } from '../i18n';
3
+
4
+ /** Copies a path to the clipboard and says so in a toast. */
5
+ export function useCopyPath(): (path: string) => void {
6
+ const toast = useToast();
7
+ return (path) =>
8
+ void copyText(path).then((ok) => toast(ok ? { message: t('explorer.pathCopied') } : { message: t('explorer.copyFailed'), kind: 'error' }));
9
+ }
@@ -0,0 +1,36 @@
1
+ import { useRef } from 'react';
2
+
3
+ /** One undoable file operation. Delete is not here: the Recycle Bin restores it. */
4
+ export type UndoOp =
5
+ | { kind: 'create' | 'copy'; project: string; paths: string[] }
6
+ | { kind: 'rename' | 'move'; project: string; moves: Array<{ from: string; to: string }> };
7
+
8
+ export const MAX_UNDO = 20;
9
+
10
+ export interface FileUndo {
11
+ push(op: UndoOp): void;
12
+ /** The newest op. */
13
+ last(): UndoOp | undefined;
14
+ /** Removes `op` wherever it sits; false when it is already gone. */
15
+ take(op: UndoOp): boolean;
16
+ }
17
+
18
+ /** Stack of the last file operations of this Explorer session, newest last. */
19
+ export function createFileUndo(): FileUndo {
20
+ const stack: UndoOp[] = [];
21
+ return {
22
+ push: (op) => {
23
+ stack.push(op);
24
+ if (stack.length > MAX_UNDO) stack.shift();
25
+ },
26
+ last: () => stack.at(-1),
27
+ take: (op) => {
28
+ const at = stack.indexOf(op);
29
+ if (at < 0) return false;
30
+ stack.splice(at, 1);
31
+ return true;
32
+ },
33
+ };
34
+ }
35
+
36
+ export const useFileUndo = (): FileUndo => (useRef<FileUndo>().current ??= createFileUndo());
@@ -0,0 +1,34 @@
1
+ import { relPath } from '@deckbuilderkit/ui';
2
+ import type { FsArchiveFormat } from '@deckbuilderkit/server-core/protocol';
3
+ import { explorerApi as api } from '../client';
4
+ import { t } from '../i18n';
5
+ import { freeName } from './os-drop';
6
+
7
+ /** File actions shared by the project tree and the details tab. */
8
+
9
+ /** The archive name without its extension(s): `a.tar.gz` → `a`. */
10
+ export const archiveStem = (name: string) => name.replace(/\.[^.]+$/, '').replace(/\.tar$/i, '') || name;
11
+
12
+ /** The folder all `items` sit in, or undefined when they span folders (or there are none). */
13
+ export const commonParent = (items: ReadonlyArray<{ path: string }>) => {
14
+ const parents = new Set(items.map((n) => relPath.dirname(n.path)));
15
+ return parents.size === 1 ? [...parents][0]! : undefined;
16
+ };
17
+
18
+ /** Names in `dir` of `project`, folded the way the OS compares them, and the fold. */
19
+ export const takenIn = async (project: string, dir: string) => {
20
+ const fold = /Windows|Mac/.test(navigator.userAgent) ? (n: string) => n.toLowerCase() : (n: string) => n;
21
+ return { fold, taken: new Set((await api.fsList(project, dir)).map((e) => fold(e.name))) };
22
+ };
23
+
24
+ /** Compresses `paths` (all in one folder) into a free name next to them; undefined when they span folders. */
25
+ export async function compressPaths(project: { id: string; name: string }, paths: string[], format: FsArchiveFormat) {
26
+ const dir = commonParent(paths.map((path) => ({ path })));
27
+ if (dir === undefined || paths.length === 0) return undefined;
28
+ const { fold, taken } = await takenIn(project.id, dir);
29
+ const base = paths.length === 1 ? relPath.basename(paths[0]!) : relPath.basename(dir) || project.name;
30
+ const name = freeName(`${base}.${format}`, false, taken, fold);
31
+ const result = await api.fsCompress(project.id, paths, name, format);
32
+ const skipped = result.skippedLinks.length;
33
+ return { dir, name, result, message: skipped ? t('explorer.compressedSkipped', { name, count: skipped }) : t('explorer.compressed', { name }) };
34
+ }