Rewritten WS handling, file selections. Minor UI fixes.

This commit is contained in:
Leo Vasanko 2023-11-07 14:48:48 +00:00
parent fc1fb3ea5d
commit d36605cd5b
8 changed files with 279 additions and 289 deletions

View File

@ -15,14 +15,8 @@
import { RouterView } from 'vue-router'
import type { ComputedRef } from 'vue'
import type HeaderMain from '@/components/HeaderMain.vue'
import { onMounted, onUnmounted, ref, watchEffect } from 'vue'
import createWebSocket from '@/repositories/WS'
import {
url_document_watch_ws,
url_document_upload_ws,
DocumentHandler,
DocumentUploadHandler
} from '@/repositories/Document'
import { onMounted, onUnmounted, ref } from 'vue'
import { watchConnect, watchDisconnect } from '@/repositories/WS'
import { useDocumentStore } from '@/stores/documents'
import { computed } from 'vue'
@ -41,23 +35,10 @@ const path: ComputedRef<Path> = computed(() => {
pathList
}
})
onMounted(watchConnect)
onUnmounted(watchDisconnect)
// Update human-readable x seconds ago messages from mtimes
setInterval(documentStore.updateModified, 1000)
watchEffect(() => {
const documentHandler = new DocumentHandler()
const documentUploadHandler = new DocumentUploadHandler()
const wsWatch = createWebSocket(
url_document_watch_ws,
documentHandler.handleWebSocketMessage
)
const wsUpload = createWebSocket(
url_document_upload_ws,
documentUploadHandler.handleWebSocketMessage
)
documentStore.wsWatch = wsWatch
documentStore.wsUpload = wsUpload
})
const headerMain = ref<typeof HeaderMain | null>(null)
let vert = 0
let timer: any = null

View File

