462 lines
14 KiB
TypeScript
462 lines
14 KiB
TypeScript
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_path_url, fs_rename } from "lib/FilesystemAPI.svelte";
|
|
import type { FSNode, FSPath, FSPermissions, FSContext } from "lib/FilesystemAPI.svelte";
|
|
|
|
export class FSNavigator {
|
|
// Parts of the raw API response
|
|
path: Array<FSNode> = []
|
|
base_index: number = 0
|
|
children: Array<FSNode> = []
|
|
permissions: FSPermissions = <FSPermissions>{}
|
|
context: FSContext = <FSContext>{}
|
|
|
|
// base equals path[base_index]. It's updated every time the path updates
|
|
base: FSNode = <FSNode>{}
|
|
|
|
// Initialized will be set to true when the first file or directory is loaded
|
|
initialized = false
|
|
shuffle = false
|
|
|
|
// Whether navigation events should update the browser history
|
|
history_enabled = true
|
|
|
|
constructor(history_enabled = true) {
|
|
this.history_enabled = history_enabled
|
|
}
|
|
|
|
// The FSNavigator acts as a svelte store. This allows for DOM reactivity.
|
|
// This works by implementing the store contract:
|
|
// https://svelte.dev/docs/svelte-components#script-4-prefix-stores-with-$-to-access-their-values
|
|
subscribers: Array<(nav: FSNavigator) => void> = []
|
|
subscribe = (sub_func: (nav: FSNavigator) => void) => {
|
|
// Immediately return the current value
|
|
sub_func(this)
|
|
|
|
this.subscribers.push(sub_func)
|
|
|
|
// Return the unsubscribe function
|
|
return () => this.subscribers.splice(this.subscribers.indexOf(sub_func), 1)
|
|
}
|
|
notify_subscribers = () => {
|
|
for (let i = 0; i < this.subscribers.length; i++) {
|
|
this.subscribers[i](this)
|
|
}
|
|
}
|
|
|
|
last_requested_path: string = ""
|
|
navigation_error: string = ""
|
|
navigate = async (path: string, push_history: boolean) => {
|
|
if (path === this.last_requested_path) {
|
|
console.debug("FSNavigator: Requested path ", path, " is equal to current path. Debouncing")
|
|
return
|
|
}
|
|
this.last_requested_path = path
|
|
|
|
if (path[0] !== "/") {
|
|
path = "/" + path
|
|
}
|
|
|
|
console.debug("FSNavigator: Navigating to path", path, push_history)
|
|
|
|
try {
|
|
loading_start()
|
|
|
|
// The server embeds the node which the page was opened on. It's only
|
|
// valid for the first navigation, which is to that same page
|
|
const resp = window.initial_node !== undefined
|
|
? fs_path_from_json(window.initial_node)
|
|
: await fs_get_node(path)
|
|
window.initial_node = undefined
|
|
this.open_node(resp, push_history)
|
|
} catch (err: any) {
|
|
if (err.value !== undefined && err.value === "path_not_found") {
|
|
if (path !== this.path[0].path && path !== "/" && path !== "") {
|
|
console.debug("Path", path, "was not found, trying to navigate to parent")
|
|
this.navigate(fs_split_path(path).parent, push_history)
|
|
}
|
|
} else if (err.value !== undefined) {
|
|
this.navigation_error = err.value
|
|
this.notify_subscribers()
|
|
} else {
|
|
console.error(err)
|
|
alert("Error: " + err)
|
|
}
|
|
} finally {
|
|
loading_finish()
|
|
}
|
|
}
|
|
|
|
navigate_up = async () => {
|
|
if (this.path.length > 1) {
|
|
await this.navigate(this.path[this.path.length - 2].path, false)
|
|
}
|
|
}
|
|
|
|
reload = async () => {
|
|
this.last_requested_path = ""
|
|
await this.navigate(this.base.path, false)
|
|
}
|
|
|
|
open_node = (node: FSPath, push_history: boolean) => {
|
|
// Update window title and navigation history. If push_history is false
|
|
// we still replace the URL with replaceState. This way the user is not
|
|
// greeted to a 404 page when refreshing after renaming a file
|
|
if (this.history_enabled) {
|
|
window.document.title = node.path[node.base_index].name + " / Nova"
|
|
const url = "/d" + fs_encode_path(node.path[node.base_index].path) + window.location.hash
|
|
if (push_history) {
|
|
window.history.pushState({}, window.document.title, url)
|
|
} else {
|
|
window.history.replaceState({}, window.document.title, url)
|
|
}
|
|
}
|
|
|
|
// If the new node is a child of the previous node we save the parent's
|
|
// children array
|
|
if (node.path.length > 1 && node.path[node.path.length - 2].path === this.base.path) {
|
|
console.debug("Current parent path and new node path match. Saving siblings")
|
|
|
|
this.cached_siblings_path = node.path[node.path.length - 1].path
|
|
this.cached_siblings = this.children
|
|
}
|
|
|
|
// Sort directory children
|
|
sort_children(node.children, this.sort_last_field, this.sort_asc)
|
|
|
|
// Update shared state
|
|
this.path = node.path
|
|
this.base_index = node.base_index
|
|
this.base = node.path[node.base_index]
|
|
this.children = node.children
|
|
this.permissions = node.permissions
|
|
this.context = node.context
|
|
this.navigation_error = "" // Clear the error value
|
|
this.initialized = true
|
|
|
|
console.debug("Opened node", node)
|
|
|
|
// Signal to our subscribers that the new node is loaded. This triggers
|
|
// the reactivity
|
|
this.notify_subscribers()
|
|
}
|
|
|
|
// These are used to navigate forward and backward within a directory (using
|
|
// the previous and next buttons on the toolbar). The cached siblings will
|
|
// be used so that we don't need to make an extra request to the parent
|
|
// directory. The siblings_path variable is used to verify that the parent
|
|
// directory is still the same. If it's different the siblings array is not
|
|
// used
|
|
cached_siblings_path = ""
|
|
cached_siblings: Array<FSNode> | null = null
|
|
|
|
get_siblings = async () => {
|
|
// If this node is a filesystem root then there are no siblings
|
|
if (this.path.length < 2) {
|
|
return []
|
|
}
|
|
|
|
// Check if we already have siblings cached
|
|
if (
|
|
this.cached_siblings === null ||
|
|
this.cached_siblings_path !== this.path[this.path.length - 2].path
|
|
) {
|
|
console.debug("Cached siblings not available. Fetching new")
|
|
const resp = await fs_get_node(this.path[this.path.length - 2].path)
|
|
|
|
// Sort directory children to make sure the order is consistent
|
|
sort_children(resp.children, this.sort_last_field, this.sort_asc)
|
|
|
|
// Save new siblings in navigator state
|
|
this.cached_siblings_path = this.path[this.path.length - 2].path
|
|
this.cached_siblings = resp.children
|
|
}
|
|
|
|
return this.cached_siblings
|
|
}
|
|
|
|
// Opens a sibling of the currently open file. The offset is relative to the
|
|
// file which is currently open. Give a positive number to move forward and
|
|
// a negative number to move backward
|
|
open_sibling = async (offset: number) => {
|
|
if (this.path.length <= 1) {
|
|
return
|
|
}
|
|
|
|
let siblings: Array<FSNode>
|
|
try {
|
|
loading_start()
|
|
siblings = await this.get_siblings()
|
|
} catch (err) {
|
|
console.error(err)
|
|
alert(err)
|
|
return
|
|
} finally {
|
|
loading_finish()
|
|
}
|
|
|
|
let next_sibling: FSNode | null = null
|
|
|
|
if (this.shuffle) {
|
|
// Shuffle is on, pick a random sibling
|
|
for (let i = 0; i < 10; i++) {
|
|
next_sibling = siblings[Math.floor(Math.random() * siblings.length)]
|
|
|
|
// If we selected the same sibling we already have open we try
|
|
// again. Else we break the loop
|
|
if (next_sibling.name !== this.base.name) {
|
|
break
|
|
}
|
|
}
|
|
} else {
|
|
// Loop over the parent node's children to find the one which is
|
|
// currently open. Then, if possible, we save the one which comes before
|
|
// or after it
|
|
for (let i = 0; i < siblings.length; i++) {
|
|
if (
|
|
siblings[i].name === this.base.name &&
|
|
i + offset >= 0 && // Prevent underflow
|
|
i + offset < siblings.length // Prevent overflow
|
|
) {
|
|
next_sibling = siblings[i + offset]
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
// If we found a sibling we open it
|
|
if (next_sibling !== null) {
|
|
console.debug("Opening sibling", next_sibling.path)
|
|
await this.navigate(next_sibling.path, true)
|
|
} else {
|
|
console.debug("No siblings found")
|
|
}
|
|
}
|
|
|
|
sort_last_field: string = "name"
|
|
sort_asc: boolean = true
|
|
sort_children = (field: string) => {
|
|
// If the field is the same as last time we invert the direction
|
|
if (field !== "" && field === this.sort_last_field) {
|
|
this.sort_asc = !this.sort_asc
|
|
}
|
|
// If the field is empty we reuse the last field
|
|
if (field === "") {
|
|
field = this.sort_last_field
|
|
}
|
|
this.sort_last_field = field
|
|
|
|
sort_children(this.children, field, this.sort_asc)
|
|
|
|
// Signal to our subscribers that the order has changed. This triggers
|
|
// 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)
|
|
}
|
|
|
|
// Downloads the selection, or the open node if nothing is selected. More
|
|
// than one node is zipped by the closest directory which they are all in
|
|
download = () => {
|
|
const nodes = [...this.selection.values()]
|
|
if (nodes.length < 2) {
|
|
return (nodes[0] ?? this.base).download()
|
|
}
|
|
|
|
let dir = nodes[0].path
|
|
do {
|
|
dir = dir.slice(0, dir.lastIndexOf("/"))
|
|
} while (!nodes.every(n => n.path.startsWith(dir + "/")))
|
|
|
|
const a = document.createElement("a")
|
|
a.href = fs_path_url(dir) + "?bulk_download&" + new URLSearchParams(
|
|
nodes.map(n => ["include", n.path.slice(dir.length + 1)])
|
|
)
|
|
a.download = ""
|
|
a.dispatchEvent(new MouseEvent("click"))
|
|
}
|
|
|
|
// 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) => {
|
|
console.log("Sorting directory children by", field, "asc", asc)
|
|
children.sort((a, b) => {
|
|
// Sort directories before files
|
|
if (a.type !== b.type) {
|
|
return a.type === "dir" ? -1 : 1
|
|
}
|
|
|
|
// If sort is descending we swap the arguments
|
|
if (asc === false) {
|
|
[a, b] = [b, a]
|
|
}
|
|
|
|
if (field === "size") {
|
|
// When doing a size comparison we also take the recursive directory
|
|
// size into account
|
|
return (a.type === "dir" ? a.recursive_size : a.file_size) -
|
|
(b.type === "dir" ? b.recursive_size : b.file_size)
|
|
} else if (a[field] === b[field]) {
|
|
// If the two values are equal then we force sort by name, since
|
|
// names are always unique
|
|
return a.name.localeCompare(b.name, undefined, { numeric: true })
|
|
} else if (typeof (a[field]) === "number") {
|
|
// Sort ints from high to low
|
|
return a[field] - b[field]
|
|
} else {
|
|
// Sort strings alphabetically
|
|
return a[field].localeCompare(b[field], undefined, { numeric: true })
|
|
}
|
|
})
|
|
}
|
|
|
|
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
|
|
node.href = "/d" + fs_encode_path(data.node.path)
|
|
|
|
// Override click handler
|
|
const click = (e: MouseEvent) => {
|
|
// Modified clicks open the link in a new tab or window, let the browser
|
|
// handle those
|
|
if (e.button !== 0 || e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) {
|
|
return
|
|
}
|
|
|
|
e.preventDefault()
|
|
data.nav.navigate(data.node.path, true)
|
|
}
|
|
|
|
node.addEventListener("click", click)
|
|
return {
|
|
destroy() {
|
|
node.removeEventListener("click", click)
|
|
}
|
|
}
|
|
}
|