@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,58 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
+ import { stream } from '@deckbuilderkit/ui';
3
+ import type { Project, SearchHit } from '../client';
4
+
5
+ export interface NameIndex {
6
+ /** Every file and folder path of each project, as `/api/fs/names` lists them. */
7
+ entries: Record<string, SearchHit[]>;
8
+ loading: boolean;
9
+ /** Loads the projects whose index is missing or older than `maxAgeMs`. */
10
+ ensure(ids: string[], maxAgeMs?: number): void;
11
+ }
12
+
13
+ /** Per-project name lists for Quick Open and tree find, cached until `ensure` finds them stale or `exclude` changes. */
14
+ export function useNameIndex(projects: Project[], exclude: string[]): NameIndex {
15
+ const [entries, setEntries] = useState<Record<string, SearchHit[]>>({});
16
+ const [pending, setPending] = useState(0);
17
+ const loaded = useRef(new Map<string, number>());
18
+ const running = useRef(new Map<string, AbortController>());
19
+ const excludeKey = JSON.stringify(exclude);
20
+ const known = useRef(projects);
21
+ known.current = projects;
22
+
23
+ const load = useCallback((id: string) => {
24
+ running.current.get(id)?.abort();
25
+ const ac = new AbortController();
26
+ running.current.set(id, ac);
27
+ const found: SearchHit[] = [];
28
+ setPending((n) => n + 1);
29
+ stream('/api/fs/names', { root: id, path: '', exclude: JSON.parse(excludeKey).length ? excludeKey : undefined }, (hit) => found.push(hit as SearchHit), ac.signal)
30
+ .then(() => {
31
+ loaded.current.set(id, Date.now());
32
+ setEntries((e) => ({ ...e, [id]: found }));
33
+ })
34
+ .catch(() => { /* an aborted or failed listing leaves the old one */ })
35
+ .finally(() => {
36
+ if (running.current.get(id) === ac) running.current.delete(id);
37
+ setPending((n) => n - 1);
38
+ });
39
+ }, [excludeKey]);
40
+
41
+ useEffect(() => {
42
+ loaded.current.clear();
43
+ return () => {
44
+ for (const ac of running.current.values()) ac.abort();
45
+ };
46
+ }, [excludeKey]);
47
+
48
+ const ensure = useCallback((ids: string[], maxAgeMs = 10_000) => {
49
+ const now = Date.now();
50
+ for (const id of ids) {
51
+ if (running.current.has(id) || !known.current.some((p) => p.id === id)) continue;
52
+ const at = loaded.current.get(id);
53
+ if (at === undefined || now - at > maxAgeMs) load(id);
54
+ }
55
+ }, [load]);
56
+
57
+ return { entries, loading: pending > 0, ensure };
58
+ }
@@ -0,0 +1,56 @@
1
+ /** A file read from a dropped folder; `rel` is its path below the dropped entry's parent, starting with the entry's own name. */
2
+ export interface DroppedFile {
3
+ rel: string[];
4
+ file: File;
5
+ }
6
+
7
+ /** What a dropped OS entry holds: every file below it, and the folders that have nothing in them. */
8
+ export interface DroppedTree {
9
+ files: DroppedFile[];
10
+ emptyDirs: string[][];
11
+ }
12
+
13
+ /** The entries of a drop; call before the first await, the browser empties `items` afterwards. */
14
+ export function entriesOf(data: DataTransfer): FileSystemEntry[] {
15
+ const entries: FileSystemEntry[] = [];
16
+ for (const item of Array.from(data.items)) {
17
+ const entry = item.kind === 'file' ? item.webkitGetAsEntry() : null;
18
+ if (entry) entries.push(entry);
19
+ }
20
+ return entries;
21
+ }
22
+
23
+ const readFile = (entry: FileSystemFileEntry) => new Promise<File>((resolve, reject) => entry.file(resolve, reject));
24
+
25
+ async function readAll(dir: FileSystemDirectoryEntry): Promise<FileSystemEntry[]> {
26
+ const reader = dir.createReader();
27
+ const all: FileSystemEntry[] = [];
28
+ for (;;) {
29
+ const batch = await new Promise<FileSystemEntry[]>((resolve, reject) => reader.readEntries(resolve, reject));
30
+ if (!batch.length) return all;
31
+ all.push(...batch);
32
+ }
33
+ }
34
+
35
+ /** Walks `entry` (a file or a folder, recursively). */
36
+ export async function walkEntry(entry: FileSystemEntry, parent: string[] = [], out: DroppedTree = { files: [], emptyDirs: [] }): Promise<DroppedTree> {
37
+ const rel = [...parent, entry.name];
38
+ if (entry.isFile) {
39
+ out.files.push({ rel, file: await readFile(entry as FileSystemFileEntry) });
40
+ } else {
41
+ const children = await readAll(entry as FileSystemDirectoryEntry);
42
+ if (!children.length) out.emptyDirs.push(rel);
43
+ for (const child of children) await walkEntry(child, rel, out);
44
+ }
45
+ return out;
46
+ }
47
+
48
+ /** `wanted`, or the first free `name copy.ext`, `name copy 2.ext`, ... among `taken` (compared with `fold`). Like the server's copy naming. */
49
+ export function freeName(wanted: string, isDir: boolean, taken: ReadonlySet<string>, fold: (name: string) => string = (n) => n): string {
50
+ const dot = wanted.lastIndexOf('.');
51
+ const ext = isDir || dot <= 0 ? '' : wanted.slice(dot);
52
+ const stem = ext ? wanted.slice(0, dot) : wanted;
53
+ let name = wanted;
54
+ for (let n = 1; taken.has(fold(name)); n++) name = `${stem} copy${n > 1 ? ` ${n}` : ''}${ext}`;
55
+ return name;
56
+ }
@@ -0,0 +1,19 @@
1
+ /** Project path of an image `src` found in the markdown file `filePath`; null when it leaves the project. */
2
+ export function resolveImagePath(filePath: string, src: string): string | null {
3
+ let raw = src.split('#')[0]!.split('?')[0]!;
4
+ try {
5
+ raw = decodeURIComponent(raw);
6
+ } catch {
7
+ return null;
8
+ }
9
+ if (!raw) return null;
10
+ const parts: string[] = raw.startsWith('/') ? [] : filePath.split('/').slice(0, -1);
11
+ for (const seg of raw.split('/')) {
12
+ if (seg === '' || seg === '.') continue;
13
+ if (seg === '..') {
14
+ if (!parts.length) return null;
15
+ parts.pop();
16
+ } else parts.push(seg);
17
+ }
18
+ return parts.length ? parts.join('/') : null;
19
+ }
@@ -0,0 +1,17 @@
1
+ import { useEffect, useRef } from 'react';
2
+ import { effectiveKeys, type TabAction, useKeybindings } from '@deckbuilderkit/ui';
3
+
4
+ /** The first key of a command, for tooltips and menus. */
5
+ export function useChordOf(): (id: string) => string | undefined {
6
+ const kb = useKeybindings();
7
+ const keys = kb ? effectiveKeys(kb.defs, kb.keymap) : undefined;
8
+ return (id) => keys?.get(id)?.[0];
9
+ }
10
+
11
+ /** Calls `onActions` after every render (the tab bar ignores unchanged lists) and clears them on unmount. */
12
+ export function useTabActions(actions: TabAction[], onActions?: (actions: TabAction[]) => void) {
13
+ const ref = useRef(onActions);
14
+ ref.current = onActions;
15
+ useEffect(() => ref.current?.(actions));
16
+ useEffect(() => () => ref.current?.([]), []);
17
+ }
@@ -0,0 +1,37 @@
1
+ import { relPath } from '@deckbuilderkit/ui/rel-path';
2
+ import type { SearchHit } from '../client';
3
+
4
+ export interface FindResult {
5
+ /** Matched entries by path: indices into the entry's name to highlight. */
6
+ matches: Map<string, number[]>;
7
+ /** Folder path -> matched entries below it ('' is the project root). */
8
+ counts: Map<string, number>;
9
+ /** Matched entries in tree order. */
10
+ order: string[];
11
+ }
12
+
13
+ const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' });
14
+
15
+ /** Entries whose name matches `query` (their path when the query has a `/`), with the folder counts above them. */
16
+ export function findInTree(query: string, entries: readonly SearchHit[]): FindResult | undefined {
17
+ const q = query.trim().replace(/\\/g, '/').toLowerCase();
18
+ if (!q) return undefined;
19
+ const byPath = q.includes('/');
20
+ const matches = new Map<string, number[]>();
21
+ const counts = new Map<string, number>();
22
+ for (const e of entries) {
23
+ const name = relPath.basename(e.path);
24
+ const at = (byPath ? e.path : name).toLowerCase().indexOf(q);
25
+ if (at < 0) continue;
26
+ const start = byPath ? at - (e.path.length - name.length) : at;
27
+ const positions: number[] = [];
28
+ for (let i = 0; i < q.length; i++) if (start + i >= 0) positions.push(start + i);
29
+ matches.set(e.path, positions);
30
+ for (let dir = relPath.dirname(e.path); ; dir = relPath.dirname(dir)) {
31
+ counts.set(dir, (counts.get(dir) ?? 0) + 1);
32
+ if (!dir) break;
33
+ }
34
+ }
35
+ const order = [...matches.keys()].sort((a, b) => collator.compare(a, b));
36
+ return { matches, counts, order };
37
+ }
@@ -0,0 +1,49 @@
1
+ import { useCallback, useEffect, useMemo, useState } from 'react';
2
+ import { requestBlob } from '@deckbuilderkit/ui';
3
+ import { resolveImagePath } from './resolve-image-path';
4
+
5
+ const PLACEHOLDER = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
6
+
7
+ /** `resolveImage` for the markdown preview: loads the project's images through the API as blob URLs. */
8
+ export function useLocalImages(project: string, filePath: string): (src: string) => string | null {
9
+ // One cache per file, so loads started for a new file survive the old file's cleanup.
10
+ const cache = useMemo(() => ({ images: new Map<string, string | null>(), live: true }), [project, filePath]);
11
+ const [version, setVersion] = useState(0);
12
+
13
+ useEffect(() => {
14
+ cache.live = true;
15
+ return () => {
16
+ cache.live = false;
17
+ for (const [path, url] of cache.images) {
18
+ if (url && url !== PLACEHOLDER) {
19
+ URL.revokeObjectURL(url);
20
+ cache.images.delete(path);
21
+ }
22
+ }
23
+ };
24
+ }, [cache]);
25
+
26
+ // eslint-disable-next-line react-hooks/exhaustive-deps
27
+ return useCallback((src: string) => {
28
+ const path = resolveImagePath(filePath, src);
29
+ if (!path) return null;
30
+ const { images } = cache;
31
+ if (images.has(path)) return images.get(path)!;
32
+ images.set(path, PLACEHOLDER);
33
+ queueMicrotask(() => {
34
+ void requestBlob('/api/fs/raw', { root: project, path })
35
+ .then((blob) => (/\.svg$/i.test(path) && blob.type !== 'image/svg+xml' ? new Blob([blob], { type: 'image/svg+xml' }) : blob))
36
+ .then((blob) => URL.createObjectURL(blob), () => null)
37
+ .then((url) => {
38
+ if (!cache.live || images.get(path) !== PLACEHOLDER) {
39
+ if (url) URL.revokeObjectURL(url);
40
+ if (!cache.live) images.delete(path);
41
+ return;
42
+ }
43
+ images.set(path, url);
44
+ setVersion((v) => v + 1);
45
+ });
46
+ });
47
+ return PLACEHOLDER;
48
+ }, [cache, version]);
49
+ }
@@ -0,0 +1,9 @@
1
+ /** Tree node id, also what `settings.explorer.expanded` stores. */
2
+ export const nodeId = (project: string, path: string) => `${project}:${path}`;
3
+
4
+ /** Absolute path of `path` inside the project folder, with the separator the folder path uses. */
5
+ export function fullPath(projectPath: string, path: string): string {
6
+ if (!path) return projectPath;
7
+ const sep = projectPath.includes('\\') ? '\\' : '/';
8
+ return `${projectPath.replace(/[\\/]+$/, '')}${sep}${path.split('/').join(sep)}`;
9
+ }
@@ -0,0 +1,21 @@
1
+ /** The stored Explorer state. */
2
+ export interface ExplorerSettings {
3
+ /** Fractions of the sidebar and the workspace. */
4
+ sizes: [number, number];
5
+ collapsed: boolean;
6
+ /** Expanded tree node ids per project id. */
7
+ expanded: Record<string, string[]>;
8
+ /** Project ids shown in the sidebar, 1 or 2. */
9
+ trees?: string[];
10
+ /** Fractions of the two trees when `trees` has 2. */
11
+ treeSizes?: [number, number];
12
+ /** Edit / split / preview of markdown files; live when omitted. */
13
+ markdownMode?: 'live' | 'edit' | 'split' | 'preview';
14
+ /** Wrap long lines in the file editors; on when omitted. */
15
+ wrapLines?: boolean;
16
+ /** Fractions of the SQL editor and its results panel. */
17
+ sqlSizes?: [number, number];
18
+ /** Last database connection id used per project id, for SQL files. */
19
+ connections?: Record<string, string>;
20
+ tabs?: unknown;
21
+ }
@@ -0,0 +1,61 @@
1
+ .xp-loading { display: flex; justify-content: center; padding: var(--space-12); }
2
+ .xp-pane { display: flex; flex-direction: column; height: 100%; min-height: 0; }
3
+ .xp-find { display: flex; align-items: center; gap: var(--space-6); padding: var(--space-4) var(--space-8); }
4
+ .xp-find input { flex: 1; min-width: 0; }
5
+ .xp-find-count { font-size: var(--fs-xs-plus); white-space: nowrap; }
6
+ .xp-pane-body { flex: 1; min-height: 0; overflow: auto; }
7
+ .xp-project { display: flex; flex-direction: column; border-top: 1px solid var(--border-soft); }
8
+ /* The tree fills the pane, so a file dropped below the last row still lands in the project. */
9
+ .xp-tree-wrap:not([hidden]) { display: flex; flex-direction: column; min-height: 100%; }
10
+ .xp-project { flex: 1; }
11
+ .xp-project > .tree { flex: 1; }
12
+ .xp-head { display: flex; align-items: center; gap: var(--space-6); padding: var(--space-8) var(--space-8) var(--space-4); }
13
+ .xp-search { flex: 1; min-width: 0; }
14
+ .xp-search input { width: 100%; }
15
+ .xp-results { display: flex; flex-direction: column; padding: var(--space-4); }
16
+ .xp-status { display: flex; align-items: center; gap: var(--space-6); padding: var(--space-4) var(--space-8); font-size: var(--fs-sm); }
17
+ .xp-hit {
18
+ display: flex; align-items: flex-start; gap: var(--space-8); padding: 5px var(--space-8); border: 0; border-radius: var(--radius-md);
19
+ background: transparent; color: var(--text-2); text-align: left; cursor: pointer;
20
+ }
21
+ .xp-hit:hover:not(:disabled) { background: var(--hover); color: var(--text); }
22
+ .xp-hit:disabled { cursor: default; opacity: .6; }
23
+ .xp-hit-text { display: flex; flex-direction: column; min-width: 0; flex: 1; }
24
+ .xp-hit-path { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: var(--fs-md); }
25
+ .xp-hit-line { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: var(--fs-xs); color: var(--text-3); }
26
+
27
+ /* The file view fills its pane edge to edge, like VS Code: breadcrumbs, banners, content, status bar. */
28
+ .xp-file { display: flex; flex-direction: column; flex: 1; min-height: 0; }
29
+ .xp-bar { display: flex; align-items: center; gap: var(--space-8); min-height: var(--control-h-xs); padding: 0 var(--space-8); font-size: var(--fs-sm); }
30
+ .xp-body { display: flex; flex-direction: column; flex: 1; min-height: 0; }
31
+ .xp-body > .code-editor { height: 100%; }
32
+ .xp-body .code-editor, .xp-body .code-view, .xp-body .code-view-plain { border: 0; border-radius: 0; }
33
+ .xp-file > .panel-banner { border-radius: 0; }
34
+ .xp-sql-editor { display: flex; flex-direction: column; flex: 1; min-height: 0; }
35
+
36
+ .xp-watermark { display: grid; gap: var(--space-8); margin: 28px auto 0; width: max-content; max-width: 100%; color: var(--text-2); font-size: var(--fs-md); }
37
+ .xp-watermark > div { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: var(--space-24); align-items: center; }
38
+ .xp-watermark dt { text-align: right; }
39
+ .xp-watermark dd { margin: 0; }
40
+
41
+ .xp-details { display: flex; flex-direction: column; flex: 1; min-height: 0; }
42
+ .xp-details-body { display: flex; flex-direction: column; flex: 1; min-height: 0; }
43
+ .xp-details-body > .dtable { flex: 1; min-height: 0; }
44
+ .xp-details-name { display: inline-flex; align-items: center; gap: var(--space-6); min-width: 0; }
45
+ .xp-details-name.dim { opacity: 0.55; }
46
+ .xp-details-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
47
+
48
+ .ex-pane { display: flex; flex-direction: column; min-width: 0; min-height: 0; height: 100%; overflow: hidden; }
49
+ /* The plan table of the SQL results (@deckbuilderkit/sql-ui classes), scoped to the Explorer panels. */
50
+ .ex-pane .wb-plan { display: flex; flex-direction: column; height: 100%; min-height: 0; }
51
+ .ex-pane .wb-plan-bar { align-items: center; gap: var(--space-8); padding: var(--space-4) var(--space-8); border-bottom: 1px solid var(--border-soft); }
52
+ .ex-pane .wb-plan-bar .mono-label { flex: 1; }
53
+ .ex-pane .wb-plan-empty { padding: var(--space-8); }
54
+ .ex-pane .wb-plan-scroll { flex: 1; min-height: 0; overflow: auto; }
55
+ .ex-pane .wb-plan-table { border-collapse: collapse; width: 100%; font-family: var(--mono, monospace); font-size: var(--fs-sm); }
56
+ .ex-pane .wb-plan-table th, .ex-pane .wb-plan-table td { padding: var(--space-2) var(--space-6); text-align: left; white-space: nowrap; border-bottom: 1px solid var(--border-soft); }
57
+ .ex-pane .wb-plan-table th { position: sticky; top: 0; background: var(--bg); color: var(--text-2); font-weight: 500; }
58
+ .ex-pane .wb-plan-table .num { text-align: right; }
59
+ .ex-pane .wb-plan-hot td { background: color-mix(in srgb, var(--warn) 18%, transparent); }
60
+ .ex-pane .wb-plan-hot td:first-child { box-shadow: inset 3px 0 var(--warn); }
61
+ .ex-pane .wb-plan-pred { color: var(--text-2); }