This commit is contained in:
vcoppe committed 2026-10-17 23:55:46 +02:00
1 parent 8fd47d8b18
commit 5373d0f0d4
15 files changed
+339 -408

No files matched your search

+16 -3
View File
@@ -5,6 +5,7 @@ import { boundsManager } from '$lib/state/bounds';
import { engine } from '$lib/engine';
import { defaultFileName } from '$lib/actions/default-file-name';
import { askOverwrite, askUnsaved } from '$lib/components/file-list/unsaved-prompt.svelte';
import { toast } from 'svelte-sonner';
import type { UnsavedChoice } from '$lib/engine';
/** The name of a new file: the translated default name, numbered if other files have it. */
@@ -139,14 +140,26 @@ export async function saveAs(selectedFileIds: string[]) {
}
}
/** Opens a file of the tree of the open folder. */
export async function openPath(path: string) {
const ids = await engine.openPaths([path]);
/**
* Opens files of the file system, like the ones of the tree of the open folder: as a preview
* (`preview`, a single click), which the next preview replaces, or for good. The map fits the files
* that were opened.
*/
export async function openPaths(paths: string[], preview = false) {
const ids = await engine.openPaths(paths, preview);
if (ids.length > 0) {
boundsManager.fitBoundsOnLoad(ids);
}
}
/** Tells the user why the engine did not do what was asked (a key of the dictionaries, or text). */
export function showEngineError() {
const error = get(engine.lastError);
if (error !== undefined && error !== 'nothing to do') {
toast.error(i18n._(error, error));
}
}
export function triggerFileInput() {
const input = document.createElement('input');
input.type = 'file';
@@ -5,8 +5,8 @@
import { ScrollArea } from '$lib/components/ui/scroll-area/index';
import ExplorerTree from './ExplorerTree.svelte';
import ExplorerRow from './ExplorerRow.svelte';
import { openFiles } from './actions';
import { clickedInExplorer, explorerSelection, renaming } from './state.svelte';
import { clickedInExplorer, explorerSelection, renaming, treeRows } 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';
@@ -52,22 +52,6 @@
}
});
/** Enter opens the selected files for good. */
function onkeydown(event: KeyboardEvent) {
if (renaming.path !== undefined || !container || event.key !== 'Enter') return;
const kinds = new Map(
[...container.querySelectorAll<HTMLElement>('[data-explorer-path]')].map((row) => [
row.dataset.explorerPath!,
row.dataset.kind,
])
);
const files = explorerSelection.paths.filter((path) => kinds.get(path) === 'file');
if (files.length > 0) {
openFiles(files, false);
event.preventDefault();
}
}
/**
* 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.
@@ -95,6 +79,18 @@
frame = requestAnimationFrame(scroll);
return () => cancelAnimationFrame(frame);
}
/** 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>
<!--
@@ -12,19 +12,17 @@
import type { Snippet } from 'svelte';
import { i18n } from '$lib/i18n/i18n.svelte';
import { engine } from '$lib/engine';
import { fileIdAt } from '$lib/engine/workspace';
import { closeFiles, closeFolder } from '$lib/actions/file-actions';
import { editableName, fileIdAt, isWithin, parentOf } from '$lib/engine/workspace';
import { closeFiles, closeFolder, openPaths, showEngineError } from '$lib/actions/file-actions';
import { isMac } from '$lib/utils';
import { openFiles, showError } from './actions';
import NameInput from './NameInput.svelte';
import {
clickedInExplorer,
creating,
dragging,
editableName,
explorerSelection,
isWithin,
parentOf,
renaming,
treeRows,
} from './state.svelte';
let {
@@ -58,17 +56,6 @@
let editing = $derived(renaming.path === path);
let target = $derived(dragging.target === path);
/** The rows of the tree, as they are shown. */
function rows(): HTMLElement[] {
const tree = row?.closest('[data-explorer]');
return tree ? [...tree.querySelectorAll<HTMLElement>('[data-explorer-path]')] : [];
}
/** Whether a path of the tree is a GPX file, a folder, or the open folder. */
function kindOf(other: string): string | undefined {
return rows().find((r) => r.dataset.explorerPath === other)?.dataset.kind;
}
/** What the menu and dragging act on: the selection, if the row is in it. */
function targets(): string[] {
return selected ? explorerSelection.paths : [path];
@@ -94,9 +81,9 @@
event.stopPropagation();
} else if (event.shiftKey && explorerSelection.anchor !== undefined) {
// everything shown between the row where the selection began and this one
const paths = rows()
.filter((r) => r.dataset.kind !== 'root')
.map((r) => r.dataset.explorerPath!);
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);
@@ -111,7 +98,7 @@
if (fileId !== undefined) {
engine.select([fileId]);
} else {
openFiles([path], true);
openPaths([path], true);
}
}
}
@@ -120,7 +107,7 @@
function ondblclick() {
if (kind === 'file' && !editing) {
clickedInExplorer.path = path;
openFiles([path], false);
openPaths([path], false);
}
}
@@ -138,9 +125,11 @@
}
function openSelected() {
const files = targets().filter((other) => kindOf(other) === 'file');
const files = treeRows(row)
.filter((other) => other.kind === 'file' && targets().includes(other.path))
.map((other) => other.path);
if (files.length > 0) {
openFiles(files, false);
openPaths(files);
}
}
@@ -155,7 +144,7 @@
const paths = targets();
explorerSelection.paths = [];
if (!(await engine.deletePaths(paths))) {
showError();
showEngineError();
}
}
@@ -167,26 +156,10 @@
const name_ = value.trim();
if (name_ === '' || name_ === editableName(name, kind !== 'file')) return;
if (!(await engine.renamePath(path, name_))) {
showError();
showEngineError();
}
}
function onkeydown(event: KeyboardEvent) {
// not for the folder row it is in, which would open or close
event.stopPropagation();
const input = event.currentTarget as HTMLInputElement;
if (event.key === 'Enter') {
commit(input.value);
} else if (event.key === 'Escape') {
renaming.path = undefined;
}
}
function focus(input: HTMLInputElement) {
input.focus();
input.select();
}
// dragging into folders
function ondragstart(event: DragEvent) {
@@ -235,7 +208,7 @@
const paths = dragging.paths;
ondragend();
if (paths.length > 0 && !(await engine.movePaths(paths, dropFolder))) {
showError();
showEngineError();
}
}
</script>
@@ -273,14 +246,10 @@
>
{@render icon?.()}
{#if editing}
<input
class="h-6 grow min-w-0 px-1 text-sm font-normal not-italic bg-background border rounded outline-none"
<NameInput
value={editableName(name, kind !== 'file')}
{@attach focus}
{onkeydown}
onkeyup={(event) => event.stopPropagation()}
onclick={(event) => event.stopPropagation()}
onblur={(event) => commit(event.currentTarget.value)}
oncommit={commit}
oncancel={() => (renaming.path = undefined)}
/>
{:else}
<span class="grow truncate text-left">{name}</span>
@@ -8,11 +8,12 @@
import ExplorerTree from './ExplorerTree.svelte';
import ExplorerRow from './ExplorerRow.svelte';
import { engine, type FolderEntry } from '$lib/engine';
import { fileIdAt } from '$lib/engine/workspace';
import { editableName, fileIdAt } from '$lib/engine/workspace';
import { i18n } from '$lib/i18n/i18n.svelte';
import { currentTool, Tool } from '$lib/components/toolbar/tools';
import { showError } from './actions';
import { creating, editableName, explorerSelection } from './state.svelte';
import { showEngineError } from '$lib/actions/file-actions';
import NameInput from './NameInput.svelte';
import { creating, explorerSelection } from './state.svelte';
let {
path,
@@ -69,24 +70,13 @@
created = await engine.createFolder(parent, name);
}
if (created === undefined) {
showError();
showEngineError();
return;
}
explorerSelection.paths = [created];
explorerSelection.anchor = created;
}
function onkeydown(event: KeyboardEvent) {
// not for the explorer, which opens the selected files with Enter
event.stopPropagation();
const input = event.currentTarget as HTMLInputElement;
if (event.key === 'Enter') {
create(input.value, true);
} else if (event.key === 'Escape') {
creating.parent = undefined;
}
}
$effect(() => {
// read again when something changed on the file system
$folderVersion;
@@ -110,14 +100,12 @@
{:else}
<Folder size="16" class="shrink-0" />
{/if}
<input
class="h-6 grow min-w-0 px-1 text-sm bg-background border rounded outline-none"
<NameInput
placeholder={i18n._(
kind === 'file' ? 'menu.explorer_menu.new_file' : 'menu.explorer_menu.new_folder'
)}
{@attach (input) => input.focus()}
{onkeydown}
onblur={(event) => create(event.currentTarget.value, false)}
oncommit={create}
oncancel={() => (creating.parent = undefined)}
/>
</div>
{/snippet}
@@ -0,0 +1,41 @@
<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) => {
input.focus();
input.select();
}}
{onkeydown}
onkeyup={(event) => event.stopPropagation()}
onclick={(event) => event.stopPropagation()}
onblur={(event) => oncommit(event.currentTarget.value, false)}
/>
@@ -1,24 +0,0 @@
import { get } from 'svelte/store';
import { toast } from 'svelte-sonner';
import { engine } from '$lib/engine';
import { i18n } from '$lib/i18n/i18n.svelte';
import { boundsManager } from '$lib/state/bounds';
/**
* Opens files of the tree: as a preview (a single click), which the next preview replaces, or for
* good. The map fits the files that were opened.
*/
export async function openFiles(paths: string[], preview: boolean) {
const ids = await engine.openPaths(paths, preview);
if (ids.length > 0) {
boundsManager.fitBoundsOnLoad(ids);
}
}
/** Tells why what the explorer did failed (a key of the dictionaries, or a message). */
export function showError() {
const error = get(engine.lastError);
if (error !== undefined && error !== 'nothing to do') {
toast.error(i18n._(error, error));
}
}
@@ -28,24 +28,11 @@ export const dragging: { paths: string[]; target: string | undefined } = $state(
target: undefined,
});
/** `\` and `/` alike. */
function isSeparator(char: string | undefined) {
return char === '/' || char === '\\';
}
/** The folder that holds `path`. */
export function parentOf(path: string): string {
const index = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'));
return index > 0 ? path.slice(0, index) : path;
}
/** Whether `path` is `folder` or something in it. */
export function isWithin(path: string, folder: string): boolean {
return path === folder || (path.startsWith(folder) && isSeparator(path[folder.length]));
}
/** The name of a file without its extension, or the name of a folder. */
export function editableName(name: string, isDir: boolean): string {
const dot = name.lastIndexOf('.');
return isDir || dot <= 0 ? name : name.slice(0, dot);
/** 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!,
}));
}
@@ -9,8 +9,7 @@
import { editMetadata } from '$lib/components/file-list/metadata/utils.svelte';
import { engine, type FileState } from '$lib/engine';
import type { FileTreeNode } from '$lib/engine/selection-helpers';
import { toast } from 'svelte-sonner';
import { get } from 'svelte/store';
import { showEngineError } from '$lib/actions/file-actions';
let {
fileState,
@@ -58,10 +57,8 @@
// the metadata of the selected elements are changed. In the desktop app, a file on
// the disk is renamed with it, which is refused if the name is the one of another
engine.metadata(name, description).then((changed) => {
const error = get(engine.lastError);
if (!changed && error !== undefined && error !== 'nothing to do') {
// a key of the dictionaries, when it comes from the desktop app
toast.error(i18n._(error, error));
if (!changed) {
showEngineError();
}
});
}}
+54 -61
View File
@@ -567,10 +567,7 @@ class Engine {
* `preview`, see `DesktopFiles.openPaths`. Resolves to the new ids.
*/
async openPaths(paths: string[], preview = false): Promise<string[]> {
const added = await this.addedBy((b) => b.desktop!.openPaths(paths, preview));
// which file is the preview may have changed, without a file changing
await this.enqueue(async () => this.refreshWorkspace(this.backend!));
return added;
return this.addedBy((b) => b.desktop!.openPaths(paths, preview));
}
// What the explorer does to the tree of the open folder (the desktop app). When it fails,
@@ -581,35 +578,29 @@ class Engine {
* Resolves to its id.
*/
async newFileIn(name: string, folder: string): Promise<string | undefined> {
const [id] = await this.addedBy((b) => b.desktop!.newFile(name, folder));
this._folderVersion.update((version) => version + 1);
const [id] = await this.addedBy((b) => b.desktop!.newFile(name, folder), true);
return id;
}
/** Makes a folder in a folder of the open folder: resolves to its path. */
async createFolder(parent: string, name: string): Promise<string | undefined> {
return (await this.treeOperation((desktop) => desktop.createFolder(parent, name))).value;
return (await this.desktopCall((desktop) => desktop.createFolder(parent, name), true))
.value;
}
/** Renames a file or a folder of the open folder. Resolves to whether it was done. */
async renamePath(path: string, name: string): Promise<boolean> {
const changed = await this.run((b) => b.desktop!.renamePath(path, name));
const failed = get(this._lastError) !== undefined;
await this.treeOperation(async () => {});
return changed || !failed;
return this.desktopRun((b) => b.desktop!.renamePath(path, name), true);
}
/** Moves files and folders of the open folder into a folder of it. */
async movePaths(paths: string[], folder: string): Promise<boolean> {
return (await this.treeOperation((desktop) => desktop.movePaths(paths, folder))).done;
return (await this.desktopCall((desktop) => desktop.movePaths(paths, folder), true)).done;
}
/** Moves files and folders of the open folder to the trash. */
async deletePaths(paths: string[]): Promise<boolean> {
const changed = await this.run((b) => b.desktop!.deletePaths(paths));
const failed = get(this._lastError) !== undefined;
await this.treeOperation(async () => {});
return changed || !failed;
return this.desktopRun((b) => b.desktop!.deletePaths(paths), true);
}
/** Shows a file or a folder in the file manager of the system. */
@@ -622,43 +613,18 @@ class Engine {
}
}
/**
* Does something to the file system, then reads where the files are and the tree again
* (without waiting for the watcher). Resolves to whether it was done, with what it gives.
*/
private async treeOperation<T>(
action: (desktop: DesktopFiles) => Promise<T>
): Promise<{ done: boolean; value?: T }> {
await this.ready;
return this.enqueue(async () => {
const backend = this.backend!;
let result: { done: boolean; value?: T } = { done: false };
try {
result = { done: true, value: await action(backend.desktop!) };
} catch (error) {
console.error('engine:', error);
this._lastError.set(String(error));
}
await this.refreshWorkspace(backend);
this._folderVersion.update((version) => version + 1);
return result;
});
}
/**
* Opens the folder that the user picks (instead of the one that was open, which is closed with
* its files like `closeFiles` does, `unsaved` being what to do with the unsaved ones). The
* folder is watched, and its tree is read with `listFolder`: its files are not opened.
*/
async openFolder(unsaved?: UnsavedChoice) {
await this.run((b) => b.desktop!.openFolder(unsaved));
await this.enqueue(async () => this.refreshWorkspace(this.backend!));
await this.desktopRun((b) => b.desktop!.openFolder(unsaved));
}
/** Closes the open folder and all the files: see `closeFiles` for `unsaved`. */
async closeFolder(unsaved?: UnsavedChoice) {
await this.run((b) => b.desktop!.closeFolder(unsaved));
await this.enqueue(async () => this.refreshWorkspace(this.backend!));
await this.desktopRun((b) => b.desktop!.closeFolder(unsaved));
}
/**
@@ -666,8 +632,7 @@ class Engine {
* first, or their edits are lost (they stay open when it is not said).
*/
async closeFiles(fileIds: string[], unsaved?: UnsavedChoice) {
await this.run((b) => b.desktop!.closeFiles(fileIds, unsaved));
await this.enqueue(async () => this.refreshWorkspace(this.backend!));
await this.desktopRun((b) => b.desktop!.closeFiles(fileIds, unsaved));
}
/**
@@ -676,23 +641,23 @@ class Engine {
* when the user cancelled or the files could not be saved.
*/
async save(fileIds: string[]): Promise<boolean> {
return this.desktopCall((desktop) => desktop.saveFiles(fileIds));
return (await this.desktopCall((desktop) => desktop.saveFiles(fileIds))).value === true;
}
/** Writes a file where the user says, which is where it is from then on. */
async saveAs(fileId: string): Promise<boolean> {
return this.desktopCall((desktop) => desktop.saveFileAs(fileId));
return (await this.desktopCall((desktop) => desktop.saveFileAs(fileId))).value === true;
}
/** Writes copies of files where the user says. The files stay as they are. */
async exportFiles(fileIds: string[], options: ExportOptions): Promise<boolean> {
return this.desktopCall((desktop) => desktop.exportFiles(fileIds, options));
const exported = await this.desktopCall((desktop) => desktop.exportFiles(fileIds, options));
return exported.value === true;
}
/** Reads files again from the file system: their edits are lost (one undo step). */
async reload(fileIds: string[]) {
await this.run((b) => b.desktop!.reloadFiles(fileIds));
await this.enqueue(async () => this.refreshWorkspace(this.backend!));
await this.desktopRun((b) => b.desktop!.reloadFiles(fileIds));
}
/** Opens a web page in the browser of the system (the desktop app). */
@@ -718,27 +683,51 @@ class Engine {
});
}
/** Calls the file system of the desktop app, and keeps the status of the files up to date. */
private async desktopCall(call: (desktop: DesktopFiles) => Promise<boolean>): Promise<boolean> {
/**
* Calls the file system of the desktop app, then reads where the files are again (without
* waiting for the app to tell), and the tree of the open folder with `tree`. Resolves to whether
* it was done, with what it gives; when it was not, `lastError` tells why.
*/
private async desktopCall<T>(
call: (desktop: DesktopFiles) => Promise<T>,
tree = false
): Promise<{ done: boolean; value?: T }> {
await this.ready;
return this.enqueue(async () => {
const backend = this.backend!;
let done = false;
let result: { done: boolean; value?: T } = { done: false };
try {
done = await call(backend.desktop!);
result = { done: true, value: await call(backend.desktop!) };
} catch (error) {
console.error('The files could not be written', error);
console.error('engine:', error);
this._lastError.set(String(error));
}
await this.refreshWorkspace(backend);
return done;
await this.refreshWorkspace(backend, tree);
return result;
});
}
/** Runs a call that adds files, and resolves to the ids that it added. */
private async addedBy(action: (backend: Backend) => Promise<Outcome>): Promise<string[]> {
/**
* Like `run`, for a call of the desktop app that can change where the files are (and the tree
* of the open folder with `tree`), which are read again. Resolves to whether it was done.
*/
private async desktopRun(
action: (backend: Backend) => Promise<Outcome>,
tree = false
): Promise<boolean> {
const changed = await this.run(action);
const failed = get(this._lastError) !== undefined;
await this.enqueue(() => this.refreshWorkspace(this.backend!, tree));
return changed || !failed;
}
/** Runs a call of the desktop app that adds files (see `desktopRun`): resolves to their ids. */
private async addedBy(
action: (backend: Backend) => Promise<Outcome>,
tree = false
): Promise<string[]> {
const before = new Set(get(this._order));
await this.run(action);
await this.desktopRun(action, tree);
return get(this._order).filter((id) => !before.has(id));
}
@@ -756,10 +745,14 @@ class Engine {
};
}
private async refreshWorkspace(backend: Backend) {
/** Reads where the files are again, and the tree of the open folder with `tree`. */
private async refreshWorkspace(backend: Backend, tree = false) {
if (backend.desktop) {
this._workspace.set(await backend.desktop.workspace());
}
if (tree) {
this._folderVersion.update((version) => version + 1);
}
}
/**
+36 -1
View File
@@ -1,6 +1,13 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { fileIdAt, folderName, subfolder } from '$lib/engine/workspace';
import {
editableName,
fileIdAt,
folderName,
isWithin,
parentOf,
subfolder,
} from '$lib/engine/workspace';
describe('subfolder', () => {
it('is where a file is in the folder', () => {
@@ -47,3 +54,31 @@ describe('fileIdAt', () => {
assert.equal(fileIdAt(undefined, '/tracks/a.gpx'), undefined);
});
});
describe('parentOf', () => {
it('is the folder that holds the path', () => {
assert.equal(parentOf('/home/me/tracks/a.gpx'), '/home/me/tracks');
assert.equal(parentOf('C:\\Users\\me\\a.gpx'), 'C:\\Users\\me');
});
});
describe('isWithin', () => {
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/2024/a.gpx', '/home/me/tracks'));
assert.ok(isWithin('C:\\me\\tracks\\a.gpx', 'C:\\me\\tracks'));
});
it('is not a folder whose name begins the same', () => {
assert.ok(!isWithin('/home/me/tracks-old/a.gpx', '/home/me/tracks'));
assert.ok(!isWithin('/home/me', '/home/me/tracks'));
});
});
describe('editableName', () => {
it('leaves out the extension of a file, not of a folder', () => {
assert.equal(editableName('ride.gpx', false), 'ride');
assert.equal(editableName('my.trips', true), 'my.trips');
assert.equal(editableName('.hidden', false), '.hidden');
});
});
+17
View File
@@ -34,3 +34,20 @@ export function fileIdAt(
): string | undefined {
return Object.entries(workspace?.files ?? {}).find(([, file]) => file.path === path)?.[0];
}
/** The folder that holds `path`. */
export function parentOf(path: string): string {
const index = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'));
return index > 0 ? path.slice(0, index) : path;
}
/** 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]));
}
/** The name of a file without its extension, or the name of a folder (`isDir`). */
export function editableName(name: string, isDir: boolean): string {
const dot = name.lastIndexOf('.');
return isDir || dot <= 0 ? name : name.slice(0, dot);
}