Migration to new Rust-based engine (#401)

* wasm first steps

* parsing start

* parsing continued

* progress

* progress

* progress

* progress

* progress

* progress

* progress

* progress

* fixes

* reorganize

* change type

* fix

* progress

* refactoring

* progress

* progress

* progress

* keep style info

* progress

* progress

* remove (copy) in name

* progress

* merge methods into 1

* splice

* progress

* progress

* progress

* cleaning

* progress

* progress

* progress

* visibility

* progress

* progress

* progress

* progress

* progress

* fixes

* progress

* fix markers

* fix

* progress

* progress

* progress

* progress

* progress

* progress

* small fix

* use engine info

* fix popups

* small fix

* progress

* progress

* progress

* clean tool

* handle self closing elements

* reduce data exchanges across boundary

* copy on wasm side to avoid any data corruption

* speed fix

* routing

* refactoring

* reverse

* reverse

* crop

* split

* merge

* progress

* time tool

* fix bounds for waypoints

* reduce

* fix reactivity

* handle corrupt time data

* export

* progress

* remove gpx js package

* remove todo

* update readme

* cleaning

* progress

* fix link fields

* remove unused fields

* refactoring and tests

* import and export of routes, ref #192

* per-chunk categories storage

* cross-tab sync

* outdated state

* refactoring

* reorder methods

* refactoring

* fix

* improve flow

* renaming

* progress

* progress

* fix

* progress

* per file history

* keep original flag

* use history variant

* fix

* progress

* file explorer

* progress

* fix

* sticky folders

* improved sticky folders

* fix sortable

* fix sortable

* fix

* fix tab size

* no file delete on desktop

* fix routing on other file

* fix sorting

* remember language

* open links in browser

* commit missing changes

* avoid selecting on resize

* clarify filename

* explorer actions

* fix folder styling

* drop anywhere in folder

* cleaning

* fixes

* remove focus-ring

* full screen

* small fix

* consistent highlight color

* improved preview

* performance improvements

* sparser data transfers

* sparser data transfers

* show popup once data is loaded

* fix routing control size

* fix

* sparse desktop build

* remove custom ign styles

* use new endpoint for openhikingmap

* strip native menu

* reorder context menu items

* route full support

* route - track conversion

* create route file action

* route export, filter out anchors at high zoom level

* clarify name handling

* move hide & center to view

* routes continued

* update docs

* fix power format, closes #378

* lint fixes

* fix display of additional data with gaps, closes #361

* fix linting issues

* fix import modules

* fix multidrag

* change base to resolve

* fix type errors

* fix layer typings

* fix last type error

* each keys

* improve typings

* deploy to cloudflare

---------

Co-authored-by: vcoppe <vcoppe@hexaly.com>
This commit is contained in:
vcoppeandvcoppe authored and GitHub committed 2026-10-31 18:08:22 +01:00
1 parent 6a0d434371
commit 8fe7d8d64d
484 files changed
+53949 -41152

No files matched your search

@@ -0,0 +1,190 @@
<script lang="ts">
import { setContext, untrack } from 'svelte';
import { CollapsibleTree, CollapsibleTreeNode } from '$lib/components/collapsible-tree/index';
import { CollapsibleTreeState } from '$lib/components/collapsible-tree/utils.svelte';
import { ScrollArea } from '$lib/components/ui/scroll-area/index';
import ExplorerTree from './ExplorerTree.svelte';
import ExplorerRow from './ExplorerRow.svelte';
import {
clickedInExplorer,
explorerFoldersKey,
explorerSelection,
renaming,
treeRows,
type ExplorerFolders,
} from './state.svelte';
import { openPaths } from '$lib/actions/file-actions';
import { i18n } from '$lib/i18n/i18n.svelte';
import { engine } from '$lib/engine';
import { selectedFileIds } from '$lib/engine/selection-helpers';
import { folderName, parentOf, subfolder } from '$lib/engine/workspace';
const { workspace, selection } = engine;
let folder = $derived($workspace?.folder);
// which folders are open: new ones when another folder is opened, the folder itself is open at
// first and its folders are closed
let trees = $derived(
folder
? {
root: new CollapsibleTreeState('open'),
folders: new CollapsibleTreeState('closed'),
}
: undefined
);
let container: HTMLElement | undefined = $state();
// the files that are selected, if they are in the folder
let selectedPaths = $derived(
selectedFileIds($selection)
.map((id) => $workspace?.files[id]?.path)
.filter((path): path is string => subfolder(folder, path) !== undefined)
);
let selectedPath = $derived(selectedPaths[0]);
$effect(() => {
// not what the click that was just made depends on, only the selection and the folder
const clicked = untrack(() => clickedInExplorer.path);
if (clicked !== undefined && selectedPath === clicked) {
return;
}
clickedInExplorer.path = undefined;
// what is selected elsewhere (the file list, the map) is selected here
explorerSelection.paths = untrack(() => selectedPaths);
explorerSelection.anchor = selectedPath;
if (folder && selectedPath !== undefined) {
return reveal(folder, selectedPath);
}
});
/**
* Opens the folders that hold a file and scrolls to the file, if it is not in view already. Its
* row may not be there yet, because the content of a folder is read when it is opened.
*/
function reveal(folder: string, path: string) {
openFolder(parentOf(path));
let frame = 0;
let attempts = 0;
const scroll = () => {
const row = container?.querySelector(`[data-path="${CSS.escape(path)}"]`);
if (row) {
row.scrollIntoView({ block: 'nearest' });
} else if (attempts++ < 120) {
frame = requestAnimationFrame(scroll);
}
};
frame = requestAnimationFrame(scroll);
return () => cancelAnimationFrame(frame);
}
/**
* The id of the node of a folder of the tree in `trees.folders`: the names of the folders it is
* in, and its own (see `CollapsibleTreeNode`). `undefined` for the open folder itself, which is
* in `trees.root`, and for what is not in it.
*/
function nodeId(path: string): string | undefined {
const names = subfolder(folder, `${path}/_`)?.split('/').filter(Boolean);
return names && names.length > 0 ? ['root', ...names].join('.') : undefined;
}
/** Opens a folder of the tree, and the folders it is in. */
function openFolder(path: string) {
if (!trees) return;
trees.root.get('root.folder').current = true;
const id = nodeId(path);
if (id === undefined) return;
const names = id.split('.');
names.forEach((_, i) => {
if (i > 0) trees!.folders.get(names.slice(0, i + 1).join('.')).current = true;
});
}
setContext<ExplorerFolders>(explorerFoldersKey, {
open: openFolder,
moved(from, to) {
const [fromId, toId] = [nodeId(from), nodeId(to)];
if (trees && fromId !== undefined && toId !== undefined) {
trees.folders.move(fromId, toId);
}
},
});
/** Enter opens the selected files for good. */
function onkeydown(event: KeyboardEvent) {
if (renaming.path !== undefined || event.key !== 'Enter') return;
const files = treeRows(container)
.filter((row) => row.kind === 'file' && explorerSelection.paths.includes(row.path))
.map((row) => row.path);
if (files.length > 0) {
openPaths(files);
event.preventDefault();
}
}
</script>
<!--
The tree of the open folder: its folders and GPX files, one level read at a time. The files are
opened from it (the file list has the ones that are open).
-->
{#if folder && trees}
<div class="flex flex-col h-full">
<div class="px-2 py-1 text-sm font-semibold shrink-0">{i18n._('menu.explorer')}</div>
<ScrollArea class="grow min-h-0">
<div
bind:this={container}
data-explorer
role="tree"
tabindex="0"
class="flex flex-col pl-1 pr-3 pb-1 outline-none"
{onkeydown}
>
<!-- starts over when another folder is opened -->
{#key folder}
<!-- the folder itself -->
<CollapsibleTree side="left" treeState={trees.root} sticky>
<CollapsibleTreeNode id="folder">
{#snippet trigger()}
<ExplorerRow
path={folder}
name={folderName(folder)}
kind="root"
class="h-7"
/>
{/snippet}
{#snippet content(opened)}
<!-- what is in it is only read when it is open -->
{#if opened}
<div class="ml-4">
<CollapsibleTree side="left" treeState={trees.folders}>
<ExplorerTree />
</CollapsibleTree>
</div>
{/if}
{/snippet}
</CollapsibleTreeNode>
</CollapsibleTree>
{/key}
</div>
</ScrollArea>
</div>
{/if}
<style lang="postcss">
@reference "../../../app.css";
/* the color of the file list: a file and a folder (the whole row of its node, with its
chevron) under the pointer, or selected */
[data-explorer] :global([data-kind='file']:hover),
[data-explorer] :global([data-slot='button']:hover),
[data-explorer] :global([data-kind='file'][aria-selected='true']),
[data-explorer] :global([data-slot='button']:has([aria-selected='true'])) {
@apply bg-[var(--selection)];
}
/* the folder where what is dragged would go */
[data-explorer] :global([data-slot='button']:has([data-drop-target])) {
@apply ring-1 ring-primary bg-[var(--selection)];
}
</style>
@@ -0,0 +1,342 @@
<script lang="ts">
import * as ContextMenu from '$lib/components/ui/context-menu';
import {
ArrowUpRight,
FileX,
FolderOpen,
FolderX,
PenLine,
Plus,
Trash2,
} from '@lucide/svelte';
import { getContext, type Snippet } from 'svelte';
import { i18n } from '$lib/i18n/i18n.svelte';
import { engine } from '$lib/engine';
import { editableName, fileIdAt, isWithin, parentOf, relocated } from '$lib/engine/workspace';
import { closeFiles, closeFolder, openPaths, showEngineError } from '$lib/actions/file-actions';
import { isMac } from '$lib/utils';
import NameInput from './NameInput.svelte';
import {
clickedInExplorer,
creating,
dragging,
explorerFoldersKey,
explorerSelection,
renaming,
treeRows,
type ExplorerFolders,
} from './state.svelte';
let {
path,
name,
kind,
icon,
class: className = '',
style = '',
}: {
path: string;
/** What the row shows, the name of the file with its extension. */
name: string;
/** The open folder itself, a folder in it, or a GPX file. */
kind: 'root' | 'folder' | 'file';
icon?: Snippet;
class?: string;
style?: string;
} = $props();
const { workspace } = engine;
const folders = getContext<ExplorerFolders>(explorerFoldersKey);
let row: HTMLElement | undefined = $state();
// the open file of the row
let fileId = $derived(kind === 'file' ? fileIdAt($workspace, path) : undefined);
let selected = $derived(explorerSelection.paths.includes(path));
let editing = $derived(renaming.path === path);
let target = $derived(dragging.target === path);
/** What the menu and dragging act on: the selection, if the row is in it. */
function targets(): string[] {
return selected ? explorerSelection.paths : [path];
}
function select(paths: string[]) {
explorerSelection.paths = paths;
explorerSelection.anchor = path;
}
function onclick(event: MouseEvent) {
if (editing || kind === 'root') {
return;
}
if (event.metaKey || event.ctrlKey) {
// added to the selection, or taken out of it, without opening anything
select(
selected
? explorerSelection.paths.filter((other) => other !== path)
: [...explorerSelection.paths, path]
);
event.preventDefault();
event.stopPropagation();
} else if (event.shiftKey && explorerSelection.anchor !== undefined) {
// everything shown between the row where the selection began and this one
const paths = treeRows(row)
.filter((other) => other.kind !== 'root')
.map((other) => other.path);
const [from, to] = [paths.indexOf(explorerSelection.anchor), paths.indexOf(path)];
if (from >= 0 && to >= 0) {
explorerSelection.paths = paths.slice(Math.min(from, to), Math.max(from, to) + 1);
}
event.preventDefault();
event.stopPropagation();
} else if (event.detail <= 1) {
select([path]);
if (kind === 'file') {
// it is in view, the explorer does not have to scroll to it
clickedInExplorer.path = path;
if (fileId !== undefined) {
engine.select([fileId]);
} else {
openPaths([path], true);
}
}
}
}
function ondblclick() {
if (kind === 'file' && !editing) {
clickedInExplorer.path = path;
openPaths([path], false);
}
}
// the menu
/** The folder where what is made from the menu goes: the folder of the row. */
let folder = $derived(kind === 'file' ? undefined : path);
/**
* Asks for the name of a file or a folder to make in the folder of the row (see `creating`).
* With `route`, the file starts with an empty route.
*/
function create(what: 'file' | 'folder', route = false) {
if (folder === undefined) return;
// where what is being named is shown
folders.open(folder);
creating.kind = what;
creating.route = route;
creating.parent = folder;
}
function openSelected() {
const files = treeRows(row)
.filter((other) => other.kind === 'file' && targets().includes(other.path))
.map((other) => other.path);
if (files.length > 0) {
openPaths(files);
}
}
/** The open files among what the menu acts on. */
function openIds(): string[] {
return targets()
.map((other) => fileIdAt($workspace, other))
.filter((id): id is string => id !== undefined);
}
async function remove() {
const paths = targets();
explorerSelection.paths = [];
if (!(await engine.deletePaths(paths))) {
showEngineError();
}
}
// renaming, in the row
async function commit(value: string) {
if (renaming.path !== path) return;
renaming.path = undefined;
const name_ = value.trim();
if (name_ === '' || name_ === editableName(name, kind !== 'file')) return;
// the folder stays open or closed (before its new row is read, back if it is not renamed)
const to = relocated(path, parentOf(path), name_);
folders.moved(path, to);
if (!(await engine.renamePath(path, name_))) {
folders.moved(to, path);
showEngineError();
}
}
// dragging into folders
function ondragstart(event: DragEvent) {
if (kind === 'root' || editing) {
event.preventDefault();
return;
}
const paths = targets();
dragging.paths = paths;
event.dataTransfer?.setData('text/plain', paths.join('\n'));
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move';
}
}
function ondragend() {
dragging.paths = [];
dragging.target = undefined;
}
/** The folder where what is dropped on the row goes: the folder of a file, among its files. */
let dropFolder = $derived(kind === 'file' ? parentOf(path) : path);
function ondragover(event: DragEvent) {
if (dragging.paths.length === 0) return;
// not into itself, nor where it is already
if (dragging.paths.some((dragged) => isWithin(dropFolder, dragged))) return;
if (dragging.paths.every((dragged) => parentOf(dragged) === dropFolder)) return;
event.preventDefault();
if (event.dataTransfer) {
event.dataTransfer.dropEffect = 'move';
}
// the row of the folder shows it
dragging.target = dropFolder;
}
function ondragleave(event: DragEvent) {
// to another row, which tells where it would go (it was entered first)
const to = event.relatedTarget as Element | null;
if (to?.closest?.('[data-explorer-path]')) return;
dragging.target = undefined;
}
async function ondrop(event: DragEvent) {
event.preventDefault();
const paths = dragging.paths;
ondragend();
if (paths.length === 0) return;
// the folders stay open or closed (before their new rows are read, back if they are not moved)
const moves = paths.map((dragged) => [dragged, relocated(dragged, dropFolder)]);
moves.forEach(([from, to]) => folders.moved(from, to));
if (await engine.movePaths(paths, dropFolder)) {
// where they went is shown
folders.open(dropFolder);
} else {
moves.forEach(([from, to]) => folders.moved(to, from));
showEngineError();
}
}
</script>
<ContextMenu.Root
onOpenChange={(open) => {
if (open && fileId !== undefined) {
// the file is open for good once its menu is
engine.keepPreview([fileId]);
}
if (open && !selected && kind !== 'root') {
select([path]);
}
}}
>
<ContextMenu.Trigger class="grow min-w-0 pointer-events-auto outline-none">
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
bind:this={row}
role="treeitem"
aria-selected={selected}
tabindex="-1"
data-explorer-path={path}
data-path={path}
data-kind={kind}
title={path}
draggable={kind !== 'root' && !editing}
data-drop-target={target || undefined}
class="flex flex-row items-center gap-1.5 min-w-0 rounded-md outline-none {className}"
{style}
{onclick}
{ondblclick}
{ondragstart}
{ondragend}
{ondragover}
ondragenter={ondragover}
{ondragleave}
{ondrop}
>
{@render icon?.()}
{#if editing}
<NameInput
value={editableName(name, kind !== 'file')}
oncommit={commit}
oncancel={() => (renaming.path = undefined)}
/>
{:else}
<span class="grow truncate text-left">{name}</span>
{/if}
</div>
</ContextMenu.Trigger>
<!-- the name being edited keeps the focus, which the menu would give back to the row -->
<ContextMenu.Content
onCloseAutoFocus={(event) => {
if (renaming.path !== undefined || creating.parent !== undefined) {
event.preventDefault();
}
}}
>
{#if kind !== 'file'}
<ContextMenu.Item onclick={() => create('file')}>
<Plus size="16" />
{i18n._('menu.explorer_menu.new_file')}
</ContextMenu.Item>
<ContextMenu.Item onclick={() => create('file', true)}>
<Plus size="16" />
{i18n._('menu.explorer_menu.new_route_file')}
</ContextMenu.Item>
<ContextMenu.Item onclick={() => create('folder')}>
<Plus size="16" />
{i18n._('menu.explorer_menu.new_folder')}
</ContextMenu.Item>
<ContextMenu.Separator />
{:else}
<ContextMenu.Item onclick={openSelected}>
<FolderOpen size="16" />
{i18n._('menu.explorer_menu.open')}
</ContextMenu.Item>
{/if}
<ContextMenu.Item onclick={() => engine.revealPath(path)}>
<ArrowUpRight size="16" />
{i18n._(isMac() ? 'menu.explorer_menu.reveal_finder' : 'menu.explorer_menu.reveal')}
</ContextMenu.Item>
<ContextMenu.Separator />
{#if kind !== 'root'}
<ContextMenu.Item
disabled={targets().length !== 1}
onclick={() => (renaming.path = path)}
>
<PenLine size="16" />
{i18n._('menu.explorer_menu.rename')}
</ContextMenu.Item>
<ContextMenu.Separator />
{/if}
{#if kind === 'root'}
<ContextMenu.Item onclick={closeFolder}>
<FolderX size="16" />
{i18n._('menu.close_folder')}
</ContextMenu.Item>
{:else}
{#if kind === 'file' && openIds().length > 0}
<ContextMenu.Item onclick={() => closeFiles(openIds())}>
<FileX size="16" />
{i18n._('menu.close')}
</ContextMenu.Item>
<ContextMenu.Separator />
{/if}
<ContextMenu.Item onclick={remove}>
<Trash2 size="16" />
{i18n._('menu.delete')}
</ContextMenu.Item>
{/if}
</ContextMenu.Content>
</ContextMenu.Root>
@@ -0,0 +1,155 @@
<script lang="ts">
import { CollapsibleTreeNode } from '$lib/components/collapsible-tree/index';
import { FileText, Folder, Route } from '@lucide/svelte';
import { getContext } from 'svelte';
import { get } from 'svelte/store';
import { toast } from 'svelte-sonner';
import { stickyKey, type StickyLevel } from '$lib/components/collapsible-tree/sticky';
import ExplorerTree from './ExplorerTree.svelte';
import ExplorerRow from './ExplorerRow.svelte';
import { engine, type FolderEntry } from '$lib/engine';
import { editableName, fileIdAt } from '$lib/engine/workspace';
import { i18n } from '$lib/i18n/i18n.svelte';
import { currentTool, Tool } from '$lib/components/toolbar/tools';
import { showEngineError } from '$lib/actions/file-actions';
import NameInput from './NameInput.svelte';
import { creating, explorerSelection } from './state.svelte';
let {
path,
}: {
/** The folder whose content is listed, the open folder if it is not given. */
path?: string;
} = $props();
const { workspace, folderVersion } = engine;
// the rows of the folders that stick above the files of this one
const level = getContext<StickyLevel>(stickyKey);
let entries: FolderEntry[] = $state([]);
// the folder of the list, where what is being made goes
let folder = $derived(path ?? $workspace?.folder);
let pending = $derived(
creating.parent !== undefined && creating.parent === folder ? creating.kind : undefined
);
// the folders come first, then the files (and what is being made goes at the top of its kind)
let folders = $derived(entries.filter((entry) => entry.isDir));
let files = $derived(entries.filter((entry) => !entry.isDir));
/**
* Makes the file or folder that was asked for, called `value`: nothing is made without a name,
* nor with the name of something that is there (the user is told, and can change it). A new
* file is selected, for the routing tool.
*/
async function create(value: string, retry: boolean) {
const kind = pending;
const parent = folder;
const route = creating.route;
if (kind === undefined || parent === undefined) return;
const name = value.trim();
const taken = entries.some(
(entry) =>
entry.isDir === (kind === 'folder') &&
editableName(entry.name, entry.isDir).toLowerCase() === name.toLowerCase()
);
if (name !== '' && taken) {
toast.error(i18n._('menu.metadata.name_taken'));
if (retry) return;
}
creating.parent = undefined;
if (name === '' || taken) return;
let created: string | undefined;
if (kind === 'file') {
const id = await engine.newFileIn(name, parent, route);
created = id === undefined ? undefined : get(workspace)?.files[id]?.path;
if (created !== undefined) {
currentTool.set(Tool.ROUTING);
}
} else {
created = await engine.createFolder(parent, name);
}
if (created === undefined) {
showEngineError();
return;
}
explorerSelection.paths = [created];
explorerSelection.anchor = created;
}
$effect(() => {
// read again when something changed on the file system
$folderVersion;
let current = true;
engine.listFolder(path).then((list) => {
if (current) {
entries = list;
}
});
return () => {
current = false;
};
});
</script>
<!-- the name of what is being made, which it is made with -->
{#snippet named(kind: 'file' | 'folder')}
<div class="flex flex-row items-center h-7 pl-1 pr-1 {kind === 'file' ? 'gap-1.5' : 'gap-1'}">
{#if kind === 'file' && creating.route}
<Route size="16" class="shrink-0" />
{:else if kind === 'file'}
<FileText size="16" class="shrink-0" />
{:else}
<Folder size="16" class="shrink-0" />
{/if}
<NameInput
placeholder={i18n._(
kind === 'folder'
? 'menu.explorer_menu.new_folder'
: creating.route
? 'menu.explorer_menu.new_route_file'
: 'menu.explorer_menu.new_file'
)}
oncommit={create}
oncancel={() => (creating.parent = undefined)}
/>
</div>
{/snippet}
{#if pending === 'folder'}
{@render named('folder')}
{/if}
{#each folders as entry (entry.path)}
<CollapsibleTreeNode id={entry.name}>
{#snippet trigger()}
<ExplorerRow path={entry.path} name={entry.name} kind="folder" class="h-7" />
{/snippet}
{#snippet content(opened)}
<!-- the content of a folder is only read when it is opened -->
{#if opened}
<div class="ml-4">
<ExplorerTree path={entry.path} />
</div>
{/if}
{/snippet}
</CollapsibleTreeNode>
{/each}
{#if pending === 'file'}
{@render named('file')}
{/if}
{#each files as entry (entry.path)}
<!-- the files that are not open are greyed -->
<ExplorerRow
path={entry.path}
name={entry.name}
kind="file"
class="w-full h-7 px-1 text-sm {fileIdAt($workspace, entry.path) === undefined
? 'text-muted-foreground'
: ''}"
style="scroll-margin-top: {level.top}px"
>
{#snippet icon()}
<FileText size="16" class="shrink-0" />
{/snippet}
</ExplorerRow>
{/each}
@@ -0,0 +1,45 @@
<script lang="ts">
let {
value = '',
placeholder,
oncommit,
oncancel,
}: {
value?: string;
placeholder?: string;
/** The name is given: with Enter (`enter`), or by leaving the field. */
oncommit: (value: string, enter: boolean) => void;
/** Escape: no name is given. */
oncancel: () => void;
} = $props();
function onkeydown(event: KeyboardEvent) {
// not for the row or the explorer it is in (a folder opens with Enter or Space, the selected
// files open with Enter)
event.stopPropagation();
if (event.key === 'Enter') {
oncommit((event.currentTarget as HTMLInputElement).value, true);
} else if (event.key === 'Escape') {
oncancel();
}
}
</script>
<!-- the name of a file or a folder of the explorer, being given or changed -->
<input
class="h-6 grow min-w-0 px-1 text-sm font-normal not-italic bg-background border rounded outline-none"
{value}
{placeholder}
{@attach (input) => {
// once it is shown: it can be in a folder that is opening
const frame = requestAnimationFrame(() => {
input.focus();
input.select();
});
return () => cancelAnimationFrame(frame);
}}
{onkeydown}
onkeyup={(event) => event.stopPropagation()}
onclick={(event) => event.stopPropagation()}
onblur={(event) => oncommit(event.currentTarget.value, false)}
/>
@@ -0,0 +1 @@
export { default as Explorer } from './Explorer.svelte';
@@ -0,0 +1,49 @@
/** The file that the user just selected by clicking it in the explorer, until the selection changes. */
export const clickedInExplorer: { path: string | undefined } = $state({ path: undefined });
/**
* What is selected in the explorer (files and folders, by path), which the menu and dragging act
* on, and where a range selected with shift begins.
*/
export const explorerSelection: { paths: string[]; anchor: string | undefined } = $state({
paths: [],
anchor: undefined,
});
/** The file or folder whose name is being edited in the explorer. */
export const renaming: { path: string | undefined } = $state({ path: undefined });
/**
* The file or folder that is being made in a folder (`parent`) of the explorer: it is only made once
* the user gave it a name. With `route`, the file starts with an empty route.
*/
export const creating: { parent: string | undefined; kind: 'file' | 'folder'; route: boolean } =
$state({
parent: undefined,
kind: 'file',
route: false,
});
/** What is dragged in the explorer, and the folder it would be dropped in. */
export const dragging: { paths: string[]; target: string | undefined } = $state({
paths: [],
target: undefined,
});
/** What the rows of the explorer can do to the folders of its tree (see `Explorer`). */
export type ExplorerFolders = {
/** Opens a folder of the tree, and the folders it is in. */
open(path: string): void;
/** A folder was renamed or moved: it stays open or closed, and so does what is in it. */
moved(from: string, to: string): void;
};
export const explorerFoldersKey = 'explorer-folders';
/** The rows of the explorer that `element` is in, as they are shown: their path and kind. */
export function treeRows(element: Element | null | undefined): { path: string; kind: string }[] {
const tree = element?.closest('[data-explorer]');
return [...(tree?.querySelectorAll<HTMLElement>('[data-explorer-path]') ?? [])].map((row) => ({
path: row.dataset.explorerPath!,
kind: row.dataset.kind!,
}));
}