mirror of
https://github.com/gpxstudio/gpx.studio.git
synced 2026-10-19 09:22:12 +00:00
fixes
This commit is contained in:
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]');
|
||||||
|
|||||||
@@ -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'));
|
||||||
|
|||||||
@@ -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]));
|
||||||
|
|||||||
Reference in new issue
Block a user