Add drag and drop to file manager

This commit is contained in:
2026-10-02 15:10:34 +02:00
parent 9a7ed0a347
commit f5b9f543c6
14 changed files with 432 additions and 466 deletions

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { fs_encode_path } from "lib/FilesystemAPI.svelte";
import { path_link, type FSNavigator } from "./FSNavigator";
import { move_target, path_link, type FSNavigator } from "./FSNavigator";
import FileMenu from "./filemanager/FileMenu.svelte";
import EditWindow from "./edit_window/EditWindow.svelte";
import { onMount } from "svelte";
@@ -29,6 +29,7 @@ onMount(() => {
href={"/d"+fs_encode_path(node.path)}
class="breadcrumb button flat"
use:path_link={{nav: nav, node: node}}
use:move_target={{nav, node: () => node}}
oncontextmenu={e => file_menu.open(node, e.target, e)}
>
{#if node.abuse_type !== undefined}

View File

@@ -1,5 +1,5 @@
import { loading_finish, loading_start } from "lib/Loading";
import { fs_get_node, fs_encode_path, fs_split_path, fs_path_from_json } from "lib/FilesystemAPI.svelte";
import { loading_finish, loading_run, loading_start } from "lib/Loading";
import { fs_get_node, fs_encode_path, fs_split_path, fs_path_from_json, fs_rename } from "lib/FilesystemAPI.svelte";
import type { FSNode, FSPath, FSPermissions, FSContext } from "lib/FilesystemAPI.svelte";
export class FSNavigator {
@@ -251,6 +251,84 @@ export class FSNavigator {
// the reactivity
this.notify_subscribers()
}
// The selection is kept by path, so it survives reloads and navigating to
// other directories. The directory views look each entry up in it
selecting = false
selection = new Map<string, FSNode>()
last_selected = ""
// Leaving selection mode clears the selection
set_selecting = (on: boolean) => {
this.selecting = on
if (!on) {
this.selection.clear()
}
this.notify_subscribers()
}
// Toggles a node. With range, everything between it and the node which was
// toggled before it is selected. Hidden nodes are left out of the range when
// the view doesn't show them
select = (node: FSNode, range = false, hidden = true) => {
const from = range ? this.children.findIndex(c => c.path === this.last_selected) : -1
if (from !== -1) {
const to = this.children.indexOf(node)
for (let i = Math.min(from, to); i <= Math.max(from, to); i++) {
if (hidden || !this.children[i].is_hidden()) {
this.selection.set(this.children[i].path, this.children[i])
}
}
} else if (!this.selection.delete(node.path)) {
this.selection.set(node.path, node)
}
this.last_selected = node.path
this.set_selecting(true)
}
// A directory can't be moved into itself, and there has to be at least one
// node which is not in dir already
can_move = (dir: string) => {
let moves = false
for (const node of this.selection.values()) {
if ((dir + "/").startsWith(node.path + "/")) {
return false
}
moves ||= node.path !== dir + "/" + node.name
}
return moves
}
move_selection = async (dir: string) => {
try {
await loading_run(() => Promise.all(
[...this.selection.values()]
.filter(node => node.path !== dir + "/" + node.name)
.map(node => fs_rename(node.path, dir + "/" + node.name))
))
} catch (err: any) {
console.error(err)
alert("Move failed: " + err.message + " (" + err.value + ")")
}
this.set_selecting(false)
await this.reload()
}
// Dragging a node drags the whole selection. The drag itself only carries a
// marker, as the selection outlives the element which is being dragged when
// the user navigates during the drag
drag_start = (e: DragEvent, node: FSNode) => {
e.dataTransfer.setData(drag_type, node.path)
e.dataTransfer.effectAllowed = "move"
if (!this.selecting) {
// The drag started the selection, so it ends it as well
e.target.addEventListener("dragend", () => this.set_selecting(false), { once: true })
}
if (!this.selection.has(node.path)) {
this.select(node)
}
}
}
const sort_children = (children: FSNode[], field: string, asc: boolean) => {
@@ -287,7 +365,55 @@ const sort_children = (children: FSNode[], field: string, asc: boolean) => {
export let global_navigator = new FSNavigator(true)
const drag_type = "application/x-nova-selection"
const is_drag = (e: DragEvent) => e.dataTransfer.types.includes(drag_type)
// Moves the selection into a directory when it's dropped on the element. The
// node is a function because an action only gets its parameters once. Bookmarks
// have no type, the server refuses the move if one turns out to be a file
export const move_target = (
el: HTMLElement,
data: { nav: FSNavigator, node: () => { path: string, type?: string } },
) => {
const accepts = (e: DragEvent) => {
if (!is_drag(e) || data.node().type === "file" || !data.nav.can_move(data.node().path)) {
return false
}
e.preventDefault()
e.stopPropagation()
return true
}
el.addEventListener("dragover", e => {
if (accepts(e)) {
el.style.boxShadow = "var(--accent_color) 0 0 0 2px inset"
}
})
el.addEventListener("dragleave", () => el.style.boxShadow = "")
el.addEventListener("drop", e => {
el.style.boxShadow = ""
if (accepts(e)) {
data.nav.move_selection(data.node().path)
}
})
}
// Clicks the element when the selection is held over it, so the navigation
// buttons work during a drag
export const drag_click = (el: HTMLElement) => {
let timer: number
el.addEventListener("dragenter", e => {
if (is_drag(e)) {
clearTimeout(timer)
timer = window.setTimeout(() => el.click(), 500)
}
})
el.addEventListener("dragleave", e => {
// Moving onto a child of the element is not leaving it
if (!el.contains(e.relatedTarget as Node)) {
clearTimeout(timer)
}
})
}
export const path_link = (node: HTMLAnchorElement, data: { nav: FSNavigator, node: FSNode }) => {
// Set url

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { fs_encode_path, fs_node_icon } from "lib/FilesystemAPI.svelte"
import type { FSNavigator } from "filesystem/FSNavigator";
import { move_target, type FSNavigator } from "filesystem/FSNavigator";
import { FileAction, type FileActionHandler } from "./FileManagerLib";
let {
@@ -24,11 +24,13 @@ let {
href={"/d"+fs_encode_path(child.path)}
onclick={e => file_event(FileAction.Click, index, e)}
oncontextmenu={e => file_event(FileAction.Context, index, e)}
ondragstart={e => file_event(FileAction.Drag, index, e)}
use:move_target={{nav, node: () => child}}
class="node"
class:node_selected={child.fm_selected}
class:node_selected={$nav.selection.has(child.path)}
class:hidden={child.name.startsWith(".") && !show_hidden}
>
<img src={fs_node_icon(child, 64, 64)} class="node_icon" class:large_icons loading="lazy" alt="icon"/>
<img src={fs_node_icon(child, 64, 64)} class="node_icon" class:large_icons loading="lazy" alt="icon" draggable="false"/>
<div class="node_name">
{child.name}
</div>

View File

@@ -5,7 +5,7 @@ const scroll_positions = new Map<string, number>()
</script>
<script lang="ts">
import { fs_rename, fs_trash, FSNode } from "lib/FilesystemAPI.svelte"
import { fs_trash } from "lib/FilesystemAPI.svelte"
import CreateDirectory from "./CreateDirectory.svelte"
import ListView from "./ListView.svelte"
import GalleryView from "./GalleryView.svelte"
@@ -13,14 +13,13 @@ import CompactView from "./CompactView.svelte"
import Button from "layout/Button.svelte";
import { formatDate } from "util/Formatting";
import { drop_target } from "lib/DropTarget"
import type { FSNavigator } from "filesystem/FSNavigator";
import { drag_click, move_target, type FSNavigator } from "filesystem/FSNavigator";
import FsUploadWidget from "filesystem/upload_widget/FSUploadWidget.svelte";
import EditWindow from "filesystem/edit_window/EditWindow.svelte";
import { FileAction, type FileActionHandler } from "./FileManagerLib";
import FileMenu from "./FileMenu.svelte";
import { loading_finish, loading_start } from "lib/Loading";
import SearchBar from "../SearchBar.svelte";
import type { GenericResponse } from "lib/NovaAPI";
import { onMount } from "svelte";
import { toolbar_store } from "wrap/HeaderStore";
@@ -42,7 +41,9 @@ let {
let large_icons = $state(localStorage.getItem("large_icons") === "true")
let uploader!: FsUploadWidget
let mode = $state("viewing")
// In selection mode a click selects. Moving is the part of selection mode where
// the user browses to the directory which the selection has to go to
let moving = $state(false)
let creating_dir = $state(false)
let show_hidden = $state(false)
let file_menu!: FileMenu
@@ -56,6 +57,15 @@ export const upload = (files: File[]) => {
// Navigation functions
const file_event: FileActionHandler = (action: FileAction, index: number, orig: Event) => {
const node = nav.children[index]
if (action === FileAction.Drag) {
// Without write access it stays a normal drag of the link
if (nav.permissions.write) {
nav.drag_start(orig as DragEvent, node)
}
return
}
orig.preventDefault()
orig.stopPropagation()
@@ -63,42 +73,34 @@ const file_event: FileActionHandler = (action: FileAction, index: number, orig:
case FileAction.Click:
creating_dir = false
if (mode === "viewing") {
nav.navigate(nav.children[index].path, true)
} else if (mode === "moving") {
if (!nav.selecting) {
nav.navigate(node.path, true)
} else if (!moving) {
nav.select(node, (orig as MouseEvent).shiftKey, show_hidden)
} else if (node.type === "dir" && !nav.selection.has(node.path)) {
// If we are moving files we can only enter directories, and only if
// they're not selected. That last requirement prevents people from
// moving a directory into itself
if (nav.children[index].type === "dir" && !nav.children[index].fm_selected) {
nav.navigate(nav.children[index].path, true)
}
} else if (mode === "selecting") {
select_node(index)
nav.navigate(node.path, true)
}
break
case FileAction.Context:
// If this is a touch event we will select the item
if (navigator.maxTouchPoints && navigator.maxTouchPoints > 0) {
select_node(index)
nav.select(node)
} else {
file_menu.open(nav.children[index], orig.target, orig)
file_menu.open(node, orig.target, orig)
}
break
case FileAction.Edit:
edit_window.edit(nav.children[index], false, "file")
break
case FileAction.Share:
creating_dir = false
edit_window.edit(nav.children[index], false, "share")
edit_window.edit(node, false, "share")
break
case FileAction.Branding:
edit_window.edit(nav.children[index], false, "branding")
break
case FileAction.Select:
select_node(index)
edit_window.edit(node, false, "branding")
break
case FileAction.Menu:
file_menu.open(nav.children[index], orig.target, orig)
file_menu.open(node, orig.target, orig)
break
}
}
@@ -114,45 +116,17 @@ const delete_selected = async () => {
try {
loading_start()
// Save all promises with deletion requests in an array
let promises: Promise<GenericResponse>[] = []
nav.children.forEach(child => {
if (!child.fm_selected) {
return
}
promises.push(fs_trash(child.path))
})
// Wait for all the promises to finish
await Promise.all(promises)
await Promise.all([...nav.selection.values()].map(node => fs_trash(node.path)))
} catch (err) {
console.log(err)
alert("Delete failed: " + err.message + " ("+err.value+")")
} finally {
viewing_mode()
nav.set_selecting(false)
loading_finish()
await nav.reload()
}
}
// Mode switches
const selecting_mode = () => {
mode = "selecting"
}
const viewing_mode = () => {
// Remove any items which we were moving
moving_items = []
// Unmark all the selected files and return to viewing mode
nav.children.forEach((child, i) => {
if (child.fm_selected) {
nav.children[i].fm_selected = false
}
})
mode = "viewing"
}
const toggle_view = () => {
if (directory_view === "list") {
directory_view = "gallery"
@@ -169,14 +143,7 @@ const toggle_large_icons = () => {
localStorage.setItem("large_icons", JSON.stringify(large_icons))
}
// Moving functions
let moving_items: FSNode[] = []
// We need to detect if shift is pressed so we can select multiple items
let shift_pressed = false
let last_selected_node = -1
const keypress = (e: KeyboardEvent) => {
const keydown = (e: KeyboardEvent) => {
if (
(document.activeElement as any).type !== undefined &&
(document.activeElement as any).type === "text"
@@ -184,102 +151,32 @@ const keypress = (e: KeyboardEvent) => {
return // Prevent shortcuts from interfering with input fields
}
if (e.key === "Shift") {
shift_pressed = e.type === "keydown"
} else if (e.type === "keydown" && e.key === "a" && e.ctrlKey) {
// CTRL + A selects all files
selecting_mode()
for (let i = 0; i < nav.children.length; i++) {
nav.children[i].fm_selected = true
if (e.key === "a" && e.ctrlKey) {
// CTRL + A selects all files which are visible
for (const child of nav.children) {
if (show_hidden || !child.is_hidden()) {
nav.selection.set(child.path, child)
}
}
nav.set_selecting(true)
e.preventDefault()
} else if (e.type === "keydown" && e.key === "Escape" && (mode !== "viewing" || creating_dir)) {
} else if (e.key === "Escape" && (nav.selecting || creating_dir)) {
// When escape is pressed we return to viewing mode
viewing_mode()
nav.set_selecting(false)
creating_dir = false
e.preventDefault()
e.stopPropagation()
}
}
const select_node = (index: number) => {
mode = "selecting"
if (shift_pressed) {
// If shift is pressed we do a range select. We select all files between
// the last selected file and the file that is being selected now
let id_low = Math.min(last_selected_node, index)
let id_high = Math.max(last_selected_node, index)
for (let i = id_low; i <= id_high; i++) {
if (i != last_selected_node) {
nav.children[i].fm_selected = !nav.children[i].fm_selected
}
}
} else {
nav.children[index].fm_selected = !nav.children[index].fm_selected
}
last_selected_node = index
}
// When the directory is reloaded we want to keep our selection, so this
// function watches the children array for changes and updates the selection
// when it changes
// Runs every time the navigator changes
$effect.pre(() => {
if (!$nav.selecting) {
moving = false
}
creating_dir = false
// Highlight the files which were previously selected
for (let i = 0; i < $nav.children.length; i++) {
for (let j = 0; j < moving_items.length; j++) {
if (moving_items[j].path === $nav.children[i].path) {
$nav.children[i].fm_selected = true
}
}
}
})
let moving_files = $state(0)
let moving_directories = $state(0)
const move_start = () => {
moving_files = 0
moving_directories = 0
moving_items = nav.children.reduce((acc, child) => {
if (child.fm_selected) {
if (child.type === "file") {
moving_files++
} else if (child.type === "dir") {
moving_directories++
}
acc.push(child)
}
return acc
}, [])
mode = "moving"
}
const move_here = async () => {
const target_dir = nav.base.path + "/"
try {
loading_start()
let promises = []
moving_items.forEach(item => {
console.log("moving", item.path, "to", target_dir + item.name)
promises.push(fs_rename(item.path, target_dir + item.name))
})
// Wait for all the promises to finish
await Promise.all(promises)
} catch (err) {
console.log(err)
alert("Move failed: " + err.message + " ("+err.value+")")
} finally {
viewing_mode()
nav.reload()
loading_finish()
}
}
// The toolbar is shown in the bar at the top of the page, under the breadcrumbs.
// Other pages don't have it
onMount(() => {
@@ -289,27 +186,30 @@ onMount(() => {
</script>
<svelte:window
onkeydown={keypress}
onkeyup={keypress}
onkeydown={keydown}
onscroll={() => scroll_positions.set(nav.base.path, window.scrollY)}
/>
{#snippet nav_buttons()}
<button onclick={navigate_back} use:drag_click title="Back">
<i class="icon">arrow_back</i>
</button>
<button onclick={() => nav.navigate_up()} use:drag_click disabled={$nav.path.length <= 1} title="Up">
<i class="icon">north</i>
</button>
<button onclick={() => nav.reload()} use:drag_click title="Refresh directory listing">
<i class="icon">refresh</i>
</button>
{/snippet}
{#snippet toolbar()}
{#if mode === "viewing"}
{#if !$nav.selecting}
<div class="toolbar">
<div class="toolbar_left">
<button onclick={() => {search_bar.open()}} title="Press / to search">
<i class="icon">search</i>
</button>
<button onclick={navigate_back} title="Back">
<i class="icon">arrow_back</i>
</button>
<button onclick={() => nav.navigate_up()} disabled={$nav.path.length <= 1} title="Up">
<i class="icon">north</i>
</button>
<button onclick={() => nav.reload()} title="Refresh directory listing">
<i class="icon">refresh</i>
</button>
{@render nav_buttons()}
{#if $nav.children.some(c => c.name === ".Trash" && c.type === "dir")}
<button
onclick={() => nav.navigate($nav.children.find(c => c.name === ".Trash").path, true)}
@@ -352,32 +252,40 @@ onMount(() => {
<Button click={() => {creating_dir = !creating_dir}} highlight={creating_dir} icon="create_new_folder" title="Make folder"/>
<button onclick={selecting_mode} title="Select and delete files">
<button onclick={() => nav.set_selecting(true)} title="Select and delete files">
<i class="icon">select_all</i>
</button>
{/if}
</div>
</div>
{:else if mode === "selecting"}
{:else}
<div class="toolbar toolbar_edit">
<Button click={viewing_mode} icon="close"/>
<div class="toolbar_spacer">Selecting files</div>
<Button click={move_start} icon="drive_file_move" label="Move"/>
<div>
<Button click={() => nav.set_selecting(false)} icon="close"/>
{@render nav_buttons()}
</div>
<div class="toolbar_spacer">
{moving ? "Moving" : "Selected"} {$nav.selection.size} items
</div>
<div>
{#if moving}
<Button click={() => {creating_dir = !creating_dir}} highlight={creating_dir} icon="create_new_folder" title="Make folder"/>
<Button
click={() => nav.move_selection(nav.base.path)}
disabled={!$nav.can_move($nav.base.path)}
highlight
icon="done"
label="Move here"
/>
{:else}
<Button click={() => {moving = true}} icon="drive_file_move" label="Move"/>
<button onclick={delete_selected} class="button_red">
<i class="icon">delete</i>
Delete
</button>
{/if}
</div>
{:else if mode === "moving"}
<div class="toolbar toolbar_edit">
<Button click={viewing_mode} icon="close"/>
<Button click={() => nav.navigate_up()} disabled={$nav.path.length <= 1} icon="north"/>
<div class="toolbar_spacer">
Moving {moving_files} files and {moving_directories} directories
</div>
<Button click={() => {creating_dir = !creating_dir}} highlight={creating_dir} icon="create_new_folder" title="Make folder"/>
<Button click={move_here} highlight icon="done" label="Move here"/>
</div>
{/if}
@@ -388,6 +296,7 @@ onMount(() => {
<div
class="container"
use:move_target={{nav, node: () => nav.base}}
use:drop_target={{
upload: (files) => upload_widget.upload_files(files),
shadow: "var(--accent_color) 0 0 10px 2px inset",

View File

@@ -1,17 +1,10 @@
export type FileEvent = {
index: number,
action: FileAction,
original: MouseEvent,
}
export enum FileAction {
Click,
Context,
Edit,
Share,
Branding,
Select,
Download,
Menu,
Drag,
}
export type FileActionHandler = (
action: FileAction,

View File

@@ -63,6 +63,9 @@ const delete_node = async () => {
<Button click={() => {dialog.close(); bookmark_add(node)}} icon="bookmark_add" label="Add bookmark"/>
{/if}
{#if $nav.permissions.write}
{#if $nav.children.includes(node)}
<Button click={() => {dialog.close(); nav.select(node)}} icon="select_all" label="Select"/>
{/if}
<Button click={() => {dialog.close(); delete_node()}} icon="delete" label="Delete"/>
{#if edit_window !== null}
<Button click={() => {dialog.close(); edit_window.edit(node, false, "file")}} icon="edit" label="Edit"/>

View File

@@ -24,16 +24,10 @@ let {
export const open = (path: string) => {
modal.show()
nav.set_selecting(false)
nav.navigate(path, false)
}
let selected_files = $derived($nav.children.reduce((acc, file) => {
if (file.fm_selected) {
acc++
}
return acc
}, 0))
// Navigation functions
const file_event: FileActionHandler = (action: FileAction, index: number, orig: Event) => {
@@ -43,20 +37,16 @@ const file_event: FileActionHandler = (action: FileAction, index: number, orig:
if (nav.children[index].type === "dir") {
nav.navigate(nav.children[index].path, true)
} else {
select_node(index)
select_node(index, orig)
}
break
case FileAction.Context:
// If this is a touch event we will select the item
if (navigator.maxTouchPoints && navigator.maxTouchPoints > 0) {
orig.preventDefault()
select_node(index)
select_node(index, orig)
}
break
case FileAction.Select:
orig.preventDefault()
nav.children[index].fm_selected = !nav.children[index].fm_selected
break
}
}
@@ -72,50 +62,16 @@ const toggle_view = () => {
localStorage.setItem("directory_view", directory_view)
}
// We need to detect if shift is pressed so we can select multiple items
let shift_pressed = false
let last_selected_node = -1
const detect_shift = (e: KeyboardEvent) => {
if (e.key === "Shift") {
shift_pressed = e.type === "keydown"
}
}
const select_node = (index: number) => {
if (select_multiple && shift_pressed) {
// If shift is pressed we do a range select. We select all files between
// the last selected file and the file that is being selected now
let id_low = Math.min(last_selected_node, index)
let id_high = Math.max(last_selected_node, index)
for (let i = id_low; i <= id_high; i++) {
if (i != last_selected_node) {
nav.children[i].fm_selected = !nav.children[i].fm_selected
}
}
} else {
// Deselect all other entries first
const select_node = (index: number, e: Event) => {
if (!select_multiple) {
for (let i = 0; i < nav.children.length; i++) {
nav.children[i].fm_selected = false
nav.selection.clear()
}
}
nav.children[index].fm_selected = !nav.children[index].fm_selected
}
last_selected_node = index
nav.select(nav.children[index], select_multiple && (e as MouseEvent).shiftKey, show_hidden)
}
let done = () => {
let selected_files: FSNode[] = []
for (let i = 0; i < nav.children.length; i++) {
if (nav.children[i].fm_selected) {
selected_files.push(nav.children[i])
}
}
if (selected_files.length > 0) {
callback(selected_files)
if (nav.selection.size > 0) {
callback([...nav.selection.values()])
}
modal.hide()
}
@@ -131,8 +87,6 @@ onMount(() => {
})
</script>
<svelte:window onkeydown={detect_shift} onkeyup={detect_shift} />
<Modal bind:this={modal} width="900px">
{#snippet header()}
<div class="header" >
@@ -147,7 +101,7 @@ onMount(() => {
</button>
<div class="title">
Selected {selected_files} files
Selected {$nav.selection.size} files
</div>
<button onclick={() => {show_hidden = !show_hidden}} title="Toggle hidden files">

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { fs_node_icon, fs_node_type, fs_encode_path } from "lib/FilesystemAPI.svelte";
import type { FSNavigator } from "filesystem/FSNavigator";
import { move_target, type FSNavigator } from "filesystem/FSNavigator";
import { FileAction, type FileActionHandler } from "./FileManagerLib";
let {
@@ -22,7 +22,9 @@ let {
href={"/d"+fs_encode_path(child.path)}
onclick={e => file_event(FileAction.Click, index, e)}
oncontextmenu={e => file_event(FileAction.Context, index, e)}
class:selected={child.fm_selected}
ondragstart={e => file_event(FileAction.Drag, index, e)}
use:move_target={{nav, node: () => child}}
class:selected={$nav.selection.has(child.path)}
class:hidden={child.name.startsWith(".") && !show_hidden}
class:large_icons
title={child.name}
@@ -33,6 +35,7 @@ let {
src={fs_node_icon(child, 256, 256)}
loading="lazy"
alt=""
draggable="false"
/>
<div class="node_name">
{child.name}

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import { formatDataVolume, formatDate, formatThousands } from "util/Formatting";
import { fs_encode_path, fs_node_icon } from "lib/FilesystemAPI.svelte"
import type { FSNavigator } from "filesystem/FSNavigator";
import { move_target, type FSNavigator } from "filesystem/FSNavigator";
import SortButton from "layout/SortButton.svelte";
import { FileAction, type FileActionHandler } from "./FileManagerLib";
import Dialog from "layout/Dialog.svelte";
@@ -15,7 +15,8 @@ let {
show_hidden = false,
large_icons = false,
hide_edit = false,
hide_branding = false
hide_branding = false,
trash = false
}: {
nav: FSNavigator
file_event: FileActionHandler
@@ -23,6 +24,7 @@ let {
large_icons?: boolean
hide_edit?: boolean
hide_branding?: boolean
trash?: boolean
} = $props();
let dialog: Dialog = $state()
@@ -78,6 +80,18 @@ onMount(load_settings)
Name
</SortButton>
</td>
{#if trash}
<td class="hide_small">
<SortButton active_field={$nav.sort_last_field} asc={$nav.sort_asc} sort_func={nav.sort_children} field="original_path">
Original location
</SortButton>
</td>
<td class="hide_small">
<SortButton active_field={$nav.sort_last_field} asc={$nav.sort_asc} sort_func={nav.sort_children} field="deletion_date">
Deleted on
</SortButton>
</td>
{/if}
{#each columns as col}
<td class="hide_small" class:hidden={!config[col.field]}>
<SortButton active_field={$nav.sort_last_field} asc={$nav.sort_asc} sort_func={nav.sort_children} field={col.field}>
@@ -95,18 +109,27 @@ onMount(load_settings)
<tr
onclick={e => file_event(FileAction.Click, index, e)}
oncontextmenu={e => file_event(FileAction.Context, index, e)}
ondragstart={e => file_event(FileAction.Drag, index, e)}
use:move_target={{nav, node: () => child}}
draggable="true"
class="node"
class:node_selected={child.fm_selected}
class:node_selected={$nav.selection.has(child.path)}
class:hidden={child.name.startsWith(".") && !show_hidden}
>
<td>
<img src={fs_node_icon(child, 64, 64)} class="node_icon" class:large_icons loading="lazy" alt="icon"/>
<img src={fs_node_icon(child, 64, 64)} class="node_icon" class:large_icons loading="lazy" alt="icon" draggable="false"/>
</td>
<td class="node_name">
<a class="title_link" href={"/d"+fs_encode_path(child.path)}>
<td class="node_name" class:half={trash}>
<a class="title_link" href={"/d"+fs_encode_path(child.path)} draggable="false">
{child.name}
</a>
</td>
{#if trash}
<td class="node_name half hide_small">{child.original_path}</td>
<td class="node_size hide_small">
{formatDate(child.deletion_date, true, true, false)}
</td>
{/if}
<td class="node_size hide_small" class:hidden={!config.modified}>
{formatDate(child.modified, true, true, false)}
</td>
@@ -213,8 +236,8 @@ td {
text-decoration: none;
}
.node.node_selected {
background-color: var(--accent_color) !important;
color: var(--accent_text_color);
background-color: var(--accent_shade) !important;
color: var(--link_color);
}
td {
vertical-align: middle;
@@ -231,9 +254,13 @@ td {
line-height: 1.2em;
word-break: break-all;
}
/* In the trash the name shares its width with the original location */
.half {
width: 50%;
}
.title_link {
text-decoration: none;
color: var(--body_text_color);
color: unset;
}
.node_size {
min-width: 5em;
@@ -265,5 +292,8 @@ td {
.hide_small {
display: none;
}
.half {
width: 100%;
}
}
</style>

View File

@@ -1,10 +1,13 @@
<script lang="ts">
import { formatDataVolume, formatDate } from "util/Formatting";
import { fs_check_response, fs_encode_path, fs_get_trash, fs_node_icon, fs_path_url, type TrashEntry } from "lib/FilesystemAPI.svelte";
import { loading_finish, loading_run, loading_start, loading_store } from "lib/Loading";
import { formatDataVolume } from "util/Formatting";
import { fs_check_response, fs_get_trash, fs_path_url, fs_rename, fs_trash, type FSNode } from "lib/FilesystemAPI.svelte";
import { loading_run } from "lib/Loading";
import { onMount } from "svelte";
import { breadcrumbs_store } from "wrap/HeaderStore";
import { toolbar_store } from "wrap/HeaderStore";
import { FSNavigator } from "filesystem/FSNavigator";
import Button from "layout/Button.svelte";
import ListView from "./ListView.svelte";
import { FileAction, type FileActionHandler } from "./FileManagerLib";
let {
nav,
@@ -14,214 +17,157 @@ let {
root_path?: string
} = $props();
let entries: TrashEntry[] = $state([]);
let error: string = $state("");
// The trash entries are not the children of the .Trash directory, so they get a
// navigator of their own for the ListView to list and sort
const trash = new FSNavigator(false)
const load = async () => {
loading_run(async () => {
try {
entries = await fs_get_trash(root_path)
error = "";
} catch (err: any) {
error = err?.message ?? JSON.stringify(err);
}
})
};
let size = $derived($trash.children.reduce(
(acc, node) => acc + (node.type === "dir" ? node.recursive_size : node.file_size), 0
))
const delete_entry = async (entry: TrashEntry) => {
if (!confirm(`Permanently delete "${entry.node.name}"?`)) return;
const load = () => loading_run(async () => {
try {
loading_start();
await fs_check_response(
await fetch(fs_path_url(entry.node.path) + "?trash", { method: "DELETE" })
);
entries = entries.filter(e => e.node.id !== entry.node.id);
trash.children = await fs_get_trash(root_path)
} catch (err: any) {
alert(err?.message ?? JSON.stringify(err));
} finally {
loading_finish();
alert("Failed to load the trash bin: " + (err.message ?? err))
}
};
trash.selecting = false
trash.selection.clear()
trash.sort_children("")
})
const restore_entry = async (entry: TrashEntry) => {
if (!entry.original_path) {
alert("Original path is not known, cannot restore this file.");
return;
const file_event: FileActionHandler = (action, index, e) => {
if (action === FileAction.Drag) {
return
} else if (action === FileAction.Context) {
// On a touch screen a long press starts selecting, like in the file
// manager. A right click is left to the browser's own menu
if (!navigator.maxTouchPoints) {
return
}
try {
loading_start();
const form = new FormData();
form.append("action", "rename");
form.append("target", "/me" + entry.original_path);
await fs_check_response(
await fetch(fs_path_url(entry.node.path), { method: "POST", body: form })
);
entries = entries.filter(e => e.node.id !== entry.node.id);
} catch (err: any) {
alert(err?.message ?? JSON.stringify(err));
} finally {
loading_finish();
trash.selecting = true
}
};
e.preventDefault()
e.stopPropagation()
if (trash.selecting) {
trash.select(trash.children[index], (e as MouseEvent).shiftKey)
} else {
nav.navigate(trash.children[index].path, true)
}
}
const selected = () => [...trash.selection.values()]
// Runs an action on all the selected entries and reloads the listing
const run_selected = async (action: (node: FSNode) => Promise<any>) => {
const results = await loading_run(() => Promise.allSettled(selected().map(action)))
const failed = results.filter(r => r.status === "rejected") as PromiseRejectedResult[]
if (failed.length !== 0) {
alert(
failed.length + " of " + results.length + " failed: " +
(failed[0].reason.message ?? failed[0].reason)
)
}
await load()
}
const restore = () => run_selected(async node => {
if (!node.original_path) {
throw new Error("the original location of " + node.name + " is not known")
}
return fs_rename(node.path, root_path + node.original_path)
})
const delete_selected = () => {
if (confirm(`Permanently delete ${selected().length} of the items in the trash?`)) {
run_selected(node => fs_trash(node.path))
}
}
const empty_trash = async () => {
if (!confirm("Permanently delete all items in the trash?")) return;
try {
loading_start();
await fs_check_response(
await fetch(fs_path_url(root_path) + "?empty_trash", { method: "DELETE" })
);
entries = [];
} catch (err: any) {
alert(err?.message ?? JSON.stringify(err));
} finally {
loading_finish();
if (!confirm("Permanently delete all items in the trash?")) {
return
}
};
try {
await loading_run(async () => fs_check_response(
await fetch(fs_path_url(root_path) + "?empty_trash", { method: "DELETE" })
))
} catch (err: any) {
alert(err.message ?? err)
}
await load()
}
onMount(() => {
breadcrumbs_store.set(breadcrumbs)
toolbar_store.set(toolbar)
load()
});
return () => toolbar_store.set(null)
})
</script>
{#snippet breadcrumbs()}
<i class="icon">delete</i>
<div>
Trash bin
{#if $loading_store === 0 && entries.length === 0}
(empty)
{:else if entries.length !== 0}
({formatDataVolume(entries.reduce(
(acc, entry) => acc + entry.node.file_size, 0
), 3)})
{/if}
{#snippet toolbar()}
{#if $trash.selecting}
<div class="toolbar toolbar_edit">
<Button icon="close" click={() => trash.set_selecting(false)}/>
<div class="toolbar_spacer">Selecting files</div>
<Button click={restore} icon="restore_from_trash" label="Restore"/>
<button onclick={delete_selected} class="button_red">
<i class="icon">delete_forever</i>
Delete
</button>
</div>
<div class="separator"></div>
<button onclick={() => window.history.back()} title="Back">
{:else}
<div class="toolbar">
<div>
<button onclick={() => history.back()} title="Back">
<i class="icon">arrow_back</i>
</button>
<button onclick={() => nav.navigate_up()} disabled={$nav.path.length <= 1} title="Up">
<button onclick={() => nav.navigate_up()} title="Up">
<i class="icon">north</i>
</button>
<button onclick={() => nav.reload()} title="Refresh directory listing">
<button onclick={load} title="Refresh trash bin">
<i class="icon">refresh</i>
</button>
{#if entries.length !== 0}
<button class="button button_red" onclick={empty_trash}>
<i class="icon">delete_forever</i>
<span>Delete all</span>
</div>
<div>Trash bin ({formatDataVolume(size, 3)})</div>
<div>
{#if $trash.children.length !== 0}
<button onclick={() => trash.set_selecting(true)} title="Select files to restore or delete">
<i class="icon">select_all</i>
</button>
<button onclick={empty_trash} class="button_red">
<i class="icon">delete_forever</i>
Delete all
</button>
{/if}
</div>
</div>
{/if}
{/snippet}
{#if error}
<p class="error">{error}</p>
{:else if entries.length > 0}
<table class="trash_table">
<thead>
<tr>
<td></td>
<td>Name</td>
<td class="hide_small">Original location</td>
<td class="hide_small">Deleted at</td>
<td class="hide_small">Size</td>
<td></td>
</tr>
</thead>
<tbody>
{#each entries as entry (entry.node.id)}
<tr class="entry">
<td class="icon_cell">
<img
src={fs_node_icon(entry.node, 32, 32)}
class="node_icon"
alt="icon"
/>
</td>
<td class="name_cell">
<a class="entry_link" href={"/d" + fs_encode_path(entry.node.path)}>
{entry.node.name}
</a>
</td>
<td class="hide_small">
{entry.original_path ? entry.original_path.slice(0, -entry.node.name.length) : "—"}
</td>
<td class="hide_small">
{entry.deletion_date ? formatDate(entry.deletion_date) : "—"}
</td>
<td class="hide_small">
{#if entry.node.type === "file"}
{formatDataVolume(entry.node.file_size, 3)}
{/if}
</td>
<td>
<div class="row_actions">
<button title="Restore to original location" onclick={() => restore_entry(entry)}>
<i class="icon">restore_from_trash</i>
</button>
<button class="button button_red" title="Delete permanently" onclick={() => delete_entry(entry)}>
<i class="icon">delete_forever</i>
</button>
</div>
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
<ListView nav={trash} file_event={file_event} show_hidden hide_edit hide_branding trash/>
<style>
.separator {
flex: 1 1 auto;
}
.error {
color: var(--red_color);
}
.trash_table {
width: 100%;
border-collapse: collapse;
background: var(--body_background);
border-radius: 8px;
}
.trash_table td {
padding: 0;
}
.entry {
cursor: default;
/* Same as the toolbar of the file manager */
.toolbar {
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: space-between;
align-items: center;
border-top: 1px solid var(--separator);
}
.entry:hover {
background: var(--input_hover_background);
}
.icon_cell {
width: 2.5em;
}
.node_icon {
display: block;
width: 2em;
height: 2em;
object-fit: contain;
}
.name_cell {
word-break: break-all;
}
.entry_link {
color: var(--body_text_color);
text-decoration: none;
}
.entry_link:hover {
text-decoration: underline;
}
.row_actions {
.toolbar > * {
flex: 0 0 content;
display: flex;
gap: 0.25em;
flex-direction: row;
}
@media (max-width: 600px) {
.hide_small {
display: none;
}
.toolbar_spacer {
flex: 1 1 auto;
text-align: center;
}
.toolbar_edit {
background-color: var(--green_tint);
}
</style>

View File

@@ -12,13 +12,16 @@ export type Bookmark = {
label: string,
}
export let bookmarks_failed = writable(false)
export let bookmarks_store = writable<Bookmark[]>(
[],
(set: (value: Bookmark[]) => void) => {
bookmarks_get().then((bm: Bookmark[]) => {
set(bm)
}).catch((err: any) => {
alert("Could not fetch bookmarks:\n" + JSON.stringify(err))
console.error("Could not fetch bookmarks", err)
bookmarks_failed.set(true)
})
},
)

View File

@@ -52,10 +52,9 @@ export class FSNode {
user_permissions?: { [index: string]: FSPermissions }
password_permissions?: { [index: string]: FSPermissions }
// Added by us
// Indicates whether the file is selected in the file manager
fm_selected?: boolean = $state(false)
// Only set on the nodes returned by fs_get_trash
original_path?: string
deletion_date?: string
is_shared = (): boolean => {
return (this.link_permissions !== undefined && this.link_permissions.read === true) ||
@@ -251,22 +250,15 @@ export const fs_trash = async (path: string) => {
) as GenericResponse
}
export type TrashEntry = {
node: FSNode;
original_path: string;
deletion_date: string;
};
export const fs_get_trash = async (path: string) => {
let resp = await fs_check_response(
const resp = await fs_check_response(
await fetch(fs_path_url(path) + "?trash")
) as TrashEntry[]
resp.forEach((entry, index) => {
resp[index].node = Object.assign(new FSNode(), entry.node)
})
) as { node: FSNode, original_path: string, deletion_date: string }[]
return resp
};
// The trash metadata is moved into the node, so the trash can be listed
// like the children of a directory
return resp.map(({ node, ...info }) => Object.assign(new FSNode(), node, info))
}
export const fs_search = async (path: string, term: string, limit = 10) => {
return await fs_check_response(

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import { bookmarks_save, bookmarks_store } from "lib/Bookmarks";
import { bookmarks_failed, bookmarks_save, bookmarks_store } from "lib/Bookmarks";
import { fs_encode_path, fs_thumbnail_url } from "lib/FilesystemAPI.svelte";
import { global_navigator, move_target } from "filesystem/FSNavigator";
import { highlight_current_page } from "lib/HighlightCurrentPage";
import MenuEntry from "./MenuEntry.svelte";
import { flip } from "svelte/animate";
@@ -94,7 +95,8 @@ const drop = (e: DragEvent, drop_idx: number) => {
ondragstart={e => drag(e, index)}
ondrop={e => drop(e, index)}
ondragover={e => {e.preventDefault(); e.stopPropagation()}}
ondragenter={() => hovering = index}
ondragenter={() => {if (editing) hovering = index}}
use:move_target={{nav: global_navigator, node: () => bookmark}}
ondragend={() => {hovering = -1}}
class:highlight={hovering === index}
animate:flip={{duration: 300}}
@@ -122,6 +124,8 @@ const drop = (e: DragEvent, drop_idx: number) => {
{/each}
{/snippet}
</MenuEntry>
{:else if $bookmarks_failed}
<div>Could not load bookmarks</div>
{/if}
<style>

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { global_navigator } from "filesystem/FSNavigator";
import { global_navigator, move_target } from "filesystem/FSNavigator";
import { fs_encode_path, fs_node_icon, FSNode } from "lib/FilesystemAPI.svelte";
import { highlight_current_page } from "lib/HighlightCurrentPage";
import { onMount } from "svelte";
@@ -39,7 +39,7 @@ onMount(() => {
{#snippet body()}
{#each $global_navigator.path.slice(0, $global_navigator.path.length-1) as node (node.id)}
{#if node.type === "dir"}
<div class="row">
<div class="row" use:move_target={{nav: global_navigator, node: () => node}}>
<a class="button" href="/d{fs_encode_path(node.path)}" title="{node.name}">
<img class="thumbnail" src="{fs_node_icon(node, 32, 32)}" alt="{node.name}"/>
<span>{node.name}</span>
@@ -64,7 +64,7 @@ onMount(() => {
{#snippet body()}
{#each siblings as node (node.id)}
{#if !node.is_hidden()}
<div class="row">
<div class="row" use:move_target={{nav: global_navigator, node: () => node}}>
<a class="button" href="/d{fs_encode_path(node.path)}" title="{node.name}" use:highlight_current_page>
<img class="thumbnail" src="{fs_node_icon(node, 32, 32)}" alt="{node.name}"/>
<span>{node.name}</span>