@ -197,6 +197,9 @@ main {
padding-bottom: 3em; /* convenience space on the bottom */
overflow-y: scroll;
}
.spacer { flex-grow: 1 }
.smallgap { margin-left: 2em }
[data-tooltip]:hover:after {
z-index: 101;
content: attr(data-tooltip);
@ -206,7 +209,7 @@ main {
padding: .5rem 1rem;
border-radius: 3rem 0 3rem 0;
box-shadow: 0 0 1rem var(--accent-color);
transform: translate(calc(1rem + -50%), 100%);
transform: translate(calc(1rem + -50%), 150%);
background-color: var(--accent-color);
color: var(--primary-color);
white-space: pre;

View File

@ -59,17 +59,13 @@
<template
v-for="doc of sorted(props.documents as Document[])"
:key="doc.key">
<tr v-if="doc.loc !== prevloc && ((prevloc = doc.loc) || true)">
<tr class="folder-change" v-if="doc.loc !== prevloc && ((prevloc = doc.loc) || true)">
<th colspan="5"><BreadCrumb :path="doc.loc ? doc.loc.split('/') : []" /></th>
</tr>
<tr
:id="`file-${doc.key}`"
:class="{
file: doc.type === 'file',
folder: doc.type === 'folder',
cursor: cursor === doc
}"
:class="{ file: !doc.dir, folder: doc.dir, cursor: cursor === doc }"
@click="cursor = cursor === doc ? null : doc"
@contextmenu.prevent="contextMenu($event, doc)"
>
@ -104,7 +100,7 @@
@focus.stop="cursor = doc"
@blur="ev => { if (!editing) cursor = null }"
@keyup.left="router.back()"
@keyup.right.stop="ev => { if (doc.type === 'folder') (ev.target as HTMLElement).click() }"
@keyup.right.stop="ev => { if (doc.dir) (ev.target as HTMLElement).click() }"
>{{ doc.name }}</a
>
<button
@ -133,7 +129,7 @@
</td>
</tr>
</template>
<tr>
<tr class="summary">
<td colspan="3" class="right">{{props.documents.length}} items shown:</td>
<td class="size right">{{ formatSize(props.documents.reduce((a, b) => a + b.size, 0)) }}</td>
<td class="menu"></td>
@ -148,7 +144,7 @@ import { ref, computed, watchEffect, onBeforeUpdate } from 'vue'
import { useDocumentStore } from '@/stores/documents'
import type { Document } from '@/repositories/Document'
import FileRenameInput from './FileRenameInput.vue'
import createWebSocket from '@/repositories/WS'
import { connect, controlUrl } from '@/repositories/WS'
import { collator, formatSize, formatUnixDate } from '@/utils'
import { useRouter } from 'vue-router'
@ -163,14 +159,15 @@ const documentStore = useDocumentStore()
const router = useRouter()
const url_for = (doc: Document) => {
const p = doc.loc ? `${doc.loc}/${doc.name}` : doc.name
return doc.type === 'folder' ? `#/${p}/` : `/files/${p}`
return doc.dir ? `#/${p}/` : `/files/${p}`
}
const cursor = ref<Document | null>(null)
// File rename
const editing = ref<Document | null>(null)
const rename = (doc: Document, newName: string) => {
const oldName = doc.name
const control = createWebSocket('/api/control', (ev: MessageEvent) => {
const control = connect(controlUrl, {
message(ev: MessageEvent) {
const msg = JSON.parse(ev.data)
if ('error' in msg) {
console.error('Rename failed', msg.error.message, msg.error)
@ -178,6 +175,7 @@ const rename = (doc: Document, newName: string) => {
} else {
console.log('Rename succeeded', msg)
}
}
})
control.onopen = () => {
control.send(
@ -204,6 +202,7 @@ defineExpose({
modified: formatUnixDate(now),
haystack: '',
}
console.log("New")
},
toggleSelectAll() {
console.log('Select')
@ -289,7 +288,16 @@ watchEffect(() => {
}
})
const mkdir = (doc: Document, name: string) => {
const control = createWebSocket('/api/control', (ev: MessageEvent) => {
const control = connect(controlUrl, {
open() {
control.send(
JSON.stringify({
op: 'mkdir',
path: `${doc.loc}/${name}`
})
)
},
message(ev: MessageEvent) {
const msg = JSON.parse(ev.data)
if ('error' in msg) {
console.error('Mkdir failed', msg.error.message, msg.error)
@ -298,15 +306,8 @@ const mkdir = (doc: Document, name: string) => {
console.log('mkdir', msg)
router.push(`/${doc.loc}/${name}/`)
}
})
control.onopen = () => {
control.send(
JSON.stringify({
op: 'mkdir',
path: `${doc.loc}/${name}`
})
)
}
})
doc.name = name // We should get an update from watch but this is quicker
}
@ -499,6 +500,9 @@ tbody .selection input {
font-size: 3rem;
color: var(--accent-color);
}
.folder-change {
margin-left: -.5rem;
}
.loc {
color: #888;
}

View File

@ -1,26 +1,3 @@
<script setup lang="ts">
import { useDocumentStore } from '@/stores/documents'
import { ref, nextTick } from 'vue'
const documentStore = useDocumentStore()
const showSearchInput = ref<boolean>(false)
const search = ref<HTMLInputElement | null>()
const searchButton = ref<HTMLButtonElement | null>()
const toggleSearchInput = () => {
showSearchInput.value = !showSearchInput.value
nextTick(() => {
const input = search.value
if (input) input.focus()
//else if (searchButton.value) document.querySelector('.breadcrumb')!.focus()
})
}
defineExpose({
toggleSearchInput
})
</script>
<template>
<nav class="headermain">
<div class="buttons">
@ -39,16 +16,46 @@ defineExpose({
v-model="documentStore.search"
placeholder="Search words"
class="margin-input"
@blur="() => { if (documentStore.search === '') toggleSearchInput() }"
@keyup.esc="toggleSearchInput"
@keyup.escape="closeSearch"
/>
</template>
<SvgButton ref="searchButton" name="find" @click="toggleSearchInput" />
<SvgButton ref="searchButton" name="find" @click.prevent="toggleSearchInput" />
<SvgButton name="cog" @click="console.log('settings menu')" />
</div>
</nav>
</template>
<script setup lang="ts">
import { useDocumentStore } from '@/stores/documents'
import { ref, nextTick } from 'vue'
const documentStore = useDocumentStore()
const showSearchInput = ref<boolean>(false)
const search = ref<HTMLInputElement | null>()
const searchButton = ref<HTMLButtonElement | null>()
const closeSearch = () => {
if (!showSearchInput.value) return // Already closing
showSearchInput.value = false
documentStore.search = ''
const breadcrumb = document.querySelector('.breadcrumb') as HTMLElement
breadcrumb.focus()
}
const toggleSearchInput = () => {
showSearchInput.value = !showSearchInput.value
if (!showSearchInput.value) return closeSearch()
nextTick(() => {
const input = search.value
if (input) input.focus()
})
}
defineExpose({
toggleSearchInput,
closeSearch,
})
</script>
<style scoped>
.buttons {
padding: 0;
@ -60,12 +67,6 @@ defineExpose({
.buttons > * {
flex-shrink: 1;
}
.spacer {
flex-grow: 1;
}
.smallgap {
margin-left: 2em;
}
input[type='search'] {
background: var(--primary-background);
color: var(--primary-color);

View File

@ -11,7 +11,7 @@
</template>
<script setup lang="ts">
import createWebSocket from '@/repositories/WS'
import {connect, controlUrl} from '@/repositories/WS'
import { useDocumentStore } from '@/stores/documents'
import { computed } from 'vue'
import type { SelectedItems } from '@/repositories/Document'
@ -26,21 +26,27 @@ const op = (op: string, dst?: string) => {
const sel = documentStore.selectedFiles
const msg = {
op,
sel: sel.ids.filter(id => sel.selected.has(id)).map(id => sel.fullpath[id])
sel: sel.keys.map(key => {
const doc = sel.docs[key]
return doc.loc ? `${doc.loc}/${doc.name}` : doc.name
})
}
// @ts-ignore
if (dst !== undefined) msg.dst = dst
const control = createWebSocket('/api/control', ev => {
const control = connect(controlUrl, {
message(ev: WebSocmetMessageEvent) {
const res = JSON.parse(ev.data)
if ('error' in res) {
console.error('Control socket error', msg, res.error)
documentStore.error = res.error.message
return
} else if (res.status === 'ack') {
console.log('Control ack OK', res)
control.close()
documentStore.selected.clear()
return
} else console.log('Unknown control respons', msg, res)
} else console.log('Unknown control response', msg, res)
}
})
control.onopen = () => {
control.send(JSON.stringify(msg))
@ -57,21 +63,15 @@ const linkdl = (href: string) => {
const filesystemdl = async (sel: SelectedItems, handle: FileSystemDirectoryHandle) => {
let hdir = ''
let h = handle
let filelist = []
for (const id of sel.ids) {
filelist.push(sel.relpath[id])
}
console.log('Downloading to filesystem', filelist)
for (const id of sel.ids) {
const rel = sel.relpath[id]
const url = sel.url[id] // Only files, not folders
console.log('Downloading to filesystem', sel.recursive)
for (const [rel, full, doc] of sel.recursive) {
// Create any missing directories
if (!rel.startsWith(hdir)) {
if (hdir && !rel.startsWith(hdir + '/')) {
hdir = ''
h = handle
}
const r = rel.slice(hdir.length)
for (const dir of r.split('/').slice(0, url ? -1 : undefined)) {
for (const dir of r.split('/').slice(0, doc.dir ? undefined : -1)) {
hdir += `${dir}/`
try {
h = await h.getDirectoryHandle(dir.normalize('NFC'), { create: true })
@ -81,17 +81,18 @@ const filesystemdl = async (sel: SelectedItems, handle: FileSystemDirectoryHandl
}
console.log('Created', hdir)
}
if (!url) continue // Target was a folder and was created
if (doc.dir) continue // Target was a folder and was created
const name = rel.split('/').pop()!.normalize('NFC')
// Download file
let fileHandle
try {
fileHandle = await h.getFileHandle(name, { create: true })
} catch (error) {
console.error('Failed to create file', hdir + name, error)
console.error('Failed to create file', rel, full, hdir + name, error)
return
}
const writable = await fileHandle.createWritable()
const url = `/files/${rel}`
console.log('Fetching', url)
const res = await fetch(url)
if (!res.ok)
@ -109,16 +110,16 @@ const filesystemdl = async (sel: SelectedItems, handle: FileSystemDirectoryHandl
const download = async () => {
const sel = documentStore.selectedFiles
console.log('Download', sel)
if (sel.selected.size === 0) {
console.warn('Attempted download but no files found. Missing:', sel.missing)
if (sel.keys.length === 0) {
console.warn('Attempted download but no files found. Missing selected keys:', sel.missing)
documentStore.selected.clear()
return
}
// Plain old a href download if only one file (ignoring any folders)
const urls = Object.values(sel.url)
if (urls.length === 1) {
const files = sel.recursive.filter(([rel, full, doc]) => !doc.dir)
if (files.length === 1) {
documentStore.selected.clear()
return linkdl(urls[0] as string)
return linkdl(`/files/${files[0][1]}`)
}
// Use FileSystem API if multiple files and the browser supports it
if ('showDirectoryPicker' in window) {
@ -137,7 +138,7 @@ const download = async () => {
}
}
// Otherwise, zip and download
linkdl(`/zip/${Array.from(sel.selected).join('+')}/download.zip`)
linkdl(`/zip/${Array.from(sel.keys).join('+')}/download.zip`)
documentStore.selected.clear()
}
</script>

View File

@ -1,6 +1,3 @@
import { useDocumentStore } from '@/stores/documents'
import createWebSocket from './WS'
export type FUID = string
export type Document = {
@ -13,7 +10,7 @@ export type Document = {
mtime: number
modified: string
haystack: string
dir?: DirList
dir: boolean
}
export type errorEvent = {
@ -52,111 +49,8 @@ export type UpdateEntry = {
// Helper structure for selections
export interface SelectedItems {
selected: Set<FUID>
keys: FUID[]
docs: Record<FUID, Document>
recursive: Array<[string, string, Document]>
missing: Set<FUID>
rootdir: DirList
entries: Record<FUID, FileEntry | DirEntry>
fullpath: Record<FUID, string>
relpath: Record<FUID, string>
url: Record<FUID, string>
ids: FUID[]
}
export const url_document_watch_ws = '/api/watch'
export const url_document_upload_ws = '/api/upload'
export const url_document_get = '/files'
export class DocumentHandler {
constructor(private store = useDocumentStore()) {
this.handleWebSocketMessage = this.handleWebSocketMessage.bind(this)
}
handleWebSocketMessage(event: MessageEvent) {
const msg = JSON.parse(event.data)
if ('error' in msg) {
if (msg.error.code === 401) {
this.store.user.isLoggedIn = false
this.store.user.isOpenLoginModal = true
} else {
this.store.error = msg.error.message
}
// The server closes the websocket after errors, so we need to reopen it
setTimeout(() => {
this.store.wsWatch = createWebSocket(
url_document_watch_ws,
this.handleWebSocketMessage
)
}, 1000)
}
switch (true) {
case !!msg.root:
this.handleRootMessage(msg)
break
case !!msg.update:
this.handleUpdateMessage(msg)
break
case !!msg.space:
console.log('Watch space', msg.space)
break
case !!msg.error:
this.handleError(msg)
break
default:
}
}
private handleRootMessage({ root }: { root: DirEntry }) {
console.log('Watch root', root)
if (this.store) {
this.store.user.isLoggedIn = true
this.store.updateRoot(root)
}
}
private handleUpdateMessage(updateData: { update: UpdateEntry[] }) {
console.log('Watch update', updateData.update)
let node: DirEntry = this.store.root
for (const elem of updateData.update) {
if (elem.deleted) {
delete node.dir[elem.name]
break // Deleted elements can't have further children
}
if (elem.name !== undefined) {
// @ts-ignore
node = node.dir[elem.name] ||= {}
}
if (elem.key !== undefined) node.key = elem.key
if (elem.size !== undefined) node.size = elem.size
if (elem.mtime !== undefined) node.mtime = elem.mtime
if (elem.dir !== undefined) node.dir = elem.dir
}
this.store.updateRoot()
}
private handleError(msg: errorEvent) {
if (msg.error.code === 401) {
this.store.user.isOpenLoginModal = true
this.store.user.isLoggedIn = false
return
}
}
}
export class DocumentUploadHandler {
constructor(private store = useDocumentStore()) {
this.handleWebSocketMessage = this.handleWebSocketMessage.bind(this)
}
handleWebSocketMessage(event: MessageEvent) {
const msg = JSON.parse(event.data)
switch (true) {
case !!msg.written:
this.handleWrittenMessage(msg)
break
default:
}
}
private handleWrittenMessage(msg: { written: number }) {
// if (this.store && this.store.root) this.store.root = root;
console.log('Written message', msg.written)
}
}

View File

@ -1,8 +1,118 @@
function createWebSocket(url: string, eventHandler: (event: MessageEvent) => void) {
const urlObject = new URL(url, location.origin.replace(/^http/, 'ws'))
const webSocket = new WebSocket(urlObject)
webSocket.onmessage = eventHandler
import { useDocumentStore } from "@/stores/documents"
import type { DirEntry, UpdateEntry, errorEvent } from "./Document"
export const controlUrl = '/api/control'
export const uploadUrl = '/api/upload'
export const watchUrl = '/api/watch'
let tree = null as DirEntry | null
let reconnectDuration = 500
let wsWatch = null as WebSocket | null
export const connect = (path: string, handlers: Partial<Record<keyof WebSocketEventMap, any>>) => {
const webSocket = new WebSocket(new URL(path, location.origin.replace(/^http/, 'ws')))
for (const [event, handler] of Object.entries(handlers)) webSocket.addEventListener(event, handler)
return webSocket
}
export default createWebSocket
export const watchConnect = async () => {
wsWatch = connect(watchUrl, {
open() { console.log("Connected to", watchUrl)},
message: handleWatchMessage,
close: watchReconnect,
})
await wsWatch
}
export const watchDisconnect = () => {
if (!wsWatch) return
wsWatch.close()
wsWatch = null
}
const watchReconnect = (event: MessageEvent) => {
const store = useDocumentStore()
if (store.connected) {
console.warn("Disconnected from server", event)
store.connected = false
}
reconnectDuration = Math.min(5000, reconnectDuration + 500)
// The server closes the websocket after errors, so we need to reopen it
setTimeout(() => {
wsWatch = connect(watchUrl, {
message: handleWatchMessage,
close: watchReconnect,
})
console.log("Attempting to reconnect...")
}, reconnectDuration)
}
const handleWatchMessage = (event: MessageEvent) => {
const store = useDocumentStore()
const msg = JSON.parse(event.data)
if ('error' in msg) {
if (msg.error.code === 401) {
store.user.isLoggedIn = false
store.user.isOpenLoginModal = true
} else {
store.error = msg.error.message
}
}
switch (true) {
case !!msg.root:
handleRootMessage(msg)
break
case !!msg.update:
handleUpdateMessage(msg)
break
case !!msg.space:
console.log('Watch space', msg.space)
break
case !!msg.error:
handleError(msg)
break
default:
}
}
function handleRootMessage({ root }: { root: DirEntry }) {
const store = useDocumentStore()
console.log('Watch root', root)
reconnectDuration = 500
store.connected = true
store.user.isLoggedIn = true
store.updateRoot(root)
tree = root
}
function handleUpdateMessage(updateData: { update: UpdateEntry[] }) {
const store = useDocumentStore()
console.log('Watch update', updateData.update)
if (!tree) return console.error('Watch update before root')
let node: DirEntry = tree
for (const elem of updateData.update) {
if (elem.deleted) {
delete node.dir[elem.name]
break // Deleted elements can't have further children
}
if (elem.name !== undefined) {
// @ts-ignore
node = node.dir[elem.name] ||= {}
}
if (elem.key !== undefined) node.key = elem.key
if (elem.size !== undefined) node.size = elem.size
if (elem.mtime !== undefined) node.mtime = elem.mtime
if (elem.dir !== undefined) node.dir = elem.dir
}
store.updateRoot(tree)
}
function handleError(msg: errorEvent) {
const store = useDocumentStore()
if (msg.error.code === 401) {
store.user.isOpenLoginModal = true
store.user.isLoggedIn = false
return
}
}

View File

@ -30,10 +30,9 @@ export const useDocumentStore = defineStore({
selected: new Set<FUID>(),
uploadingDocuments: [],
uploadCount: 0 as number,
wsWatch: undefined,
wsUpload: undefined,
fileExplorer: null,
error: '' as string,
connected: false,
user: {
username: '',
privileged: false,
@ -44,14 +43,16 @@ export const useDocumentStore = defineStore({
actions: {
updateRoot(root: DirEntry | null = null) {
root ??= this.root
if (!root) {
this.document = []
return
}
// Transform tree data to flat documents array
let loc = ""
const mapper = ([name, attr]: [string, FileEntry | DirEntry]) => ({
...attr,
loc,
name,
type: 'dir' in attr ? 'folder' : 'file' as 'folder' | 'file',
...attr,
sizedisp: formatSize(attr.size),
modified: formatUnixDate(attr.mtime),
haystack: haystackFormat(name),
@ -61,17 +62,19 @@ export const useDocumentStore = defineStore({
for (let doc; (doc = queue.shift()) !== undefined;) {
docs.push(doc)
if ("dir" in doc) {
// Recurse but replace recursive structure with boolean
loc = doc.loc ? `${doc.loc}/${doc.name}` : doc.name
queue.push(...Object.entries(doc.dir).map(mapper))
doc.dir = true
}
else doc.dir = false
}
// Pre sort directory entries folders first then files, names in natural ordering
docs.sort((a, b) =>
// @ts-ignore
(a.type === "file") - (b.type === "file") ||
b.dir - a.dir ||
collator.compare(a.name, b.name)
)
this.root = root
this.document = docs
},
updateUploadingDocuments(key: number, progress: number) {
@ -119,53 +122,46 @@ export const useDocumentStore = defineStore({
return ret
},
selectedFiles(): SelectedItems {
function traverseDir(data: DirEntry | FileEntry, path: string, relpath: string) {
if (!('dir' in data)) return
for (const [name, attr] of Object.entries(data.dir)) {
const fullname = path ? `${path}/${name}` : name
const key = attr.key
// Is this the file we are looking for? Ignore if nested within another selection.
let r = relpath
if (selected.has(key) && !relpath) {
ret.selected.add(key)
ret.rootdir[name] = attr
r = name
} else if (relpath) {
r = `${relpath}/${name}`
}
if (r) {
ret.entries[key] = attr
ret.fullpath[key] = fullname
ret.relpath[key] = r
ret.ids.push(key)
if (!('dir' in attr)) ret.url[key] = `/files/${fullname}`
}
traverseDir(attr, fullname, r)
}
}
const selected = this.selected
const found = new Set<FUID>()
const ret: SelectedItems = {
selected: new Set<FUID>(),
missing: new Set<FUID>(),
rootdir: {} as DirList,
entries: {} as Record<FUID, FileEntry | DirEntry>,
fullpath: {} as Record<FUID, string>,
relpath: {} as Record<FUID, string>,
url: {} as Record<FUID, string>,
ids: [] as FUID[]
missing: new Set(),
docs: {},
keys: [],
recursive: [],
}
for (const doc of this.document) {
if (selected.has(doc.key)) {
found.add(doc.key)
ret.keys.push(doc.key)
ret.docs[doc.key] = doc
}
}
traverseDir(this.root, '', '')
// What did we not select?
for (const id of selected) {
if (!ret.selected.has(id)) ret.missing.add(id)
for (const key of selected) if (!found.has(key)) ret.missing.add(key)
// Build a flat list including contents recursively
const relnames = new Set<string>()
function add(rel: string, full: string, doc: Document) {
if (!doc.dir && relnames.has(rel)) throw Error(`Multiple selections conflict for: ${rel}`)
relnames.add(rel)
ret.recursive.push([rel, full, doc])
}
// Sorted array of FUIDs for easy traversal
ret.ids.sort((a, b) =>
ret.relpath[a].localeCompare(ret.relpath[b], undefined, {
numeric: true,
sensitivity: 'base'
})
)
for (const key of ret.keys) {
const base = ret.docs[key]
const basepath = base.loc ? `${base.loc}/${base.name}` : base.name
const nremove = base.loc.length
add(base.name, basepath, base)
for (const doc of this.document) {
if (doc.loc === basepath || doc.loc.startsWith(basepath) && doc.loc[basepath.length] === '/') {
const full = doc.loc ? `${doc.loc}/${doc.name}` : doc.name
const rel = full.slice(nremove)
add(rel, full, doc)
}
}
}
// Sort by rel (name stored as on download)
ret.recursive.sort((a, b) => collator.compare(a[0], b[0]))
return ret
}
}