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

No files matched your search

@@ -28,6 +28,16 @@ export class CollapsibleTreeState {
} }
return this._open[id]; 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"> <script lang="ts">
import { untrack } from 'svelte'; import { setContext, untrack } from 'svelte';
import { CollapsibleTree, CollapsibleTreeNode } from '$lib/components/collapsible-tree/index'; import { CollapsibleTree, CollapsibleTreeNode } from '$lib/components/collapsible-tree/index';
import { CollapsibleTreeState } from '$lib/components/collapsible-tree/utils.svelte'; import { CollapsibleTreeState } from '$lib/components/collapsible-tree/utils.svelte';
import { ScrollArea } from '$lib/components/ui/scroll-area/index'; import { ScrollArea } from '$lib/components/ui/scroll-area/index';
import ExplorerTree from './ExplorerTree.svelte'; import ExplorerTree from './ExplorerTree.svelte';
import ExplorerRow from './ExplorerRow.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 { openPaths } from '$lib/actions/file-actions';
import { i18n } from '$lib/i18n/i18n.svelte'; import { i18n } from '$lib/i18n/i18n.svelte';
import { engine } from '$lib/engine'; import { engine } from '$lib/engine';
import { selectedFileIds } from '$lib/engine/selection-helpers'; 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; 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. * row may not be there yet, because the content of a folder is read when it is opened.
*/ */
function reveal(folder: string, path: string) { function reveal(folder: string, path: string) {
if (!trees) { openFolder(parentOf(path));
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;
}
let frame = 0; let frame = 0;
let attempts = 0; let attempts = 0;
const scroll = () => { const scroll = () => {
@@ -80,6 +79,38 @@
return () => cancelAnimationFrame(frame); 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. */ /** Enter opens the selected files for good. */
function onkeydown(event: KeyboardEvent) { function onkeydown(event: KeyboardEvent) {
if (renaming.path !== undefined || event.key !== 'Enter') return; if (renaming.path !== undefined || event.key !== 'Enter') return;
@@ -121,8 +152,6 @@
name={folderName(folder)} name={folderName(folder)}
kind="root" kind="root"
class="h-7" class="h-7"
onOpenFolder={() =>
trees && (trees.root.get('root.folder').current = true)}
/> />
{/snippet} {/snippet}
{#snippet content(opened)} {#snippet content(opened)}
@@ -9,10 +9,10 @@
Plus, Plus,
Trash2, Trash2,
} from '@lucide/svelte'; } from '@lucide/svelte';
import type { Snippet } from 'svelte'; import { getContext, type Snippet } from 'svelte';
import { i18n } from '$lib/i18n/i18n.svelte'; import { i18n } from '$lib/i18n/i18n.svelte';
import { engine } from '$lib/engine'; 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 { closeFiles, closeFolder, openPaths, showEngineError } from '$lib/actions/file-actions';
import { isMac } from '$lib/utils'; import { isMac } from '$lib/utils';
import NameInput from './NameInput.svelte'; import NameInput from './NameInput.svelte';
@@ -20,16 +20,17 @@
clickedInExplorer, clickedInExplorer,
creating, creating,
dragging, dragging,
explorerFoldersKey,
explorerSelection, explorerSelection,
renaming, renaming,
treeRows, treeRows,
type ExplorerFolders,
} from './state.svelte'; } from './state.svelte';
let { let {
path, path,
name, name,
kind, kind,
onOpenFolder,
icon, icon,
class: className = '', class: className = '',
style = '', style = '',
@@ -39,14 +40,13 @@
name: string; name: string;
/** The open folder itself, a folder in it, or a GPX file. */ /** The open folder itself, a folder in it, or a GPX file. */
kind: 'root' | 'folder' | 'file'; kind: 'root' | 'folder' | 'file';
/** Opens the folder of the row, to show what was made in it. */
onOpenFolder?: () => void;
icon?: Snippet; icon?: Snippet;
class?: string; class?: string;
style?: string; style?: string;
} = $props(); } = $props();
const { workspace } = engine; const { workspace } = engine;
const folders = getContext<ExplorerFolders>(explorerFoldersKey);
let row: HTMLElement | undefined = $state(); 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`). */ /** 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') { function create(what: 'file' | 'folder') {
if (folder === undefined) return; if (folder === undefined) return;
onOpenFolder?.(); // where what is being named is shown
folders.open(folder);
creating.kind = what; creating.kind = what;
creating.parent = folder; creating.parent = folder;
} }
@@ -155,7 +156,11 @@
renaming.path = undefined; renaming.path = undefined;
const name_ = value.trim(); const name_ = value.trim();
if (name_ === '' || name_ === editableName(name, kind !== 'file')) return; 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_))) { if (!(await engine.renamePath(path, name_))) {
folders.moved(to, path);
showEngineError(); showEngineError();
} }
} }
@@ -207,7 +212,15 @@
event.preventDefault(); event.preventDefault();
const paths = dragging.paths; const paths = dragging.paths;
ondragend(); 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(); showEngineError();
} }
} }
@@ -27,16 +27,15 @@
const level = getContext<StickyLevel>(stickyKey); const level = getContext<StickyLevel>(stickyKey);
let entries: FolderEntry[] = $state([]); 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 // the folder of the list, where what is being made goes
let folder = $derived(path ?? $workspace?.folder); let folder = $derived(path ?? $workspace?.folder);
let pending = $derived( let pending = $derived(
creating.parent !== undefined && creating.parent === folder ? creating.kind : undefined creating.parent !== undefined && creating.parent === folder ? creating.kind : undefined
); );
// the files come after the folders // the folders come first, then the files (and what is being made goes at the top of its kind)
let firstFile = $derived(entries.find((entry) => !entry.isDir)); 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, * Makes the file or folder that was asked for, called `value`: nothing is made without a name,
@@ -113,48 +112,38 @@
{#if pending === 'folder'} {#if pending === 'folder'}
{@render named('folder')} {@render named('folder')}
{/if} {/if}
{#each entries as entry (entry.path)} {#each folders as entry (entry.path)}
{#if pending === 'file' && entry === firstFile} <CollapsibleTreeNode id={entry.name}>
{@render named('file')} {#snippet trigger()}
{/if} <ExplorerRow path={entry.path} name={entry.name} kind="folder" class="h-7" />
{#if entry.isDir} {/snippet}
<CollapsibleTreeNode id={entry.name} bind:this={nodes[entry.path]}> {#snippet content(opened)}
{#snippet trigger()} <!-- the content of a folder is only read when it is opened -->
<ExplorerRow {#if opened}
path={entry.path} <div class="ml-4">
name={entry.name} <ExplorerTree path={entry.path} />
kind="folder" </div>
class="h-7" {/if}
onOpenFolder={() => nodes[entry.path]?.openNode()} {/snippet}
/> </CollapsibleTreeNode>
{/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>
{:else}
<!-- 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 hover:bg-muted {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>
{/if}
{/each} {/each}
{#if pending === 'file' && firstFile === undefined} {#if pending === 'file'}
{@render named('file')} {@render named('file')}
{/if} {/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 hover:bg-muted {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}
@@ -31,8 +31,12 @@
{value} {value}
{placeholder} {placeholder}
{@attach (input) => { {@attach (input) => {
input.focus(); // once it is shown: it can be in a folder that is opening
input.select(); const frame = requestAnimationFrame(() => {
input.focus();
input.select();
});
return () => cancelAnimationFrame(frame);
}} }}
{onkeydown} {onkeydown}
onkeyup={(event) => event.stopPropagation()} onkeyup={(event) => event.stopPropagation()}
@@ -28,6 +28,15 @@ export const dragging: { paths: string[]; target: string | undefined } = $state(
target: undefined, 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. */ /** 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 }[] { export function treeRows(element: Element | null | undefined): { path: string; kind: string }[] {
const tree = element?.closest('[data-explorer]'); const tree = element?.closest('[data-explorer]');
+9
View File
@@ -6,6 +6,7 @@ import {
folderName, folderName,
isWithin, isWithin,
parentOf, parentOf,
relocated,
subfolder, subfolder,
} from '$lib/engine/workspace'; } 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', () => { describe('isWithin', () => {
it('is the folder itself or what is in it', () => { it('is the folder itself or what is in it', () => {
assert.ok(isWithin('/home/me/tracks', '/home/me/tracks')); 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; 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. */ /** Whether `path` is `folder` or something in it. */
export function isWithin(path: string, folder: string): boolean { export function isWithin(path: string, folder: string): boolean {
return path === folder || (path.startsWith(folder) && /[\\/]/.test(path[folder.length])); return path === folder || (path.startsWith(folder) && /[\\/]/.test(path[folder.length]));