This commit is contained in:
vcoppe committed 2026-10-18 00:24:16 +02:00
1 parent 5373d0f0d4
commit 3012f6bf73
8 files changed
+112 -43

No files matched your search

@@ -28,6 +28,16 @@ export class CollapsibleTreeState {
}
return this._open[id];
}
/** The node `from` is now `to` (it was renamed or moved): it stays open or closed, and so do the nodes in it. */
move(from: string, to: string) {
for (const id of Object.keys(this._open)) {
if (id === from || id.startsWith(`${from}.`)) {
this._open[to + id.slice(from.length)] = this._open[id];
delete this._open[id];
}
}
}
}
/**
@@ -1,16 +1,23 @@
<script lang="ts">
import { untrack } from 'svelte';
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, explorerSelection, renaming, treeRows } from './state.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, subfolder } from '$lib/engine/workspace';
import { folderName, parentOf, subfolder } from '$lib/engine/workspace';
const { workspace, selection } = engine;
@@ -57,15 +64,7 @@
* row may not be there yet, because the content of a folder is read when it is opened.
*/
function reveal(folder: string, path: string) {
if (!trees) {
return;
}
trees.root.get('root.folder').current = true;
let id = 'root';
for (const name of subfolder(folder, path)?.split('/').filter(Boolean) ?? []) {
id += `.${name}`;
trees.folders.get(id).current = true;
}
openFolder(parentOf(path));
let frame = 0;
let attempts = 0;
const scroll = () => {
@@ -80,6 +79,38 @@
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;
@@ -121,8 +152,6 @@
name={folderName(folder)}
kind="root"
class="h-7"
onOpenFolder={() =>
trees && (trees.root.get('root.folder').current = true)}
/>
{/snippet}
{#snippet content(opened)}
@@ -9,10 +9,10 @@
Plus,
Trash2,
} from '@lucide/svelte';
import type { Snippet } from 'svelte';
import { getContext, type Snippet } from 'svelte';
import { i18n } from '$lib/i18n/i18n.svelte';
import { engine } from '$lib/engine';
import { editableName, fileIdAt, isWithin, parentOf } from '$lib/engine/workspace';
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';
@@ -20,16 +20,17 @@
clickedInExplorer,
creating,
dragging,
explorerFoldersKey,
explorerSelection,
renaming,
treeRows,
type ExplorerFolders,
} from './state.svelte';
let {
path,
name,
kind,
onOpenFolder,
icon,
class: className = '',
style = '',
@@ -39,14 +40,13 @@
name: string;
/** The open folder itself, a folder in it, or a GPX file. */
kind: 'root' | 'folder' | 'file';
/** Opens the folder of the row, to show what was made in it. */
onOpenFolder?: () => void;
icon?: Snippet;
class?: string;
style?: string;
} = $props();
const { workspace } = engine;
const folders = getContext<ExplorerFolders>(explorerFoldersKey);
let row: HTMLElement | undefined = $state();
@@ -119,7 +119,8 @@
/** Asks for the name of a file or a folder to make in the folder of the row (see `creating`). */
function create(what: 'file' | 'folder') {
if (folder === undefined) return;
onOpenFolder?.();
// where what is being named is shown
folders.open(folder);
creating.kind = what;
creating.parent = folder;
}
@@ -155,7 +156,11 @@
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();
}
}
@@ -207,7 +212,15 @@
event.preventDefault();
const paths = dragging.paths;
ondragend();
if (paths.length > 0 && !(await engine.movePaths(paths, dropFolder))) {
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();
}
}
@@ -27,16 +27,15 @@
const level = getContext<StickyLevel>(stickyKey);
let entries: FolderEntry[] = $state([]);
// the nodes of the folders, to open them when something is made in them
let nodes: Record<string, CollapsibleTreeNode | undefined> = $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 files come after the folders
let firstFile = $derived(entries.find((entry) => !entry.isDir));
// 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,
@@ -113,20 +112,10 @@
{#if pending === 'folder'}
{@render named('folder')}
{/if}
{#each entries as entry (entry.path)}
{#if pending === 'file' && entry === firstFile}
{@render named('file')}
{/if}
{#if entry.isDir}
<CollapsibleTreeNode id={entry.name} bind:this={nodes[entry.path]}>
{#each folders as entry (entry.path)}
<CollapsibleTreeNode id={entry.name}>
{#snippet trigger()}
<ExplorerRow
path={entry.path}
name={entry.name}
kind="folder"
class="h-7"
onOpenFolder={() => nodes[entry.path]?.openNode()}
/>
<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 -->
@@ -137,7 +126,11 @@
{/if}
{/snippet}
</CollapsibleTreeNode>
{:else}
{/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}
@@ -153,8 +146,4 @@
<FileText size="16" class="shrink-0" />
{/snippet}
</ExplorerRow>
{/if}
{/each}
{#if pending === 'file' && firstFile === undefined}
{@render named('file')}
{/if}
@@ -31,8 +31,12 @@
{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()}
@@ -28,6 +28,15 @@ export const dragging: { paths: string[]; target: string | undefined } = $state(
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]');
+9
View File
@@ -6,6 +6,7 @@ import {
folderName,
isWithin,
parentOf,
relocated,
subfolder,
} from '$lib/engine/workspace';
@@ -62,6 +63,14 @@ describe('parentOf', () => {
});
});
describe('relocated', () => {
it('is where a path is once moved or renamed', () => {
assert.equal(relocated('/me/tracks/a.gpx', '/me/trips'), '/me/trips/a.gpx');
assert.equal(relocated('/me/tracks', '/me', 'rides'), '/me/rides');
assert.equal(relocated('C:\\me\\tracks', 'C:\\me', 'rides'), 'C:\\me\\rides');
});
});
describe('isWithin', () => {
it('is the folder itself or what is in it', () => {
assert.ok(isWithin('/home/me/tracks', '/home/me/tracks'));
+6
View File
@@ -41,6 +41,12 @@ export function parentOf(path: string): string {
return index > 0 ? path.slice(0, index) : path;
}
/** Where `path` is once moved into `folder`, renamed `name` if it is given. */
export function relocated(path: string, folder: string, name?: string): string {
const parent = parentOf(path);
return `${folder}${path[parent.length]}${name ?? path.slice(parent.length + 1)}`;
}
/** Whether `path` is `folder` or something in it. */
export function isWithin(path: string, folder: string): boolean {
return path === folder || (path.startsWith(folder) && /[\\/]/.test(path[folder.length]));