mirror of
https://github.com/gpxstudio/gpx.studio.git
synced 2026-10-19 09:22:12 +00:00
file explorer
This commit is contained in:
1 parent
b0a3c3a9ba
commit
ed90ba6a1f
14 files changed
+263
-19
No files matched your search
@@ -10,7 +10,8 @@
|
||||
id: string | number;
|
||||
class?: ClassValue;
|
||||
trigger: Snippet;
|
||||
content: Snippet;
|
||||
/** Gets whether the node is open, for contents that are only worth building then. */
|
||||
content: Snippet<[boolean]>;
|
||||
} = $props();
|
||||
|
||||
let state = getContext<CollapsibleTreeState>('collapsible-tree-state');
|
||||
@@ -88,6 +89,6 @@
|
||||
</Button>
|
||||
{/if}
|
||||
<Collapsible.Content>
|
||||
{@render props.content()}
|
||||
{@render props.content(open.current)}
|
||||
</Collapsible.Content>
|
||||
</Collapsible.Root>
|
||||
@@ -0,0 +1,50 @@
|
||||
<script lang="ts">
|
||||
import { CollapsibleTree, CollapsibleTreeNode } from '$lib/components/collapsible-tree/index';
|
||||
import { ScrollArea } from '$lib/components/ui/scroll-area/index';
|
||||
import ExplorerTree from './ExplorerTree.svelte';
|
||||
import { i18n } from '$lib/i18n/i18n.svelte';
|
||||
import { engine } from '$lib/engine';
|
||||
import { folderName } from '$lib/engine/workspace';
|
||||
|
||||
const { workspace } = engine;
|
||||
|
||||
let folder = $derived($workspace?.folder);
|
||||
</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}
|
||||
<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 class="flex flex-col pl-1 pr-3 pb-1">
|
||||
<!-- starts over when another folder is opened -->
|
||||
{#key folder}
|
||||
<!-- the folder itself, which is open at first -->
|
||||
<CollapsibleTree side="left" defaultState="open">
|
||||
<CollapsibleTreeNode id="folder">
|
||||
{#snippet trigger()}
|
||||
<span class="grow truncate text-left" title={folder}>
|
||||
{folderName(folder)}
|
||||
</span>
|
||||
{/snippet}
|
||||
{#snippet content(opened)}
|
||||
<!-- what is in it is only read when it is open -->
|
||||
{#if opened}
|
||||
<div class="ml-4">
|
||||
<!-- its folders are closed at first -->
|
||||
<CollapsibleTree side="left" defaultState="closed">
|
||||
<ExplorerTree />
|
||||
</CollapsibleTree>
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</CollapsibleTreeNode>
|
||||
</CollapsibleTree>
|
||||
{/key}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,80 @@
|
||||
<script lang="ts">
|
||||
import { CollapsibleTreeNode } from '$lib/components/collapsible-tree/index';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { FileText } from '@lucide/svelte';
|
||||
import ExplorerTree from './ExplorerTree.svelte';
|
||||
import { openPath } from '$lib/actions/file-actions';
|
||||
import { engine, type FolderEntry } from '$lib/engine';
|
||||
import { selectedFileIds } from '$lib/engine/selection-helpers';
|
||||
import { fileIdAt } from '$lib/engine/workspace';
|
||||
|
||||
let {
|
||||
path,
|
||||
}: {
|
||||
/** The folder whose content is listed, the open folder if it is not given. */
|
||||
path?: string;
|
||||
} = $props();
|
||||
|
||||
const { workspace, selection, folderVersion } = engine;
|
||||
|
||||
let entries: FolderEntry[] = $state([]);
|
||||
|
||||
$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;
|
||||
};
|
||||
});
|
||||
|
||||
/** Opens the file, or selects it if it is open already. */
|
||||
function open(entry: FolderEntry, id: string | undefined) {
|
||||
if (id !== undefined) {
|
||||
engine.select([id]);
|
||||
} else {
|
||||
openPath(entry.path);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#each entries as entry (entry.path)}
|
||||
{#if entry.isDir}
|
||||
<CollapsibleTreeNode id={entry.name}>
|
||||
{#snippet trigger()}
|
||||
<span class="grow truncate text-left">{entry.name}</span>
|
||||
{/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}
|
||||
{@const id = fileIdAt($workspace, entry.path)}
|
||||
{@const selected = id !== undefined && selectedFileIds($selection).includes(id)}
|
||||
{@const dirty = id !== undefined && $workspace?.files[id]?.dirty === true}
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="w-full h-7 px-1 justify-start gap-1.5 border-none font-normal {selected
|
||||
? 'bg-accent'
|
||||
: ''} {id === undefined ? 'text-muted-foreground' : ''}"
|
||||
title={entry.path}
|
||||
onclick={() => open(entry, id)}
|
||||
>
|
||||
<FileText size="16" class="shrink-0" />
|
||||
<span class="grow truncate text-left">{entry.name}</span>
|
||||
{#if dirty}
|
||||
<span class="size-1.5 shrink-0 rounded-full bg-foreground"></span>
|
||||
{/if}
|
||||
</Button>
|
||||
{/if}
|
||||
{/each}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as Explorer } from './Explorer.svelte';
|
||||
@@ -36,14 +36,14 @@
|
||||
</script>
|
||||
|
||||
<ScrollArea
|
||||
class="shrink-0 {orientation === 'vertical' ? 'p-0 pr-3' : 'h-10 px-1'}"
|
||||
class="shrink-0 {orientation === 'vertical' ? 'p-0' : 'h-10 px-1'}"
|
||||
{orientation}
|
||||
scrollbarXClasses={orientation === 'vertical' ? '' : 'hidden'}
|
||||
scrollbarYClasses={orientation === 'vertical' ? '' : ''}
|
||||
>
|
||||
<div
|
||||
class="flex {orientation === 'vertical'
|
||||
? 'flex-col py-1 pl-1 min-h-screen'
|
||||
? 'flex-col py-1 pl-1 pr-2 min-h-screen'
|
||||
: 'flex-row'} {className ?? ''}"
|
||||
{style}
|
||||
>
|
||||
|
||||
@@ -32,6 +32,14 @@ export class SortableFileList {
|
||||
private _sortable: Sortable | null = null;
|
||||
private _elements: { [id: string]: HTMLElement } = {};
|
||||
private _updatingSelection: boolean = false;
|
||||
/**
|
||||
* Whether a change of the selection that this list made is being applied by the engine. A click
|
||||
* makes several events, handled after one another, and the engine can answer after them (in
|
||||
* the desktop app, it is in another process): until it has, the list holds the selection, and
|
||||
* the engine and the list disagree because the engine is behind, not because the user did
|
||||
* something else.
|
||||
*/
|
||||
private _applying: boolean = false;
|
||||
/** The elements of the list when a drag from it started, to put them back when it ends. */
|
||||
private _snapshot: ChildNode[] = [];
|
||||
private _unsubscribes: (() => void)[] = [];
|
||||
@@ -196,6 +204,9 @@ export class SortableFileList {
|
||||
}
|
||||
|
||||
updateFromSelection() {
|
||||
if (this._applying) {
|
||||
return;
|
||||
}
|
||||
const changed = this.getChangedIds();
|
||||
if (changed.length === 0) {
|
||||
return;
|
||||
@@ -220,7 +231,7 @@ export class SortableFileList {
|
||||
|
||||
updateToSelection(e: Sortable.SortableEvent) {
|
||||
if (!this._sortable) return;
|
||||
if (this._updatingSelection) return;
|
||||
if (this._updatingSelection || this._applying) return;
|
||||
this._updatingSelection = true;
|
||||
const changed = this.getChangedIds();
|
||||
if (changed.length == 0) {
|
||||
@@ -242,28 +253,41 @@ export class SortableFileList {
|
||||
}
|
||||
|
||||
const parent = this._parent;
|
||||
let request: Promise<boolean>;
|
||||
if (selected.length === 0 || !this.childNode(selected[0])) {
|
||||
engine.select([]);
|
||||
request = engine.select([]);
|
||||
} else {
|
||||
switch (this._sortableLevel) {
|
||||
case 'file':
|
||||
engine.select(selected);
|
||||
request = engine.select(selected);
|
||||
break;
|
||||
case 'track':
|
||||
engine.selectTracks(parent!.fileId, selected);
|
||||
request = engine.selectTracks(parent!.fileId, selected);
|
||||
break;
|
||||
case 'segment':
|
||||
engine.selectSegments(parent!.fileId, (parent as any).trackId, selected);
|
||||
request = engine.selectSegments(
|
||||
parent!.fileId,
|
||||
(parent as any).trackId,
|
||||
selected
|
||||
);
|
||||
break;
|
||||
case 'waypoints':
|
||||
engine.selectWaypointGroup(parent!.fileId);
|
||||
request = engine.selectWaypointGroup(parent!.fileId);
|
||||
break;
|
||||
case 'waypoint':
|
||||
engine.selectWaypoints(parent!.fileId, selected);
|
||||
request = engine.selectWaypoints(parent!.fileId, selected);
|
||||
break;
|
||||
}
|
||||
}
|
||||
this._updatingSelection = false;
|
||||
|
||||
// the events of the same click that come before the engine answers are not new choices
|
||||
this._applying = true;
|
||||
request.finally(() => {
|
||||
this._applying = false;
|
||||
// the list shows what the engine selected
|
||||
this.updateFromSelection();
|
||||
});
|
||||
}
|
||||
|
||||
updateFromFileOrder() {
|
||||
|
||||
Reference in new issue
Block a user