diff --git a/website/src/lib/components/collapsible-tree/CollapsibleTreeNode.svelte b/website/src/lib/components/collapsible-tree/CollapsibleTreeNode.svelte index a9d33139c..e24caffb9 100644 --- a/website/src/lib/components/collapsible-tree/CollapsibleTreeNode.svelte +++ b/website/src/lib/components/collapsible-tree/CollapsibleTreeNode.svelte @@ -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('collapsible-tree-state'); @@ -88,6 +89,6 @@ {/if} - {@render props.content()} + {@render props.content(open.current)} diff --git a/website/src/lib/components/explorer/Explorer.svelte b/website/src/lib/components/explorer/Explorer.svelte new file mode 100644 index 000000000..a82f4713f --- /dev/null +++ b/website/src/lib/components/explorer/Explorer.svelte @@ -0,0 +1,50 @@ + + + +{#if folder} +
+
{i18n._('menu.explorer')}
+ +
+ + {#key folder} + + + + {#snippet trigger()} + + {folderName(folder)} + + {/snippet} + {#snippet content(opened)} + + {#if opened} +
+ + + + +
+ {/if} + {/snippet} +
+
+ {/key} +
+
+
+{/if} diff --git a/website/src/lib/components/explorer/ExplorerTree.svelte b/website/src/lib/components/explorer/ExplorerTree.svelte new file mode 100644 index 000000000..cf2f770d0 --- /dev/null +++ b/website/src/lib/components/explorer/ExplorerTree.svelte @@ -0,0 +1,80 @@ + + +{#each entries as entry (entry.path)} + {#if entry.isDir} + + {#snippet trigger()} + {entry.name} + {/snippet} + {#snippet content(opened)} + + {#if opened} +
+ +
+ {/if} + {/snippet} +
+ {:else} + {@const id = fileIdAt($workspace, entry.path)} + {@const selected = id !== undefined && selectedFileIds($selection).includes(id)} + {@const dirty = id !== undefined && $workspace?.files[id]?.dirty === true} + + {/if} +{/each} diff --git a/website/src/lib/components/explorer/index.ts b/website/src/lib/components/explorer/index.ts new file mode 100644 index 000000000..6b86bed1c --- /dev/null +++ b/website/src/lib/components/explorer/index.ts @@ -0,0 +1 @@ +export { default as Explorer } from './Explorer.svelte'; diff --git a/website/src/lib/components/file-list/FileList.svelte b/website/src/lib/components/file-list/FileList.svelte index 96a6dd2d6..d3c1b523c 100644 --- a/website/src/lib/components/file-list/FileList.svelte +++ b/website/src/lib/components/file-list/FileList.svelte @@ -36,14 +36,14 @@
diff --git a/website/src/lib/components/file-list/sortable-file-list.ts b/website/src/lib/components/file-list/sortable-file-list.ts index 764d9a042..7f4cfb341 100644 --- a/website/src/lib/components/file-list/sortable-file-list.ts +++ b/website/src/lib/components/file-list/sortable-file-list.ts @@ -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; 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() { diff --git a/website/src/lib/engine/backend/tauri.ts b/website/src/lib/engine/backend/tauri.ts index 004862191..1bc677e1f 100644 --- a/website/src/lib/engine/backend/tauri.ts +++ b/website/src/lib/engine/backend/tauri.ts @@ -221,12 +221,13 @@ export async function createTauriBackend(): Promise { exportFiles: (fileIds, options) => invoke('export_files', { fileIds, options }), listFolder: (path) => invoke('list_folder', { path }), - listen: async (onEngineChanged, onWorkspaceChanged, onFilesOpened) => { + listen: async (onEngineChanged, onWorkspaceChanged, onFilesOpened, onFolderChanged) => { await listen('files-opened', (event) => onFilesOpened(event.payload)); await listen('engine-changed', (event) => onEngineChanged(event.payload)); await listen('workspace-changed', (event) => onWorkspaceChanged(event.payload) ); + await listen('folder-changed', () => onFolderChanged()); }, }, diff --git a/website/src/lib/engine/backend/types.ts b/website/src/lib/engine/backend/types.ts index 5ba870fa6..6cb1548c1 100644 --- a/website/src/lib/engine/backend/types.ts +++ b/website/src/lib/engine/backend/types.ts @@ -91,13 +91,15 @@ export interface DesktopFiles { listFolder(path?: string): Promise; /** * Tells when the engine changed without being asked, because the files on the disk did (the - * outcome to apply), when the status of the files changed, and when files were opened with the - * app from the system (the outcome to apply, which selected them). + * outcome to apply), when the status of the files changed, when files were opened with the + * app from the system (the outcome to apply, which selected them), and when something changed + * in the open folder (the tree of its files is to be read again). */ listen( onEngineChanged: (outcome: Outcome) => void, onWorkspaceChanged: (workspace: WorkspaceState) => void, - onFilesOpened: (outcome: Outcome) => void + onFilesOpened: (outcome: Outcome) => void, + onFolderChanged: () => void ): Promise; } diff --git a/website/src/lib/engine/engine.ts b/website/src/lib/engine/engine.ts index cac566810..b54344643 100644 --- a/website/src/lib/engine/engine.ts +++ b/website/src/lib/engine/engine.ts @@ -65,6 +65,7 @@ class Engine { private _lastError = writable(undefined); private _outdated = writable(false); private _workspace = writable(undefined); + private _folderVersion = writable(0); private _canUndo = writable(false); private _canRedo = writable(false); private _canPaste = writable(false); @@ -86,6 +87,11 @@ class Engine { readonly workspace: Readable = { subscribe: this._workspace.subscribe, }; + /** + * A number that changes when something changed in the open folder on the file system, to read + * its tree again. + */ + readonly folderVersion: Readable = { subscribe: this._folderVersion.subscribe }; /** Why the last call to the engine did nothing, `undefined` if it did something. */ readonly lastError: Readable = { subscribe: this._lastError.subscribe }; /** What was copied or cut, waiting to be pasted. */ @@ -689,7 +695,8 @@ class Engine { await this.sync(backend, outcome); this.report(outcome); this._opened.forEach((callback) => callback(outcome.added)); - }) + }), + () => this._folderVersion.update((version) => version + 1) ); } diff --git a/website/src/lib/engine/workspace.test.ts b/website/src/lib/engine/workspace.test.ts index 0854b30df..8579e5286 100644 --- a/website/src/lib/engine/workspace.test.ts +++ b/website/src/lib/engine/workspace.test.ts @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; -import { folderName, subfolder } from '$lib/engine/workspace'; +import { fileIdAt, folderName, subfolder } from '$lib/engine/workspace'; describe('subfolder', () => { it('is where a file is in the folder', () => { @@ -27,3 +27,23 @@ describe('folderName', () => { assert.equal(folderName('C:\\Users\\me\\tracks'), 'tracks'); }); }); + +describe('fileIdAt', () => { + const workspace = { + files: { + a: { path: '/tracks/a.gpx' }, + b: { path: '/tracks/sub/b.gpx' }, + c: {}, + }, + }; + + it('is the open file that is linked to the path', () => { + assert.equal(fileIdAt(workspace, '/tracks/a.gpx'), 'a'); + assert.equal(fileIdAt(workspace, '/tracks/sub/b.gpx'), 'b'); + }); + + it('is nothing for a file that is not open', () => { + assert.equal(fileIdAt(workspace, '/tracks/other.gpx'), undefined); + assert.equal(fileIdAt(undefined, '/tracks/a.gpx'), undefined); + }); +}); diff --git a/website/src/lib/engine/workspace.ts b/website/src/lib/engine/workspace.ts index 5723c6021..a44ef4ea3 100644 --- a/website/src/lib/engine/workspace.ts +++ b/website/src/lib/engine/workspace.ts @@ -26,3 +26,11 @@ export function subfolder( } return file.slice(root.length, -1).join('/'); } + +/** The id of the open file that is linked to the file at `path`, `undefined` if there is none. */ +export function fileIdAt( + workspace: { files: Record } | undefined, + path: string +): string | undefined { + return Object.entries(workspace?.files ?? {}).find(([, file]) => file.path === path)?.[0]; +} diff --git a/website/src/lib/state/settings.ts b/website/src/lib/state/settings.ts index 709d5951f..07778f9ea 100644 --- a/website/src/lib/state/settings.ts +++ b/website/src/lib/state/settings.ts @@ -156,6 +156,7 @@ export const settings = { defaultWidth: new Setting('defaultWidth', browser && window.innerWidth < 600 ? 8 : 5), bottomPanelSize: new Setting('bottomPanelSize', 170), rightPanelSize: new Setting('rightPanelSize', 240), + explorerPanelSize: new Setting('explorerPanelSize', 0), /** Takes the saved values (see `Engine.openStorage`). */ connect(stored: StoredSettings) { for (const key in settings) { diff --git a/website/src/locales/en.json b/website/src/locales/en.json index 337878a6b..9e88fe6a5 100644 --- a/website/src/locales/en.json +++ b/website/src/locales/en.json @@ -14,6 +14,8 @@ "new_segment": "New segment", "open": "Open...", "open_folder": "Open folder...", + "explorer": "Explorer", + "workspace": "Workspace", "save": "Save", "save_all": "Save all", "save_as": "Save as...", diff --git a/website/src/routes/[[language]]/app/+page.svelte b/website/src/routes/[[language]]/app/+page.svelte index 063871d12..7a380f79d 100644 --- a/website/src/routes/[[language]]/app/+page.svelte +++ b/website/src/routes/[[language]]/app/+page.svelte @@ -2,6 +2,7 @@ import GPXLayers from '$lib/components/map/gpx-layer/GPXLayers.svelte'; import ElevationProfile from '$lib/components/elevation-profile/ElevationProfile.svelte'; import FileList from '$lib/components/file-list/FileList.svelte'; + import Explorer from '$lib/components/explorer/Explorer.svelte'; import GPXStatistics from '$lib/components/GPXStatistics.svelte'; import Map from '$lib/components/map/Map.svelte'; import Menu from '$lib/components/Menu.svelte'; @@ -23,17 +24,29 @@ import { getURLForGoogleDriveFile } from '$lib/components/embedding/embedding'; const outdated = engine.outdated; + const workspace = engine.workspace; const { treeFileView, elevationProfile, bottomPanelSize, rightPanelSize, + explorerPanelSize, additionalDatasets, elevationFill, } = settings; let bottomPanelWidth: number | undefined = $state(); + // the file list and the explorer share the right panel, half of it each until it is resized + let rightPanelHeight = $state(0); + let explorerHeight = $derived( + $treeFileView + ? Math.min( + Math.max($explorerPanelSize > 0 ? $explorerPanelSize : rightPanelHeight / 2, 60), + Math.max(rightPanelHeight - 80, 60) + ) + : rightPanelHeight + ); let bottomPanelOrientation = $derived( bottomPanelWidth && bottomPanelWidth >= 540 && $elevationProfile ? 'horizontal' : 'vertical' ); @@ -153,9 +166,43 @@ {/if}
- {#if $treeFileView} + {#if $treeFileView || $workspace?.folder} - +
+ {#if $treeFileView} + {#if $workspace} +
+ {i18n._('menu.workspace')} +
+ {/if} + +
+ +
+ {/if} + + {#if $workspace?.folder} + {#if $treeFileView} + explorerHeight, (height) => ($explorerPanelSize = height)} + minAfter={60} + maxAfter={Math.max(rightPanelHeight - 80, 60)} + /> + {/if} +
+ +
+ {/if} +
{/if}