mirror of
https://github.com/gpxstudio/gpx.studio.git
synced 2026-10-17 08:42:28 +00:00
progress
This commit is contained in:
1 parent
2e1ca00afa
commit
1ba5436af3
29 files changed
+3348
-190
No files matched your search
@@ -4,6 +4,9 @@ import { get } from 'svelte/store';
|
||||
import { boundsManager } from '$lib/state/bounds';
|
||||
import { engine } from '$lib/engine';
|
||||
import { defaultFileName } from '$lib/actions/default-file-name';
|
||||
import { askUnsaved } from '$lib/components/file-list/unsaved-prompt.svelte';
|
||||
import { subfolder } from '$lib/engine/workspace';
|
||||
import type { UnsavedChoice } from '$lib/engine';
|
||||
|
||||
/** The name of a new file: the translated default name, numbered if other files have it. */
|
||||
export function newFileName() {
|
||||
@@ -17,6 +20,86 @@ export function createFile() {
|
||||
currentTool.set(Tool.ROUTING);
|
||||
}
|
||||
|
||||
/** Asks for the files to open: with the file picker of the system in the desktop app. */
|
||||
export async function openFiles() {
|
||||
if (get(engine.workspace)) {
|
||||
const ids = await engine.openFiles();
|
||||
if (ids.length > 0) {
|
||||
boundsManager.fitBoundsOnLoad(ids);
|
||||
}
|
||||
} else {
|
||||
triggerFileInput();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What to do with the files of the open folder that have unsaved changes, which the user is asked
|
||||
* when there are some: `undefined` when there are none, `'cancel'` when they do not want to go on.
|
||||
*/
|
||||
async function askAboutUnsavedFiles(): Promise<UnsavedChoice | undefined | 'cancel'> {
|
||||
const workspace = get(engine.workspace);
|
||||
if (!workspace?.folder) {
|
||||
return undefined;
|
||||
}
|
||||
const files = get(engine.files);
|
||||
const names = Object.entries(workspace.files)
|
||||
// the files in the folder, and the new ones, which are saved in it
|
||||
.filter(
|
||||
([, file]) =>
|
||||
file.dirty &&
|
||||
(file.path === undefined || subfolder(workspace.folder, file.path) !== undefined)
|
||||
)
|
||||
.map(([id]) => {
|
||||
const file = files.get(id);
|
||||
return file ? get(file).structure.name : id;
|
||||
});
|
||||
return names.length > 0 ? askUnsaved(names) : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a folder, which is watched (the desktop app). The folder that was open is closed: the user
|
||||
* is asked what to do with its unsaved files.
|
||||
*/
|
||||
export async function openFolder() {
|
||||
const unsaved = await askAboutUnsavedFiles();
|
||||
if (unsaved === 'cancel') {
|
||||
return;
|
||||
}
|
||||
const ids = await engine.openFolder(unsaved);
|
||||
if (ids.length > 0) {
|
||||
boundsManager.fitBoundsOnLoad(ids);
|
||||
}
|
||||
}
|
||||
|
||||
/** Closes the open folder and its files: the user is asked what to do with the unsaved ones. */
|
||||
export async function closeFolder() {
|
||||
const unsaved = await askAboutUnsavedFiles();
|
||||
if (unsaved !== 'cancel') {
|
||||
await engine.closeFolder(unsaved);
|
||||
}
|
||||
}
|
||||
|
||||
/** Saves the selected files to the file system, all the ones that have unsaved edits if none is. */
|
||||
export function saveSelected(selectedFileIds: string[]) {
|
||||
const workspace = get(engine.workspace);
|
||||
const ids =
|
||||
selectedFileIds.length > 0
|
||||
? selectedFileIds
|
||||
: Object.keys(workspace?.files ?? {}).filter((id) => workspace?.files[id].dirty);
|
||||
if (ids.length > 0) {
|
||||
engine.save(ids);
|
||||
}
|
||||
}
|
||||
|
||||
/** Saves all the files that have unsaved edits to the file system. */
|
||||
export function saveAll() {
|
||||
const workspace = get(engine.workspace);
|
||||
const ids = Object.keys(workspace?.files ?? {}).filter((id) => workspace?.files[id].dirty);
|
||||
if (ids.length > 0) {
|
||||
engine.save(ids);
|
||||
}
|
||||
}
|
||||
|
||||
export function triggerFileInput() {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import type { RouteAttributes, StorageOpened } from 'gpx-wasm';
|
||||
import type { Backend } from './types';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import type { Outcome, RouteAttributes, StorageOpened } from 'gpx-wasm';
|
||||
import type { Backend, WorkspaceState } from './types';
|
||||
|
||||
/** An array that a command returns as its bytes. */
|
||||
const array = async <T>(
|
||||
@@ -207,6 +208,21 @@ export async function createTauriBackend(): Promise<Backend> {
|
||||
segment_ids: () => invoke('segment_ids'),
|
||||
routing_revision: () => invoke('routing_revision'),
|
||||
|
||||
desktop: {
|
||||
workspace: () => invoke<WorkspaceState>('workspace_state'),
|
||||
openFiles: () => invoke<Outcome>('open_files'),
|
||||
openFolder: (unsaved) => invoke<Outcome>('open_folder', { unsaved }),
|
||||
closeFolder: (unsaved) => invoke<Outcome>('close_folder', { unsaved }),
|
||||
saveFiles: (fileIds) => invoke('save_files', { fileIds }),
|
||||
listen: async (onEngineChanged, onWorkspaceChanged, onFilesOpened) => {
|
||||
await listen<Outcome>('files-opened', (event) => onFilesOpened(event.payload));
|
||||
await listen<Outcome>('engine-changed', (event) => onEngineChanged(event.payload));
|
||||
await listen<WorkspaceState>('workspace-changed', (event) =>
|
||||
onWorkspaceChanged(event.payload)
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
importsLegacyData: false,
|
||||
open_storage: () => invoke<StorageOpened>('open_storage'),
|
||||
flush_storage: () => invoke('flush_storage'),
|
||||
|
||||
@@ -12,6 +12,61 @@ import type {
|
||||
} from 'gpx-wasm';
|
||||
import type { MergeType, SelectMode, SplitType } from '../types';
|
||||
|
||||
/** What the desktop app knows of a file of the engine and the file system. */
|
||||
export type FileStatus = {
|
||||
/** Where the file is on the disk, absent for a file that only is in the engine. */
|
||||
path?: string;
|
||||
/** The file has edits that the file on the disk does not have (or is not on the disk). */
|
||||
dirty: boolean;
|
||||
/** The file on the disk was changed by something else while this one had edits. */
|
||||
conflict: boolean;
|
||||
};
|
||||
|
||||
/** What to do with the files of a folder that have unsaved edits when it is closed. */
|
||||
export type UnsavedChoice = 'save' | 'discard';
|
||||
|
||||
export type WorkspaceState = {
|
||||
/** The folder that is open, and watched. */
|
||||
folder?: string;
|
||||
files: Record<string, FileStatus>;
|
||||
};
|
||||
|
||||
/**
|
||||
* What the desktop app adds: its files are linked to files of the file system. Only the backend of
|
||||
* the desktop app has it.
|
||||
*/
|
||||
export interface DesktopFiles {
|
||||
workspace(): Promise<WorkspaceState>;
|
||||
/** Opens the files that the user picks, which get selected. */
|
||||
openFiles(): Promise<Outcome>;
|
||||
/**
|
||||
* Opens the folder that the user picks: its files are opened, and it is watched. The folder
|
||||
* that was open is closed (see `closeFolder` for `unsaved`).
|
||||
*/
|
||||
openFolder(unsaved?: UnsavedChoice): Promise<Outcome>;
|
||||
/**
|
||||
* Closes the open folder: it is not watched anymore, and its files are closed. What to do with
|
||||
* the ones that have unsaved edits: `save` them first, `discard` the edits, or keep them open
|
||||
* when it is not said. Rejects, and closes nothing, if they could not be saved.
|
||||
*/
|
||||
closeFolder(unsaved?: UnsavedChoice): Promise<Outcome>;
|
||||
/**
|
||||
* Writes files to the disk, replacing what is there (the user is asked where to put the ones
|
||||
* that are not on the disk yet).
|
||||
*/
|
||||
saveFiles(fileIds: string[]): Promise<void>;
|
||||
/**
|
||||
* 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).
|
||||
*/
|
||||
listen(
|
||||
onEngineChanged: (outcome: Outcome) => void,
|
||||
onWorkspaceChanged: (workspace: WorkspaceState) => void,
|
||||
onFilesOpened: (outcome: Outcome) => void
|
||||
): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the facade needs of the engine of the files, wherever it runs: the WASM module in the
|
||||
* browser (`wasm.ts`), or the Rust process of the desktop app, through its commands (`tauri.ts`).
|
||||
@@ -184,6 +239,9 @@ export interface Backend {
|
||||
segment_ids(): Promise<string[]>;
|
||||
routing_revision(): Promise<number>;
|
||||
|
||||
/** Present when the files are linked to the file system (the desktop app). */
|
||||
readonly desktop?: DesktopFiles;
|
||||
|
||||
// Storage
|
||||
/**
|
||||
* Whether the files that the previous versions of the app kept in the browser can be imported
|
||||
|
||||
@@ -29,6 +29,8 @@ import {
|
||||
type SelectMode,
|
||||
type SplitType,
|
||||
type StatisticsRequest,
|
||||
type UnsavedChoice,
|
||||
type WorkspaceState,
|
||||
type TrackpointDetails,
|
||||
type WaypointDetails,
|
||||
} from './types';
|
||||
@@ -60,6 +62,7 @@ class Engine {
|
||||
private _selection = writable<Selection>({ type: 'empty' });
|
||||
private _lastError = writable<string | undefined>(undefined);
|
||||
private _outdated = writable(false);
|
||||
private _workspace = writable<WorkspaceState | undefined>(undefined);
|
||||
private _canUndo = writable(false);
|
||||
private _canRedo = writable(false);
|
||||
private _canPaste = writable(false);
|
||||
@@ -74,6 +77,13 @@ class Engine {
|
||||
readonly outdated: Readable<boolean> = { subscribe: this._outdated.subscribe };
|
||||
readonly canUndo: Readable<boolean> = { subscribe: this._canUndo.subscribe };
|
||||
readonly canRedo: Readable<boolean> = { subscribe: this._canRedo.subscribe };
|
||||
/**
|
||||
* Where the files are on the file system, and which ones have edits that are not saved there.
|
||||
* `undefined` unless the files are linked to the file system (the desktop app).
|
||||
*/
|
||||
readonly workspace: Readable<WorkspaceState | undefined> = {
|
||||
subscribe: this._workspace.subscribe,
|
||||
};
|
||||
/** Why the last call to the engine did nothing, `undefined` if it did something. */
|
||||
readonly lastError: Readable<string | undefined> = { subscribe: this._lastError.subscribe };
|
||||
/** What was copied or cut, waiting to be pasted. */
|
||||
@@ -92,8 +102,9 @@ class Engine {
|
||||
|
||||
constructor() {
|
||||
this.ready = browser
|
||||
? loadBackend().then((backend) => {
|
||||
? loadBackend().then(async (backend) => {
|
||||
this.backend = backend;
|
||||
await this.followDisk(backend);
|
||||
})
|
||||
: new Promise(() => {});
|
||||
}
|
||||
@@ -533,6 +544,106 @@ class Engine {
|
||||
return this.run((b) => b.change_loop_start(revision, index));
|
||||
}
|
||||
|
||||
// The file system (the desktop app)
|
||||
|
||||
/**
|
||||
* Opens the files that the user picks on the file system, which get selected. Resolves to the
|
||||
* ids of the files that were added.
|
||||
*/
|
||||
async openFiles(): Promise<string[]> {
|
||||
return this.addedBy((b) => b.desktop!.openFiles());
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the folder that the user picks (instead of the one that was open, which is closed like
|
||||
* `closeFolder` does, `unsaved` being what to do with its unsaved files): its files are opened,
|
||||
* and it is watched: the files that appear, change or disappear in it are brought in the engine.
|
||||
* New files are saved in it. Resolves to the ids of the files that were added.
|
||||
*/
|
||||
async openFolder(unsaved?: UnsavedChoice): Promise<string[]> {
|
||||
return this.addedBy((b) => b.desktop!.openFolder(unsaved));
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the open folder: it is not watched anymore, and its files are closed. `unsaved` says
|
||||
* what to do with the ones that have unsaved edits: they are saved first, or their edits are
|
||||
* discarded (they stay open when it is not said).
|
||||
*/
|
||||
async closeFolder(unsaved?: UnsavedChoice) {
|
||||
await this.run((b) => b.desktop!.closeFolder(unsaved));
|
||||
await this.enqueue(async () => this.refreshWorkspace(this.backend!));
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes files to the file system, replacing what is there; they are not edited anymore from
|
||||
* then on. The user is asked where to put the ones that are not on the file system yet.
|
||||
*/
|
||||
async save(fileIds: string[]) {
|
||||
await this.ready;
|
||||
await this.enqueue(async () => {
|
||||
const backend = this.backend!;
|
||||
try {
|
||||
await backend.desktop!.saveFiles(fileIds);
|
||||
} catch (error) {
|
||||
console.error('The files could not be saved', error);
|
||||
this._lastError.set(String(error));
|
||||
}
|
||||
await this.refreshWorkspace(backend);
|
||||
});
|
||||
}
|
||||
|
||||
/** Runs a call that adds files, and resolves to the ids that it added. */
|
||||
private async addedBy(action: (backend: Backend) => Promise<Outcome>): Promise<string[]> {
|
||||
const before = new Set(get(this._order));
|
||||
await this.run(action);
|
||||
return get(this._order).filter((id) => !before.has(id));
|
||||
}
|
||||
|
||||
private _opened: ((ids: string[]) => void)[] = [];
|
||||
|
||||
/**
|
||||
* Calls `callback` with the ids of the files that were added when the system opens files with
|
||||
* the app (open with..., double click), which is already running to receive them. Returns what
|
||||
* stops it.
|
||||
*/
|
||||
onFilesOpened(callback: (ids: string[]) => void): () => void {
|
||||
this._opened.push(callback);
|
||||
return () => {
|
||||
this._opened = this._opened.filter((other) => other !== callback);
|
||||
};
|
||||
}
|
||||
|
||||
private async refreshWorkspace(backend: Backend) {
|
||||
if (backend.desktop) {
|
||||
this._workspace.set(await backend.desktop.workspace());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Follows what happens on the file system when it is not the user who does it: the files that
|
||||
* changed on the disk are brought in the engine, which says so.
|
||||
*/
|
||||
private async followDisk(backend: Backend) {
|
||||
if (!backend.desktop) {
|
||||
return;
|
||||
}
|
||||
this._workspace.set(await backend.desktop.workspace());
|
||||
await backend.desktop.listen(
|
||||
(outcome) =>
|
||||
void this.enqueue(async () => {
|
||||
await this.sync(backend, outcome);
|
||||
this.report(outcome);
|
||||
}),
|
||||
(workspace) => this._workspace.set(workspace),
|
||||
(outcome) =>
|
||||
void this.enqueue(async () => {
|
||||
await this.sync(backend, outcome);
|
||||
this.report(outcome);
|
||||
this._opened.forEach((callback) => callback(outcome.added));
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Visibility, which is kept by the frontend and not by the engine
|
||||
|
||||
/** Hides or shows the selected elements. */
|
||||
@@ -685,10 +796,17 @@ class Engine {
|
||||
await this.ready;
|
||||
return this.enqueue(async () => {
|
||||
const backend = this.backend!;
|
||||
const outcome = await action(backend);
|
||||
await this.sync(backend, outcome);
|
||||
this.report(outcome);
|
||||
return outcome.changed;
|
||||
try {
|
||||
const outcome = await action(backend);
|
||||
await this.sync(backend, outcome);
|
||||
this.report(outcome);
|
||||
return outcome.changed;
|
||||
} catch (error) {
|
||||
// what the engine refuses to do, like closing a folder whose files cannot be saved
|
||||
console.error('engine:', error);
|
||||
this._lastError.set(String(error));
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -768,6 +886,10 @@ class Engine {
|
||||
}
|
||||
});
|
||||
|
||||
if (update.added.length + update.modified.length + update.removed.length > 0) {
|
||||
await this.refreshWorkspace(backend);
|
||||
}
|
||||
|
||||
// the statistics depend on the selection, on its order, and on the files
|
||||
if (
|
||||
update.selectionChanged ||
|
||||
|
||||
@@ -4,6 +4,8 @@ import type { Visibility } from '$lib/engine/file-visibility';
|
||||
import type { CategoryIntervals } from '$lib/engine/trackpoint-categories';
|
||||
import type { FileStructure, GlobalStatistics } from 'gpx-wasm';
|
||||
|
||||
export type { FileStatus, UnsavedChoice, WorkspaceState } from './backend/types';
|
||||
|
||||
export type {
|
||||
Clipboard,
|
||||
WaypointDetails,
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
import { folderName, subfolder } from '$lib/engine/workspace';
|
||||
|
||||
describe('subfolder', () => {
|
||||
it('is where a file is in the folder', () => {
|
||||
assert.equal(subfolder('/home/me/tracks', '/home/me/tracks/a.gpx'), '');
|
||||
assert.equal(subfolder('/home/me/tracks', '/home/me/tracks/2024/may/a.gpx'), '2024/may');
|
||||
assert.equal(
|
||||
subfolder('C:\\Users\\me\\tracks', 'C:\\Users\\me\\tracks\\2024\\a.gpx'),
|
||||
'2024'
|
||||
);
|
||||
});
|
||||
|
||||
it('is nothing for a file that is not in the folder', () => {
|
||||
assert.equal(subfolder('/home/me/tracks', '/home/me/other/a.gpx'), undefined);
|
||||
assert.equal(subfolder('/home/me/tracks', '/home/me/tracks-old/a.gpx'), undefined);
|
||||
assert.equal(subfolder('/home/me/tracks', undefined), undefined);
|
||||
assert.equal(subfolder(undefined, '/home/me/tracks/a.gpx'), undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('folderName', () => {
|
||||
it('is the last part of the path', () => {
|
||||
assert.equal(folderName('/home/me/tracks'), 'tracks');
|
||||
assert.equal(folderName('/home/me/tracks/'), 'tracks');
|
||||
assert.equal(folderName('C:\\Users\\me\\tracks'), 'tracks');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
/** `\` and `/` alike. */
|
||||
function parts(path: string): string[] {
|
||||
return path.split(/[\\/]+/).filter((part) => part !== '');
|
||||
}
|
||||
|
||||
/** The name of the folder, the last part of its path. */
|
||||
export function folderName(folder: string): string {
|
||||
return parts(folder).at(-1) ?? folder;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a file is in the open folder: the folders between them, as a path (`''` if it is in the
|
||||
* folder itself). `undefined` for a file that is not in the folder (or not on the disk).
|
||||
*/
|
||||
export function subfolder(
|
||||
folder: string | undefined,
|
||||
path: string | undefined
|
||||
): string | undefined {
|
||||
if (folder === undefined || path === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const root = parts(folder);
|
||||
const file = parts(path);
|
||||
if (file.length <= root.length || root.some((part, i) => part !== file[i])) {
|
||||
return undefined;
|
||||
}
|
||||
return file.slice(root.length, -1).join('/');
|
||||
}
|
||||
@@ -19,6 +19,7 @@
|
||||
import { page } from '$app/state';
|
||||
import { hoveredPoint, slicedStatistics } from '$lib/state/selection-statistics';
|
||||
import { engine } from '$lib/engine';
|
||||
import { boundsManager } from '$lib/state/bounds';
|
||||
import { getURLForGoogleDriveFile } from '$lib/components/embedding/embedding';
|
||||
|
||||
const outdated = engine.outdated;
|
||||
@@ -38,6 +39,8 @@
|
||||
);
|
||||
|
||||
onMount(async () => {
|
||||
// files that the system opens with the app while it runs
|
||||
engine.onFilesOpened((ids) => boundsManager.fitBoundsOnLoad(ids));
|
||||
settings.connect(await engine.openStorage());
|
||||
|
||||
let files: string[] = JSON.parse(page.url.searchParams.get('files') || '[]');
|
||||
|
||||
Reference in new issue
Block a user