Compare commits
	
		
			12 Commits
		
	
	
		
			v0.4.0
			...
			2978e0c968
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | 2978e0c968 | ||
|   | 540e825cc3 | ||
|   | 0be72827db | ||
|   | 88aca511e7 | ||
|   | be1c4c1504 | ||
|   | 00a4297c0b | ||
|   | ef5e37187d | ||
|   | a70549e6ec | ||
|   | 535905780a | ||
|   | 82bc449bbc | ||
|   | 5d32396127 | ||
|   | 84ce4b9220 | 
							
								
								
									
										67
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										67
									
								
								README.md
									
									
									
									
									
								
							| @@ -1,23 +1,19 @@ | |||||||
| # Web File Storage | # Web File Storage | ||||||
|  |  | ||||||
| The Python package installs a `cista` executable. Use `hatch shell` to initiate and install in a virtual environment, or `pip install` it on your system. Alternatively `hatch run cista` may be used to skip the shell step but stay virtual. `pip install hatch` first if needed. | Run directly from repository with Hatch (or use pip install as usual): | ||||||
|  |  | ||||||
|  | ```sh | ||||||
|  | hatch run cista -l :3000 /path/to/files | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | Settings incl. these arguments are stored to config file on the first startup and later `hatch run cista` is sufficient. If the `cista` script is missing, consider `pip install -e .` (within `hatch shell`) or some other trickery (known issue with installs made prior to adding the startup script). | ||||||
|  |  | ||||||
| Create your user account: | Create your user account: | ||||||
|  |  | ||||||
| ```sh | ```sh | ||||||
| cista --user admin --privileged | hatch run cista --user admin --privileged | ||||||
| ``` | ``` | ||||||
|  |  | ||||||
| ## Running the server |  | ||||||
|  |  | ||||||
| Serve your files on localhost:8000: |  | ||||||
|  |  | ||||||
| ```sh |  | ||||||
| cista -l :8000 /path/to/files |  | ||||||
| ``` |  | ||||||
|  |  | ||||||
| The Git repository does not contain a frontend build, so you should first do that... |  | ||||||
|  |  | ||||||
| ## Build frontend | ## Build frontend | ||||||
|  |  | ||||||
| Frontend needs to be built before using and after any frontend changes: | Frontend needs to be built before using and after any frontend changes: | ||||||
| @@ -29,50 +25,3 @@ npm run build | |||||||
| ``` | ``` | ||||||
|  |  | ||||||
| This will place the front in `cista/wwwroot` from where the backend server delivers it, and that also gets included in the Python package built via `hatch build`. | This will place the front in `cista/wwwroot` from where the backend server delivers it, and that also gets included in the Python package built via `hatch build`. | ||||||
|  |  | ||||||
| ## Development setup |  | ||||||
|  |  | ||||||
| For rapid turnaround during development, you should run `npm run dev` Vite development server on the Vue frontend. While that is running, start the backend on another terminal `hatch run cista --dev -l :8000` and connect to the frontend. |  | ||||||
|  |  | ||||||
| The backend and the frontend will each reload automatically at any code or config changes. |  | ||||||
|  |  | ||||||
| ## System deployment |  | ||||||
|  |  | ||||||
| Clone the repository to `/srv/cista/cista-storage` or other suitable location accessible to the storage user account you plan to use. `sudo -u storage -s` and build the frontend if you hadn't already. |  | ||||||
|  |  | ||||||
| Create **/etc/systemd/system/cista@.service**: |  | ||||||
|  |  | ||||||
| ```ini |  | ||||||
| [Unit] |  | ||||||
| Description=Cista storage %i |  | ||||||
|  |  | ||||||
| [Service] |  | ||||||
| User=storage |  | ||||||
| WorkingDirectory=/srv/cista/cista-storage |  | ||||||
| ExecStart=hatch run cista -c /srv/cista/%i -l /srv/cista/%i/socket /media/storage/@%i/ |  | ||||||
| TimeoutStopSec=2 |  | ||||||
| Restart=always |  | ||||||
|  |  | ||||||
| [Install] |  | ||||||
| WantedBy=multi-user.target |  | ||||||
| ``` |  | ||||||
|  |  | ||||||
| This assumes you may want to run multiple separate storages, each having their files under `/media/storage/<domain>` and configuration under `/srv/cista/<domain>/`. Instead of numeric ports, we use UNIX sockets for convenience. |  | ||||||
|  |  | ||||||
| ```sh |  | ||||||
| systemctl daemon-reload |  | ||||||
| systemctl enable --now cista@foo.example.com |  | ||||||
| systemctl enable --now cista@bar.example.com |  | ||||||
| ``` |  | ||||||
|  |  | ||||||
| Exposing this publicly online is the most convenient using the [Caddy](https://caddyserver.com/) web server but you can of course use Nginx or others as well. Or even run the server with `-l domain.example.com` given TLS certificates in the config folder. |  | ||||||
|  |  | ||||||
| **/etc/caddy/Caddyfile**: |  | ||||||
|  |  | ||||||
| ```Caddyfile |  | ||||||
| foo.example.com, bar.example.com { |  | ||||||
|     reverse_proxy unix//srv/cista/{host}/socket |  | ||||||
| } |  | ||||||
| ``` |  | ||||||
|  |  | ||||||
| Using the `{host}` placeholder we can just put all the domains on the same block. That's the full server configuration you need. `systemctl enable --now caddy` or `systemctl restart caddy` for the config to take effect. |  | ||||||
|   | |||||||
							
								
								
									
										13
									
								
								cista/api.py
									
									
									
									
									
								
							
							
						
						
									
										13
									
								
								cista/api.py
									
									
									
									
									
								
							| @@ -37,23 +37,16 @@ async def upload(req, ws): | |||||||
|             ) |             ) | ||||||
|         req = msgspec.json.decode(text, type=FileRange) |         req = msgspec.json.decode(text, type=FileRange) | ||||||
|         pos = req.start |         pos = req.start | ||||||
|         while True: |         data = None | ||||||
|             data = await ws.recv() |         while pos < req.end and (data := await ws.recv()) and isinstance(data, bytes): | ||||||
|             if not isinstance(data, bytes): |  | ||||||
|                 break |  | ||||||
|             if len(data) > req.end - pos: |  | ||||||
|                 raise ValueError( |  | ||||||
|                     f"Expected up to {req.end - pos} bytes, got {len(data)} bytes" |  | ||||||
|                 ) |  | ||||||
|             sentsize = await alink(("upload", req.name, pos, data, req.size)) |             sentsize = await alink(("upload", req.name, pos, data, req.size)) | ||||||
|             pos += typing.cast(int, sentsize) |             pos += typing.cast(int, sentsize) | ||||||
|             if pos >= req.end: |  | ||||||
|                 break |  | ||||||
|         if pos != req.end: |         if pos != req.end: | ||||||
|             d = f"{len(data)} bytes" if isinstance(data, bytes) else data |             d = f"{len(data)} bytes" if isinstance(data, bytes) else data | ||||||
|             raise ValueError(f"Expected {req.end - pos} more bytes, got {d}") |             raise ValueError(f"Expected {req.end - pos} more bytes, got {d}") | ||||||
|         # Report success |         # Report success | ||||||
|         res = StatusMsg(status="ack", req=req) |         res = StatusMsg(status="ack", req=req) | ||||||
|  |         print("ack", res) | ||||||
|         await asend(ws, res) |         await asend(ws, res) | ||||||
|  |  | ||||||
|  |  | ||||||
|   | |||||||
| @@ -34,9 +34,7 @@ class File: | |||||||
|             self.open_rw() |             self.open_rw() | ||||||
|         assert self.fd is not None |         assert self.fd is not None | ||||||
|         if file_size is not None: |         if file_size is not None: | ||||||
|             assert pos + len(buffer) <= file_size |  | ||||||
|             os.ftruncate(self.fd, file_size) |             os.ftruncate(self.fd, file_size) | ||||||
|         if buffer: |  | ||||||
|         os.lseek(self.fd, pos, os.SEEK_SET) |         os.lseek(self.fd, pos, os.SEEK_SET) | ||||||
|         os.write(self.fd, buffer) |         os.write(self.fd, buffer) | ||||||
|  |  | ||||||
|   | |||||||
| @@ -120,9 +120,6 @@ class FileEntry(msgspec.Struct, array_like=True): | |||||||
|     size: int |     size: int | ||||||
|     isfile: int |     isfile: int | ||||||
|  |  | ||||||
|     def __repr__(self): |  | ||||||
|         return self.key or "FileEntry()" |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class Update(msgspec.Struct, array_like=True): | class Update(msgspec.Struct, array_like=True): | ||||||
|     ... |     ... | ||||||
| @@ -140,10 +137,6 @@ class UpdIns(Update, tag="i"): | |||||||
|     items: list[FileEntry] |     items: list[FileEntry] | ||||||
|  |  | ||||||
|  |  | ||||||
| class UpdateMessage(msgspec.Struct): |  | ||||||
|     update: list[UpdKeep | UpdDel | UpdIns] |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class Space(msgspec.Struct): | class Space(msgspec.Struct): | ||||||
|     disk: int |     disk: int | ||||||
|     free: int |     free: int | ||||||
|   | |||||||
| @@ -1,6 +1,6 @@ | |||||||
| import os | import os | ||||||
| import re | import re | ||||||
| from pathlib import Path | from pathlib import Path, PurePath | ||||||
|  |  | ||||||
| from sanic import Sanic | from sanic import Sanic | ||||||
|  |  | ||||||
| @@ -15,6 +15,7 @@ def run(*, dev=False): | |||||||
|     # Silence Sanic's warning about running in production rather than debug |     # Silence Sanic's warning about running in production rather than debug | ||||||
|     os.environ["SANIC_IGNORE_PRODUCTION_WARNING"] = "1" |     os.environ["SANIC_IGNORE_PRODUCTION_WARNING"] = "1" | ||||||
|     confdir = config.conffile.parent |     confdir = config.conffile.parent | ||||||
|  |     wwwroot = PurePath(__file__).parent / "wwwroot" | ||||||
|     if opts.get("ssl"): |     if opts.get("ssl"): | ||||||
|         # Run plain HTTP redirect/acme server on port 80 |         # Run plain HTTP redirect/acme server on port 80 | ||||||
|         server80.app.prepare(port=80, motd=False) |         server80.app.prepare(port=80, motd=False) | ||||||
| @@ -26,7 +27,7 @@ def run(*, dev=False): | |||||||
|         motd=False, |         motd=False, | ||||||
|         dev=dev, |         dev=dev, | ||||||
|         auto_reload=dev, |         auto_reload=dev, | ||||||
|         reload_dir={confdir}, |         reload_dir={confdir, wwwroot}, | ||||||
|         access_log=True, |         access_log=True, | ||||||
|     )  # type: ignore |     )  # type: ignore | ||||||
|     if dev: |     if dev: | ||||||
|   | |||||||
| @@ -50,42 +50,37 @@ class State: | |||||||
|         begin, end = 0, len(self._listing) |         begin, end = 0, len(self._listing) | ||||||
|         level = 0 |         level = 0 | ||||||
|         isfile = 0 |         isfile = 0 | ||||||
|  |         while level < len(relpath.parts): | ||||||
|         # Special case for root |             # Enter a subdirectory | ||||||
|         if not relpath.parts: |  | ||||||
|             return slice(begin, end) |  | ||||||
|  |  | ||||||
|         begin += 1 |  | ||||||
|         for part in relpath.parts: |  | ||||||
|             level += 1 |             level += 1 | ||||||
|             found = False |             begin += 1 | ||||||
|  |  | ||||||
|             while begin < end: |  | ||||||
|                 entry = self._listing[begin] |  | ||||||
|  |  | ||||||
|                 if entry.level < level: |  | ||||||
|                     break |  | ||||||
|  |  | ||||||
|                 if entry.level == level: |  | ||||||
|                     if entry.name == part: |  | ||||||
|                         found = True |  | ||||||
|             if level == len(relpath.parts): |             if level == len(relpath.parts): | ||||||
|                 isfile = relfile |                 isfile = relfile | ||||||
|                         else: |             name = relpath.parts[level - 1] | ||||||
|  |             namesort = sortkey(name) | ||||||
|  |             r = self._listing[begin] | ||||||
|  |             assert r.level == level | ||||||
|  |             # Iterate over items at this level | ||||||
|  |             while ( | ||||||
|  |                 begin < end | ||||||
|  |                 and r.name != name | ||||||
|  |                 and r.isfile <= isfile | ||||||
|  |                 and sortkey(r.name) < namesort | ||||||
|  |             ): | ||||||
|  |                 # Skip contents | ||||||
|                 begin += 1 |                 begin += 1 | ||||||
|                         break |                 while begin < end and self._listing[begin].level > level: | ||||||
|                     cmp = entry.isfile - isfile or sortkey(entry.name) > sortkey(part) |  | ||||||
|                     if cmp > 0: |  | ||||||
|                         break |  | ||||||
|  |  | ||||||
|                     begin += 1 |                     begin += 1 | ||||||
|  |                 # Not found? | ||||||
|             if not found: |                 if begin == end or self._listing[begin].level < level: | ||||||
|                     return slice(begin, begin) |                     return slice(begin, begin) | ||||||
|  |                 r = self._listing[begin] | ||||||
|         # Found the starting point, now find the end of the slice |             # Not found? | ||||||
|         for end in range(begin + 1, len(self._listing) + 1): |             if begin == end or r.name != name: | ||||||
|             if end == len(self._listing) or self._listing[end].level <= level: |                 return slice(begin, begin) | ||||||
|  |         # Found an item, now find its end | ||||||
|  |         for end in range(begin + 1, len(self._listing)): | ||||||
|  |             if self._listing[end].level <= level: | ||||||
|                 break |                 break | ||||||
|         return slice(begin, end) |         return slice(begin, end) | ||||||
|  |  | ||||||
| @@ -153,12 +148,11 @@ def watcher_thread(loop): | |||||||
|         rootpath = config.config.path |         rootpath = config.config.path | ||||||
|         i = inotify.adapters.InotifyTree(rootpath.as_posix()) |         i = inotify.adapters.InotifyTree(rootpath.as_posix()) | ||||||
|         # Initialize the tree from filesystem |         # Initialize the tree from filesystem | ||||||
|         new = walk() |         old, new = state.root, walk() | ||||||
|         with state.lock: |  | ||||||
|             old = state.root |  | ||||||
|         if old != new: |         if old != new: | ||||||
|  |             with state.lock: | ||||||
|                 state.root = new |                 state.root = new | ||||||
|                 broadcast(format_update(old, new), loop) |                 broadcast(format_root(new), loop) | ||||||
|  |  | ||||||
|         # The watching is not entirely reliable, so do a full refresh every minute |         # The watching is not entirely reliable, so do a full refresh every minute | ||||||
|         refreshdl = time.monotonic() + 60.0 |         refreshdl = time.monotonic() + 60.0 | ||||||
| @@ -196,10 +190,10 @@ def watcher_thread_poll(loop): | |||||||
|  |  | ||||||
|     while not quit: |     while not quit: | ||||||
|         rootpath = config.config.path |         rootpath = config.config.path | ||||||
|         new = walk() |  | ||||||
|         with state.lock: |  | ||||||
|         old = state.root |         old = state.root | ||||||
|  |         new = walk() | ||||||
|         if old != new: |         if old != new: | ||||||
|  |             with state.lock: | ||||||
|                 state.root = new |                 state.root = new | ||||||
|                 broadcast(format_update(old, new), loop) |                 broadcast(format_update(old, new), loop) | ||||||
|  |  | ||||||
| @@ -289,11 +283,13 @@ def format_update(old, new): | |||||||
|  |  | ||||||
|         del_count = 0 |         del_count = 0 | ||||||
|         rest = new[nidx:] |         rest = new[nidx:] | ||||||
|         while oidx < len(old) and old[oidx] not in rest: |         while old[oidx] not in rest: | ||||||
|             del_count += 1 |             del_count += 1 | ||||||
|             oidx += 1 |             oidx += 1 | ||||||
|  |  | ||||||
|         if del_count: |         if del_count: | ||||||
|             update.append(UpdDel(del_count)) |             update.append(UpdDel(del_count)) | ||||||
|  |             oidx += 1 | ||||||
|             continue |             continue | ||||||
|  |  | ||||||
|         insert_items = [] |         insert_items = [] | ||||||
| @@ -337,9 +333,8 @@ async def abroadcast(msg): | |||||||
|  |  | ||||||
| async def start(app, loop): | async def start(app, loop): | ||||||
|     config.load_config() |     config.load_config() | ||||||
|     use_inotify = False and sys.platform == "linux" |  | ||||||
|     app.ctx.watcher = threading.Thread( |     app.ctx.watcher = threading.Thread( | ||||||
|         target=watcher_thread if use_inotify else watcher_thread_poll, |         target=watcher_thread if sys.platform == "linux" else watcher_thread_poll, | ||||||
|         args=[loop], |         args=[loop], | ||||||
|     ) |     ) | ||||||
|     app.ctx.watcher.start() |     app.ctx.watcher.start() | ||||||
|   | |||||||
| @@ -17,7 +17,7 @@ import type { ComputedRef } from 'vue' | |||||||
| import type HeaderMain from '@/components/HeaderMain.vue' | import type HeaderMain from '@/components/HeaderMain.vue' | ||||||
| import { onMounted, onUnmounted, ref, watchEffect } from 'vue' | import { onMounted, onUnmounted, ref, watchEffect } from 'vue' | ||||||
| import { loadSession, watchConnect, watchDisconnect } from '@/repositories/WS' | import { loadSession, watchConnect, watchDisconnect } from '@/repositories/WS' | ||||||
| import { useMainStore } from '@/stores/main' | import { useDocumentStore } from '@/stores/documents' | ||||||
|  |  | ||||||
| import { computed } from 'vue' | import { computed } from 'vue' | ||||||
| import Router from '@/router/index' | import Router from '@/router/index' | ||||||
| @@ -27,7 +27,7 @@ interface Path { | |||||||
|   pathList: string[] |   pathList: string[] | ||||||
|   query: string |   query: string | ||||||
| } | } | ||||||
| const store = useMainStore() | const documentStore = useDocumentStore() | ||||||
| const path: ComputedRef<Path> = computed(() => { | const path: ComputedRef<Path> = computed(() => { | ||||||
|   const p = decodeURIComponent(Router.currentRoute.value.path).split('//') |   const p = decodeURIComponent(Router.currentRoute.value.path).split('//') | ||||||
|   const pathList = p[0].split('/').filter(value => value !== '') |   const pathList = p[0].split('/').filter(value => value !== '') | ||||||
| @@ -39,16 +39,18 @@ const path: ComputedRef<Path> = computed(() => { | |||||||
|   } |   } | ||||||
| }) | }) | ||||||
| watchEffect(() => { | watchEffect(() => { | ||||||
|   document.title = path.value.path.replace(/\/$/, '').split('/').pop() || store.server.name || 'Cista Storage' |   document.title = path.value.path.replace(/\/$/, '').split('/').pop() || documentStore.server.name || 'Cista Storage' | ||||||
| }) | }) | ||||||
| onMounted(loadSession) | onMounted(loadSession) | ||||||
| onMounted(watchConnect) | onMounted(watchConnect) | ||||||
| onUnmounted(watchDisconnect) | onUnmounted(watchDisconnect) | ||||||
|  | // Update human-readable x seconds ago messages from mtimes | ||||||
|  | setInterval(documentStore.updateModified, 1000) | ||||||
| const headerMain = ref<typeof HeaderMain | null>(null) | const headerMain = ref<typeof HeaderMain | null>(null) | ||||||
| let vert = 0 | let vert = 0 | ||||||
| let timer: any = null | let timer: any = null | ||||||
| const globalShortcutHandler = (event: KeyboardEvent) => { | const globalShortcutHandler = (event: KeyboardEvent) => { | ||||||
|   const fileExplorer = store.fileExplorer as any |   const fileExplorer = documentStore.fileExplorer as any | ||||||
|   if (!fileExplorer) return |   if (!fileExplorer) return | ||||||
|   const c = fileExplorer.isCursor() |   const c = fileExplorer.isCursor() | ||||||
|   const keyup = event.type === 'keyup' |   const keyup = event.type === 'keyup' | ||||||
| @@ -124,4 +126,3 @@ onUnmounted(() => { | |||||||
| }) | }) | ||||||
| export type { Path } | export type { Path } | ||||||
| </script> | </script> | ||||||
| @/stores/main |  | ||||||
|   | |||||||
| @@ -4,7 +4,7 @@ | |||||||
|     aria-label="Breadcrumb" |     aria-label="Breadcrumb" | ||||||
|     @keyup.left.stop="move(-1)" |     @keyup.left.stop="move(-1)" | ||||||
|     @keyup.right.stop="move(1)" |     @keyup.right.stop="move(1)" | ||||||
|     @keyup.enter="move(0)" |     @focus="move(0)" | ||||||
|   > |   > | ||||||
|     <a href="#/" |     <a href="#/" | ||||||
|       :ref="el => setLinkRef(0, el)" |       :ref="el => setLinkRef(0, el)" | ||||||
| @@ -48,11 +48,9 @@ const navigate = (index: number) => { | |||||||
|   if (!link) throw Error(`No link at index ${index} (path: ${props.path})`) |   if (!link) throw Error(`No link at index ${index} (path: ${props.path})`) | ||||||
|   const url = `/${longest.value.slice(0, index).join('/')}/` |   const url = `/${longest.value.slice(0, index).join('/')}/` | ||||||
|   const here = `/${longest.value.join('/')}/` |   const here = `/${longest.value.join('/')}/` | ||||||
|   const current = decodeURIComponent(location.hash.slice(1).split('//')[0]) |  | ||||||
|   const u = url.replaceAll('?', '%3F').replaceAll('#', '%23') |  | ||||||
|   if (here.startsWith(current)) router.replace(u) |  | ||||||
|   else router.push(u) |  | ||||||
|   link.focus() |   link.focus() | ||||||
|  |   if (here.startsWith(location.hash.slice(1))) router.replace(url) | ||||||
|  |   else router.push(url) | ||||||
| } | } | ||||||
|  |  | ||||||
| const move = (dir: number) => { | const move = (dir: number) => { | ||||||
|   | |||||||
| @@ -5,9 +5,9 @@ | |||||||
|         <th class="selection"> |         <th class="selection"> | ||||||
|           <input type="checkbox" tabindex="-1" v-model="allSelected" :indeterminate="selectionIndeterminate"> |           <input type="checkbox" tabindex="-1" v-model="allSelected" :indeterminate="selectionIndeterminate"> | ||||||
|         </th> |         </th> | ||||||
|         <th class="sortcolumn" :class="{ sortactive: store.sortOrder === 'name' }" @click="store.toggleSort('name')">Name</th> |         <th class="sortcolumn" :class="{ sortactive: sort === 'name' }" @click="toggleSort('name')">Name</th> | ||||||
|         <th class="sortcolumn modified right" :class="{ sortactive: store.sortOrder === 'modified' }" @click="store.toggleSort('modified')">Modified</th> |         <th class="sortcolumn modified right" :class="{ sortactive: sort === 'modified' }" @click="toggleSort('modified')">Modified</th> | ||||||
|         <th class="sortcolumn size right" :class="{ sortactive: store.sortOrder === 'size' }" @click="store.toggleSort('size')">Size</th> |         <th class="sortcolumn size right" :class="{ sortactive: sort === 'size' }" @click="toggleSort('size')">Size</th> | ||||||
|         <th class="menu"></th> |         <th class="menu"></th> | ||||||
|       </tr> |       </tr> | ||||||
|     </thead> |     </thead> | ||||||
| @@ -17,11 +17,11 @@ | |||||||
|         <td class="name"> |         <td class="name"> | ||||||
|           <FileRenameInput :doc="editing" :rename="mkdir" :exit="() => {editing = null}" /> |           <FileRenameInput :doc="editing" :rename="mkdir" :exit="() => {editing = null}" /> | ||||||
|         </td> |         </td> | ||||||
|         <FileModified :doc=editing :key=nowkey /> |         <FileModified :doc=editing /> | ||||||
|         <FileSize :doc=editing /> |         <FileSize :doc=editing /> | ||||||
|         <td class="menu"></td> |         <td class="menu"></td> | ||||||
|       </tr> |       </tr> | ||||||
|       <template v-for="(doc, index) in documents" :key="doc.key"> |       <template v-for="(doc, index) in sortedDocuments" :key="doc.key"> | ||||||
|         <tr class="folder-change" v-if="showFolderBreadcrumb(index)"> |         <tr class="folder-change" v-if="showFolderBreadcrumb(index)"> | ||||||
|           <th colspan="5"><BreadCrumb :path="doc.loc ? doc.loc.split('/') : []" /></th> |           <th colspan="5"><BreadCrumb :path="doc.loc ? doc.loc.split('/') : []" /></th> | ||||||
|         </tr> |         </tr> | ||||||
| @@ -36,11 +36,11 @@ | |||||||
|             <input |             <input | ||||||
|               type="checkbox" |               type="checkbox" | ||||||
|               tabindex="-1" |               tabindex="-1" | ||||||
|               :checked="store.selected.has(doc.key)" |               :checked="documentStore.selected.has(doc.key)" | ||||||
|               @change=" |               @change=" | ||||||
|                 ($event.target as HTMLInputElement).checked |                 ($event.target as HTMLInputElement).checked | ||||||
|                   ? store.selected.add(doc.key) |                   ? documentStore.selected.add(doc.key) | ||||||
|                   : store.selected.delete(doc.key) |                   : documentStore.selected.delete(doc.key) | ||||||
|               " |               " | ||||||
|             /> |             /> | ||||||
|           </td> |           </td> | ||||||
| @@ -50,7 +50,7 @@ | |||||||
|             </template> |             </template> | ||||||
|             <template v-else> |             <template v-else> | ||||||
|               <a |               <a | ||||||
|                 :href="doc.url" |                 :href="url_for(doc)" | ||||||
|                 tabindex="-1" |                 tabindex="-1" | ||||||
|                 @contextmenu.prevent |                 @contextmenu.prevent | ||||||
|                 @focus.stop="cursor = doc" |                 @focus.stop="cursor = doc" | ||||||
| @@ -61,7 +61,7 @@ | |||||||
|               <button v-if="cursor == doc" class="rename-button" @click="() => (editing = doc)">🖊️</button> |               <button v-if="cursor == doc" class="rename-button" @click="() => (editing = doc)">🖊️</button> | ||||||
|             </template> |             </template> | ||||||
|           </td> |           </td> | ||||||
|           <FileModified :doc=doc :key=nowkey /> |           <FileModified :doc=doc /> | ||||||
|           <FileSize :doc=doc /> |           <FileSize :doc=doc /> | ||||||
|           <td class="menu"> |           <td class="menu"> | ||||||
|             <button tabindex="-1" @click.stop="contextMenu($event, doc)">⋮</button> |             <button tabindex="-1" @click.stop="contextMenu($event, doc)">⋮</button> | ||||||
| @@ -79,26 +79,28 @@ | |||||||
| </template> | </template> | ||||||
|  |  | ||||||
| <script setup lang="ts"> | <script setup lang="ts"> | ||||||
| import { ref, computed, watchEffect, shallowRef, onMounted, onUnmounted } from 'vue' | import { ref, computed, watchEffect } from 'vue' | ||||||
| import { useMainStore } from '@/stores/main' | import { useDocumentStore } from '@/stores/documents' | ||||||
| import { Doc } from '@/repositories/Document' | import type { Document } from '@/repositories/Document' | ||||||
| import FileRenameInput from './FileRenameInput.vue' | import FileRenameInput from './FileRenameInput.vue' | ||||||
| import { connect, controlUrl } from '@/repositories/WS' | import { connect, controlUrl } from '@/repositories/WS' | ||||||
| import { formatSize } from '@/utils' | import { collator, formatSize, formatUnixDate } from '@/utils' | ||||||
| import { useRouter } from 'vue-router' | import { useRouter } from 'vue-router' | ||||||
| import ContextMenu from '@imengyu/vue3-context-menu' |  | ||||||
| import type { SortOrder } from '@/utils/docsort' |  | ||||||
|  |  | ||||||
| const props = defineProps<{ | const props = defineProps<{ | ||||||
|   path: Array<string> |   path: Array<string> | ||||||
|   documents: Doc[] |   documents: Document[] | ||||||
| }>() | }>() | ||||||
| const store = useMainStore() | const documentStore = useDocumentStore() | ||||||
| const router = useRouter() | const router = useRouter() | ||||||
| const cursor = shallowRef<Doc | null>(null) | const url_for = (doc: Document) => { | ||||||
|  |   const p = doc.loc ? `${doc.loc}/${doc.name}` : doc.name | ||||||
|  |   return doc.dir ? `#/${p}/` : `/files/${p}` | ||||||
|  | } | ||||||
|  | const cursor = ref<Document | null>(null) | ||||||
| // File rename | // File rename | ||||||
| const editing = shallowRef<Doc | null>(null) | const editing = ref<Document | null>(null) | ||||||
| const rename = (doc: Doc, newName: string) => { | const rename = (doc: Document, newName: string) => { | ||||||
|   const oldName = doc.name |   const oldName = doc.name | ||||||
|   const control = connect(controlUrl, { |   const control = connect(controlUrl, { | ||||||
|     message(ev: MessageEvent) { |     message(ev: MessageEvent) { | ||||||
| @@ -122,25 +124,35 @@ const rename = (doc: Doc, newName: string) => { | |||||||
|   } |   } | ||||||
|   doc.name = newName // We should get an update from watch but this is quicker |   doc.name = newName // We should get an update from watch but this is quicker | ||||||
| } | } | ||||||
|  | const sortedDocuments = computed(() => sorted(props.documents as Document[])) | ||||||
|  | const showFolderBreadcrumb = (i: number) => { | ||||||
|  |   const docs = sortedDocuments.value | ||||||
|  |   const docloc = docs[i].loc | ||||||
|  |   return i === 0 ? docloc !== loc.value : docloc !== docs[i - 1].loc | ||||||
|  | } | ||||||
| defineExpose({ | defineExpose({ | ||||||
|   newFolder() { |   newFolder() { | ||||||
|     const now = Math.floor(Date.now() / 1000) |     const now = Date.now() / 1000 | ||||||
|     editing.value = new Doc({ |     editing.value = { | ||||||
|       loc: loc.value, |       loc: loc.value, | ||||||
|       key: 'new', |       key: 'new', | ||||||
|       name: 'New Folder', |       name: 'New Folder', | ||||||
|       dir: true, |       dir: true, | ||||||
|       mtime: now, |       mtime: now, | ||||||
|       size: 0, |       size: 0, | ||||||
|     }) |       sizedisp: formatSize(0), | ||||||
|  |       modified: formatUnixDate(now), | ||||||
|  |       haystack: '', | ||||||
|  |     } | ||||||
|  |     console.log("New") | ||||||
|   }, |   }, | ||||||
|   toggleSelectAll() { |   toggleSelectAll() { | ||||||
|     console.log('Select') |     console.log('Select') | ||||||
|     allSelected.value = !allSelected.value |     allSelected.value = !allSelected.value | ||||||
|   }, |   }, | ||||||
|   toggleSortColumn(column: number) { |   toggleSortColumn(column: number) { | ||||||
|     const order = ['', 'name', 'modified', 'size', ''][column] |     const columns = ['', 'name', 'modified', 'size', ''] | ||||||
|     if (order) store.toggleSort(order as SortOrder) |     toggleSort(columns[column]) | ||||||
|   }, |   }, | ||||||
|   isCursor() { |   isCursor() { | ||||||
|     return cursor.value !== null && editing.value === null |     return cursor.value !== null && editing.value === null | ||||||
| @@ -151,36 +163,36 @@ defineExpose({ | |||||||
|   cursorSelect() { |   cursorSelect() { | ||||||
|     const doc = cursor.value |     const doc = cursor.value | ||||||
|     if (!doc) return |     if (!doc) return | ||||||
|     if (store.selected.has(doc.key)) { |     if (documentStore.selected.has(doc.key)) { | ||||||
|       store.selected.delete(doc.key) |       documentStore.selected.delete(doc.key) | ||||||
|     } else { |     } else { | ||||||
|       store.selected.add(doc.key) |       documentStore.selected.add(doc.key) | ||||||
|     } |     } | ||||||
|     this.cursorMove(1) |     this.cursorMove(1) | ||||||
|   }, |   }, | ||||||
|   cursorMove(d: number, select = false) { |   cursorMove(d: number, select = false) { | ||||||
|     // Move cursor up or down (keyboard navigation) |     // Move cursor up or down (keyboard navigation) | ||||||
|     const docs = props.documents |     const documents = sortedDocuments.value | ||||||
|     if (docs.length === 0) { |     if (documents.length === 0) { | ||||||
|       cursor.value = null |       cursor.value = null | ||||||
|       return |       return | ||||||
|     } |     } | ||||||
|     const N = docs.length |     const N = documents.length | ||||||
|     const mod = (a: number, b: number) => ((a % b) + b) % b |     const mod = (a: number, b: number) => ((a % b) + b) % b | ||||||
|     const increment = (i: number, d: number) => mod(i + d, N + 1) |     const increment = (i: number, d: number) => mod(i + d, N + 1) | ||||||
|     const index = |     const index = | ||||||
|       cursor.value !== null ? docs.indexOf(cursor.value) : docs.length |       cursor.value !== null ? documents.indexOf(cursor.value) : documents.length | ||||||
|     const moveto = increment(index, d) |     const moveto = increment(index, d) | ||||||
|     cursor.value = docs[moveto] ?? null |     cursor.value = documents[moveto] ?? null | ||||||
|     const tr = cursor.value ? document.getElementById(`file-${cursor.value.key}`) : null |     const tr = cursor.value ? document.getElementById(`file-${cursor.value.key}`) : null | ||||||
|     if (select) { |     if (select) { | ||||||
|       // Go forwards, possibly wrapping over the end; the last entry is not toggled |       // Go forwards, possibly wrapping over the end; the last entry is not toggled | ||||||
|       let [begin, end] = d > 0 ? [index, moveto] : [moveto, index] |       let [begin, end] = d > 0 ? [index, moveto] : [moveto, index] | ||||||
|       for (let p = begin; p !== end; p = increment(p, 1)) { |       for (let p = begin; p !== end; p = increment(p, 1)) { | ||||||
|         if (p === N) continue |         if (p === N) continue | ||||||
|         const key = docs[p].key |         const key = documents[p].key | ||||||
|         if (store.selected.has(key)) store.selected.delete(key) |         if (documentStore.selected.has(key)) documentStore.selected.delete(key) | ||||||
|         else store.selected.add(key) |         else documentStore.selected.add(key) | ||||||
|       } |       } | ||||||
|     } |     } | ||||||
|     // @ts-ignore |     // @ts-ignore | ||||||
| @@ -217,14 +229,7 @@ watchEffect(() => { | |||||||
|     focusBreadcrumb() |     focusBreadcrumb() | ||||||
|   } |   } | ||||||
| }) | }) | ||||||
| let nowkey = ref(0) | const mkdir = (doc: Document, name: string) => { | ||||||
| let modifiedTimer: any = null |  | ||||||
| const updateModified = () => { |  | ||||||
|   nowkey.value = Math.floor(Date.now() / 1000) |  | ||||||
| } |  | ||||||
| onMounted(() => { updateModified(); modifiedTimer = setInterval(updateModified, 1000) }) |  | ||||||
| onUnmounted(() => { clearInterval(modifiedTimer) }) |  | ||||||
| const mkdir = (doc: Doc, name: string) => { |  | ||||||
|   const control = connect(controlUrl, { |   const control = connect(controlUrl, { | ||||||
|     open() { |     open() { | ||||||
|       control.send( |       control.send( | ||||||
| @@ -241,24 +246,34 @@ const mkdir = (doc: Doc, name: string) => { | |||||||
|         editing.value = null |         editing.value = null | ||||||
|       } else { |       } else { | ||||||
|         console.log('mkdir', msg) |         console.log('mkdir', msg) | ||||||
|         router.push(doc.urlrouter) |         router.push(doc.loc ? `/${doc.loc}/${name}/` : `/${name}/`) | ||||||
|       } |       } | ||||||
|     } |     } | ||||||
|   }) |   }) | ||||||
|   // We should get an update from watch but this is quicker |   doc.name = name // We should get an update from watch but this is quicker | ||||||
|   doc.name = name |  | ||||||
|   doc.key = crypto.randomUUID() |  | ||||||
| } | } | ||||||
| const showFolderBreadcrumb = (i: number) => { |  | ||||||
|   const docs = props.documents | // Column sort | ||||||
|   const docloc = docs[i].loc | const toggleSort = (name: string) => { | ||||||
|   return i === 0 ? docloc !== loc.value : docloc !== docs[i - 1].loc |   sort.value = sort.value === name ? '' : name | ||||||
|  | } | ||||||
|  | const sort = ref<string>('') | ||||||
|  | const sortCompare = { | ||||||
|  |   name: (a: Document, b: Document) => collator.compare(a.name, b.name), | ||||||
|  |   modified: (a: Document, b: Document) => b.mtime - a.mtime, | ||||||
|  |   size: (a: Document, b: Document) => b.size - a.size | ||||||
|  | } | ||||||
|  | const sorted = (documents: Document[]) => { | ||||||
|  |   const cmp = sortCompare[sort.value as keyof typeof sortCompare] | ||||||
|  |   const sorted = [...documents] | ||||||
|  |   if (cmp) sorted.sort(cmp) | ||||||
|  |   return sorted | ||||||
| } | } | ||||||
| const selectionIndeterminate = computed({ | const selectionIndeterminate = computed({ | ||||||
|   get: () => { |   get: () => { | ||||||
|     return ( |     return ( | ||||||
|       props.documents.length > 0 && |       props.documents.length > 0 && | ||||||
|       props.documents.some((doc: Doc) => store.selected.has(doc.key)) && |       props.documents.some((doc: Document) => documentStore.selected.has(doc.key)) && | ||||||
|       !allSelected.value |       !allSelected.value | ||||||
|     ) |     ) | ||||||
|   }, |   }, | ||||||
| @@ -269,16 +284,16 @@ const allSelected = computed({ | |||||||
|   get: () => { |   get: () => { | ||||||
|     return ( |     return ( | ||||||
|       props.documents.length > 0 && |       props.documents.length > 0 && | ||||||
|       props.documents.every((doc: Doc) => store.selected.has(doc.key)) |       props.documents.every((doc: Document) => documentStore.selected.has(doc.key)) | ||||||
|     ) |     ) | ||||||
|   }, |   }, | ||||||
|   set: (value: boolean) => { |   set: (value: boolean) => { | ||||||
|     console.log('Setting allSelected', value) |     console.log('Setting allSelected', value) | ||||||
|     for (const doc of props.documents) { |     for (const doc of props.documents) { | ||||||
|       if (value) { |       if (value) { | ||||||
|         store.selected.add(doc.key) |         documentStore.selected.add(doc.key) | ||||||
|       } else { |       } else { | ||||||
|         store.selected.delete(doc.key) |         documentStore.selected.delete(doc.key) | ||||||
|       } |       } | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
| @@ -286,13 +301,9 @@ const allSelected = computed({ | |||||||
|  |  | ||||||
| const loc = computed(() => props.path.join('/')) | const loc = computed(() => props.path.join('/')) | ||||||
|  |  | ||||||
| const contextMenu = (ev: MouseEvent, doc: Doc) => { | const contextMenu = (ev: Event, doc: Document) => { | ||||||
|   cursor.value = doc |   cursor.value = doc | ||||||
|   ContextMenu.showContextMenu({ |   console.log('Context menu', ev, doc) | ||||||
|     x: ev.x, y: ev.y, items: [ |  | ||||||
|       { label: 'Rename', onClick: () => { editing.value = doc } }, |  | ||||||
|     ], |  | ||||||
|   }) |  | ||||||
| } | } | ||||||
| </script> | </script> | ||||||
|  |  | ||||||
| @@ -440,4 +451,3 @@ tbody .selection input { | |||||||
|   color: #888; |   color: #888; | ||||||
| } | } | ||||||
| </style> | </style> | ||||||
| @/stores/main |  | ||||||
|   | |||||||
| @@ -5,7 +5,7 @@ | |||||||
| </template> | </template> | ||||||
|  |  | ||||||
| <script setup lang="ts"> | <script setup lang="ts"> | ||||||
| import { Doc } from '@/repositories/Document' | import type { Document } from '@/repositories/Document' | ||||||
| import { computed } from 'vue' | import { computed } from 'vue' | ||||||
|  |  | ||||||
| const datetime = computed(() => | const datetime = computed(() => | ||||||
| @@ -17,6 +17,6 @@ const tooltip = computed(() => | |||||||
| ) | ) | ||||||
|  |  | ||||||
| const props = defineProps<{ | const props = defineProps<{ | ||||||
|     doc: Doc |     doc: Document | ||||||
| }>() | }>() | ||||||
| </script> | </script> | ||||||
|   | |||||||
| @@ -12,7 +12,7 @@ | |||||||
| </template> | </template> | ||||||
|  |  | ||||||
| <script setup lang="ts"> | <script setup lang="ts"> | ||||||
| import { Doc } from '@/repositories/Document' | import type { Document } from '@/repositories/Document' | ||||||
| import { ref, onMounted, nextTick } from 'vue' | import { ref, onMounted, nextTick } from 'vue' | ||||||
|  |  | ||||||
| const input = ref<HTMLInputElement | null>(null) | const input = ref<HTMLInputElement | null>(null) | ||||||
| @@ -28,8 +28,8 @@ onMounted(() => { | |||||||
| }) | }) | ||||||
|  |  | ||||||
| const props = defineProps<{ | const props = defineProps<{ | ||||||
|   doc: Doc |   doc: Document | ||||||
|   rename: (doc: Doc, newName: string) => void |   rename: (doc: Document, newName: string) => void | ||||||
|   exit: () => void |   exit: () => void | ||||||
| }>() | }>() | ||||||
|  |  | ||||||
|   | |||||||
| @@ -3,7 +3,7 @@ | |||||||
| </template> | </template> | ||||||
|  |  | ||||||
| <script setup lang="ts"> | <script setup lang="ts"> | ||||||
| import { Doc } from '@/repositories/Document' | import type { Document } from '@/repositories/Document' | ||||||
| import { computed } from 'vue' | import { computed } from 'vue' | ||||||
|  |  | ||||||
| const sizeClass = computed(() => { | const sizeClass = computed(() => { | ||||||
| @@ -12,7 +12,7 @@ const sizeClass = computed(() => { | |||||||
| }) | }) | ||||||
|  |  | ||||||
| const props = defineProps<{ | const props = defineProps<{ | ||||||
|     doc: Doc |     doc: Document | ||||||
| }>() | }>() | ||||||
| </script> | </script> | ||||||
|  |  | ||||||
|   | |||||||
							
								
								
									
										52
									
								
								frontend/src/components/FileViewer.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										52
									
								
								frontend/src/components/FileViewer.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,52 @@ | |||||||
|  | <template> | ||||||
|  |   <object | ||||||
|  |     v-if="props.type === 'pdf'" | ||||||
|  |     :data="dataURL" | ||||||
|  |     type="application/pdf" | ||||||
|  |     width="100%" | ||||||
|  |     height="100%" | ||||||
|  |   ></object> | ||||||
|  |   <a-image | ||||||
|  |     v-else-if="props.type === 'image'" | ||||||
|  |     width="50%" | ||||||
|  |     :src="dataURL" | ||||||
|  |     @click="() => setVisible(true)" | ||||||
|  |     :previewMask="false" | ||||||
|  |     :preview="{ | ||||||
|  |       visibleImg, | ||||||
|  |       onVisibleChange: setVisible | ||||||
|  |     }" | ||||||
|  |   /> | ||||||
|  |   <!-- Unknown case --> | ||||||
|  |   <h1 v-else>Unsupported file type</h1> | ||||||
|  | </template> | ||||||
|  |  | ||||||
|  | <script setup lang="ts"> | ||||||
|  | import { watchEffect, ref } from 'vue' | ||||||
|  | import Router from '@/router/index' | ||||||
|  | import { url_document_get } from '@/repositories/Document' | ||||||
|  |  | ||||||
|  | const dataURL = ref('') | ||||||
|  | watchEffect(() => { | ||||||
|  |   dataURL.value = new URL( | ||||||
|  |     url_document_get + Router.currentRoute.value.path, | ||||||
|  |     location.origin | ||||||
|  |   ).toString() | ||||||
|  | }) | ||||||
|  | const emit = defineEmits({ | ||||||
|  |   visibleImg(value: boolean) { | ||||||
|  |     return value | ||||||
|  |   } | ||||||
|  | }) | ||||||
|  |  | ||||||
|  | function setVisible(value: boolean) { | ||||||
|  |   emit('visibleImg', value) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | const props = defineProps<{ | ||||||
|  |   type?: string | ||||||
|  |   visibleImg: boolean | ||||||
|  | }>() | ||||||
|  | </script> | ||||||
|  |  | ||||||
|  | <style></style> | ||||||
| @@ -1,15 +1,15 @@ | |||||||
| <template> | <template> | ||||||
|   <nav class="headermain"> |   <nav class="headermain"> | ||||||
|     <div class="buttons"> |     <div class="buttons"> | ||||||
|       <template v-if="store.error"> |       <template v-if="documentStore.error"> | ||||||
|         <div class="error-message" @click="store.error = ''">{{ store.error }}</div> |         <div class="error-message" @click="documentStore.error = ''">{{ documentStore.error }}</div> | ||||||
|         <div class="smallgap"></div> |         <div class="smallgap"></div> | ||||||
|       </template> |       </template> | ||||||
|       <UploadButton :path="props.path" /> |       <UploadButton :path="props.path" /> | ||||||
|       <SvgButton |       <SvgButton | ||||||
|         name="create-folder" |         name="create-folder" | ||||||
|         data-tooltip="New folder" |         data-tooltip="New folder" | ||||||
|         @click="() => store.fileExplorer!.newFolder()" |         @click="() => documentStore.fileExplorer.newFolder()" | ||||||
|       /> |       /> | ||||||
|       <slot></slot> |       <slot></slot> | ||||||
|       <div class="spacer smallgap"></div> |       <div class="spacer smallgap"></div> | ||||||
| @@ -18,6 +18,7 @@ | |||||||
|           ref="search" |           ref="search" | ||||||
|           type="search" |           type="search" | ||||||
|           :value="query" |           :value="query" | ||||||
|  |           @blur="ev => { if (!query) closeSearch(ev) }" | ||||||
|           @input="updateSearch" |           @input="updateSearch" | ||||||
|           placeholder="Search words" |           placeholder="Search words" | ||||||
|           class="margin-input" |           class="margin-input" | ||||||
| @@ -31,39 +32,35 @@ | |||||||
| </template> | </template> | ||||||
|  |  | ||||||
| <script setup lang="ts"> | <script setup lang="ts"> | ||||||
| import { useMainStore } from '@/stores/main' | import { useDocumentStore } from '@/stores/documents' | ||||||
| import { ref, nextTick, watchEffect } from 'vue' | import { ref, nextTick, watchEffect } from 'vue' | ||||||
| import ContextMenu from '@imengyu/vue3-context-menu' | import ContextMenu from '@imengyu/vue3-context-menu' | ||||||
| import router from '@/router'; | import router from '@/router'; | ||||||
|  |  | ||||||
| const store = useMainStore() | const documentStore = useDocumentStore() | ||||||
| const showSearchInput = ref<boolean>(false) | const showSearchInput = ref<boolean>(false) | ||||||
| const search = ref<HTMLInputElement | null>() | const search = ref<HTMLInputElement | null>() | ||||||
| const searchButton = ref<HTMLButtonElement | null>() | const searchButton = ref<HTMLButtonElement | null>() | ||||||
|   const props = defineProps<{ |  | ||||||
|   path: Array<string> |  | ||||||
|   query: string |  | ||||||
| }>() |  | ||||||
|  |  | ||||||
| const closeSearch = (ev: Event) => { | const closeSearch = ev => { | ||||||
|   if (!showSearchInput.value) return  // Already closing |   if (!showSearchInput.value) return  // Already closing | ||||||
|   showSearchInput.value = false |   showSearchInput.value = false | ||||||
|   const breadcrumb = document.querySelector('.breadcrumb') as HTMLElement |   const breadcrumb = document.querySelector('.breadcrumb') as HTMLElement | ||||||
|   breadcrumb.focus() |   breadcrumb.focus() | ||||||
|   updateSearch(ev) |   updateSearch(ev) | ||||||
| } | } | ||||||
| const updateSearch = (ev: Event) => { | const updateSearch = ev => { | ||||||
|   const q = (ev.target as HTMLInputElement).value |   const q = ev.target.value | ||||||
|   let p = props.path.join('/') |   let p = props.path.join('/') | ||||||
|   p = p ? `/${p}` : '' |   p = p ? `/${p}` : '' | ||||||
|   const url = q ? `${p}//${q}` : (p || '/') |   const url = q ? `${p}//${q}` : (p || '/') | ||||||
|   const u = url.replaceAll('?', '%3F').replaceAll('#', '%23') |   console.log("Update search", url) | ||||||
|   if (!props.query && q) router.push(u) |   if (!props.query && q) router.push(url) | ||||||
|   else router.replace(u) |   else router.replace(url) | ||||||
| } | } | ||||||
| const toggleSearchInput = (ev: Event) => { | const toggleSearchInput = () => { | ||||||
|   showSearchInput.value = !showSearchInput.value |   showSearchInput.value = !showSearchInput.value | ||||||
|   if (!showSearchInput.value) return closeSearch(ev) |   if (!showSearchInput.value) return closeSearch() | ||||||
|   nextTick(() => { |   nextTick(() => { | ||||||
|     const input = search.value |     const input = search.value | ||||||
|     if (input) input.focus() |     if (input) input.focus() | ||||||
| @@ -75,10 +72,10 @@ watchEffect(() => { | |||||||
| const settingsMenu = (e: Event) => { | const settingsMenu = (e: Event) => { | ||||||
|   // show the context menu |   // show the context menu | ||||||
|   const items = [] |   const items = [] | ||||||
|   if (store.user.isLoggedIn) { |   if (documentStore.user.isLoggedIn) { | ||||||
|     items.push({ label: `Logout ${store.user.username ?? ''}`, onClick: () => store.logout() }) |     items.push({ label: `Logout ${documentStore.user.username ?? ''}`, onClick: () => documentStore.logout() }) | ||||||
|   } else { |   } else { | ||||||
|     items.push({ label: 'Login', onClick: () => store.loginDialog() }) |     items.push({ label: 'Login', onClick: () => documentStore.loginDialog() }) | ||||||
|   } |   } | ||||||
|   ContextMenu.showContextMenu({ |   ContextMenu.showContextMenu({ | ||||||
|     // @ts-ignore |     // @ts-ignore | ||||||
| @@ -86,6 +83,11 @@ const settingsMenu = (e: Event) => { | |||||||
|     items, |     items, | ||||||
|   }) |   }) | ||||||
| } | } | ||||||
|  | const props = defineProps<{ | ||||||
|  |   path: Array<string> | ||||||
|  |   query: string | ||||||
|  | }>() | ||||||
|  |  | ||||||
| defineExpose({ | defineExpose({ | ||||||
|   toggleSearchInput, |   toggleSearchInput, | ||||||
|   closeSearch, |   closeSearch, | ||||||
| @@ -114,4 +116,3 @@ input[type='search'] { | |||||||
|   max-width: 30vw; |   max-width: 30vw; | ||||||
| } | } | ||||||
| </style> | </style> | ||||||
| @/stores/main |  | ||||||
|   | |||||||
| @@ -1,29 +1,29 @@ | |||||||
| <template> | <template> | ||||||
|   <template v-if="store.selected.size"> |   <template v-if="documentStore.selected.size"> | ||||||
|     <div class="smallgap"></div> |     <div class="smallgap"></div> | ||||||
|     <p class="select-text">{{ store.selected.size }} selected ➤</p> |     <p class="select-text">{{ documentStore.selected.size }} selected ➤</p> | ||||||
|     <SvgButton name="download" data-tooltip="Download" @click="download" /> |     <SvgButton name="download" data-tooltip="Download" @click="download" /> | ||||||
|     <SvgButton name="copy" data-tooltip="Copy here" @click="op('cp', dst)" /> |     <SvgButton name="copy" data-tooltip="Copy here" @click="op('cp', dst)" /> | ||||||
|     <SvgButton name="paste" data-tooltip="Move here" @click="op('mv', dst)" /> |     <SvgButton name="paste" data-tooltip="Move here" @click="op('mv', dst)" /> | ||||||
|     <SvgButton name="trash" data-tooltip="Delete ⚠️" @click="op('rm')" /> |     <SvgButton name="trash" data-tooltip="Delete ⚠️" @click="op('rm')" /> | ||||||
|     <button class="action-button unselect" data-tooltip="Unselect all" @click="store.selected.clear()">❌</button> |     <button class="action-button unselect" data-tooltip="Unselect all" @click="documentStore.selected.clear()">❌</button> | ||||||
|   </template> |   </template> | ||||||
| </template> | </template> | ||||||
|  |  | ||||||
| <script setup lang="ts"> | <script setup lang="ts"> | ||||||
| import {connect, controlUrl} from '@/repositories/WS' | import {connect, controlUrl} from '@/repositories/WS' | ||||||
| import { useMainStore } from '@/stores/main' | import { useDocumentStore } from '@/stores/documents' | ||||||
| import { computed } from 'vue' | import { computed } from 'vue' | ||||||
| import type { SelectedItems } from '@/repositories/Document' | import type { SelectedItems } from '@/repositories/Document' | ||||||
|  |  | ||||||
| const store = useMainStore() | const documentStore = useDocumentStore() | ||||||
| const props = defineProps({ | const props = defineProps({ | ||||||
|   path: Array<string> |   path: Array<string> | ||||||
| }) | }) | ||||||
|  |  | ||||||
| const dst = computed(() => props.path!.join('/')) | const dst = computed(() => props.path!.join('/')) | ||||||
| const op = (op: string, dst?: string) => { | const op = (op: string, dst?: string) => { | ||||||
|   const sel = store.selectedFiles |   const sel = documentStore.selectedFiles | ||||||
|   const msg = { |   const msg = { | ||||||
|     op, |     op, | ||||||
|     sel: sel.keys.map(key => { |     sel: sel.keys.map(key => { | ||||||
| @@ -34,16 +34,16 @@ const op = (op: string, dst?: string) => { | |||||||
|   // @ts-ignore |   // @ts-ignore | ||||||
|   if (dst !== undefined) msg.dst = dst |   if (dst !== undefined) msg.dst = dst | ||||||
|   const control = connect(controlUrl, { |   const control = connect(controlUrl, { | ||||||
|     message(ev: MessageEvent) { |     message(ev: WebSocmetMessageEvent) { | ||||||
|       const res = JSON.parse(ev.data) |       const res = JSON.parse(ev.data) | ||||||
|       if ('error' in res) { |       if ('error' in res) { | ||||||
|         console.error('Control socket error', msg, res.error) |         console.error('Control socket error', msg, res.error) | ||||||
|         store.error = res.error.message |         documentStore.error = res.error.message | ||||||
|         return |         return | ||||||
|       } else if (res.status === 'ack') { |       } else if (res.status === 'ack') { | ||||||
|         console.log('Control ack OK', res) |         console.log('Control ack OK', res) | ||||||
|         control.close() |         control.close() | ||||||
|         store.selected.clear() |         documentStore.selected.clear() | ||||||
|         return |         return | ||||||
|       } else console.log('Unknown control response', msg, res) |       } else console.log('Unknown control response', msg, res) | ||||||
|     } |     } | ||||||
| @@ -108,17 +108,17 @@ const filesystemdl = async (sel: SelectedItems, handle: FileSystemDirectoryHandl | |||||||
| } | } | ||||||
|  |  | ||||||
| const download = async () => { | const download = async () => { | ||||||
|   const sel = store.selectedFiles |   const sel = documentStore.selectedFiles | ||||||
|   console.log('Download', sel) |   console.log('Download', sel) | ||||||
|   if (sel.keys.length === 0) { |   if (sel.keys.length === 0) { | ||||||
|     console.warn('Attempted download but no files found. Missing selected keys:', sel.missing) |     console.warn('Attempted download but no files found. Missing selected keys:', sel.missing) | ||||||
|     store.selected.clear() |     documentStore.selected.clear() | ||||||
|     return |     return | ||||||
|   } |   } | ||||||
|   // Plain old a href download if only one file (ignoring any folders) |   // Plain old a href download if only one file (ignoring any folders) | ||||||
|   const files = sel.recursive.filter(([rel, full, doc]) => !doc.dir) |   const files = sel.recursive.filter(([rel, full, doc]) => !doc.dir) | ||||||
|   if (files.length === 1) { |   if (files.length === 1) { | ||||||
|     store.selected.clear() |     documentStore.selected.clear() | ||||||
|     return linkdl(`/files/${files[0][1]}`) |     return linkdl(`/files/${files[0][1]}`) | ||||||
|   } |   } | ||||||
|   // Use FileSystem API if multiple files and the browser supports it |   // Use FileSystem API if multiple files and the browser supports it | ||||||
| @@ -130,7 +130,7 @@ const download = async () => { | |||||||
|         mode: 'readwrite' |         mode: 'readwrite' | ||||||
|       }) |       }) | ||||||
|       filesystemdl(sel, handle).then(() => { |       filesystemdl(sel, handle).then(() => { | ||||||
|         store.selected.clear() |         documentStore.selected.clear() | ||||||
|       }) |       }) | ||||||
|       return |       return | ||||||
|     } catch (e) { |     } catch (e) { | ||||||
| @@ -140,7 +140,7 @@ const download = async () => { | |||||||
|   // Otherwise, zip and download |   // Otherwise, zip and download | ||||||
|   const name = sel.keys.length === 1 ? sel.docs[sel.keys[0]].name : 'download' |   const name = sel.keys.length === 1 ? sel.docs[sel.keys[0]].name : 'download' | ||||||
|   linkdl(`/zip/${Array.from(sel.keys).join('+')}/${name}.zip`) |   linkdl(`/zip/${Array.from(sel.keys).join('+')}/${name}.zip`) | ||||||
|   store.selected.clear() |   documentStore.selected.clear() | ||||||
| } | } | ||||||
| </script> | </script> | ||||||
|  |  | ||||||
| @@ -152,4 +152,3 @@ const download = async () => { | |||||||
|   text-overflow: ellipsis; |   text-overflow: ellipsis; | ||||||
| } | } | ||||||
| </style> | </style> | ||||||
| @/stores/main |  | ||||||
|   | |||||||
| @@ -39,10 +39,10 @@ | |||||||
| import { reactive, ref } from 'vue' | import { reactive, ref } from 'vue' | ||||||
| import { loginUser } from '@/repositories/User' | import { loginUser } from '@/repositories/User' | ||||||
| import type { ISimpleError } from '@/repositories/Client' | import type { ISimpleError } from '@/repositories/Client' | ||||||
| import { useMainStore } from '@/stores/main' | import { useDocumentStore } from '@/stores/documents' | ||||||
|  |  | ||||||
| const confirmLoading = ref<boolean>(false) | const confirmLoading = ref<boolean>(false) | ||||||
| const store = useMainStore() | const store = useDocumentStore() | ||||||
|  |  | ||||||
| const loginForm = reactive({ | const loginForm = reactive({ | ||||||
|   username: '', |   username: '', | ||||||
| @@ -99,4 +99,3 @@ const login = async () => { | |||||||
|   height: 1em; |   height: 1em; | ||||||
| } | } | ||||||
| </style> | </style> | ||||||
| @/stores/main |  | ||||||
|   | |||||||
							
								
								
									
										27
									
								
								frontend/src/components/NotificationLoading.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								frontend/src/components/NotificationLoading.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,27 @@ | |||||||
|  | <template> | ||||||
|  |   <template v-for="upload in documentStore.uploadingDocuments" :key="upload.key"> | ||||||
|  |     <span>{{ upload.name }}</span> | ||||||
|  |     <div class="progress-container"> | ||||||
|  |       <a-progress :percent="upload.progress" /> | ||||||
|  |       <CloseCircleOutlined class="close-button" @click="dismissUpload(upload.key)" /> | ||||||
|  |     </div> | ||||||
|  |   </template> | ||||||
|  | </template> | ||||||
|  | <script setup lang="ts"> | ||||||
|  | import { useDocumentStore } from '@/stores/documents' | ||||||
|  | const documentStore = useDocumentStore() | ||||||
|  |  | ||||||
|  | function dismissUpload(key: number) { | ||||||
|  |   documentStore.deleteUploadingDocument(key) | ||||||
|  | } | ||||||
|  | </script> | ||||||
|  |  | ||||||
|  | <style scoped> | ||||||
|  | .progress-container { | ||||||
|  |   display: flex; | ||||||
|  |   align-items: center; | ||||||
|  | } | ||||||
|  | .close-button:hover { | ||||||
|  |   color: #b81414; | ||||||
|  | } | ||||||
|  | </style> | ||||||
| @@ -1,89 +1,39 @@ | |||||||
| <script setup lang="ts"> | <script setup lang="ts"> | ||||||
| import { connect, uploadUrl } from '@/repositories/WS'; | import { connect, uploadUrl } from '@/repositories/WS'; | ||||||
| import { useMainStore } from '@/stores/main' | import { useDocumentStore } from '@/stores/documents' | ||||||
| import { collator } from '@/utils'; | import { collator } from '@/utils'; | ||||||
| import { computed, onMounted, onUnmounted, reactive, ref } from 'vue' | import { computed, onMounted, onUnmounted, reactive, ref } from 'vue' | ||||||
|  |  | ||||||
| const fileInput = ref() | const fileInput = ref() | ||||||
| const folderInput = ref() | const folderInput = ref() | ||||||
| const store = useMainStore() | const documentStore = useDocumentStore() | ||||||
| const props = defineProps({ | const props = defineProps({ | ||||||
|   path: Array<string> |   path: Array<string> | ||||||
| }) | }) | ||||||
|  |  | ||||||
| type CloudFile = { |  | ||||||
|   file: File |  | ||||||
|   cloudName: string |  | ||||||
|   cloudPos: number |  | ||||||
| } |  | ||||||
| function pasteHandler(event: ClipboardEvent) { |  | ||||||
|   const items = Array.from(event.clipboardData?.items ?? []) |  | ||||||
|   const infiles = [] as File[] |  | ||||||
|   const dirs = [] as FileSystemDirectoryEntry[] |  | ||||||
|   for (const item of items) { |  | ||||||
|     if (item.kind !== 'file') continue |  | ||||||
|     const entry = item.webkitGetAsEntry() |  | ||||||
|     if (entry?.isFile) { |  | ||||||
|       const file = item.getAsFile() |  | ||||||
|       if (file) infiles.push(file) |  | ||||||
|     } else if (entry?.isDirectory) { |  | ||||||
|       dirs.push(entry as FileSystemDirectoryEntry) |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
|   if (infiles.length || dirs.length) { |  | ||||||
|     event.preventDefault() |  | ||||||
|     uploadFiles(infiles) |  | ||||||
|     for (const entry of dirs) pasteDirectory(entry, `${props.path!.join('/')}/${entry.name}`) |  | ||||||
|   } |  | ||||||
| } |  | ||||||
| const pasteDirectory = async (entry: FileSystemDirectoryEntry, loc: string) => { |  | ||||||
|   const reader = entry.createReader() |  | ||||||
|   const entries = await new Promise<any[]>(resolve => reader.readEntries(resolve)) |  | ||||||
|   const cloudfiles = [] as CloudFile[] |  | ||||||
|   for (const entry of entries) { |  | ||||||
|     const cloudName = `${loc}/${entry.name}` |  | ||||||
|     if (entry.isFile) { |  | ||||||
|       const file = await new Promise(resolve => entry.file(resolve)) as File |  | ||||||
|       cloudfiles.push({file, cloudName, cloudPos: 0}) |  | ||||||
|     } else if (entry.isDirectory) { |  | ||||||
|       await pasteDirectory(entry, cloudName) |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
|   if (cloudfiles.length) uploadCloudFiles(cloudfiles) |  | ||||||
| } |  | ||||||
| function uploadHandler(event: Event) { | function uploadHandler(event: Event) { | ||||||
|   event.preventDefault() |   event.preventDefault() | ||||||
|  |   event.stopPropagation() | ||||||
|   // @ts-ignore |   // @ts-ignore | ||||||
|   const input = event.target as HTMLInputElement | null |   let infiles = Array.from(event.dataTransfer?.files || event.target.files) as File[] | ||||||
|   const infiles = Array.from((input ?? (event as DragEvent).dataTransfer)?.files ?? []) as File[] |   if (!infiles.length) return | ||||||
|   if (input) input.value = '' |  | ||||||
|   if (infiles.length) uploadFiles(infiles) |  | ||||||
| } |  | ||||||
|  |  | ||||||
| const uploadFiles = (infiles: File[]) => { |  | ||||||
|   const loc = props.path!.join('/') |   const loc = props.path!.join('/') | ||||||
|   let files = [] |   for (const f of infiles) { | ||||||
|   for (const file of infiles) { |     f.cloudName = loc + '/' + (f.webkitRelativePath || f.name) | ||||||
|     files.push({ |     f.cloudPos = 0 | ||||||
|       file, |  | ||||||
|       cloudName: loc + '/' + (file.webkitRelativePath || file.name), |  | ||||||
|       cloudPos: 0, |  | ||||||
|     }) |  | ||||||
|   } |   } | ||||||
|   uploadCloudFiles(files) |   const dotfiles = infiles.filter(f => f.cloudName.includes('/.')) | ||||||
| } |  | ||||||
| const uploadCloudFiles = (files: CloudFile[]) => { |  | ||||||
|   const dotfiles = files.filter(f => f.cloudName.includes('/.')) |  | ||||||
|   if (dotfiles.length) { |   if (dotfiles.length) { | ||||||
|     store.error = "Won't upload dotfiles" |     documentStore.error = "Won't upload dotfiles" | ||||||
|     console.log("Dotfiles omitted", dotfiles) |     console.log("Dotfiles omitted", dotfiles) | ||||||
|     files = files.filter(f => !f.cloudName.includes('/.')) |     infiles = infiles.filter(f => !f.cloudName.includes('/.')) | ||||||
|   } |   } | ||||||
|   if (!files.length) return |   if (!infiles.length) return | ||||||
|   files.sort((a, b) => collator.compare(a.cloudName, b.cloudName)) |   infiles.sort((a, b) => collator.compare(a.cloudName, b.cloudName)) | ||||||
|   // @ts-ignore |   // @ts-ignore | ||||||
|   upqueue = [...upqueue, ...files] |   upqueue = upqueue.concat(infiles) | ||||||
|   statsAdd(files) |   statsAdd(infiles) | ||||||
|   startWorker() |   startWorker() | ||||||
| } | } | ||||||
|  |  | ||||||
| @@ -99,14 +49,13 @@ const uprogress_init = { | |||||||
|   tlast: 0, |   tlast: 0, | ||||||
|   statbytes: 0, |   statbytes: 0, | ||||||
|   statdur: 0, |   statdur: 0, | ||||||
|   files: [] as CloudFile[], |   files: [], | ||||||
|   filestart: 0, |   filestart: 0, | ||||||
|   fileidx: 0, |   fileidx: 0, | ||||||
|   filecount: 0, |   filecount: 0, | ||||||
|   filename: '', |   filename: '', | ||||||
|   filesize: 0, |   filesize: 0, | ||||||
|   filepos: 0, |   filepos: 0, | ||||||
|   status: 'idle', |  | ||||||
| } | } | ||||||
| const uprogress = reactive({...uprogress_init}) | const uprogress = reactive({...uprogress_init}) | ||||||
| const percent = computed(() => uprogress.uploaded / uprogress.total * 100) | const percent = computed(() => uprogress.uploaded / uprogress.total * 100) | ||||||
| @@ -117,7 +66,7 @@ const speed = computed(() => { | |||||||
|   if (tsince > 1 / s) return 1 / tsince  // Next block is late or not coming, decay |   if (tsince > 1 / s) return 1 / tsince  // Next block is late or not coming, decay | ||||||
|   return s  // "Current speed" |   return s  // "Current speed" | ||||||
| }) | }) | ||||||
| const speeddisp = computed(() => speed.value ? speed.value.toFixed(speed.value < 10 ? 1 : 0) + '\u202FMB/s': 'stalled') | const speeddisp = computed(() => speed.value ? speed.value.toFixed(speed.value < 100 ? 1 : 0) + '\u202FMB/s': 'stalled') | ||||||
| setInterval(() => { | setInterval(() => { | ||||||
|   if (Date.now() - uprogress.tlast > 3000) { |   if (Date.now() - uprogress.tlast > 3000) { | ||||||
|     // Reset |     // Reset | ||||||
| @@ -129,7 +78,7 @@ setInterval(() => { | |||||||
|     uprogress.statdur *= .9 |     uprogress.statdur *= .9 | ||||||
|   } |   } | ||||||
| }, 100) | }, 100) | ||||||
| const statUpdate = ({name, size, start, end}: {name: string, size: number, start: number, end: number}) => { | const statUpdate = ({name, size, start, end}) => { | ||||||
|   if (name !== uprogress.filename) return  // If stats have been reset |   if (name !== uprogress.filename) return  // If stats have been reset | ||||||
|   const now = Date.now() |   const now = Date.now() | ||||||
|   uprogress.uploaded = uprogress.filestart + end |   uprogress.uploaded = uprogress.filestart + end | ||||||
| @@ -148,7 +97,7 @@ const statNextFile = () => { | |||||||
|   const f = uprogress.files.shift() |   const f = uprogress.files.shift() | ||||||
|   if (!f) return statReset() |   if (!f) return statReset() | ||||||
|   uprogress.filepos = 0 |   uprogress.filepos = 0 | ||||||
|   uprogress.filesize = f.file.size |   uprogress.filesize = f.size | ||||||
|   uprogress.filename = f.cloudName |   uprogress.filename = f.cloudName | ||||||
| } | } | ||||||
| const statReset = () => { | const statReset = () => { | ||||||
| @@ -156,14 +105,14 @@ const statReset = () => { | |||||||
|   uprogress.t0 = Date.now() |   uprogress.t0 = Date.now() | ||||||
|   uprogress.tlast = uprogress.t0 + 1 |   uprogress.tlast = uprogress.t0 + 1 | ||||||
| } | } | ||||||
| const statsAdd = (f: CloudFile[]) => { | const statsAdd = (f: Array<File>) => { | ||||||
|   if (uprogress.files.length === 0) statReset() |   if (uprogress.files.length === 0) statReset() | ||||||
|   uprogress.total += f.reduce((a, b) => a + b.file.size, 0) |   uprogress.total += f.reduce((a, b) => a + b.size, 0) | ||||||
|   uprogress.filecount += f.length |   uprogress.filecount += f.length | ||||||
|   uprogress.files = [...uprogress.files, ...f] |   uprogress.files = uprogress.files.concat(f) | ||||||
|   statNextFile() |   statNextFile() | ||||||
| } | } | ||||||
| let upqueue = [] as CloudFile[] | let upqueue = [] as File[] | ||||||
|  |  | ||||||
| // TODO: Rewrite as WebSocket class | // TODO: Rewrite as WebSocket class | ||||||
| const WSCreate = async () => await new Promise<WebSocket>(resolve => { | const WSCreate = async () => await new Promise<WebSocket>(resolve => { | ||||||
| @@ -171,13 +120,13 @@ const WSCreate = async () => await new Promise<WebSocket>(resolve => { | |||||||
|     open(ev: Event) { resolve(ws) }, |     open(ev: Event) { resolve(ws) }, | ||||||
|     error(ev: Event) { |     error(ev: Event) { | ||||||
|       console.error('Upload socket error', ev) |       console.error('Upload socket error', ev) | ||||||
|       store.error = 'Upload socket error' |       documentStore.error = 'Upload socket error' | ||||||
|     }, |     }, | ||||||
|     message(ev: MessageEvent) { |     message(ev: MessageEvent) { | ||||||
|       const res = JSON.parse(ev!.data) |       const res = JSON.parse(ev!.data) | ||||||
|       if ('error' in res) { |       if ('error' in res) { | ||||||
|         console.error('Upload socket error', res.error) |         console.error('Upload socket error', res.error) | ||||||
|         store.error = res.error.message |         documentStore.error = res.error.message | ||||||
|         return |         return | ||||||
|       } |       } | ||||||
|       if (res.status === 'ack') { |       if (res.status === 'ack') { | ||||||
| @@ -206,17 +155,18 @@ const worker = async () => { | |||||||
|   const ws = await WSCreate() |   const ws = await WSCreate() | ||||||
|   while (upqueue.length) { |   while (upqueue.length) { | ||||||
|     const f = upqueue[0] |     const f = upqueue[0] | ||||||
|  |     if (f.cloudPos === f.size) { | ||||||
|  |       upqueue.shift() | ||||||
|  |       continue | ||||||
|  |     } | ||||||
|     const start = f.cloudPos |     const start = f.cloudPos | ||||||
|     const end = Math.min(f.file.size, start + (1<<20)) |     const end = Math.min(f.size, start + (1<<20)) | ||||||
|     const control = { name: f.cloudName, size: f.file.size, start, end } |     const control = { name: f.cloudName, size: f.size, start, end } | ||||||
|     const data = f.file.slice(start, end) |     const data = f.slice(start, end) | ||||||
|     f.cloudPos = end |     f.cloudPos = end | ||||||
|     // Note: files may get modified during I/O |     // Note: files may get modified during I/O | ||||||
|     // @ts-ignore FIXME proper WebSocket class, avoid attaching functions to WebSocket object |  | ||||||
|     ws.sendMsg(control) |     ws.sendMsg(control) | ||||||
|     // @ts-ignore |  | ||||||
|     await ws.sendData(data) |     await ws.sendData(data) | ||||||
|     if (f.cloudPos === f.file.size) upqueue.shift() |  | ||||||
|   } |   } | ||||||
|   if (upqueue.length) startWorker() |   if (upqueue.length) startWorker() | ||||||
|   uprogress.status = "idle" |   uprogress.status = "idle" | ||||||
| @@ -234,10 +184,8 @@ onMounted(() => { | |||||||
|   // Need to prevent both to prevent browser from opening the file |   // Need to prevent both to prevent browser from opening the file | ||||||
|   addEventListener('dragover', uploadHandler) |   addEventListener('dragover', uploadHandler) | ||||||
|   addEventListener('drop', uploadHandler) |   addEventListener('drop', uploadHandler) | ||||||
|   addEventListener('paste', pasteHandler) |  | ||||||
| }) | }) | ||||||
| onUnmounted(() => { | onUnmounted(() => { | ||||||
|   removeEventListener('paste', pasteHandler) |  | ||||||
|   removeEventListener('dragover', uploadHandler) |   removeEventListener('dragover', uploadHandler) | ||||||
|   removeEventListener('drop', uploadHandler) |   removeEventListener('drop', uploadHandler) | ||||||
| }) | }) | ||||||
| @@ -259,7 +207,7 @@ onUnmounted(() => { | |||||||
|           {{ (uprogress.filepos / uprogress.filesize * 100).toFixed(0) + '\u202F%' }} |           {{ (uprogress.filepos / uprogress.filesize * 100).toFixed(0) + '\u202F%' }} | ||||||
|         </span> |         </span> | ||||||
|       </span> |       </span> | ||||||
|       <span class="position" v-if="uprogress.total > 1e7"> |       <span class="position" v-if="uprogress.filesize > 1e7"> | ||||||
|         {{ (uprogress.uploaded / 1e6).toFixed(0) + '\u202F/\u202F' + (uprogress.total / 1e6).toFixed(0) + '\u202FMB' }} |         {{ (uprogress.uploaded / 1e6).toFixed(0) + '\u202F/\u202F' + (uprogress.total / 1e6).toFixed(0) + '\u202FMB' }} | ||||||
|       </span> |       </span> | ||||||
|       <span class="speed">{{ speeddisp }}</span> |       <span class="speed">{{ speeddisp }}</span> | ||||||
| @@ -302,4 +250,3 @@ span { | |||||||
| .position { min-width: 4em } | .position { min-width: 4em } | ||||||
| .speed { min-width: 4em } | .speed { min-width: 4em } | ||||||
| </style> | </style> | ||||||
| @/stores/main |  | ||||||
|   | |||||||
| @@ -1,42 +1,17 @@ | |||||||
| import { formatSize, formatUnixDate, haystackFormat } from "@/utils" |  | ||||||
|  |  | ||||||
| export type FUID = string | export type FUID = string | ||||||
|  |  | ||||||
| export type DocProps = { | export type Document = { | ||||||
|   loc: string |   loc: string | ||||||
|   name: string |   name: string | ||||||
|   key: FUID |   key: FUID | ||||||
|   size: number |   size: number | ||||||
|  |   sizedisp: string | ||||||
|   mtime: number |   mtime: number | ||||||
|  |   modified: string | ||||||
|  |   haystack: string | ||||||
|   dir: boolean |   dir: boolean | ||||||
| } | } | ||||||
|  |  | ||||||
| export class Doc { |  | ||||||
|   private _name: string = "" |  | ||||||
|   public loc: string = "" |  | ||||||
|   public key: FUID = "" |  | ||||||
|   public size: number = 0 |  | ||||||
|   public mtime: number = 0 |  | ||||||
|   public haystack: string = "" |  | ||||||
|   public dir: boolean = false |  | ||||||
|  |  | ||||||
|   constructor(props: Partial<DocProps> = {}) { Object.assign(this, props) } |  | ||||||
|   get name() { return this._name } |  | ||||||
|   set name(name: string) { |  | ||||||
|     if (name.includes('/') || name.startsWith('.')) throw Error(`Invalid name: ${name}`) |  | ||||||
|     this._name = name |  | ||||||
|     this.haystack = haystackFormat(name) |  | ||||||
|   } |  | ||||||
|   get sizedisp(): string { return formatSize(this.size) } |  | ||||||
|   get modified(): string { return formatUnixDate(this.mtime) } |  | ||||||
|   get url(): string { |  | ||||||
|     const p = this.loc ? `${this.loc}/${this.name}` : this.name |  | ||||||
|     return this.dir ? '/#/' + `${p}/`.replaceAll('#', '%23') : `/files/${p}`.replaceAll('?', '%3F').replaceAll('#', '%23') |  | ||||||
|   } |  | ||||||
|   get urlrouter(): string { |  | ||||||
|     return this.url.replace(/^\/#/, '') |  | ||||||
|   } |  | ||||||
| } |  | ||||||
| export type errorEvent = { | export type errorEvent = { | ||||||
|   error: { |   error: { | ||||||
|     code: number |     code: number | ||||||
| @@ -61,7 +36,7 @@ export type UpdateEntry = ['k', number] | ['d', number] | ['i', Array<FileEntry> | |||||||
| // Helper structure for selections | // Helper structure for selections | ||||||
| export interface SelectedItems { | export interface SelectedItems { | ||||||
|   keys: FUID[] |   keys: FUID[] | ||||||
|   docs: Record<FUID, Doc> |   docs: Record<FUID, Document> | ||||||
|   recursive: Array<[string, string, Doc]> |   recursive: Array<[string, string, Document]> | ||||||
|   missing: Set<FUID> |   missing: Set<FUID> | ||||||
| } | } | ||||||
|   | |||||||
| @@ -1,4 +1,4 @@ | |||||||
| import { useMainStore } from "@/stores/main" | import { useDocumentStore } from "@/stores/documents" | ||||||
| import type { FileEntry, UpdateEntry, errorEvent } from "./Document" | import type { FileEntry, UpdateEntry, errorEvent } from "./Document" | ||||||
|  |  | ||||||
| export const controlUrl = '/api/control' | export const controlUrl = '/api/control' | ||||||
| @@ -6,26 +6,22 @@ export const uploadUrl = '/api/upload' | |||||||
| export const watchUrl = '/api/watch' | export const watchUrl = '/api/watch' | ||||||
|  |  | ||||||
| let tree = [] as FileEntry[] | let tree = [] as FileEntry[] | ||||||
| let reconnDelay = 500 | let reconnectDuration = 500 | ||||||
| let wsWatch = null as WebSocket | null | let wsWatch = null as WebSocket | null | ||||||
|  |  | ||||||
| export const loadSession = () => { | export const loadSession = () => { | ||||||
|   const s = localStorage['cista-files'] |   const store = useDocumentStore() | ||||||
|   if (!s) return false |  | ||||||
|   const store = useMainStore() |  | ||||||
|   try { |   try { | ||||||
|     tree = JSON.parse(s) |     tree = JSON.parse(sessionStorage["cista-files"]) | ||||||
|     store.updateRoot(tree) |     store.updateRoot(tree) | ||||||
|     console.log(`Loaded session with ${tree.length} items cached`) |  | ||||||
|     return true |     return true | ||||||
|   } catch (error) { |   } catch (error) { | ||||||
|     console.log("Loading session failed", error) |  | ||||||
|     return false |     return false | ||||||
|   } |   } | ||||||
| } | } | ||||||
|  |  | ||||||
| const saveSession = () => { | const saveSession = () => { | ||||||
|   localStorage["cista-files"] = JSON.stringify(tree) |   sessionStorage["cista-files"] = JSON.stringify(tree) | ||||||
| } | } | ||||||
|  |  | ||||||
| export const connect = (path: string, handlers: Partial<Record<keyof WebSocketEventMap, any>>) => { | export const connect = (path: string, handlers: Partial<Record<keyof WebSocketEventMap, any>>) => { | ||||||
| @@ -39,7 +35,7 @@ export const watchConnect = () => { | |||||||
|     clearTimeout(watchTimeout) |     clearTimeout(watchTimeout) | ||||||
|     watchTimeout = null |     watchTimeout = null | ||||||
|   } |   } | ||||||
|   const store = useMainStore() |   const store = useDocumentStore() | ||||||
|   if (store.error !== 'Reconnecting...') store.error = 'Connecting...' |   if (store.error !== 'Reconnecting...') store.error = 'Connecting...' | ||||||
|   console.log(store.error) |   console.log(store.error) | ||||||
|  |  | ||||||
| @@ -63,7 +59,7 @@ export const watchConnect = () => { | |||||||
|       console.log('Connected to backend', msg) |       console.log('Connected to backend', msg) | ||||||
|       store.server = msg.server |       store.server = msg.server | ||||||
|       store.connected = true |       store.connected = true | ||||||
|       reconnDelay = 500 |       reconnectDuration = 500 | ||||||
|       store.error = '' |       store.error = '' | ||||||
|       if (msg.user) store.login(msg.user.username, msg.user.privileged) |       if (msg.user) store.login(msg.user.username, msg.user.privileged) | ||||||
|       else if (store.isUserLogged) store.logout() |       else if (store.isUserLogged) store.logout() | ||||||
| @@ -81,16 +77,16 @@ export const watchDisconnect = () => { | |||||||
| let watchTimeout: any = null | let watchTimeout: any = null | ||||||
|  |  | ||||||
| const watchReconnect = (event: MessageEvent) => { | const watchReconnect = (event: MessageEvent) => { | ||||||
|   const store = useMainStore() |   const store = useDocumentStore() | ||||||
|   if (store.connected) { |   if (store.connected) { | ||||||
|     console.warn("Disconnected from server", event) |     console.warn("Disconnected from server", event) | ||||||
|     store.connected = false |     store.connected = false | ||||||
|     store.error = 'Reconnecting...' |     store.error = 'Reconnecting...' | ||||||
|   } |   } | ||||||
|   reconnDelay = Math.min(5000, reconnDelay + 500) |   reconnectDuration = Math.min(5000, reconnectDuration + 500) | ||||||
|   // The server closes the websocket after errors, so we need to reopen it |   // The server closes the websocket after errors, so we need to reopen it | ||||||
|   if (watchTimeout !== null) clearTimeout(watchTimeout) |   if (watchTimeout !== null) clearTimeout(watchTimeout) | ||||||
|   watchTimeout = setTimeout(watchConnect, reconnDelay) |   watchTimeout = setTimeout(watchConnect, reconnectDuration) | ||||||
| } | } | ||||||
|  |  | ||||||
|  |  | ||||||
| @@ -113,8 +109,8 @@ const handleWatchMessage = (event: MessageEvent) => { | |||||||
|   } |   } | ||||||
| } | } | ||||||
|  |  | ||||||
| function handleRootMessage({ root }: { root: FileEntry[] }) { | function handleRootMessage({ root }: { root: DirEntry }) { | ||||||
|   const store = useMainStore() |   const store = useDocumentStore() | ||||||
|   console.log('Watch root', root) |   console.log('Watch root', root) | ||||||
|   store.updateRoot(root) |   store.updateRoot(root) | ||||||
|   tree = root |   tree = root | ||||||
| @@ -122,7 +118,7 @@ function handleRootMessage({ root }: { root: FileEntry[] }) { | |||||||
| } | } | ||||||
|  |  | ||||||
| function handleUpdateMessage(updateData: { update: UpdateEntry[] }) { | function handleUpdateMessage(updateData: { update: UpdateEntry[] }) { | ||||||
|   const store = useMainStore() |   const store = useDocumentStore() | ||||||
|   const update = updateData.update |   const update = updateData.update | ||||||
|   console.log('Watch update', update) |   console.log('Watch update', update) | ||||||
|   if (!tree) return console.error('Watch update before root') |   if (!tree) return console.error('Watch update before root') | ||||||
| @@ -139,14 +135,14 @@ function handleUpdateMessage(updateData: { update: UpdateEntry[] }) { | |||||||
|       else console.log("Unknown update action", action, arg) |       else console.log("Unknown update action", action, arg) | ||||||
|   } |   } | ||||||
|   if (oidx != tree.length) |   if (oidx != tree.length) | ||||||
|     throw Error(`Tree update out of sync, number of entries mismatch: got ${oidx}, expected ${tree.length}, new tree ${newtree.length}`) |     throw Error(`Tree update out of sync, number of entries mismatch: got ${oidx}, expected ${tree.length}`) | ||||||
|   store.updateRoot(newtree) |   store.updateRoot(newtree) | ||||||
|   tree = newtree |   tree = newtree | ||||||
|   saveSession() |   saveSession() | ||||||
| } | } | ||||||
|  |  | ||||||
| function handleError(msg: errorEvent) { | function handleError(msg: errorEvent) { | ||||||
|   const store = useMainStore() |   const store = useDocumentStore() | ||||||
|   if (msg.error.code === 401) { |   if (msg.error.code === 401) { | ||||||
|     store.user.isOpenLoginModal = true |     store.user.isOpenLoginModal = true | ||||||
|     store.user.isLoggedIn = false |     store.user.isLoggedIn = false | ||||||
|   | |||||||
| @@ -1,12 +1,15 @@ | |||||||
| import type { FileEntry, FUID, SelectedItems } from '@/repositories/Document' | import type { Document, FileEntry, FUID, SelectedItems } from '@/repositories/Document' | ||||||
| import { Doc } from '@/repositories/Document' | import { formatSize, formatUnixDate, haystackFormat } from '@/utils' | ||||||
| import { defineStore } from 'pinia' | import { defineStore } from 'pinia' | ||||||
| import { collator } from '@/utils' | import { collator } from '@/utils' | ||||||
| import { logoutUser } from '@/repositories/User' | import { logoutUser } from '@/repositories/User' | ||||||
| import { watchConnect } from '@/repositories/WS' | import { watchConnect } from '@/repositories/WS' | ||||||
| import { shallowRef } from 'vue' | import { format } from 'path' | ||||||
| import { sorted, type SortOrder } from '@/utils/docsort' |  | ||||||
| 
 | 
 | ||||||
|  | type FileData = { id: string; mtime: number; size: number; dir: DirectoryData } | ||||||
|  | type DirectoryData = { | ||||||
|  |   [filename: string]: FileData | ||||||
|  | } | ||||||
| type User = { | type User = { | ||||||
|   username: string |   username: string | ||||||
|   privileged: boolean |   privileged: boolean | ||||||
| @@ -14,20 +17,17 @@ type User = { | |||||||
|   isLoggedIn: boolean |   isLoggedIn: boolean | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| export const useMainStore = defineStore({ | export const useDocumentStore = defineStore({ | ||||||
|   id: 'main', |   id: 'documents', | ||||||
|   state: () => ({ |   state: () => ({ | ||||||
|     document: shallowRef<Doc[]>([]), |     document: [] as Document[], | ||||||
|     selected: new Set<FUID>(), |     selected: new Set<FUID>(), | ||||||
|     query: '' as string, |     uploadingDocuments: [], | ||||||
|     fileExplorer: null as any, |     uploadCount: 0 as number, | ||||||
|  |     fileExplorer: null, | ||||||
|     error: '' as string, |     error: '' as string, | ||||||
|     connected: false, |     connected: false, | ||||||
|     server: {} as Record<string, any>, |     server: {} as Record<string, any>, | ||||||
|     prefs: { |  | ||||||
|       sortListing: '' as SortOrder, |  | ||||||
|       sortFiltered: '' as SortOrder, |  | ||||||
|     }, |  | ||||||
|     user: { |     user: { | ||||||
|       username: '', |       username: '', | ||||||
|       privileged: false, |       privileged: false, | ||||||
| @@ -35,26 +35,30 @@ export const useMainStore = defineStore({ | |||||||
|       isOpenLoginModal: false |       isOpenLoginModal: false | ||||||
|     } as User |     } as User | ||||||
|   }), |   }), | ||||||
|   persist: { |  | ||||||
|     paths: ['prefs'], |  | ||||||
|   }, |  | ||||||
|   actions: { |   actions: { | ||||||
|     updateRoot(root: FileEntry[]) { |     updateRoot(root: FileEntry[]) { | ||||||
|       const docs = [] |       const docs = [] | ||||||
|       let loc = [] as string[] |       let loc = [] as string[] | ||||||
|       for (const [level, name, key, mtime, size, isfile] of root) { |       for (const [level, name, key, mtime, size, isfile] of root) { | ||||||
|         loc = loc.slice(0, level - 1) |         loc = loc.slice(0, level - 1) | ||||||
|         docs.push(new Doc({ |         docs.push({ | ||||||
|           name, |           name, | ||||||
|           loc: level ? loc.join('/') : '/', |           loc: level ? loc.join('/') : '/', | ||||||
|           key, |           key, | ||||||
|           size, |           size, | ||||||
|  |           sizedisp: formatSize(size), | ||||||
|           mtime, |           mtime, | ||||||
|  |           modified: formatUnixDate(mtime), | ||||||
|  |           haystack: haystackFormat(name), | ||||||
|           dir: !isfile, |           dir: !isfile, | ||||||
|         })) |         }) | ||||||
|         loc.push(name) |         loc.push(name) | ||||||
|       } |       } | ||||||
|       this.document = docs |       console.log("Documents", docs) | ||||||
|  |       this.document = docs as Document[] | ||||||
|  |     }, | ||||||
|  |     updateModified() { | ||||||
|  |       for (const doc of this.document) doc.modified = formatUnixDate(doc.mtime) | ||||||
|     }, |     }, | ||||||
|     login(username: string, privileged: boolean) { |     login(username: string, privileged: boolean) { | ||||||
|       this.user.username = username |       this.user.username = username | ||||||
| @@ -70,18 +74,23 @@ export const useMainStore = defineStore({ | |||||||
|       console.log("Logout") |       console.log("Logout") | ||||||
|       await logoutUser() |       await logoutUser() | ||||||
|       this.$reset() |       this.$reset() | ||||||
|       localStorage.clear() |  | ||||||
|       history.go() // Reload page
 |       history.go() // Reload page
 | ||||||
|     }, |     } | ||||||
|     toggleSort(name: SortOrder) { |  | ||||||
|       if (this.query) this.prefs.sortFiltered = this.prefs.sortFiltered === name ? '' : name |  | ||||||
|       else this.prefs.sortListing = this.prefs.sortListing === name ? '' : name |  | ||||||
|     }, |  | ||||||
|   }, |   }, | ||||||
|   getters: { |   getters: { | ||||||
|     sortOrder(): SortOrder { return this.query ? this.prefs.sortFiltered : this.prefs.sortListing }, |     isUserLogged(): boolean { | ||||||
|     isUserLogged(): boolean { return this.user.isLoggedIn }, |       return this.user.isLoggedIn | ||||||
|     recentDocuments(): Doc[] { return sorted(this.document, 'modified') }, |     }, | ||||||
|  |     recentDocuments(): Document[] { | ||||||
|  |       const ret = [...this.document] | ||||||
|  |       ret.sort((a, b) => b.mtime - a.mtime) | ||||||
|  |       return ret | ||||||
|  |     }, | ||||||
|  |     largeDocuments(): Document[] { | ||||||
|  |       const ret = [...this.document] | ||||||
|  |       ret.sort((a, b) => b.size - a.size) | ||||||
|  |       return ret | ||||||
|  |     }, | ||||||
|     selectedFiles(): SelectedItems { |     selectedFiles(): SelectedItems { | ||||||
|       const selected = this.selected |       const selected = this.selected | ||||||
|       const found = new Set<FUID>() |       const found = new Set<FUID>() | ||||||
| @@ -102,7 +111,7 @@ export const useMainStore = defineStore({ | |||||||
|       for (const key of selected) if (!found.has(key)) ret.missing.add(key) |       for (const key of selected) if (!found.has(key)) ret.missing.add(key) | ||||||
|       // Build a flat list including contents recursively
 |       // Build a flat list including contents recursively
 | ||||||
|       const relnames = new Set<string>() |       const relnames = new Set<string>() | ||||||
|       function add(rel: string, full: string, doc: Doc) { |       function add(rel: string, full: string, doc: Document) { | ||||||
|         if (!doc.dir && relnames.has(rel)) throw Error(`Multiple selections conflict for: ${rel}`) |         if (!doc.dir && relnames.has(rel)) throw Error(`Multiple selections conflict for: ${rel}`) | ||||||
|         relnames.add(rel) |         relnames.add(rel) | ||||||
|         ret.recursive.push([rel, full, doc]) |         ret.recursive.push([rel, full, doc]) | ||||||
| @@ -1,15 +0,0 @@ | |||||||
| import { Doc } from '@/repositories/Document' |  | ||||||
| import { collator } from '@/utils' |  | ||||||
|  |  | ||||||
| export const ordering = { |  | ||||||
|   name: (a: Doc, b: Doc) => collator.compare(a.name, b.name), |  | ||||||
|   modified: (a: Doc, b: Doc) => b.mtime - a.mtime, |  | ||||||
|   size: (a: Doc, b: Doc) => b.size - a.size |  | ||||||
| } |  | ||||||
| export type SortOrder = keyof typeof ordering | '' |  | ||||||
| export const sorted = (documents: Doc[], order: SortOrder) => { |  | ||||||
|   if (!order) return documents |  | ||||||
|   const sorted = [...documents] |  | ||||||
|   sorted.sort(ordering[order]) |  | ||||||
|   return sorted |  | ||||||
| } |  | ||||||
| @@ -86,7 +86,7 @@ export function haystackFormat(str: string) { | |||||||
| // Preformat search string for faster search | // Preformat search string for faster search | ||||||
| export function needleFormat(query: string) { | export function needleFormat(query: string) { | ||||||
|   const based = query.normalize('NFKD').replace(/[\u0300-\u036f]/g, '').toLowerCase() |   const based = query.normalize('NFKD').replace(/[\u0300-\u036f]/g, '').toLowerCase() | ||||||
|   return {based, words: based.split(/\s+/)} |   return {based, words: based.split(/\W+/)} | ||||||
| } | } | ||||||
|  |  | ||||||
| // Test if haystack includes needle | // Test if haystack includes needle | ||||||
|   | |||||||
| @@ -10,12 +10,11 @@ | |||||||
|  |  | ||||||
| <script setup lang="ts"> | <script setup lang="ts"> | ||||||
| import { watchEffect, ref, computed } from 'vue' | import { watchEffect, ref, computed } from 'vue' | ||||||
| import { useMainStore } from '@/stores/main' | import { useDocumentStore } from '@/stores/documents' | ||||||
| import Router from '@/router/index' | import Router from '@/router/index' | ||||||
| import { needleFormat, localeIncludes, collator } from '@/utils'; | import { needleFormat, localeIncludes, collator } from '@/utils'; | ||||||
| import { sorted } from '@/utils/docsort'; |  | ||||||
|  |  | ||||||
| const store = useMainStore() | const documentStore = useDocumentStore() | ||||||
| const fileExplorer = ref() | const fileExplorer = ref() | ||||||
| const props = defineProps<{ | const props = defineProps<{ | ||||||
|   path: Array<string> |   path: Array<string> | ||||||
| @@ -25,25 +24,19 @@ const documents = computed(() => { | |||||||
|   const loc = props.path.join('/') |   const loc = props.path.join('/') | ||||||
|   const query = props.query |   const query = props.query | ||||||
|   // List the current location |   // List the current location | ||||||
|   if (!query) return sorted( |   if (!query) return documentStore.document.filter(doc => doc.loc === loc) | ||||||
|     store.document.filter(doc => doc.loc === loc), |  | ||||||
|     store.prefs.sortListing, |  | ||||||
|   ) |  | ||||||
|   // Find up to 100 newest documents that match the search |   // Find up to 100 newest documents that match the search | ||||||
|   const needle = needleFormat(query) |   const needle = needleFormat(query) | ||||||
|   let limit = 100 |   let limit = 100 | ||||||
|   let docs = [] |   let docs = [] | ||||||
|   for (const doc of store.recentDocuments) { |   for (const doc of documentStore.recentDocuments) { | ||||||
|     if (localeIncludes(doc.haystack, needle)) { |     if (localeIncludes(doc.haystack, needle)) { | ||||||
|       docs.push(doc) |       docs.push(doc) | ||||||
|       if (--limit === 0) break |       if (--limit === 0) break | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
|  |   // Organize by folder, by relevance | ||||||
|   const locsub = loc + '/' |   const locsub = loc + '/' | ||||||
|   // Custom sort override in effect? |  | ||||||
|   const order = store.prefs.sortFiltered |  | ||||||
|   if (order) return sorted(docs, order) |  | ||||||
|   // Sort by relevance - current folder, then subfolders, then others |  | ||||||
|   docs.sort((a, b) => ( |   docs.sort((a, b) => ( | ||||||
|     // @ts-ignore |     // @ts-ignore | ||||||
|     (b.loc === loc) - (a.loc === loc) || |     (b.loc === loc) - (a.loc === loc) || | ||||||
| @@ -60,7 +53,6 @@ const documents = computed(() => { | |||||||
| }) | }) | ||||||
|  |  | ||||||
| watchEffect(() => { | watchEffect(() => { | ||||||
|   store.fileExplorer = fileExplorer.value |   documentStore.fileExplorer = fileExplorer.value | ||||||
|   store.query = props.query |  | ||||||
| }) | }) | ||||||
| </script> | </script> | ||||||
|   | |||||||
| @@ -29,7 +29,7 @@ dependencies = [ | |||||||
| ] | ] | ||||||
|  |  | ||||||
| [project.urls] | [project.urls] | ||||||
| Homepage = "https://git.zi.fi/Vasanko/cista-storage" | Homepage = "" | ||||||
|  |  | ||||||
| [project.scripts] | [project.scripts] | ||||||
| cista = "cista.__main__:main" | cista = "cista.__main__:main" | ||||||
|   | |||||||
| @@ -1,136 +0,0 @@ | |||||||
| from pathlib import PurePosixPath |  | ||||||
|  |  | ||||||
| import msgspec |  | ||||||
| import pytest |  | ||||||
|  |  | ||||||
| from cista.protocol import FileEntry, UpdateMessage, UpdDel, UpdIns, UpdKeep |  | ||||||
| from cista.watching import State, format_update |  | ||||||
|  |  | ||||||
|  |  | ||||||
| def decode(data: str): |  | ||||||
|     return msgspec.json.decode(data, type=UpdateMessage).update |  | ||||||
|  |  | ||||||
|  |  | ||||||
| # Helper function to create a list of FileEntry objects |  | ||||||
| def f(count, start=0): |  | ||||||
|     return [FileEntry(i, str(i), str(i), 0, 0, 0) for i in range(start, start + count)] |  | ||||||
|  |  | ||||||
|  |  | ||||||
| def test_identical_lists(): |  | ||||||
|     old_list = f(3) |  | ||||||
|     new_list = old_list.copy() |  | ||||||
|     expected = [UpdKeep(3)] |  | ||||||
|     assert decode(format_update(old_list, new_list)) == expected |  | ||||||
|  |  | ||||||
|  |  | ||||||
| def test_completely_different_lists(): |  | ||||||
|     old_list = f(3) |  | ||||||
|     new_list = f(3, 3)  # Different entries |  | ||||||
|     expected = [UpdDel(3), UpdIns(new_list)] |  | ||||||
|     assert decode(format_update(old_list, new_list)) == expected |  | ||||||
|  |  | ||||||
|  |  | ||||||
| def test_insertions(): |  | ||||||
|     old_list = f(3) |  | ||||||
|     new_list = old_list[:2] + f(1, 10) + old_list[2:] |  | ||||||
|     expected = [UpdKeep(2), UpdIns(f(1, 10)), UpdKeep(1)] |  | ||||||
|     assert decode(format_update(old_list, new_list)) == expected |  | ||||||
|  |  | ||||||
|  |  | ||||||
| def test_deletions(): |  | ||||||
|     old_list = f(3) |  | ||||||
|     new_list = [old_list[0], old_list[2]] |  | ||||||
|     expected = [UpdKeep(1), UpdDel(1), UpdKeep(1)] |  | ||||||
|     assert decode(format_update(old_list, new_list)) == expected |  | ||||||
|  |  | ||||||
|  |  | ||||||
| def test_mixed_operations(): |  | ||||||
|     old_list = f(4) |  | ||||||
|     new_list = [old_list[0], old_list[2], *f(1, 10)] |  | ||||||
|     expected = [UpdKeep(1), UpdDel(1), UpdKeep(1), UpdDel(1), UpdIns(f(1, 10))] |  | ||||||
|     assert decode(format_update(old_list, new_list)) == expected |  | ||||||
|  |  | ||||||
|  |  | ||||||
| def test_empty_old_list(): |  | ||||||
|     old_list = [] |  | ||||||
|     new_list = f(3) |  | ||||||
|     expected = [UpdIns(new_list)] |  | ||||||
|     assert decode(format_update(old_list, new_list)) == expected |  | ||||||
|  |  | ||||||
|  |  | ||||||
| def test_empty_new_list(): |  | ||||||
|     old_list = f(3) |  | ||||||
|     new_list = [] |  | ||||||
|     expected = [UpdDel(3)] |  | ||||||
|     assert decode(format_update(old_list, new_list)) == expected |  | ||||||
|  |  | ||||||
|  |  | ||||||
| def test_longer_lists(): |  | ||||||
|     old_list = f(6) |  | ||||||
|     new_list = f(1, 6) + old_list[1:3] + old_list[4:5] + f(2, 7) |  | ||||||
|     expected = [ |  | ||||||
|         UpdDel(1), |  | ||||||
|         UpdIns(f(1, 6)), |  | ||||||
|         UpdKeep(2), |  | ||||||
|         UpdDel(1), |  | ||||||
|         UpdKeep(1), |  | ||||||
|         UpdDel(1), |  | ||||||
|         UpdIns(f(2, 7)), |  | ||||||
|     ] |  | ||||||
|     assert decode(format_update(old_list, new_list)) == expected |  | ||||||
|  |  | ||||||
|  |  | ||||||
| def sortkey(name): |  | ||||||
|     # Define the sorting key for names here |  | ||||||
|     return name.lower() |  | ||||||
|  |  | ||||||
|  |  | ||||||
| @pytest.fixture() |  | ||||||
| def state(): |  | ||||||
|     entries = [ |  | ||||||
|         FileEntry(0, "", "root", 0, 0, 0), |  | ||||||
|         FileEntry(1, "bar", "bar", 0, 0, 0), |  | ||||||
|         FileEntry(2, "baz", "bar/baz", 0, 0, 0), |  | ||||||
|         FileEntry(1, "foo", "foo", 0, 0, 0), |  | ||||||
|         FileEntry(1, "xxx", "xxx", 0, 0, 0), |  | ||||||
|         FileEntry(2, "yyy", "xxx/yyy", 0, 0, 1), |  | ||||||
|     ] |  | ||||||
|     s = State() |  | ||||||
|     s._listing = entries |  | ||||||
|     return s |  | ||||||
|  |  | ||||||
|  |  | ||||||
| def test_existing_directory(state): |  | ||||||
|     path = PurePosixPath("bar") |  | ||||||
|     expected_slice = slice(1, 3)  # Includes 'bar' and 'baz' |  | ||||||
|     assert state._slice(path) == expected_slice |  | ||||||
|  |  | ||||||
|  |  | ||||||
| def test_existing_file(state): |  | ||||||
|     path = PurePosixPath("xxx/yyy") |  | ||||||
|     expected_slice = slice(5, 6)  # Only includes 'yyy' |  | ||||||
|     assert state._slice(path) == expected_slice |  | ||||||
|  |  | ||||||
|  |  | ||||||
| def test_nonexistent_directory(state): |  | ||||||
|     path = PurePosixPath("zzz") |  | ||||||
|     expected_slice = slice(6, 6)  # 'zzz' would be inserted at end |  | ||||||
|     assert state._slice(path) == expected_slice |  | ||||||
|  |  | ||||||
|  |  | ||||||
| def test_nonexistent_file(state): |  | ||||||
|     path = (PurePosixPath("bar/mmm"), 1) |  | ||||||
|     expected_slice = slice(3, 3)  # A file would be inserted after 'baz' under 'bar' |  | ||||||
|     assert state._slice(path) == expected_slice |  | ||||||
|  |  | ||||||
|  |  | ||||||
| def test_root_directory(state): |  | ||||||
|     path = PurePosixPath() |  | ||||||
|     expected_slice = slice(0, 6)  # Entire tree |  | ||||||
|     assert state._slice(path) == expected_slice |  | ||||||
|  |  | ||||||
|  |  | ||||||
| def test_directory_with_subdirs_and_files(state): |  | ||||||
|     path = PurePosixPath("xxx") |  | ||||||
|     expected_slice = slice(4, 6)  # Includes 'xxx' and 'yyy' |  | ||||||
|     assert state._slice(path) == expected_slice |  | ||||||
		Reference in New Issue
	
	Block a user