explorer actions

This commit is contained in:
vcoppe committed 2026-10-17 23:23:29 +02:00
1 parent adbf89e318
commit 3ef2bf135a
20 files changed
+1183 -65

No files matched your search

+2 -1
View File
@@ -47,6 +47,7 @@
Maximize,
Maximize2,
Minimize2,
FolderX,
} from '@lucide/svelte';
import { map } from '$lib/components/map/map';
import { editMetadata } from '$lib/components/file-list/metadata/utils.svelte';
@@ -215,7 +216,7 @@
<Shortcut key="W" ctrl={true} shift={true} />
</Menubar.Item>
<Menubar.Item onclick={closeFolder} disabled={!$workspace?.folder}>
<FileX size="16" />
<FolderX size="16" />
{i18n._('menu.close_folder')}
</Menubar.Item>
{:else}
@@ -4,7 +4,9 @@
import { CollapsibleTreeState } from '$lib/components/collapsible-tree/utils.svelte';
import { ScrollArea } from '$lib/components/ui/scroll-area/index';
import ExplorerTree from './ExplorerTree.svelte';
import { clickedInExplorer } from './state.svelte';
import ExplorerRow from './ExplorerRow.svelte';
import { openFiles } from './actions';
import { clickedInExplorer, explorerSelection, renaming } from './state.svelte';
import { i18n } from '$lib/i18n/i18n.svelte';
import { engine } from '$lib/engine';
import { selectedFileIds } from '$lib/engine/selection-helpers';
@@ -27,12 +29,13 @@
let container: HTMLElement | undefined = $state();
// the file that is selected, if it is in the folder
let selectedPath = $derived.by(() => {
const id = selectedFileIds($selection)[0];
const path = id === undefined ? undefined : $workspace?.files[id]?.path;
return subfolder(folder, path) === undefined ? undefined : path;
});
// the files that are selected, if they are in the folder
let selectedPaths = $derived(
selectedFileIds($selection)
.map((id) => $workspace?.files[id]?.path)
.filter((path): path is string => subfolder(folder, path) !== undefined)
);
let selectedPath = $derived(selectedPaths[0]);
$effect(() => {
// not what the click that was just made depends on, only the selection and the folder
@@ -41,11 +44,30 @@
return;
}
clickedInExplorer.path = undefined;
// what is selected elsewhere (the file list, the map) is selected here
explorerSelection.paths = untrack(() => selectedPaths);
explorerSelection.anchor = selectedPath;
if (folder && selectedPath !== undefined) {
return reveal(folder, selectedPath);
}
});
/** 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.
@@ -83,16 +105,28 @@
<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 bind:this={container} class="flex flex-col pl-1 pr-3 pb-1">
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<div
bind:this={container}
data-explorer
role="tree"
tabindex="0"
class="flex flex-col pl-1 pr-3 pb-1 outline-none"
{onkeydown}
>
<!-- starts over when another folder is opened -->
{#key folder}
<!-- the folder itself -->
<CollapsibleTree side="left" treeState={trees.root} sticky>
<CollapsibleTreeNode id="folder">
{#snippet trigger()}
<span class="grow truncate text-left" title={folder}>
{folderName(folder)}
</span>
<ExplorerRow
path={folder}
name={folderName(folder)}
kind="root"
onOpenFolder={() =>
trees && (trees.root.get('root.folder').current = true)}
/>
{/snippet}
{#snippet content(opened)}
<!-- what is in it is only read when it is open -->
@@ -0,0 +1,341 @@
<script lang="ts">
import * as ContextMenu from '$lib/components/ui/context-menu';
import {
ExternalLink,
FileX,
FolderOpen,
FolderX,
PencilLine,
Plus,
Trash2,
} from '@lucide/svelte';
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 { isMac } from '$lib/utils';
import { openFiles, showError } from './actions';
import {
clickedInExplorer,
creating,
dragging,
editableName,
explorerSelection,
isWithin,
renaming,
} from './state.svelte';
let {
path,
name,
kind,
onOpenFolder,
icon,
class: className = '',
style = '',
}: {
path: string;
/** What the row shows, the name of the file with its extension. */
name: string;
/** The open folder itself, a folder in it, or a GPX file. */
kind: 'root' | 'folder' | 'file';
/** Opens the folder of the row, to show what was made in it. */
onOpenFolder?: () => void;
icon?: Snippet;
class?: string;
style?: string;
} = $props();
const { workspace } = engine;
let row: HTMLElement | undefined = $state();
// the open file of the row
let fileId = $derived(kind === 'file' ? fileIdAt($workspace, path) : undefined);
let selected = $derived(explorerSelection.paths.includes(path));
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];
}
function select(paths: string[]) {
explorerSelection.paths = paths;
explorerSelection.anchor = path;
}
function onclick(event: MouseEvent) {
if (editing || kind === 'root') {
return;
}
if (event.metaKey || event.ctrlKey) {
// added to the selection, or taken out of it, without opening anything
select(
selected
? explorerSelection.paths.filter((other) => other !== path)
: [...explorerSelection.paths, path]
);
event.preventDefault();
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 [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);
}
event.preventDefault();
event.stopPropagation();
} else if (event.detail <= 1) {
select([path]);
if (kind === 'file') {
// it is in view, the explorer does not have to scroll to it
clickedInExplorer.path = path;
if (fileId !== undefined) {
engine.select([fileId]);
} else {
openFiles([path], true);
}
}
}
}
function ondblclick() {
if (kind === 'file' && !editing) {
clickedInExplorer.path = path;
openFiles([path], false);
}
}
// the menu
/** The folder where what is made from the menu goes: the folder of the row. */
let folder = $derived(kind === 'file' ? undefined : path);
/** 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') {
if (folder === undefined) return;
onOpenFolder?.();
creating.kind = what;
creating.parent = folder;
}
function openSelected() {
const files = targets().filter((other) => kindOf(other) === 'file');
if (files.length > 0) {
openFiles(files, false);
}
}
/** The open files among what the menu acts on. */
function openIds(): string[] {
return targets()
.map((other) => fileIdAt($workspace, other))
.filter((id): id is string => id !== undefined);
}
async function remove() {
const paths = targets();
explorerSelection.paths = [];
if (!(await engine.deletePaths(paths))) {
showError();
}
}
// renaming, in the row
async function commit(value: string) {
if (renaming.path !== path) return;
renaming.path = undefined;
const name_ = value.trim();
if (name_ === '' || name_ === editableName(name, kind !== 'file')) return;
if (!(await engine.renamePath(path, name_))) {
showError();
}
}
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) {
if (kind === 'root' || editing) {
event.preventDefault();
return;
}
const paths = targets();
dragging.paths = paths;
event.dataTransfer?.setData('text/plain', paths.join('\n'));
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move';
}
}
function ondragend() {
dragging.paths = [];
dragging.target = undefined;
}
function ondragover(event: DragEvent) {
if (kind === 'file' || dragging.paths.length === 0) return;
// not into itself
if (dragging.paths.some((dragged) => isWithin(path, dragged))) return;
event.preventDefault();
if (event.dataTransfer) {
event.dataTransfer.dropEffect = 'move';
}
dragging.target = path;
}
function ondragleave(event: DragEvent) {
if (dragging.target === path && !row?.contains(event.relatedTarget as Node | null)) {
dragging.target = undefined;
}
}
async function ondrop(event: DragEvent) {
event.preventDefault();
const paths = dragging.paths;
ondragend();
if (paths.length > 0 && !(await engine.movePaths(paths, path))) {
showError();
}
}
</script>
<ContextMenu.Root
onOpenChange={(open) => {
if (open && !selected && kind !== 'root') {
select([path]);
}
}}
>
<ContextMenu.Trigger class="grow min-w-0 pointer-events-auto">
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
bind:this={row}
role="treeitem"
aria-selected={selected}
tabindex="-1"
data-explorer-path={path}
data-path={path}
data-kind={kind}
title={path}
draggable={kind !== 'root' && !editing}
class="flex flex-row items-center gap-1.5 min-w-0 rounded-md {selected
? 'bg-accent'
: ''} {target ? 'ring-1 ring-primary bg-accent/60' : ''} {className}"
{style}
{onclick}
{ondblclick}
{ondragstart}
{ondragend}
{ondragover}
ondragenter={ondragover}
{ondragleave}
{ondrop}
>
{@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"
value={editableName(name, kind !== 'file')}
{@attach focus}
{onkeydown}
onkeyup={(event) => event.stopPropagation()}
onclick={(event) => event.stopPropagation()}
onblur={(event) => commit(event.currentTarget.value)}
/>
{:else}
<span class="grow truncate text-left">{name}</span>
{/if}
</div>
</ContextMenu.Trigger>
<!-- the name being edited keeps the focus, which the menu would give back to the row -->
<ContextMenu.Content
onCloseAutoFocus={(event) => {
if (renaming.path !== undefined || creating.parent !== undefined) {
event.preventDefault();
}
}}
>
{#if kind !== 'file'}
<ContextMenu.Item onclick={() => create('file')}>
<Plus size="16" />
{i18n._('menu.explorer_menu.new_file')}
</ContextMenu.Item>
<ContextMenu.Item onclick={() => create('folder')}>
<Plus size="16" />
{i18n._('menu.explorer_menu.new_folder')}
</ContextMenu.Item>
<ContextMenu.Separator />
{:else}
<ContextMenu.Item onclick={openSelected}>
<FolderOpen size="16" />
{i18n._('menu.explorer_menu.open')}
</ContextMenu.Item>
{#if openIds().length > 0}
<ContextMenu.Item onclick={() => closeFiles(openIds())}>
<FileX size="16" />
{i18n._('menu.close')}
</ContextMenu.Item>
{/if}
<ContextMenu.Separator />
{/if}
{#if kind !== 'root'}
<ContextMenu.Item
disabled={targets().length !== 1}
onclick={() => (renaming.path = path)}
>
<PencilLine size="16" />
{i18n._('menu.explorer_menu.rename')}
</ContextMenu.Item>
<ContextMenu.Item onclick={remove}>
<Trash2 size="16" />
{i18n._('menu.delete')}
</ContextMenu.Item>
<ContextMenu.Separator />
{/if}
<ContextMenu.Item onclick={() => engine.revealPath(path)}>
<ExternalLink size="16" />
{i18n._(isMac() ? 'menu.explorer_menu.reveal_finder' : 'menu.explorer_menu.reveal')}
</ContextMenu.Item>
{#if kind === 'root'}
<ContextMenu.Separator />
<ContextMenu.Item onclick={closeFolder}>
<FolderX size="16" />
{i18n._('menu.close_folder')}
</ContextMenu.Item>
{/if}
</ContextMenu.Content>
</ContextMenu.Root>
@@ -1,15 +1,18 @@
<script lang="ts">
import { CollapsibleTreeNode } from '$lib/components/collapsible-tree/index';
import { Button } from '$lib/components/ui/button';
import { FileText } from '@lucide/svelte';
import { FileText, Folder } from '@lucide/svelte';
import { getContext } from 'svelte';
import { clickedInExplorer } from './state.svelte';
import { get } from 'svelte/store';
import { toast } from 'svelte-sonner';
import { stickyKey, type StickyLevel } from '$lib/components/collapsible-tree/sticky';
import ExplorerTree from './ExplorerTree.svelte';
import { openPath } from '$lib/actions/file-actions';
import ExplorerRow from './ExplorerRow.svelte';
import { engine, type FolderEntry } from '$lib/engine';
import { selectedFileIds } from '$lib/engine/selection-helpers';
import { 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';
let {
path,
@@ -18,11 +21,71 @@
path?: string;
} = $props();
const { workspace, selection, folderVersion } = engine;
const { workspace, folderVersion } = engine;
// the rows of the folders that stick above the files of this one
const level = getContext<StickyLevel>(stickyKey);
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
let folder = $derived(path ?? $workspace?.folder);
let pending = $derived(
creating.parent !== undefined && creating.parent === folder ? creating.kind : undefined
);
// the files come after the folders
let firstFile = $derived(entries.find((entry) => !entry.isDir));
/**
* Makes the file or folder that was asked for, called `value`: nothing is made without a name,
* nor with the name of something that is there (the user is told, and can change it). A new
* file is selected, for the routing tool.
*/
async function create(value: string, retry: boolean) {
const kind = pending;
const parent = folder;
if (kind === undefined || parent === undefined) return;
const name = value.trim();
const taken = entries.some(
(entry) =>
entry.isDir === (kind === 'folder') &&
editableName(entry.name, entry.isDir).toLowerCase() === name.toLowerCase()
);
if (name !== '' && taken) {
toast.error(i18n._('menu.metadata.name_taken'));
if (retry) return;
}
creating.parent = undefined;
if (name === '' || taken) return;
let created: string | undefined;
if (kind === 'file') {
const id = await engine.newFileIn(name, parent);
created = id === undefined ? undefined : get(workspace)?.files[id]?.path;
if (created !== undefined) {
currentTool.set(Tool.ROUTING);
}
} else {
created = await engine.createFolder(parent, name);
}
if (created === undefined) {
showError();
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
@@ -37,25 +100,44 @@
current = false;
};
});
/** Opens the file, or selects it if it is open already. */
function open(entry: FolderEntry, id: string | undefined) {
// it is in view, the explorer does not have to scroll to it (which would move the rows
// under the pointer, and leave the one it was on highlighted)
clickedInExplorer.path = entry.path;
if (id !== undefined) {
engine.select([id]);
} else {
openPath(entry.path);
}
}
</script>
<!-- the name of what is being made, which it is made with -->
{#snippet named(kind: 'file' | 'folder')}
<div class="flex flex-row items-center h-7 pl-1 pr-1 {kind === 'file' ? 'gap-1.5' : 'gap-1'}">
{#if kind === 'file'}
<FileText size="16" class="shrink-0" />
{: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"
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)}
/>
</div>
{/snippet}
{#if pending === 'folder'}
{@render named('folder')}
{/if}
{#each entries as entry (entry.path)}
{#if pending === 'file' && entry === firstFile}
{@render named('file')}
{/if}
{#if entry.isDir}
<CollapsibleTreeNode id={entry.name}>
<CollapsibleTreeNode id={entry.name} bind:this={nodes[entry.path]}>
{#snippet trigger()}
<span class="grow truncate text-left">{entry.name}</span>
<ExplorerRow
path={entry.path}
name={entry.name}
kind="folder"
onOpenFolder={() => nodes[entry.path]?.openNode()}
/>
{/snippet}
{#snippet content(opened)}
<!-- the content of a folder is only read when it is opened -->
@@ -67,20 +149,23 @@
{/snippet}
</CollapsibleTreeNode>
{:else}
{@const id = fileIdAt($workspace, entry.path)}
{@const selected = id !== undefined && selectedFileIds($selection).includes(id)}
<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}
data-path={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"
onclick={() => open(entry, id)}
>
<FileText size="16" class="shrink-0" />
<span class="grow truncate text-left">{entry.name}</span>
</Button>
{#snippet icon()}
<FileText size="16" class="shrink-0" />
{/snippet}
</ExplorerRow>
{/if}
{/each}
{#if pending === 'file' && firstFile === undefined}
{@render named('file')}
{/if}
@@ -0,0 +1,24 @@
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));
}
}
@@ -1,2 +1,45 @@
/** The file that the user just selected by clicking it in the explorer, until the selection changes. */
export const clickedInExplorer: { path: string | undefined } = $state({ path: undefined });
/**
* What is selected in the explorer (files and folders, by path), which the menu and dragging act
* on, and where a range selected with shift begins.
*/
export const explorerSelection: { paths: string[]; anchor: string | undefined } = $state({
paths: [],
anchor: undefined,
});
/** The file or folder whose name is being edited in the explorer. */
export const renaming: { path: string | undefined } = $state({ path: undefined });
/**
* The file or folder that is being made in a folder (`parent`) of the explorer: it is only made once
* the user gave it a name.
*/
export const creating: { parent: string | undefined; kind: 'file' | 'folder' } = $state({
parent: undefined,
kind: 'file',
});
/** What is dragged in the explorer, and the folder it would be dropped in. */
export const dragging: { paths: string[]; target: string | undefined } = $state({
paths: [],
target: undefined,
});
/** `\` and `/` alike. */
function isSeparator(char: string | undefined) {
return char === '/' || char === '\\';
}
/** 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);
}
@@ -100,6 +100,10 @@
// the file is not the one on the disk (the desktop app)
let unsaved = $derived(node.type === 'file' && $workspace?.files[node.fileId]?.dirty === true);
// the file is open as a preview, which the next one replaces (the desktop app)
let preview = $derived(
node.type === 'file' && $workspace?.files[node.fileId]?.preview === true
);
let hidden = $derived(isHidden(fileState, elementId(node)));
// cut elements are greyed until they are pasted
let isCut = $derived($clipboard?.cut === true && isInClipboard($clipboard, node));
@@ -195,6 +199,7 @@
{/if}
<!-- a tab of the horizontal list is as wide as its content: the name is not cut -->
<span
class:italic={preview}
class="grow select-none {orientation === 'vertical'
? 'truncate last:mr-2'
: 'shrink-0'}"
+7 -1
View File
@@ -211,7 +211,7 @@ export async function createTauriBackend(): Promise<Backend> {
desktop: {
workspace: () => invoke<WorkspaceState>('workspace_state'),
openFiles: () => invoke<Outcome>('open_files'),
openPaths: (paths) => invoke<Outcome>('open_paths', { paths }),
openPaths: (paths, preview) => invoke<Outcome>('open_paths', { paths, preview }),
openFolder: (unsaved) => invoke<Outcome>('open_folder', { unsaved }),
closeFolder: (unsaved) => invoke<Outcome>('close_folder', { unsaved }),
closeFiles: (fileIds, unsaved) => invoke<Outcome>('close_files', { fileIds, unsaved }),
@@ -222,6 +222,12 @@ export async function createTauriBackend(): Promise<Backend> {
invoke<boolean>('export_files', { fileIds, options }),
listFolder: (path) => invoke<FolderEntry[]>('list_folder', { path }),
openUrl: (url) => invoke('open_url', { url }),
newFile: (name, folder) => invoke<Outcome>('new_file', { name, folder }),
createFolder: (parent, name) => invoke<string>('create_folder', { parent, name }),
renamePath: (path, name) => invoke<Outcome>('rename_path', { path, name }),
movePaths: (paths, folder) => invoke('move_paths', { paths, folder }),
deletePaths: (paths) => invoke<Outcome>('delete_paths', { paths }),
revealPath: (path) => invoke('reveal_path', { path }),
listen: async (onEngineChanged, onWorkspaceChanged, onFilesOpened, onFolderChanged) => {
await listen<Outcome>('files-opened', (event) => onFilesOpened(event.payload));
await listen<Outcome>('engine-changed', (event) => onEngineChanged(event.payload));
+26 -2
View File
@@ -30,6 +30,8 @@ export type FileStatus = {
replaced: boolean;
/** The file on the disk is gone. */
missing: boolean;
/** The file is open as a preview (a single click in the tree), which the next one replaces. */
preview: boolean;
};
/** An entry of the tree of the open folder: a folder, or a GPX file. */
@@ -56,8 +58,12 @@ export interface DesktopFiles {
workspace(): Promise<WorkspaceState>;
/** Opens the files that the user picks, which get selected. */
openFiles(): Promise<Outcome>;
/** Opens files of the file system by their path, like the ones of the tree of the folder. */
openPaths(paths: string[]): Promise<Outcome>;
/**
* Opens files of the file system by their path, like the ones of the tree of the folder. With
* `preview`, a single file is open as a preview: the next preview replaces it, until it is
* edited or opened for good. Otherwise they are open for good.
*/
openPaths(paths: string[], preview?: boolean): Promise<Outcome>;
/**
* Opens the folder that the user picks, which is watched. Its files are not opened: they are
* in its tree (`listFolder`). The folder that was open is closed with its files (see
@@ -91,6 +97,24 @@ export interface DesktopFiles {
listFolder(path?: string): Promise<FolderEntry[]>;
/** Opens a web page (or an email to write) in the app of the system for it. */
openUrl(url: string): Promise<void>;
/** Makes a new file, written at once in `folder` (a folder of the open folder), and selects it. */
newFile(name: string, folder: string): Promise<Outcome>;
/** Makes a folder in a folder of the open folder (numbered if the name is taken): its path. */
createFolder(parent: string, name: string): Promise<string>;
/**
* Renames a file or a folder of the open folder. An open file is renamed like in its info (it
* is selected), which can be undone.
*/
renamePath(path: string, name: string): Promise<Outcome>;
/** Moves files and folders of the open folder into a folder of it. */
movePaths(paths: string[], folder: string): Promise<void>;
/**
* Moves files and folders of the open folder to the trash. The open files among them are
* closed, unless they have edits.
*/
deletePaths(paths: string[]): Promise<Outcome>;
/** Shows a file or a folder in the file manager of the system. */
revealPath(path: 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, when files were opened with the
+81 -3
View File
@@ -562,9 +562,87 @@ class Engine {
return this.addedBy((b) => b.desktop!.openFiles());
}
/** Opens files of the file system by their path, which get selected. Resolves to the new ids. */
async openPaths(paths: string[]): Promise<string[]> {
return this.addedBy((b) => b.desktop!.openPaths(paths));
/**
* Opens files of the file system by their path, which get selected: as a preview with
* `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;
}
// What the explorer does to the tree of the open folder (the desktop app). When it fails,
// `lastError` tells why (a key of the dictionaries, or a message).
/**
* Makes a new file in a folder of the open folder, which is written there at once and selected.
* 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);
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;
}
/** 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;
}
/** 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;
}
/** 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;
}
/** Shows a file or a folder in the file manager of the system. */
async revealPath(path: string) {
await this.ready;
try {
await this.backend!.desktop?.revealPath(path);
} catch (error) {
console.error('engine:', error);
}
}
/**
* 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;
});
}
/**