Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f646fb344 | ||
|
|
d6304d0029 | ||
|
|
77e35cf0fc | ||
|
|
bf8a049b92 | ||
|
|
6d7f44bd88 | ||
|
|
e2097a1563 | ||
|
|
b864936eaa | ||
|
|
72b3c0d8ce | ||
|
|
07daf372e8 | ||
|
|
e979d679b2 | ||
|
|
eea66c0013 | ||
|
|
9220c457c0 | ||
|
|
d5b77932ea | ||
|
|
9b9d3e1cc1 | ||
|
|
536efc4ce1 | ||
|
|
1b2267587f | ||
|
|
2864e9f041 | ||
|
|
b5a94b5eee | ||
|
|
d4be755d46 |
+34
-6
@@ -574,6 +574,13 @@ def _basic_auth_login(request):
|
|||||||
if username == "token":
|
if username == "token":
|
||||||
token = config.config.tokens.get(password)
|
token = config.config.tokens.get(password)
|
||||||
if token:
|
if token:
|
||||||
|
if _allow_anonymous_share_token(token):
|
||||||
|
request.ctx.session = None
|
||||||
|
request.ctx.username = None
|
||||||
|
request.ctx.user = None
|
||||||
|
request.ctx.auth_token_id = password
|
||||||
|
request.ctx.auth_token = token
|
||||||
|
return None
|
||||||
user = config.config.users.get(token.username)
|
user = config.config.users.get(token.username)
|
||||||
if user:
|
if user:
|
||||||
request.ctx.session = None
|
request.ctx.session = None
|
||||||
@@ -873,14 +880,16 @@ async def verify(request, *, privileged=False):
|
|||||||
"""
|
"""
|
||||||
hydrate_request_auth_context(request, source="auth.verify")
|
hydrate_request_auth_context(request, source="auth.verify")
|
||||||
|
|
||||||
# Public mode: skip auth unless privileged access is required
|
|
||||||
if config.config.public and not privileged:
|
|
||||||
return
|
|
||||||
|
|
||||||
auth_header = request.headers.get("authorization", "")
|
auth_header = request.headers.get("authorization", "")
|
||||||
has_auth_header = bool(auth_header)
|
has_auth_header = bool(auth_header)
|
||||||
scheme = auth_header.split()[0].lower() if has_auth_header else None
|
scheme = auth_header.split()[0].lower() if has_auth_header else None
|
||||||
|
|
||||||
|
# Public mode: skip auth unless privileged access is required.
|
||||||
|
# Still parse explicit Authorization headers so share-token URLs can
|
||||||
|
# activate share scoping even while public access is enabled.
|
||||||
|
if config.config.public and not privileged and not has_auth_header:
|
||||||
|
return
|
||||||
|
|
||||||
# Concise auth flow for diagnostics (populated by use_session + verify)
|
# Concise auth flow for diagnostics (populated by use_session + verify)
|
||||||
auth_flow = list(getattr(request.ctx, "auth_flow", ["session:skipped"]))
|
auth_flow = list(getattr(request.ctx, "auth_flow", ["session:skipped"]))
|
||||||
tried: list[str] = []
|
tried: list[str] = []
|
||||||
@@ -941,6 +950,13 @@ async def verify(request, *, privileged=False):
|
|||||||
quiet=True,
|
quiet=True,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
token = request_share_token(request)
|
||||||
|
if (
|
||||||
|
token is not None
|
||||||
|
and _allow_anonymous_share_token(token)
|
||||||
|
and not privileged
|
||||||
|
):
|
||||||
|
return
|
||||||
elif scheme in ("ntlm", "negotiate"):
|
elif scheme in ("ntlm", "negotiate"):
|
||||||
tried.append("ntlm")
|
tried.append("ntlm")
|
||||||
try:
|
try:
|
||||||
@@ -1275,6 +1291,17 @@ def _token_belongs_to_user(token, username, sso_user_id):
|
|||||||
return bool(sso_user_id is not None and token.sso_user_id == sso_user_id)
|
return bool(sso_user_id is not None and token.sso_user_id == sso_user_id)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_anonymous_share_token(token: config.Token) -> bool:
|
||||||
|
return (
|
||||||
|
sharefs.is_share_token(token) and not token.username and not token.sso_user_id
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _allow_anonymous_share_token(token: config.Token) -> bool:
|
||||||
|
# Anonymous share links are intentionally coupled to public mode.
|
||||||
|
return config.config.public and _is_anonymous_share_token(token)
|
||||||
|
|
||||||
|
|
||||||
def request_token(request) -> config.Token | None:
|
def request_token(request) -> config.Token | None:
|
||||||
token = getattr(request.ctx, "auth_token", None)
|
token = getattr(request.ctx, "auth_token", None)
|
||||||
return token if isinstance(token, config.Token) else None
|
return token if isinstance(token, config.Token) else None
|
||||||
@@ -1439,10 +1466,11 @@ async def create_share_token_handler(request):
|
|||||||
raise BadRequest("Could not determine SSO user")
|
raise BadRequest("Could not determine SSO user")
|
||||||
else:
|
else:
|
||||||
username = current_username or ""
|
username = current_username or ""
|
||||||
if not username:
|
if username:
|
||||||
raise BadRequest("Could not determine user")
|
|
||||||
if username not in config.config.users:
|
if username not in config.config.users:
|
||||||
raise BadRequest("User does not exist")
|
raise BadRequest("User does not exist")
|
||||||
|
elif not config.config.public:
|
||||||
|
raise BadRequest("Could not determine user")
|
||||||
|
|
||||||
token = secrets.token_urlsafe(12)
|
token = secrets.token_urlsafe(12)
|
||||||
changes = {
|
changes = {
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
|
import errno
|
||||||
import os
|
import os
|
||||||
import threading
|
import threading
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from cista import config
|
from cista import config
|
||||||
from cista.util import filename
|
from cista.util import filename
|
||||||
|
from cista.util.diskspace import InsufficientStorageError, check_free_space
|
||||||
from cista.util.lrucache import LRUCache
|
from cista.util.lrucache import LRUCache
|
||||||
|
|
||||||
|
|
||||||
@@ -34,13 +36,24 @@ class File:
|
|||||||
self.open_rw()
|
self.open_rw()
|
||||||
if self.fd is None:
|
if self.fd is None:
|
||||||
raise RuntimeError("file descriptor is not available for write")
|
raise RuntimeError("file descriptor is not available for write")
|
||||||
|
check_free_space(self.path)
|
||||||
if file_size is not None:
|
if file_size is not None:
|
||||||
if pos + len(buffer) > file_size:
|
if pos + len(buffer) > file_size:
|
||||||
raise ValueError("write exceeds declared file size")
|
raise ValueError("write exceeds declared file size")
|
||||||
|
try:
|
||||||
os.ftruncate(self.fd, file_size)
|
os.ftruncate(self.fd, file_size)
|
||||||
|
except OSError as e:
|
||||||
|
if e.errno == errno.ENOSPC:
|
||||||
|
raise InsufficientStorageError("No space left on device") from e
|
||||||
|
raise
|
||||||
if buffer:
|
if buffer:
|
||||||
os.lseek(self.fd, pos, os.SEEK_SET)
|
os.lseek(self.fd, pos, os.SEEK_SET)
|
||||||
|
try:
|
||||||
os.write(self.fd, buffer)
|
os.write(self.fd, buffer)
|
||||||
|
except OSError as e:
|
||||||
|
if e.errno == errno.ENOSPC:
|
||||||
|
raise InsufficientStorageError("No space left on device") from e
|
||||||
|
raise
|
||||||
|
|
||||||
def __getitem__(self, slc):
|
def __getitem__(self, slc):
|
||||||
if self.fd is None:
|
if self.fd is None:
|
||||||
|
|||||||
+12
-1
@@ -1,5 +1,6 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import contextlib
|
import contextlib
|
||||||
|
import errno
|
||||||
import mimetypes
|
import mimetypes
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
@@ -12,11 +13,12 @@ from urllib.parse import unquote, urlparse
|
|||||||
from wsgiref.handlers import format_date_time
|
from wsgiref.handlers import format_date_time
|
||||||
|
|
||||||
from sanic import Blueprint, HTTPResponse, empty, json
|
from sanic import Blueprint, HTTPResponse, empty, json
|
||||||
from sanic.exceptions import BadRequest, NotFound
|
from sanic.exceptions import BadRequest, NotFound, SanicException
|
||||||
|
|
||||||
from cista import auth, config, sharefs, watching
|
from cista import auth, config, sharefs, watching
|
||||||
from cista.api import fileserver
|
from cista.api import fileserver
|
||||||
from cista.util import filename
|
from cista.util import filename
|
||||||
|
from cista.util.diskspace import InsufficientStorageError
|
||||||
|
|
||||||
bp = Blueprint("fileserver", url_prefix="/files")
|
bp = Blueprint("fileserver", url_prefix="/files")
|
||||||
|
|
||||||
@@ -52,6 +54,7 @@ async def upload_file_chunk(request, name):
|
|||||||
|
|
||||||
rel, path = _safe_relpath(name, request=request)
|
rel, path = _safe_relpath(name, request=request)
|
||||||
rel_name = rel.as_posix()
|
rel_name = rel.as_posix()
|
||||||
|
try:
|
||||||
upload_info = await asyncio.to_thread(
|
upload_info = await asyncio.to_thread(
|
||||||
fileserver.upload_info,
|
fileserver.upload_info,
|
||||||
rel_name,
|
rel_name,
|
||||||
@@ -59,6 +62,14 @@ async def upload_file_chunk(request, name):
|
|||||||
body,
|
body,
|
||||||
total,
|
total,
|
||||||
)
|
)
|
||||||
|
except InsufficientStorageError as e:
|
||||||
|
raise SanicException(str(e), status_code=507, quiet=True) from e
|
||||||
|
except OSError as e:
|
||||||
|
if e.errno == errno.ENOSPC:
|
||||||
|
raise SanicException(
|
||||||
|
"No space left on device", status_code=507, quiet=True
|
||||||
|
) from e
|
||||||
|
raise
|
||||||
extras = []
|
extras = []
|
||||||
chunk_len = end - start
|
chunk_len = end - start
|
||||||
whole_file = start == 0 and end == total
|
whole_file = start == 0 and end == total
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import shutil
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
MIN_FREE_BYTES = 128 * 1024 * 1024
|
||||||
|
_CHECK_CACHE_TTL = 1.0
|
||||||
|
|
||||||
|
|
||||||
|
class InsufficientStorageError(Exception):
|
||||||
|
"""Raised when there is not enough disk space for an operation."""
|
||||||
|
|
||||||
|
|
||||||
|
_cache: dict[Path, tuple[float, int]] = {}
|
||||||
|
_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def check_free_space(path: Path) -> None:
|
||||||
|
"""Raise InsufficientStorageError if free space on the filesystem containing *path*
|
||||||
|
|
||||||
|
is below MIN_FREE_BYTES. Results are cached per directory for 1 second.
|
||||||
|
"""
|
||||||
|
check_path = path.parent if path.parent.exists() else path
|
||||||
|
check_path = check_path.resolve()
|
||||||
|
|
||||||
|
now = time.monotonic()
|
||||||
|
with _lock:
|
||||||
|
ts, free = _cache.get(check_path, (0, 0))
|
||||||
|
if now - ts < _CHECK_CACHE_TTL:
|
||||||
|
if free < MIN_FREE_BYTES:
|
||||||
|
raise InsufficientStorageError(
|
||||||
|
f"Insufficient storage: {free} bytes free, "
|
||||||
|
f"need at least {MIN_FREE_BYTES} bytes"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
free = shutil.disk_usage(check_path).free
|
||||||
|
except OSError as e:
|
||||||
|
raise InsufficientStorageError(f"Cannot check disk usage: {e}") from e
|
||||||
|
|
||||||
|
with _lock:
|
||||||
|
_cache[check_path] = (now, free)
|
||||||
|
|
||||||
|
if free < MIN_FREE_BYTES:
|
||||||
|
raise InsufficientStorageError(
|
||||||
|
f"Insufficient storage: {free} bytes free, "
|
||||||
|
f"need at least {MIN_FREE_BYTES} bytes"
|
||||||
|
)
|
||||||
+10
-3
@@ -9,7 +9,6 @@ from os import stat_result
|
|||||||
from pathlib import Path, PurePosixPath
|
from pathlib import Path, PurePosixPath
|
||||||
from stat import S_ISDIR, S_ISREG
|
from stat import S_ISDIR, S_ISREG
|
||||||
|
|
||||||
import inotify.adapters
|
|
||||||
import msgspec
|
import msgspec
|
||||||
from natsort import humansorted, natsort_keygen, ns
|
from natsort import humansorted, natsort_keygen, ns
|
||||||
from sanic.log import logger
|
from sanic.log import logger
|
||||||
@@ -18,6 +17,11 @@ from cista import config
|
|||||||
from cista.fileio import fuid
|
from cista.fileio import fuid
|
||||||
from cista.protocol import FileEntry, Space, UpdDel, UpdIns, UpdKeep
|
from cista.protocol import FileEntry, Space, UpdDel, UpdIns, UpdKeep
|
||||||
|
|
||||||
|
try:
|
||||||
|
import inotify.adapters as inotify_adapters
|
||||||
|
except Exception:
|
||||||
|
inotify_adapters = None
|
||||||
|
|
||||||
# Platform-specific allocated size calculation
|
# Platform-specific allocated size calculation
|
||||||
if sys.platform == "win32":
|
if sys.platform == "win32":
|
||||||
import ctypes
|
import ctypes
|
||||||
@@ -665,10 +669,13 @@ DEBOUNCE_MAX = 0.1 # But no more than 100ms total
|
|||||||
|
|
||||||
def watcher(loop):
|
def watcher(loop):
|
||||||
"""Unified watcher thread handling inotify, websocket signals, and periodic scans."""
|
"""Unified watcher thread handling inotify, websocket signals, and periodic scans."""
|
||||||
use_inotify = sys.platform == "linux"
|
use_inotify = sys.platform == "linux" and inotify_adapters is not None
|
||||||
inotify_tree = None
|
inotify_tree = None
|
||||||
modified_flags = frozenset()
|
modified_flags = frozenset()
|
||||||
|
|
||||||
|
if sys.platform == "linux" and inotify_adapters is None:
|
||||||
|
logger.warning("inotify unavailable; falling back to periodic scanning")
|
||||||
|
|
||||||
if use_inotify:
|
if use_inotify:
|
||||||
modified_flags = frozenset(
|
modified_flags = frozenset(
|
||||||
(
|
(
|
||||||
@@ -684,7 +691,7 @@ def watcher(loop):
|
|||||||
|
|
||||||
while not stop_event.is_set():
|
while not stop_event.is_set():
|
||||||
if use_inotify:
|
if use_inotify:
|
||||||
inotify_tree = inotify.adapters.InotifyTree(rootpath.as_posix())
|
inotify_tree = inotify_adapters.InotifyTree(rootpath.as_posix())
|
||||||
|
|
||||||
# Initialize the tree from filesystem
|
# Initialize the tree from filesystem
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -18,8 +18,11 @@
|
|||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@codemirror/language-data": "^6.5.2",
|
||||||
|
"@codemirror/theme-one-dark": "^6.1.3",
|
||||||
"@imengyu/vue3-context-menu": "^1.5.3",
|
"@imengyu/vue3-context-menu": "^1.5.3",
|
||||||
"@vueuse/core": "^14.1.0",
|
"@vueuse/core": "^14.1.0",
|
||||||
|
"codemirror": "^6.0.2",
|
||||||
"esbuild": "^0.27.2",
|
"esbuild": "^0.27.2",
|
||||||
"lodash": "^4.17.23",
|
"lodash": "^4.17.23",
|
||||||
"lodash-es": "^4.17.23",
|
"lodash-es": "^4.17.23",
|
||||||
|
|||||||
+103
-10
@@ -11,11 +11,33 @@
|
|||||||
<AboutModal />
|
<AboutModal />
|
||||||
<AccessDeniedModal />
|
<AccessDeniedModal />
|
||||||
<header>
|
<header>
|
||||||
<HeaderMain ref="headerMain" :path="path.pathList" :query="path.query" />
|
<HeaderMain
|
||||||
<BreadCrumb :path="path.pathList" primary />
|
ref="headerMain"
|
||||||
|
:path="path.pathList"
|
||||||
|
:query="path.query"
|
||||||
|
:editor-mode="path.isEditorPath"
|
||||||
|
/>
|
||||||
|
<BreadCrumb
|
||||||
|
:path="path.breadcrumbPathList"
|
||||||
|
:links="path.breadcrumbLinks"
|
||||||
|
primary
|
||||||
|
/>
|
||||||
</header>
|
</header>
|
||||||
<main>
|
<main class="transition-wrapper">
|
||||||
<RouterView :path="path.pathList" :query="path.query" />
|
<Transition
|
||||||
|
:name="routeTransitionName"
|
||||||
|
@after-enter="store.transitionDirection = 'none'"
|
||||||
|
>
|
||||||
|
<div :key="routeViewKey" class="explorer-content">
|
||||||
|
<KeepAlive>
|
||||||
|
<component
|
||||||
|
:is="routeViewComponent"
|
||||||
|
:key="routeViewKey"
|
||||||
|
v-bind="routeViewProps"
|
||||||
|
/>
|
||||||
|
</KeepAlive>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
</main>
|
</main>
|
||||||
<footer v-if="store.selected.size || store.uprogress.total || store.dprogress.total">
|
<footer v-if="store.selected.size || store.uprogress.total || store.dprogress.total">
|
||||||
<SelectionToolbar :path="path.pathList" />
|
<SelectionToolbar :path="path.pathList" />
|
||||||
@@ -27,10 +49,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type HeaderMain from '@/components/HeaderMain.vue'
|
import type HeaderMain from '@/components/HeaderMain.vue'
|
||||||
import { loadSession, watchConnect, watchDisconnect } from '@/repositories/WS'
|
import { loadSession, watchConnect, watchDisconnect } from '@/repositories/WS'
|
||||||
|
import { getDocuments } from '@/stores/documentStore'
|
||||||
import { useMainStore } from '@/stores/main'
|
import { useMainStore } from '@/stores/main'
|
||||||
import type { ComputedRef } from 'vue'
|
import type { ComputedRef } from 'vue'
|
||||||
import { onMounted, onUnmounted, ref, watch } from 'vue'
|
import { onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
import { RouterView } from 'vue-router'
|
|
||||||
|
|
||||||
import Router from '@/router/index'
|
import Router from '@/router/index'
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
@@ -41,23 +63,84 @@ import type SettingsModalVue from './components/SettingsModal.vue'
|
|||||||
import UserManagementModal from './components/UserManagementModal.vue'
|
import UserManagementModal from './components/UserManagementModal.vue'
|
||||||
import UserTokensModal from './components/UserTokensModal.vue'
|
import UserTokensModal from './components/UserTokensModal.vue'
|
||||||
import type { SortOrder } from './utils/docsort'
|
import type { SortOrder } from './utils/docsort'
|
||||||
|
import ExplorerView from './views/ExplorerView.vue'
|
||||||
|
import TextEditorView from './views/TextEditorView.vue'
|
||||||
|
|
||||||
interface Path {
|
interface Path {
|
||||||
path: string
|
path: string
|
||||||
|
canonicalPath: string
|
||||||
|
isEditorPath: boolean
|
||||||
pathList: string[]
|
pathList: string[]
|
||||||
|
breadcrumbPathList: string[]
|
||||||
|
breadcrumbLinks?: string[]
|
||||||
query: string
|
query: string
|
||||||
}
|
}
|
||||||
const store = useMainStore()
|
const store = useMainStore()
|
||||||
|
|
||||||
|
const getDocByPath = (fullPath: string) =>
|
||||||
|
getDocuments().find(
|
||||||
|
doc => (doc.loc ? `${doc.loc}/${doc.name}` : doc.name) === fullPath
|
||||||
|
)
|
||||||
|
|
||||||
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 rawPath = p[0] ?? ''
|
||||||
|
const routePathList = rawPath.split('/').filter(value => value !== '')
|
||||||
const query = p.slice(1).join('//')
|
const query = p.slice(1).join('//')
|
||||||
|
const fullPath = routePathList.join('/')
|
||||||
|
// Access docVersion to make route mode reactive to tree updates
|
||||||
|
void store.docVersion
|
||||||
|
const doc = fullPath ? getDocByPath(fullPath) : null
|
||||||
|
const isEditorPath = !!(doc && !doc.dir && doc.text)
|
||||||
|
const canonicalBase = !fullPath ? '/' : doc?.dir ? `/${fullPath}/` : `/${fullPath}`
|
||||||
|
const canonicalPath = query
|
||||||
|
? rawPath // keep search URL shape untouched
|
||||||
|
: canonicalBase
|
||||||
|
const pathList = isEditorPath ? routePathList.slice(0, -1) : routePathList
|
||||||
|
const breadcrumbPathList = routePathList
|
||||||
|
const breadcrumbLinks = isEditorPath
|
||||||
|
? [
|
||||||
|
'/',
|
||||||
|
...routePathList
|
||||||
|
.slice(0, -1)
|
||||||
|
.map((_, index) => `/${routePathList.slice(0, index + 1).join('/')}/`),
|
||||||
|
`/${fullPath}`
|
||||||
|
]
|
||||||
|
: undefined
|
||||||
return {
|
return {
|
||||||
path: p[0] ?? '',
|
path: rawPath,
|
||||||
|
canonicalPath,
|
||||||
|
isEditorPath,
|
||||||
pathList,
|
pathList,
|
||||||
|
breadcrumbPathList,
|
||||||
|
breadcrumbLinks,
|
||||||
query
|
query
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
const routeTransitionName = computed(() => {
|
||||||
|
if (store.transitionDirection === 'forward') return 'slide-forward'
|
||||||
|
if (store.transitionDirection === 'backward') return 'slide-backward'
|
||||||
|
return ''
|
||||||
|
})
|
||||||
|
const routeViewComponent = computed(() =>
|
||||||
|
path.value.isEditorPath ? TextEditorView : ExplorerView
|
||||||
|
)
|
||||||
|
const routeViewKey = computed(() => {
|
||||||
|
return path.value.isEditorPath ? `editor:${path.value.path}` : 'explorer'
|
||||||
|
})
|
||||||
|
const routeViewProps = computed(() =>
|
||||||
|
path.value.isEditorPath ? {} : { path: path.value.pathList, query: path.value.query }
|
||||||
|
)
|
||||||
|
watch(
|
||||||
|
() => path.value.canonicalPath,
|
||||||
|
canonical => {
|
||||||
|
const current = decodeURIComponent(Router.currentRoute.value.path)
|
||||||
|
if (canonical && current !== canonical) {
|
||||||
|
Router.replace(canonical.replaceAll('?', '%3F').replaceAll('#', '%23'))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
watch(
|
watch(
|
||||||
() => path.value.path,
|
() => path.value.path,
|
||||||
() => {
|
() => {
|
||||||
@@ -86,7 +169,9 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
|
|||||||
const fileExplorer = store.fileExplorer as any
|
const fileExplorer = store.fileExplorer as any
|
||||||
if (!fileExplorer) return
|
if (!fileExplorer) return
|
||||||
const c = fileExplorer.isCursor()
|
const c = fileExplorer.isCursor()
|
||||||
const input = (event.target as HTMLElement).tagName === 'INPUT'
|
const target = event.target as HTMLElement
|
||||||
|
const input =
|
||||||
|
['INPUT', 'TEXTAREA'].includes(target.tagName) || !!target.closest('.cm-editor')
|
||||||
const keyup = event.type === 'keyup'
|
const keyup = event.type === 'keyup'
|
||||||
|
|
||||||
// Always clear repeat timer on arrow keyup, even if focus moved to input
|
// Always clear repeat timer on arrow keyup, even if focus moved to input
|
||||||
@@ -142,11 +227,17 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
|
|||||||
// Paging/navigation key handling - fall through to bottom
|
// Paging/navigation key handling - fall through to bottom
|
||||||
}
|
}
|
||||||
// Find: process on keydown so that we can bypass the built-in search hotkey
|
// Find: process on keydown so that we can bypass the built-in search hotkey
|
||||||
else if (!keyup && event.key === 'f' && (event.ctrlKey || event.metaKey)) {
|
else if (
|
||||||
|
!path.value.isEditorPath &&
|
||||||
|
!input &&
|
||||||
|
!keyup &&
|
||||||
|
event.key === 'f' &&
|
||||||
|
(event.ctrlKey || event.metaKey)
|
||||||
|
) {
|
||||||
headerMain.value!.toggleSearchInput()
|
headerMain.value!.toggleSearchInput()
|
||||||
}
|
}
|
||||||
// Search also on / (UNIX style) - use code to support any keyboard layout
|
// Search also on / (UNIX style) - use code to support any keyboard layout
|
||||||
else if (!input && keyup && event.code === 'Slash') {
|
else if (!path.value.isEditorPath && !input && keyup && event.code === 'Slash') {
|
||||||
// Record the actual character for display (varies by keyboard layout)
|
// Record the actual character for display (varies by keyboard layout)
|
||||||
if (event.key.length === 1 && event.key !== store.prefs.searchHotkey) {
|
if (event.key.length === 1 && event.key !== store.prefs.searchHotkey) {
|
||||||
store.prefs.searchHotkey = event.key
|
store.prefs.searchHotkey = event.key
|
||||||
@@ -159,7 +250,9 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
|
|||||||
store.clearToast()
|
store.clearToast()
|
||||||
// Keep rename and other non-search inputs isolated from search behavior.
|
// Keep rename and other non-search inputs isolated from search behavior.
|
||||||
if (input && !searchInput) return
|
if (input && !searchInput) return
|
||||||
|
if (!path.value.isEditorPath) {
|
||||||
headerMain.value!.clearSearch(event)
|
headerMain.value!.clearSearch(event)
|
||||||
|
}
|
||||||
store.focusBreadcrumb()
|
store.focusBreadcrumb()
|
||||||
} else if (!input && keyup && event.key === 'Backspace') {
|
} else if (!input && keyup && event.key === 'Backspace') {
|
||||||
Router.back()
|
Router.back()
|
||||||
|
|||||||
@@ -57,6 +57,68 @@
|
|||||||
align-self: stretch;
|
align-self: stretch;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/* Directory navigation slide transitions */
|
||||||
|
.transition-wrapper {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
grid-template-rows: 1fr;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.explorer-content {
|
||||||
|
grid-area: 1 / 1;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-forward-enter-active,
|
||||||
|
.slide-backward-enter-active {
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-forward-leave-active,
|
||||||
|
.slide-backward-leave-active {
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-forward-enter-active,
|
||||||
|
.slide-forward-leave-active,
|
||||||
|
.slide-backward-enter-active,
|
||||||
|
.slide-backward-leave-active {
|
||||||
|
transition: transform 0.22s cubic-bezier(0.32, 0.72, 0, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-forward-enter-from {
|
||||||
|
transform: translate3d(100%, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-forward-enter-to {
|
||||||
|
transform: translate3d(0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-forward-leave-from {
|
||||||
|
transform: translate3d(0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-forward-leave-to {
|
||||||
|
transform: translate3d(-100%, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-backward-enter-from {
|
||||||
|
transform: translate3d(-100%, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-backward-enter-to {
|
||||||
|
transform: translate3d(0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-backward-leave-from {
|
||||||
|
transform: translate3d(0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-backward-leave-to {
|
||||||
|
transform: translate3d(100%, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
@media print {
|
@media print {
|
||||||
:root {
|
:root {
|
||||||
--primary-color: black;
|
--primary-color: black;
|
||||||
@@ -206,6 +268,8 @@ main {
|
|||||||
min-height: 0; /* Allow flex child to shrink below content size */
|
min-height: 0; /* Allow flex child to shrink below content size */
|
||||||
padding-bottom: 3em; /* convenience space on the bottom */
|
padding-bottom: 3em; /* convenience space on the bottom */
|
||||||
overflow-y: scroll;
|
overflow-y: scroll;
|
||||||
|
overflow-x: hidden;
|
||||||
|
position: relative;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1 +1,3 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M19.2 2.6H6.1V29h19.8V9.3l-6.7-6.7zm3 15c0 .2-.2.4-.4.4h-4.4v4.4c0 .2-.2.4-.4.4h-2.4c-.2 0-.4-.2-.4-.4V18H9.9c-.2 0-.4-.2-.4-.4v-2.4c0-.2.2-.4.4-.4h4.4v-4.4c0-.2.2-.4.4-.4H17c.2 0 .4.2.4.4v4.4h4.4c.2 0 .4.2.4.4v2.4z"/></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 28 28">
|
||||||
|
<path fill-rule="evenodd" d="M19.2 2.6H6.1V29h19.8V9.3l-6.7-6.7zM22.75 18.55c0 .2625-.175.4375-.4375.4375h-4.55v4.55c0 .2625-.175.4375-.4375.4375h-2.45c-.2625 0-.4375-.175-.4375-.4375v-4.55h-4.55c-.2625 0-.4375-.175-.4375-.4375V16.1c0-.2625.175-.4375.4375-.4375h4.55v-4.55c0-.2625.175-.4375.4375-.4375h2.45c.2625 0 .4375.175.4375.4375v4.55h4.55c.2625 0 .4375.175.4375.4375v2.45z" />
|
||||||
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 293 B After Width: | Height: | Size: 452 B |
@@ -1 +1 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512"><path d="M384 128h-69c24 16 46.5 44.5 53.5 64h15c32.5 0 64 32 64 64s-32.5 64-64 64h-96c-31.5 0-64-32-64-64 0-11.5 3.5-22.5 9-32H164c-2.5 10.5-4 21-4 32 0 64 63.5 128 127.5 128H384c64 0 128-64 128-128s-64-128-128-128zM143.5 320h-15c-32.5 0-64-32-64-64s32.5-64 64-64h96c31.5 0 64 32 64 64 0 11.5-3.5 22.5-9 32H348c2.5-10.5 4-21 4-32 0-64-63.5-128-127.5-128H128C64 128 0 192 0 256s64 128 128 128h69c-24-16-46.5-44.5-53.5-64z"/></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M384 128h-69c24 16 46.5 44.5 53.5 64h15c32.5 0 64 32 64 64s-32.5 64-64 64h-96c-31.5 0-64-32-64-64 0-11.5 3.5-22.5 9-32H164c-2.5 10.5-4 21-4 32 0 64 63.5 128 127.5 128H384c64 0 128-64 128-128s-64-128-128-128zM143.5 320h-15c-32.5 0-64-32-64-64s32.5-64 64-64h96c31.5 0 64 32 64 64 0 11.5-3.5 22.5-9 32H348c2.5-10.5 4-21 4-32 0-64-63.5-128-127.5-128H128C64 128 0 192 0 256s64 128 128 128h69c-24-16-46.5-44.5-53.5-64z"/></svg>
|
||||||
|
Before Width: | Height: | Size: 517 B After Width: | Height: | Size: 492 B |
@@ -8,7 +8,7 @@
|
|||||||
@focus=focusCurrent
|
@focus=focusCurrent
|
||||||
tabindex=0
|
tabindex=0
|
||||||
>
|
>
|
||||||
<a href="#/"
|
<a :href="`/#${urlAt(0)}`"
|
||||||
:ref="el => setLinkRef(0, el)"
|
:ref="el => setLinkRef(0, el)"
|
||||||
class="home"
|
class="home"
|
||||||
:class="{ current: !!isCurrent(0) }"
|
:class="{ current: !!isCurrent(0) }"
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
<CursorTooltip ref="homeTooltip" text="/">/</CursorTooltip>
|
<CursorTooltip ref="homeTooltip" text="/">/</CursorTooltip>
|
||||||
</a>
|
</a>
|
||||||
<template v-for="(location, index) in longest" :key="index">
|
<template v-for="(location, index) in longest" :key="index">
|
||||||
<a :href="`/#/${longest.slice(0, index + 1).join('/')}/`"
|
<a :href="`/#${urlAt(index + 1)}`"
|
||||||
:class="{ current: !!isCurrent(index + 1) }"
|
:class="{ current: !!isCurrent(index + 1) }"
|
||||||
:aria-current="isCurrent(index + 1)"
|
:aria-current="isCurrent(index + 1)"
|
||||||
@click.prevent="navigate(index + 1)"
|
@click.prevent="navigate(index + 1)"
|
||||||
@@ -62,10 +62,17 @@ const setPathTooltipRef = (index: number, el: any) => {
|
|||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
path: Array<string>
|
path: Array<string>
|
||||||
|
links?: Array<string>
|
||||||
primary?: boolean
|
primary?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const longest = ref<Array<string>>([])
|
const longest = ref<Array<string>>([])
|
||||||
|
const longestLinks = ref<Array<string>>(['/'])
|
||||||
|
|
||||||
|
const defaultLinks = (segments: Array<string>) => [
|
||||||
|
'/',
|
||||||
|
...segments.map((_, index) => `/${segments.slice(0, index + 1).join('/')}/`)
|
||||||
|
]
|
||||||
|
|
||||||
const isCurrent = (index: number) =>
|
const isCurrent = (index: number) =>
|
||||||
index == props.path.length ? 'location' : undefined
|
index == props.path.length ? 'location' : undefined
|
||||||
@@ -77,16 +84,22 @@ const focusCurrent = () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const urlAt = (index: number) => {
|
||||||
|
const explicit = longestLinks.value[index]
|
||||||
|
return explicit ?? (index ? `/${longest.value.slice(0, index).join('/')}/` : '/')
|
||||||
|
}
|
||||||
|
|
||||||
const navigate = (index: number) => {
|
const navigate = (index: number) => {
|
||||||
const link = links[index]
|
const link = links[index]
|
||||||
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 = index ? `/${longest.value.slice(0, index).join('/')}/` : '/'
|
const url = urlAt(index)
|
||||||
const long = longest.value.length ? `/${longest.value.join('/')}/` : '/'
|
const long = longest.value.length ? `/${longest.value.join('/')}/` : '/'
|
||||||
const browser = decodeURIComponent(location.hash.slice(1).split('//')[0] ?? '')
|
const browser = decodeURIComponent(location.hash.slice(1).split('//')[0] ?? '')
|
||||||
const u = url.replaceAll('?', '%3F').replaceAll('#', '%23')
|
const u = url.replaceAll('?', '%3F').replaceAll('#', '%23')
|
||||||
// Clicking on current link clears the rest of the path and adds new history
|
// Clicking on current link clears the rest of the path and adds new history
|
||||||
if (isCurrent(index)) {
|
if (isCurrent(index)) {
|
||||||
longest.value.splice(index)
|
longest.value.splice(index)
|
||||||
|
longestLinks.value.splice(index + 1)
|
||||||
router.push(u)
|
router.push(u)
|
||||||
}
|
}
|
||||||
// Moving along breadcrumbs doesn't create new history
|
// Moving along breadcrumbs doesn't create new history
|
||||||
@@ -102,20 +115,26 @@ const move = (dir: number) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
watchEffect(() => {
|
watchEffect(() => {
|
||||||
|
const currentLinks = props.links ?? defaultLinks(props.path)
|
||||||
const longcut = longest.value.slice(0, props.path.length)
|
const longcut = longest.value.slice(0, props.path.length)
|
||||||
const same = longcut.every((value, index) => value === props.path[index])
|
const same = longcut.every((value, index) => value === props.path[index])
|
||||||
// Navigated out of previous path, reset longest to current
|
// Navigated out of previous path, reset longest to current
|
||||||
if (!same) longest.value = props.path
|
if (!same) {
|
||||||
else if (props.path.length > longcut.length) {
|
longest.value = props.path
|
||||||
|
longestLinks.value = currentLinks
|
||||||
|
} else if (props.path.length > longcut.length) {
|
||||||
longest.value = longcut.concat(props.path.slice(longcut.length))
|
longest.value = longcut.concat(props.path.slice(longcut.length))
|
||||||
|
longestLinks.value.splice(0, currentLinks.length, ...currentLinks)
|
||||||
} else {
|
} else {
|
||||||
// Prune deleted folders from longest
|
// Prune deleted folders from longest
|
||||||
for (let i = props.path.length; i < longest.value.length; ++i) {
|
for (let i = props.path.length; i < longest.value.length; ++i) {
|
||||||
if (!exists(longest.value.slice(0, i + 1))) {
|
if (!exists(longest.value.slice(0, i + 1))) {
|
||||||
longest.value = longest.value.slice(0, i)
|
longest.value = longest.value.slice(0, i)
|
||||||
|
longestLinks.value = longestLinks.value.slice(0, i + 1)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
longestLinks.value.splice(0, currentLinks.length, ...currentLinks)
|
||||||
}
|
}
|
||||||
// If needed, focus primary navigation to new location
|
// If needed, focus primary navigation to new location
|
||||||
if (props.primary)
|
if (props.primary)
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
</defs>
|
</defs>
|
||||||
|
|
||||||
<g :filter="isExpanded ? 'url(#pieShadow)' : 'none'">
|
<g :filter="isExpanded ? 'url(#pieShadow)' : 'none'">
|
||||||
<circle :r="midRadius" :cx="pieCx" :cy="pieCy" fill="transparent" stroke="url(#otherGradient)" :stroke-width="ringWidth" />
|
<circle :r="midRadius" :cx="pieCx" :cy="pieCy" fill="transparent" :stroke="showOtherCategory ? 'url(#otherGradient)' : freeColor" :stroke-width="ringWidth" />
|
||||||
<circle :r="midRadius" :cx="pieCx" :cy="pieCy" fill="transparent" :stroke="freeColor" :stroke-width="ringWidth" :stroke-dasharray="pieFreeDash" :stroke-dashoffset="pieFreeOffsetVal" :transform="`rotate(-90 ${pieCx} ${pieCy})`" />
|
<circle :r="midRadius" :cx="pieCx" :cy="pieCy" fill="transparent" :stroke="freeColor" :stroke-width="ringWidth" :stroke-dasharray="pieFreeDash" :stroke-dashoffset="pieFreeOffsetVal" :transform="`rotate(-90 ${pieCx} ${pieCy})`" />
|
||||||
<circle :r="midRadius" :cx="pieCx" :cy="pieCy" fill="transparent" stroke="url(#storageGradient)" :stroke-width="ringWidth" :stroke-dasharray="pieStorageDash" :transform="`rotate(-90 ${pieCx} ${pieCy})`" />
|
<circle :r="midRadius" :cx="pieCx" :cy="pieCy" fill="transparent" stroke="url(#storageGradient)" :stroke-width="ringWidth" :stroke-dasharray="pieStorageDash" :transform="`rotate(-90 ${pieCx} ${pieCy})`" />
|
||||||
<circle :r="midRadius" :cx="pieCx" :cy="pieCy" fill="transparent" stroke="url(#highlightOverlay)" :stroke-width="ringWidth" />
|
<circle :r="midRadius" :cx="pieCx" :cy="pieCy" fill="transparent" stroke="url(#highlightOverlay)" :stroke-width="ringWidth" />
|
||||||
@@ -38,12 +38,12 @@
|
|||||||
<g ref="labelsRef" class="pie-labels">
|
<g ref="labelsRef" class="pie-labels">
|
||||||
<text :x="storageInnerPos.x" :y="storageInnerPos.y" class="pie-label-inner" :text-anchor="getSizeAnchor(sectorInfo.storage.angle)" dominant-baseline="middle" :transform="`rotate(${getSizeRotation(sectorInfo.storage.angle)} ${storageInnerPos.x} ${storageInnerPos.y})`">{{ fmtSize(store.space.allocated, sectorInfo.storage.angle) }}</text>
|
<text :x="storageInnerPos.x" :y="storageInnerPos.y" class="pie-label-inner" :text-anchor="getSizeAnchor(sectorInfo.storage.angle)" dominant-baseline="middle" :transform="`rotate(${getSizeRotation(sectorInfo.storage.angle)} ${storageInnerPos.x} ${storageInnerPos.y})`">{{ fmtSize(store.space.allocated, sectorInfo.storage.angle) }}</text>
|
||||||
<text :x="freeInnerPos.x" :y="freeInnerPos.y" class="pie-label-inner" :text-anchor="getSizeAnchor(sectorInfo.free.angle)" dominant-baseline="middle" :transform="`rotate(${getSizeRotation(sectorInfo.free.angle)} ${freeInnerPos.x} ${freeInnerPos.y})`">{{ fmtSize(store.space.free, sectorInfo.free.angle) }}</text>
|
<text :x="freeInnerPos.x" :y="freeInnerPos.y" class="pie-label-inner" :text-anchor="getSizeAnchor(sectorInfo.free.angle)" dominant-baseline="middle" :transform="`rotate(${getSizeRotation(sectorInfo.free.angle)} ${freeInnerPos.x} ${freeInnerPos.y})`">{{ fmtSize(store.space.free, sectorInfo.free.angle) }}</text>
|
||||||
<text :x="otherInnerPos.x" :y="otherInnerPos.y" class="pie-label-inner" :text-anchor="getSizeAnchor(sectorInfo.other.angle)" dominant-baseline="middle" :transform="`rotate(${getSizeRotation(sectorInfo.other.angle)} ${otherInnerPos.x} ${otherInnerPos.y})`">{{ fmtSize(store.space.used - store.space.allocated, sectorInfo.other.angle) }}</text>
|
<text v-if="showOtherCategory" :x="otherInnerPos.x" :y="otherInnerPos.y" class="pie-label-inner" :text-anchor="getSizeAnchor(sectorInfo.other.angle)" dominant-baseline="middle" :transform="`rotate(${getSizeRotation(sectorInfo.other.angle)} ${otherInnerPos.x} ${otherInnerPos.y})`">{{ fmtSize(store.space.used - store.space.allocated, sectorInfo.other.angle) }}</text>
|
||||||
|
|
||||||
<defs>
|
<defs>
|
||||||
<path :id="storageLabelPath.id" :d="storageLabelPath.d" fill="none" />
|
<path :id="storageLabelPath.id" :d="storageLabelPath.d" fill="none" />
|
||||||
<path :id="freeLabelPath.id" :d="freeLabelPath.d" fill="none" />
|
<path :id="freeLabelPath.id" :d="freeLabelPath.d" fill="none" />
|
||||||
<path :id="otherLabelPath.id" :d="otherLabelPath.d" fill="none" />
|
<path v-if="showOtherCategory" :id="otherLabelPath.id" :d="otherLabelPath.d" fill="none" />
|
||||||
</defs>
|
</defs>
|
||||||
|
|
||||||
<text class="pie-label-sub" fill="#93e">
|
<text class="pie-label-sub" fill="#93e">
|
||||||
@@ -52,7 +52,7 @@
|
|||||||
<text class="pie-label-sub" :fill="freeColor">
|
<text class="pie-label-sub" :fill="freeColor">
|
||||||
<textPath :href="'#' + freeLabelPath.id" startOffset="50%" text-anchor="middle" dominant-baseline="middle">free</textPath>
|
<textPath :href="'#' + freeLabelPath.id" startOffset="50%" text-anchor="middle" dominant-baseline="middle">free</textPath>
|
||||||
</text>
|
</text>
|
||||||
<text class="pie-label-sub" fill="#d9f">
|
<text v-if="showOtherCategory" class="pie-label-sub" fill="#d9f">
|
||||||
<textPath :href="'#' + otherLabelPath.id" startOffset="50%" text-anchor="middle" dominant-baseline="middle">other</textPath>
|
<textPath :href="'#' + otherLabelPath.id" startOffset="50%" text-anchor="middle" dominant-baseline="middle">other</textPath>
|
||||||
</text>
|
</text>
|
||||||
</g>
|
</g>
|
||||||
@@ -98,18 +98,30 @@ const truncateLabel = (name: string, maxLen = 10): string => {
|
|||||||
return name.slice(0, maxLen - 1) + '…'
|
return name.slice(0, maxLen - 1) + '…'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const otherBytes = computed(() => Math.max(0, store.space.used - store.space.allocated))
|
||||||
|
const showOtherCategory = computed(() => {
|
||||||
|
const s = store.space
|
||||||
|
return !!s.disk && otherBytes.value / s.disk >= 0.01
|
||||||
|
})
|
||||||
|
const freeSliceBytes = computed(() =>
|
||||||
|
showOtherCategory.value
|
||||||
|
? store.space.free
|
||||||
|
: Math.max(0, store.space.disk - store.space.allocated)
|
||||||
|
)
|
||||||
|
|
||||||
// Calculate max label length based on angular gap to neighbor labels
|
// Calculate max label length based on angular gap to neighbor labels
|
||||||
const storageMaxLen = computed(() => {
|
const storageMaxLen = computed(() => {
|
||||||
const s = store.space
|
const s = store.space
|
||||||
if (!s.disk) return 10
|
if (!s.disk) return 10
|
||||||
// Sector spans in degrees
|
// Sector spans in degrees
|
||||||
const storageSpan = (s.allocated / s.disk) * 360
|
const storageSpan = (s.allocated / s.disk) * 360
|
||||||
const freeSpan = (s.free / s.disk) * 360
|
const freeSpan = (freeSliceBytes.value / s.disk) * 360
|
||||||
const otherSpan = ((s.used - s.allocated) / s.disk) * 360
|
const otherSpan = (otherBytes.value / s.disk) * 360
|
||||||
// Angular gap from storage label midpoint to neighbor label midpoints
|
// Angular gap from storage label midpoint to neighbor label midpoints
|
||||||
const gapToFree = (storageSpan + freeSpan) / 2
|
const gapToFree = (storageSpan + freeSpan) / 2
|
||||||
const gapToOther = (storageSpan + otherSpan) / 2
|
const minGap = showOtherCategory.value
|
||||||
const minGap = Math.min(gapToFree, gapToOther)
|
? Math.min(gapToFree, (storageSpan + otherSpan) / 2)
|
||||||
|
: gapToFree
|
||||||
// Allow longer names when there's sufficient gap to both neighbors
|
// Allow longer names when there's sufficient gap to both neighbors
|
||||||
if (minGap > 70) return 18
|
if (minGap > 70) return 18
|
||||||
if (minGap > 55) return 14
|
if (minGap > 55) return 14
|
||||||
@@ -143,7 +155,7 @@ const pieStorageDash = computed(() => {
|
|||||||
const pieFreeDash = computed(() => {
|
const pieFreeDash = computed(() => {
|
||||||
const s = store.space
|
const s = store.space
|
||||||
if (!s.disk) return `0 ${CIRC}`
|
if (!s.disk) return `0 ${CIRC}`
|
||||||
return `${(s.free / s.disk) * CIRC} ${CIRC}`
|
return `${(freeSliceBytes.value / s.disk) * CIRC} ${CIRC}`
|
||||||
})
|
})
|
||||||
|
|
||||||
const pieFreeOffsetVal = computed(() => {
|
const pieFreeOffsetVal = computed(() => {
|
||||||
@@ -179,8 +191,8 @@ const sectorInfo = computed(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const storagePct = s.allocated / s.disk
|
const storagePct = s.allocated / s.disk
|
||||||
const freePct = s.free / s.disk
|
const freePct = freeSliceBytes.value / s.disk
|
||||||
const otherPct = (s.used - s.allocated) / s.disk
|
const otherPct = showOtherCategory.value ? otherBytes.value / s.disk : 0
|
||||||
|
|
||||||
const storageAngle = storagePct * 180 // midpoint of storage sector
|
const storageAngle = storagePct * 180 // midpoint of storage sector
|
||||||
const freeStart = storagePct * 360
|
const freeStart = storagePct * 360
|
||||||
@@ -198,7 +210,7 @@ const sectorInfo = computed(() => {
|
|||||||
const rawAngles = computed(() => ({
|
const rawAngles = computed(() => ({
|
||||||
storage: sectorInfo.value.storage.angle,
|
storage: sectorInfo.value.storage.angle,
|
||||||
free: sectorInfo.value.free.angle,
|
free: sectorInfo.value.free.angle,
|
||||||
other: sectorInfo.value.other.angle
|
...(showOtherCategory.value ? { other: sectorInfo.value.other.angle } : {})
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const getSizeRotation = (angle: number) => (angle < 180 ? angle - 90 : angle + 90)
|
const getSizeRotation = (angle: number) => (angle < 180 ? angle - 90 : angle + 90)
|
||||||
@@ -219,7 +231,7 @@ const otherInnerPos = computed(() =>
|
|||||||
const labelLengths = computed(() => ({
|
const labelLengths = computed(() => ({
|
||||||
storage: storageName.value.length,
|
storage: storageName.value.length,
|
||||||
free: 4,
|
free: 4,
|
||||||
other: 5
|
...(showOtherCategory.value ? { other: 5 } : {})
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const getGapForPair = (len1: number, len2: number) => {
|
const getGapForPair = (len1: number, len2: number) => {
|
||||||
@@ -232,7 +244,9 @@ const adjustedLabelAngles = computed(() => {
|
|||||||
const labels = [
|
const labels = [
|
||||||
{ id: 'storage', angle: angles.storage, len: lens.storage },
|
{ id: 'storage', angle: angles.storage, len: lens.storage },
|
||||||
{ id: 'free', angle: angles.free, len: lens.free },
|
{ id: 'free', angle: angles.free, len: lens.free },
|
||||||
{ id: 'other', angle: angles.other, len: lens.other }
|
...(showOtherCategory.value
|
||||||
|
? [{ id: 'other', angle: angles.other!, len: lens.other! }]
|
||||||
|
: [])
|
||||||
]
|
]
|
||||||
labels.sort((a, b) => a.angle - b.angle)
|
labels.sort((a, b) => a.angle - b.angle)
|
||||||
|
|
||||||
@@ -283,7 +297,11 @@ const freeLabelPath = computed(() =>
|
|||||||
createArcPath(adjustedLabelAngles.value.free!, 'free', 4)
|
createArcPath(adjustedLabelAngles.value.free!, 'free', 4)
|
||||||
)
|
)
|
||||||
const otherLabelPath = computed(() =>
|
const otherLabelPath = computed(() =>
|
||||||
createArcPath(adjustedLabelAngles.value.other!, 'other', 5)
|
createArcPath(
|
||||||
|
adjustedLabelAngles.value.other ?? sectorInfo.value.other.angle,
|
||||||
|
'other',
|
||||||
|
5
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleClick = () => (isExpanded.value ? collapse() : expand())
|
const handleClick = () => (isExpanded.value ? collapse() : expand())
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="!props.path || documents.length === 0" class="empty-container">
|
<div v-if="showEmpty" class="empty-container">
|
||||||
<component :is="cog" :class="['cog', { stopped: store.dialog === 'accessdenied' || store.authInProgress }]"/>
|
<component :is="cog" :class="['cog', { stopped: store.dialog === 'accessdenied' || store.authInProgress }]"/>
|
||||||
<p v-if="store.dialog === 'accessdenied'">Access Denied</p>
|
<p v-if="store.dialog === 'accessdenied'">Access Denied</p>
|
||||||
<p v-else-if="!store.connected">No Connection</p>
|
<p v-else-if="!store.connected">No Connection</p>
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
import { Cog } from '@/assets/svg'
|
import { Cog } from '@/assets/svg'
|
||||||
import { useMainStore } from '@/stores/main'
|
import { useMainStore } from '@/stores/main'
|
||||||
import { exists } from '@/utils/fileutil'
|
import { exists } from '@/utils/fileutil'
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
const cog = Cog
|
const cog = Cog
|
||||||
const store = useMainStore()
|
const store = useMainStore()
|
||||||
@@ -21,9 +22,29 @@ const props = defineProps<{
|
|||||||
path: string[]
|
path: string[]
|
||||||
documents: Document[]
|
documents: Document[]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const showEmpty = computed(() => {
|
||||||
|
const loc = props.path.join('/')
|
||||||
|
const hasVisibleGhost = store.ghosts.some(g => {
|
||||||
|
const full = g.loc ? `${g.loc}/${g.name}` : g.name
|
||||||
|
return g.loc === loc && !store.hiddenPaths.has(full)
|
||||||
|
})
|
||||||
|
|
||||||
|
return !props.path || (props.documents.length === 0 && !hasVisibleGhost)
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.empty-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
font-size: 2rem;
|
||||||
|
text-shadow: 0 0 .3rem #000, 0 0 2rem #0008;
|
||||||
|
color: var(--accent-color);
|
||||||
|
}
|
||||||
@keyframes rotate {
|
@keyframes rotate {
|
||||||
0% { transform: rotate(0deg); }
|
0% { transform: rotate(0deg); }
|
||||||
100% { transform: rotate(360deg); }
|
100% { transform: rotate(360deg); }
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
|
<div class="file-explorer">
|
||||||
<table v-if="props.documents.length || editing">
|
<table v-if="props.documents.length || editing">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -12,10 +13,10 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-if="editing?.key === 'new'" class="folder">
|
<tr v-if="editing?.key === 'new'" :class="editing.dir ? 'folder' : 'file'">
|
||||||
<td class="selection"></td>
|
<td class="selection"></td>
|
||||||
<td class="name">
|
<td class="name">
|
||||||
<FileRenameInput :doc="editing" :rename="mkdir" :exit="() => {editing = null}" />
|
<FileRenameInput :doc="editing" :rename="createItem" :exit="exitEditing" />
|
||||||
</td>
|
</td>
|
||||||
<FileModified :doc=editing :now=nowkey />
|
<FileModified :doc=editing :now=nowkey />
|
||||||
<FileSize :doc=editing />
|
<FileSize :doc=editing />
|
||||||
@@ -46,10 +47,10 @@
|
|||||||
</td>
|
</td>
|
||||||
<td class="name">
|
<td class="name">
|
||||||
<template v-if="editing === doc">
|
<template v-if="editing === doc">
|
||||||
<FileRenameInput :doc="doc" :rename="rename" :exit="() => {editing = null}" />
|
<FileRenameInput :doc="doc" :rename="rename" :exit="exitEditing" />
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<a :href=doc.url tabindex=-1 @contextmenu.stop @focus.stop="store.cursor = doc.key">
|
<a :href="doc.text ? doc.editurl : doc.url" tabindex=-1 @contextmenu.stop @focus.stop="store.cursor = doc.key">
|
||||||
{{ doc.name }}
|
{{ doc.name }}
|
||||||
</a>
|
</a>
|
||||||
<button tabindex=-1 v-if="store.cursor == doc.key" class="rename-button" @click="() => (editing = doc)">🖊️</button>
|
<button tabindex=-1 v-if="store.cursor == doc.key" class="rename-button" @click="() => (editing = doc)">🖊️</button>
|
||||||
@@ -69,6 +70,8 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
<EmptyFolder v-else :documents="documents" :path="props.path" />
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
@@ -81,11 +84,12 @@ import ContextMenu from '@imengyu/vue3-context-menu'
|
|||||||
import {
|
import {
|
||||||
computed,
|
computed,
|
||||||
nextTick,
|
nextTick,
|
||||||
|
onDeactivated,
|
||||||
onMounted,
|
onMounted,
|
||||||
onUnmounted,
|
onUnmounted,
|
||||||
ref,
|
ref,
|
||||||
shallowRef,
|
shallowRef,
|
||||||
watchEffect
|
watch
|
||||||
} from 'vue'
|
} from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import FileRenameInput from './FileRenameInput.vue'
|
import FileRenameInput from './FileRenameInput.vue'
|
||||||
@@ -189,6 +193,9 @@ const pageMove = (direction: 1 | -1, ev: KeyboardEvent) => {
|
|||||||
|
|
||||||
// File rename
|
// File rename
|
||||||
const editing = shallowRef<Doc | null>(null)
|
const editing = shallowRef<Doc | null>(null)
|
||||||
|
const exitEditing = () => {
|
||||||
|
editing.value = null
|
||||||
|
}
|
||||||
const rename = async (doc: Doc, newName: string) => {
|
const rename = async (doc: Doc, newName: string) => {
|
||||||
const oldName = doc.name
|
const oldName = doc.name
|
||||||
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
|
||||||
@@ -206,8 +213,20 @@ const rename = async (doc: Doc, newName: string) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
defineExpose({
|
defineExpose({
|
||||||
|
newFile() {
|
||||||
|
const now = Math.floor(Date.now() / 1000)
|
||||||
|
editing.value = new Doc({
|
||||||
|
loc: loc.value,
|
||||||
|
key: 'new',
|
||||||
|
name: 'New File.txt',
|
||||||
|
dir: false,
|
||||||
|
mtime: now,
|
||||||
|
size: 0,
|
||||||
|
allocated: 0
|
||||||
|
})
|
||||||
|
store.cursor = editing.value.key
|
||||||
|
},
|
||||||
newFolder() {
|
newFolder() {
|
||||||
console.log('New folder')
|
|
||||||
const now = Math.floor(Date.now() / 1000)
|
const now = Math.floor(Date.now() / 1000)
|
||||||
editing.value = new Doc({
|
editing.value = new Doc({
|
||||||
loc: loc.value,
|
loc: loc.value,
|
||||||
@@ -231,7 +250,7 @@ defineExpose({
|
|||||||
const docs = props.documents
|
const docs = props.documents
|
||||||
if (docs.length > 0) {
|
if (docs.length > 0) {
|
||||||
store.cursor = docs[0]!.key
|
store.cursor = docs[0]!.key
|
||||||
// Also focus the element directly (watchEffect won't trigger if cursor unchanged)
|
// Also focus the element directly (post-flush watcher won't trigger if cursor unchanged)
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
const a = document.querySelector(
|
const a = document.querySelector(
|
||||||
`#file-${store.cursor} .name a`
|
`#file-${store.cursor} .name a`
|
||||||
@@ -317,22 +336,35 @@ const focusBreadcrumb = () => {
|
|||||||
const keyboardFollowScroll = createKeyboardFollowScroll()
|
const keyboardFollowScroll = createKeyboardFollowScroll()
|
||||||
const markKeyboardFollow = keyboardFollowScroll.markKeyboardFollow
|
const markKeyboardFollow = keyboardFollowScroll.markKeyboardFollow
|
||||||
const keepCursorVisibleSmooth = keyboardFollowScroll.keepVisible
|
const keepCursorVisibleSmooth = keyboardFollowScroll.keepVisible
|
||||||
watchEffect(() => {
|
watch(
|
||||||
if (store.cursor && store.cursor !== editing.value?.key) editing.value = null
|
() => store.cursor,
|
||||||
if (editing.value) store.cursor = editing.value?.key
|
cursor => {
|
||||||
if (store.cursor) {
|
if (cursor && editing.value && cursor !== editing.value.key) {
|
||||||
|
exitEditing()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
watch(
|
||||||
|
() => store.cursor,
|
||||||
|
cursor => {
|
||||||
|
if (cursor && !editing.value) {
|
||||||
const a = document.querySelector(
|
const a = document.querySelector(
|
||||||
`#file-${store.cursor} .name a`
|
`#file-${cursor} .name a`
|
||||||
) as HTMLAnchorElement | null
|
) as HTMLAnchorElement | null
|
||||||
if (a) a.focus({ preventScroll: true })
|
if (a) a.focus({ preventScroll: true })
|
||||||
}
|
}
|
||||||
})
|
},
|
||||||
watchEffect(() => {
|
{ flush: 'post' }
|
||||||
if (!props.documents.length && store.cursor && !store.query) {
|
)
|
||||||
|
watch(
|
||||||
|
() => [props.documents.length, store.cursor, store.query, editing.value] as const,
|
||||||
|
([len, cursor, query, editingDoc]) => {
|
||||||
|
if (!len && cursor && !query && !editingDoc) {
|
||||||
store.cursor = ''
|
store.cursor = ''
|
||||||
focusBreadcrumb()
|
focusBreadcrumb()
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
|
)
|
||||||
let nowkey = ref(0)
|
let nowkey = ref(0)
|
||||||
let modifiedTimer: any = null
|
let modifiedTimer: any = null
|
||||||
const updateModified = () => {
|
const updateModified = () => {
|
||||||
@@ -346,23 +378,44 @@ onMounted(() => {
|
|||||||
active.focus({ preventScroll: true })
|
active.focus({ preventScroll: true })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
onDeactivated(() => {
|
||||||
|
if (editing.value) exitEditing()
|
||||||
|
})
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
keyboardFollowScroll.cancel()
|
keyboardFollowScroll.cancel()
|
||||||
clearInterval(modifiedTimer)
|
clearInterval(modifiedTimer)
|
||||||
})
|
})
|
||||||
const mkdir = async (doc: Doc, name: string) => {
|
const editRoute = (path: string) =>
|
||||||
|
'/' +
|
||||||
|
path
|
||||||
|
.split('/')
|
||||||
|
.map(part => encodeURIComponent(part))
|
||||||
|
.join('/')
|
||||||
|
|
||||||
|
const createItem = async (doc: Doc, name: string) => {
|
||||||
doc.name = name
|
doc.name = name
|
||||||
doc.key = crypto.randomUUID()
|
doc.key = crypto.randomUUID()
|
||||||
store.addGhost(doc)
|
store.addGhost(doc)
|
||||||
editing.value = null
|
store.cursor = doc.key
|
||||||
|
exitEditing()
|
||||||
const path = doc.loc ? `${doc.loc}/${name}` : name
|
const path = doc.loc ? `${doc.loc}/${name}` : name
|
||||||
try {
|
try {
|
||||||
const res = await apiFetch(filesUrl(path), { method: 'MKCOL' })
|
const res = doc.dir
|
||||||
|
? await apiFetch(filesUrl(path), { method: 'MKCOL' })
|
||||||
|
: await apiFetch(filesUrl(path), {
|
||||||
|
method: 'PUT',
|
||||||
|
body: '',
|
||||||
|
headers: { 'Content-Type': 'text/plain; charset=utf-8' }
|
||||||
|
})
|
||||||
if (!res.ok) throw new Error(await parseErrorMessage(res))
|
if (!res.ok) throw new Error(await parseErrorMessage(res))
|
||||||
|
if (doc.dir) {
|
||||||
router.push(doc.urlrouter)
|
router.push(doc.urlrouter)
|
||||||
|
} else {
|
||||||
|
router.push(editRoute(path))
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Mkdir failed', err)
|
console.error('Create failed', err)
|
||||||
store.showToast(err instanceof Error ? err.message : 'Mkdir failed')
|
store.showToast(err instanceof Error ? err.message : 'Create failed')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const showFolderBreadcrumb = (i: number) => {
|
const showFolderBreadcrumb = (i: number) => {
|
||||||
@@ -496,9 +549,14 @@ const contextMenu = (ev: MouseEvent, doc: Doc) => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.file-explorer {
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
table {
|
table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
table-layout: fixed;
|
table-layout: fixed;
|
||||||
|
height: auto;
|
||||||
}
|
}
|
||||||
thead tr {
|
thead tr {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
@@ -629,12 +687,6 @@ tbody .selection input {
|
|||||||
content: '📁';
|
content: '📁';
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
}
|
}
|
||||||
.empty-container {
|
|
||||||
padding-top: 3rem;
|
|
||||||
text-align: center;
|
|
||||||
font-size: 3rem;
|
|
||||||
color: var(--accent-color);
|
|
||||||
}
|
|
||||||
.folder-change {
|
.folder-change {
|
||||||
margin-left: -.5rem;
|
margin-left: -.5rem;
|
||||||
}
|
}
|
||||||
@@ -645,4 +697,3 @@ tbody .selection input {
|
|||||||
color: #888;
|
color: #888;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@/stores/main
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="props.documents.length || editing" class="gallery" ref="gallery">
|
<div v-if="props.documents.length || editing" class="gallery" ref="gallery">
|
||||||
<GalleryFigure v-if="editing?.key === 'new'" :doc="editing" :key=editing.key :editing="{rename: mkdir, exit}" />
|
<GalleryFigure v-if="editing?.key === 'new'" :doc="editing" :key=editing.key :editing="{rename: createItem, exit}" />
|
||||||
<template v-for="(doc, index) in documents" :key=doc.key>
|
<template v-for="(doc, index) in documents" :key=doc.key>
|
||||||
<BreadCrumb v-if="showFolderBreadcrumb(index)" :path="doc.loc ? doc.loc.split('/') : []" class="folder-indicator"/>
|
<BreadCrumb v-if="showFolderBreadcrumb(index)" :path="doc.loc ? doc.loc.split('/') : []" class="folder-indicator"/>
|
||||||
<GalleryFigure
|
<GalleryFigure
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
<EmptyFolder v-else :documents="documents" :path="props.path" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
@@ -25,12 +26,13 @@ import ContextMenu from '@imengyu/vue3-context-menu'
|
|||||||
import {
|
import {
|
||||||
computed,
|
computed,
|
||||||
nextTick,
|
nextTick,
|
||||||
|
onActivated,
|
||||||
|
onDeactivated,
|
||||||
onMounted,
|
onMounted,
|
||||||
onUnmounted,
|
onUnmounted,
|
||||||
ref,
|
ref,
|
||||||
shallowRef,
|
shallowRef,
|
||||||
watch,
|
watch
|
||||||
watchEffect
|
|
||||||
} from 'vue'
|
} from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
@@ -170,6 +172,7 @@ const onImgLoad = (e: Event) => {
|
|||||||
}
|
}
|
||||||
const updateColumns = () => {
|
const updateColumns = () => {
|
||||||
if (!gallery.value) return
|
if (!gallery.value) return
|
||||||
|
if (gallery.value.getBoundingClientRect().width <= 0) return
|
||||||
const style = getComputedStyle(gallery.value)
|
const style = getComputedStyle(gallery.value)
|
||||||
const templates = style.gridTemplateColumns
|
const templates = style.gridTemplateColumns
|
||||||
.split(' ')
|
.split(' ')
|
||||||
@@ -260,6 +263,19 @@ const pageMove = (direction: 1 | -1, ev: KeyboardEvent) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
defineExpose({
|
defineExpose({
|
||||||
|
newFile() {
|
||||||
|
const now = Math.floor(Date.now() / 1000)
|
||||||
|
editing.value = new Doc({
|
||||||
|
loc: loc.value,
|
||||||
|
key: 'new',
|
||||||
|
name: 'New File.txt',
|
||||||
|
dir: false,
|
||||||
|
mtime: now,
|
||||||
|
size: 0,
|
||||||
|
allocated: 0
|
||||||
|
})
|
||||||
|
store.cursor = editing.value.key
|
||||||
|
},
|
||||||
newFolder() {
|
newFolder() {
|
||||||
const now = Math.floor(Date.now() / 1000)
|
const now = Math.floor(Date.now() / 1000)
|
||||||
editing.value = new Doc({
|
editing.value = new Doc({
|
||||||
@@ -288,7 +304,7 @@ defineExpose({
|
|||||||
const docs = props.documents
|
const docs = props.documents
|
||||||
if (docs.length > 0) {
|
if (docs.length > 0) {
|
||||||
store.cursor = docs[0]!.key
|
store.cursor = docs[0]!.key
|
||||||
// Also focus the element directly (watchEffect won't trigger if cursor unchanged)
|
// Also focus the element directly (post-flush watcher won't trigger if cursor unchanged)
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
const a = document.querySelector(
|
const a = document.querySelector(
|
||||||
`#file-${store.cursor}`
|
`#file-${store.cursor}`
|
||||||
@@ -380,25 +396,49 @@ const focusBreadcrumb = () => {
|
|||||||
const keyboardFollowScroll = createKeyboardFollowScroll()
|
const keyboardFollowScroll = createKeyboardFollowScroll()
|
||||||
const markKeyboardFollow = keyboardFollowScroll.markKeyboardFollow
|
const markKeyboardFollow = keyboardFollowScroll.markKeyboardFollow
|
||||||
const keepCursorVisibleSmooth = keyboardFollowScroll.keepVisible
|
const keepCursorVisibleSmooth = keyboardFollowScroll.keepVisible
|
||||||
watchEffect(() => {
|
watch(
|
||||||
if (store.cursor && store.cursor !== editing.value?.key) editing.value = null
|
() => store.cursor,
|
||||||
if (editing.value) store.cursor = editing.value.key
|
cursor => {
|
||||||
if (store.cursor && !editing.value) {
|
if (cursor && editing.value && cursor !== editing.value.key) {
|
||||||
const a = document.querySelector(
|
exit()
|
||||||
`#file-${store.cursor}`
|
}
|
||||||
) as HTMLAnchorElement | null
|
}
|
||||||
|
)
|
||||||
|
watch(
|
||||||
|
() => store.cursor,
|
||||||
|
cursor => {
|
||||||
|
if (cursor && !editing.value) {
|
||||||
|
const a = document.querySelector(`#file-${cursor}`) as HTMLAnchorElement | null
|
||||||
if (a) {
|
if (a) {
|
||||||
a.focus({ preventScroll: true })
|
a.focus({ preventScroll: true })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
},
|
||||||
watchEffect(() => {
|
{ flush: 'post' }
|
||||||
if (!props.documents.length && store.cursor && !store.query) {
|
)
|
||||||
|
watch(
|
||||||
|
() => [props.documents.length, store.cursor, store.query, editing.value] as const,
|
||||||
|
([len, cursor, query, editingDoc]) => {
|
||||||
|
if (!len && cursor && !query && !editingDoc) {
|
||||||
store.cursor = ''
|
store.cursor = ''
|
||||||
focusBreadcrumb()
|
focusBreadcrumb()
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
|
)
|
||||||
let resizeObserver: ResizeObserver | null = null
|
let resizeObserver: ResizeObserver | null = null
|
||||||
|
const attachGalleryObservers = () => {
|
||||||
|
if (!gallery.value || resizeObserver) return
|
||||||
|
resizeObserver = new ResizeObserver(updateColumns)
|
||||||
|
resizeObserver.observe(gallery.value)
|
||||||
|
gallery.value.addEventListener('load', onImgLoad, { capture: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
const detachGalleryObservers = () => {
|
||||||
|
resizeObserver?.disconnect()
|
||||||
|
resizeObserver = null
|
||||||
|
gallery.value?.removeEventListener('load', onImgLoad, { capture: true })
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
const active = document.querySelector('.cursor') as HTMLElement | null
|
const active = document.querySelector('.cursor') as HTMLElement | null
|
||||||
if (active) {
|
if (active) {
|
||||||
@@ -406,33 +446,56 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
updateColumns()
|
updateColumns()
|
||||||
seedFromDocs()
|
seedFromDocs()
|
||||||
if (gallery.value) {
|
attachGalleryObservers()
|
||||||
resizeObserver = new ResizeObserver(updateColumns)
|
})
|
||||||
resizeObserver.observe(gallery.value)
|
onActivated(() => {
|
||||||
gallery.value.addEventListener('load', onImgLoad, { capture: true })
|
nextTick(() => {
|
||||||
}
|
updateColumns()
|
||||||
|
attachGalleryObservers()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
onDeactivated(() => {
|
||||||
|
detachGalleryObservers()
|
||||||
|
if (editing.value) exit()
|
||||||
})
|
})
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
keyboardFollowScroll.cancel()
|
keyboardFollowScroll.cancel()
|
||||||
resizeObserver?.disconnect()
|
detachGalleryObservers()
|
||||||
gallery.value?.removeEventListener('load', onImgLoad, { capture: true })
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Re-seed aspect ratios whenever docs update (e.g., ar patch from server)
|
// Re-seed aspect ratios whenever docs update (e.g., ar patch from server)
|
||||||
watch(() => props.documents, seedFromDocs)
|
watch(() => props.documents, seedFromDocs)
|
||||||
const mkdir = async (doc: Doc, name: string) => {
|
const editRoute = (path: string) =>
|
||||||
|
'/' +
|
||||||
|
path
|
||||||
|
.split('/')
|
||||||
|
.map(part => encodeURIComponent(part))
|
||||||
|
.join('/')
|
||||||
|
|
||||||
|
const createItem = async (doc: Doc, name: string) => {
|
||||||
doc.name = name
|
doc.name = name
|
||||||
doc.key = crypto.randomUUID()
|
doc.key = crypto.randomUUID()
|
||||||
store.addGhost(doc)
|
store.addGhost(doc)
|
||||||
editing.value = null
|
store.cursor = doc.key
|
||||||
|
exit()
|
||||||
const path = doc.loc ? `${doc.loc}/${name}` : name
|
const path = doc.loc ? `${doc.loc}/${name}` : name
|
||||||
try {
|
try {
|
||||||
const res = await apiFetch(filesUrl(path), { method: 'MKCOL' })
|
const res = doc.dir
|
||||||
|
? await apiFetch(filesUrl(path), { method: 'MKCOL' })
|
||||||
|
: await apiFetch(filesUrl(path), {
|
||||||
|
method: 'PUT',
|
||||||
|
body: '',
|
||||||
|
headers: { 'Content-Type': 'text/plain; charset=utf-8' }
|
||||||
|
})
|
||||||
if (!res.ok) throw new Error(await parseErrorMessage(res))
|
if (!res.ok) throw new Error(await parseErrorMessage(res))
|
||||||
|
if (doc.dir) {
|
||||||
router.push(doc.urlrouter)
|
router.push(doc.urlrouter)
|
||||||
|
} else {
|
||||||
|
router.push(editRoute(path))
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Mkdir failed', err)
|
console.error('Create failed', err)
|
||||||
store.showToast(err instanceof Error ? err.message : 'Mkdir failed')
|
store.showToast(err instanceof Error ? err.message : 'Create failed')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const showFolderBreadcrumb = (i: number) => {
|
const showFolderBreadcrumb = (i: number) => {
|
||||||
@@ -562,7 +625,8 @@ const contextMenu = (ev: MouseEvent, doc: Doc) => {
|
|||||||
display: grid;
|
display: grid;
|
||||||
gap: .5em;
|
gap: .5em;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(15em, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(15em, 1fr));
|
||||||
align-items: end;
|
align-items: start;
|
||||||
|
align-content: start;
|
||||||
}
|
}
|
||||||
.folder-indicator {
|
.folder-indicator {
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
|
|||||||
@@ -49,10 +49,12 @@ import { Doc } from '@/repositories/Document'
|
|||||||
import { useMainStore } from '@/stores/main'
|
import { useMainStore } from '@/stores/main'
|
||||||
import { formatSize } from '@/utils'
|
import { formatSize } from '@/utils'
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
import CursorTooltip from './CursorTooltip.vue'
|
import CursorTooltip from './CursorTooltip.vue'
|
||||||
import SparseIndicator from './SparseIndicator.vue'
|
import SparseIndicator from './SparseIndicator.vue'
|
||||||
|
|
||||||
const store = useMainStore()
|
const store = useMainStore()
|
||||||
|
const router = useRouter()
|
||||||
type EditingProp = {
|
type EditingProp = {
|
||||||
rename: (doc: Doc, newName: string) => void
|
rename: (doc: Doc, newName: string) => void
|
||||||
exit: () => void
|
exit: () => void
|
||||||
@@ -87,7 +89,12 @@ const snap = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const onclick = (ev: Event) => {
|
const onclick = (ev: Event) => {
|
||||||
if (m.value!.play()) ev.preventDefault()
|
if (m.value!.play()) {
|
||||||
|
ev.preventDefault()
|
||||||
|
} else if (props.doc.text) {
|
||||||
|
ev.preventDefault()
|
||||||
|
router.push(props.doc.editurl.replace('/#', ''))
|
||||||
|
}
|
||||||
store.cursor = props.doc.key
|
store.cursor = props.doc.key
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -211,9 +218,9 @@ figcaption {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
figcaption input[type='checkbox'] {
|
figcaption input[type='checkbox'] {
|
||||||
width: 1.5em;
|
width: 1.1em;
|
||||||
height: 1.5em;
|
height: 1.1em;
|
||||||
margin: .25em 0 .25em .25em;
|
margin: .25em .4em .25em .35em;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
transition: opacity var(--transition-time) ease-in-out;
|
transition: opacity var(--transition-time) ease-in-out;
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
<template>
|
<template>
|
||||||
<nav class="headermain buttons">
|
<nav class="headermain buttons">
|
||||||
|
<template v-if="!props.editorMode">
|
||||||
<UploadButton :path="props.path" />
|
<UploadButton :path="props.path" />
|
||||||
|
<SvgButton
|
||||||
|
name="create-file"
|
||||||
|
tooltip="New file"
|
||||||
|
@click="() => { store.fileExplorer!.newFile() }"
|
||||||
|
/>
|
||||||
<SvgButton
|
<SvgButton
|
||||||
name="create-folder"
|
name="create-folder"
|
||||||
tooltip="New folder"
|
tooltip="New folder"
|
||||||
@@ -19,12 +25,19 @@
|
|||||||
/>
|
/>
|
||||||
<span v-if="!query" class="search-hint" @click="focusSearch">{{ store.prefs.searchHotkey }}</span>
|
<span v-if="!query" class="search-hint" @click="focusSearch">{{ store.prefs.searchHotkey }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="showSortHints" class="sort-hints">
|
</template>
|
||||||
|
<div v-if="!props.editorMode && showSortHints" class="sort-hints">
|
||||||
<span class="sort-label">Order</span>
|
<span class="sort-label">Order</span>
|
||||||
<span class="keycap">1</span>
|
<span class="keycap">1</span>
|
||||||
<span class="keycap">2</span>
|
<span class="keycap">2</span>
|
||||||
<span class="keycap">3</span>
|
<span class="keycap">3</span>
|
||||||
</div>
|
</div>
|
||||||
|
<SvgButton
|
||||||
|
v-if="props.editorMode"
|
||||||
|
name="disk"
|
||||||
|
tooltip="Save (Ctrl/Cmd+S)"
|
||||||
|
@click="store.editorSave?.()"
|
||||||
|
/>
|
||||||
<div class="spacer smallgap"></div>
|
<div class="spacer smallgap"></div>
|
||||||
<DiskSpace v-if="store.space.disk" />
|
<DiskSpace v-if="store.space.disk" />
|
||||||
<SvgButton name="cog" @click="settingsMenu" />
|
<SvgButton name="cog" @click="settingsMenu" />
|
||||||
@@ -49,6 +62,7 @@ const textInputFocused = ref(false)
|
|||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
path: Array<string>
|
path: Array<string>
|
||||||
query: string
|
query: string
|
||||||
|
editorMode?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const isInputElement = (el: Element | null): boolean => {
|
const isInputElement = (el: Element | null): boolean => {
|
||||||
|
|||||||
@@ -15,11 +15,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<span class="select-size">{{ selectionDisplay.size }}</span>
|
<span class="select-size">{{ selectionDisplay.size }}</span>
|
||||||
<DownloadButton />
|
<DownloadButton />
|
||||||
<button
|
<SvgButton
|
||||||
class="action-button"
|
name="link"
|
||||||
title="Copy share link (Alt-click for read/write)"
|
tooltip="Copy share link (Alt-click for read/write)"
|
||||||
@click="copyShareLink"
|
@click="copyShareLink"
|
||||||
>share</button>
|
/>
|
||||||
<SvgButton name="copy" tooltip="Copy here" @click="op('cp', dst)" />
|
<SvgButton name="copy" tooltip="Copy here" @click="op('cp', dst)" />
|
||||||
<SvgButton name="paste" tooltip="Move here" @click="op('mv', dst)" />
|
<SvgButton name="paste" tooltip="Move here" @click="op('mv', dst)" />
|
||||||
<SvgButton name="trash" tooltip="Delete ⚠️" @click="op('rm')" />
|
<SvgButton name="trash" tooltip="Delete ⚠️" @click="op('rm')" />
|
||||||
@@ -29,7 +29,7 @@
|
|||||||
@mouseenter="unselectTooltip?.startHover"
|
@mouseenter="unselectTooltip?.startHover"
|
||||||
@mousemove="unselectTooltip?.updatePosition"
|
@mousemove="unselectTooltip?.updatePosition"
|
||||||
@mouseleave="unselectTooltip?.endHover"
|
@mouseleave="unselectTooltip?.endHover"
|
||||||
>✖ selection</button>
|
>✖ deselect</button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
import { Doc } from '@/repositories/Document'
|
import { Doc } from '@/repositories/Document'
|
||||||
import { getDocuments } from '@/stores/documentStore'
|
import { getDocuments } from '@/stores/documentStore'
|
||||||
import { useMainStore } from '@/stores/main'
|
import { useMainStore } from '@/stores/main'
|
||||||
import { collator } from '@/utils'
|
import { collator, formatSize } from '@/utils'
|
||||||
import { onMounted, onUnmounted, ref } from 'vue'
|
import { onMounted, onUnmounted, ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
@@ -44,6 +44,7 @@ type InflightBlock = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const UPLOAD_BLOCK_SIZE = 16 << 20 // 16 MiB
|
const UPLOAD_BLOCK_SIZE = 16 << 20 // 16 MiB
|
||||||
|
const UPLOAD_MARGIN_BYTES = 512 * 1024 * 1024 // 512 MiB
|
||||||
function pasteHandler(event: ClipboardEvent) {
|
function pasteHandler(event: ClipboardEvent) {
|
||||||
const items = Array.from(event.clipboardData?.items ?? [])
|
const items = Array.from(event.clipboardData?.items ?? [])
|
||||||
const infiles = [] as File[]
|
const infiles = [] as File[]
|
||||||
@@ -116,6 +117,28 @@ const uploadCloudFiles = (files: CloudFile[]) => {
|
|||||||
}
|
}
|
||||||
if (!files.length) return
|
if (!files.length) return
|
||||||
files.sort((a, b) => collator.compare(a.cloudName, b.cloudName))
|
files.sort((a, b) => collator.compare(a.cloudName, b.cloudName))
|
||||||
|
|
||||||
|
// Space check: reject the whole batch if there isn't enough free space.
|
||||||
|
const batchTotal = files.reduce((sum, f) => sum + f.file.size, 0)
|
||||||
|
const allDocs = getDocuments()
|
||||||
|
const docByPath = new Map<string, Doc>()
|
||||||
|
for (const d of allDocs) {
|
||||||
|
const path = d.loc ? `${d.loc}/${d.name}` : d.name
|
||||||
|
docByPath.set(path, d)
|
||||||
|
}
|
||||||
|
let overwriteSize = 0
|
||||||
|
for (const f of files) {
|
||||||
|
const existing = docByPath.get(f.cloudName)
|
||||||
|
if (existing && !existing.dir) overwriteSize += existing.size
|
||||||
|
}
|
||||||
|
const netNeed = batchTotal - overwriteSize
|
||||||
|
if (store.space.free < netNeed + UPLOAD_MARGIN_BYTES) {
|
||||||
|
store.showToast(
|
||||||
|
`Not enough free space (need ${formatSize(netNeed + UPLOAD_MARGIN_BYTES)}, have ${formatSize(store.space.free)})`
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Optimistic update: ghost folders and files
|
// Optimistic update: ghost folders and files
|
||||||
const now = Math.floor(Date.now() / 1000)
|
const now = Math.floor(Date.now() / 1000)
|
||||||
const docs = getDocuments()
|
const docs = getDocuments()
|
||||||
|
|||||||
@@ -89,6 +89,14 @@ export class Doc {
|
|||||||
get print(): boolean {
|
get print(): boolean {
|
||||||
return (FILE_TYPES.print as readonly string[]).includes(this.ext)
|
return (FILE_TYPES.print as readonly string[]).includes(this.ext)
|
||||||
}
|
}
|
||||||
|
get text(): boolean {
|
||||||
|
return (FILE_TYPES.text as readonly string[]).includes(this.ext)
|
||||||
|
}
|
||||||
|
get editurl(): string {
|
||||||
|
if (!this.text) return ''
|
||||||
|
const p = this.loc ? `${this.loc}/${this.name}` : this.name
|
||||||
|
return '/#/' + p.replaceAll('?', '%3F').replaceAll('#', '%23')
|
||||||
|
}
|
||||||
get complete(): boolean {
|
get complete(): boolean {
|
||||||
return !this.ghost && (this.dir || this.size <= this.allocated)
|
return !this.ghost && (this.dir || this.size <= this.allocated)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
|
import { useMainStore } from '@/stores/main'
|
||||||
import ExplorerView from '@/views/ExplorerView.vue'
|
import ExplorerView from '@/views/ExplorerView.vue'
|
||||||
import { createRouter, createWebHashHistory } from 'vue-router'
|
import { createRouter, createWebHashHistory } from 'vue-router'
|
||||||
|
|
||||||
|
function getPathDepth(path: string): number {
|
||||||
|
const pathPart = decodeURIComponent(path).split('//')[0] ?? ''
|
||||||
|
return pathPart.split('/').filter(Boolean).length
|
||||||
|
}
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHashHistory(import.meta.env.BASE_URL),
|
history: createWebHashHistory(import.meta.env.BASE_URL),
|
||||||
routes: [
|
routes: [
|
||||||
@@ -12,4 +18,17 @@ const router = createRouter({
|
|||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|
||||||
|
router.beforeEach((to, from) => {
|
||||||
|
const store = useMainStore()
|
||||||
|
const toDepth = getPathDepth(to.path)
|
||||||
|
const fromDepth = getPathDepth(from.path)
|
||||||
|
if (toDepth > fromDepth) {
|
||||||
|
store.transitionDirection = 'forward'
|
||||||
|
} else if (toDepth < fromDepth) {
|
||||||
|
store.transitionDirection = 'backward'
|
||||||
|
} else {
|
||||||
|
store.transitionDirection = 'none'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
export default router
|
export default router
|
||||||
|
|||||||
@@ -98,6 +98,8 @@ export const useMainStore = defineStore('main', {
|
|||||||
privileged: false as boolean,
|
privileged: false as boolean,
|
||||||
isLoggedIn: false as boolean
|
isLoggedIn: false as boolean
|
||||||
},
|
},
|
||||||
|
transitionDirection: 'none' as 'forward' | 'backward' | 'none',
|
||||||
|
editorSave: null as null | (() => void),
|
||||||
space: {
|
space: {
|
||||||
disk: 0,
|
disk: 0,
|
||||||
free: 0,
|
free: 0,
|
||||||
@@ -326,6 +328,7 @@ export const useMainStore = defineStore('main', {
|
|||||||
this.connected = false
|
this.connected = false
|
||||||
this.dialog = ''
|
this.dialog = ''
|
||||||
this.cursor = ''
|
this.cursor = ''
|
||||||
|
this.editorSave = null
|
||||||
},
|
},
|
||||||
async logout() {
|
async logout() {
|
||||||
console.log('Logout')
|
console.log('Logout')
|
||||||
|
|||||||
@@ -5,10 +5,18 @@ export const exists = (path: string[]) => {
|
|||||||
const store = useMainStore()
|
const store = useMainStore()
|
||||||
// Access docVersion to make this reactive
|
// Access docVersion to make this reactive
|
||||||
void store.docVersion
|
void store.docVersion
|
||||||
|
if (path.length === 0) return true
|
||||||
const p = path.join('/')
|
const p = path.join('/')
|
||||||
return getDocuments().some(
|
const hidden = store.hiddenPaths
|
||||||
doc => (doc.loc ? `${doc.loc}/${doc.name}` : doc.name) === p
|
const inDocs = getDocuments().some(doc => {
|
||||||
)
|
const full = doc.loc ? `${doc.loc}/${doc.name}` : doc.name
|
||||||
|
return full === p && !hidden.has(full)
|
||||||
|
})
|
||||||
|
if (inDocs) return true
|
||||||
|
return store.ghosts.some(g => {
|
||||||
|
const full = g.loc ? `${g.loc}/${g.name}` : g.name
|
||||||
|
return full === p && !hidden.has(full)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Strip file extension intelligently (handles .tar.gz, name.with.dots.pdf, etc.) */
|
/** Strip file extension intelligently (handles .tar.gz, name.with.dots.pdf, etc.) */
|
||||||
|
|||||||
@@ -77,7 +77,65 @@ export const FILE_TYPES = {
|
|||||||
imageBrowser: ['avif', 'gif', 'jpg', 'jpeg', 'png', 'webp', 'svg'],
|
imageBrowser: ['avif', 'gif', 'jpg', 'jpeg', 'png', 'webp', 'svg'],
|
||||||
// Images that require server-side preview (browsers cannot display them natively)
|
// Images that require server-side preview (browsers cannot display them natively)
|
||||||
image: ['bmp', 'heic', 'heif', 'ico', 'tif', 'tiff'],
|
image: ['bmp', 'heic', 'heif', 'ico', 'tif', 'tiff'],
|
||||||
print: ['epub', 'mobi', 'pdf']
|
print: ['epub', 'mobi', 'pdf'],
|
||||||
|
text: [
|
||||||
|
'txt',
|
||||||
|
'md',
|
||||||
|
'json',
|
||||||
|
'xml',
|
||||||
|
'yaml',
|
||||||
|
'yml',
|
||||||
|
'toml',
|
||||||
|
'ini',
|
||||||
|
'conf',
|
||||||
|
'config',
|
||||||
|
'cfg',
|
||||||
|
'log',
|
||||||
|
'csv',
|
||||||
|
'tsv',
|
||||||
|
'py',
|
||||||
|
'js',
|
||||||
|
'ts',
|
||||||
|
'jsx',
|
||||||
|
'tsx',
|
||||||
|
'html',
|
||||||
|
'htm',
|
||||||
|
'css',
|
||||||
|
'scss',
|
||||||
|
'sass',
|
||||||
|
'less',
|
||||||
|
'vue',
|
||||||
|
'php',
|
||||||
|
'rb',
|
||||||
|
'go',
|
||||||
|
'rs',
|
||||||
|
'java',
|
||||||
|
'c',
|
||||||
|
'cpp',
|
||||||
|
'h',
|
||||||
|
'hpp',
|
||||||
|
'cs',
|
||||||
|
'swift',
|
||||||
|
'kt',
|
||||||
|
'sh',
|
||||||
|
'bash',
|
||||||
|
'zsh',
|
||||||
|
'fish',
|
||||||
|
'ps1',
|
||||||
|
'bat',
|
||||||
|
'cmd',
|
||||||
|
'sql',
|
||||||
|
'lua',
|
||||||
|
'r',
|
||||||
|
'pl',
|
||||||
|
'dockerfile',
|
||||||
|
'makefile',
|
||||||
|
'gitignore',
|
||||||
|
'gitattributes',
|
||||||
|
'env',
|
||||||
|
'diff',
|
||||||
|
'patch'
|
||||||
|
]
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export type FileCategory = keyof typeof FILE_TYPES
|
export type FileCategory = keyof typeof FILE_TYPES
|
||||||
|
|||||||
@@ -1,29 +1,32 @@
|
|||||||
<template>
|
<template>
|
||||||
<Gallery
|
<div class="transition-wrapper">
|
||||||
v-if="store.prefs.gallery"
|
<Transition
|
||||||
|
:name="transitionName"
|
||||||
|
@after-enter="onAfterEnter"
|
||||||
|
>
|
||||||
|
<KeepAlive>
|
||||||
|
<component
|
||||||
|
:is="store.prefs.gallery ? Gallery : FileExplorer"
|
||||||
|
:key="cacheKey"
|
||||||
ref="fileExplorer"
|
ref="fileExplorer"
|
||||||
:key="`gallery-${folderPath}`"
|
class="explorer-content"
|
||||||
:path="props.path"
|
|
||||||
:documents="documents"
|
|
||||||
/>
|
|
||||||
<FileExplorer
|
|
||||||
v-else
|
|
||||||
ref="fileExplorer"
|
|
||||||
:key="`explorer-${folderPath}`"
|
|
||||||
:path="props.path"
|
:path="props.path"
|
||||||
:documents="documents"
|
:documents="documents"
|
||||||
/>
|
/>
|
||||||
|
</KeepAlive>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
<div v-if="store.searchLoading" class="search-loading">Searching...</div>
|
<div v-if="store.searchLoading" class="search-loading">Searching...</div>
|
||||||
<EmptyFolder :documents=documents :path=props.path />
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import FileExplorer from '@/components/FileExplorer.vue'
|
import FileExplorer from '@/components/FileExplorer.vue'
|
||||||
|
import Gallery from '@/components/Gallery.vue'
|
||||||
import { getDocuments } from '@/stores/documentStore'
|
import { getDocuments } from '@/stores/documentStore'
|
||||||
import { useMainStore } from '@/stores/main'
|
import { useMainStore } from '@/stores/main'
|
||||||
import { collator } from '@/utils'
|
import { collator } from '@/utils'
|
||||||
import { sorted, sortedGrouped } from '@/utils/docsort'
|
import { sorted, sortedGrouped } from '@/utils/docsort'
|
||||||
import { computed, ref, watch, watchEffect } from 'vue'
|
import { computed, nextTick, ref, watch, watchEffect } from 'vue'
|
||||||
|
|
||||||
const store = useMainStore()
|
const store = useMainStore()
|
||||||
const fileExplorer = ref()
|
const fileExplorer = ref()
|
||||||
@@ -34,6 +37,31 @@ const props = defineProps<{
|
|||||||
|
|
||||||
// Folder path for component keys - only recreate component when folder changes, not search
|
// Folder path for component keys - only recreate component when folder changes, not search
|
||||||
const folderPath = computed(() => props.path.join('/'))
|
const folderPath = computed(() => props.path.join('/'))
|
||||||
|
const cacheKey = computed(
|
||||||
|
() => `${store.prefs.gallery ? 'gallery' : 'list'}:${folderPath.value}`
|
||||||
|
)
|
||||||
|
|
||||||
|
const transitionName = computed(() => {
|
||||||
|
if (store.transitionDirection === 'forward') return 'slide-forward'
|
||||||
|
if (store.transitionDirection === 'backward') return 'slide-backward'
|
||||||
|
return ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const folderScrollTop = new Map<string, number>()
|
||||||
|
const scrollKey = (path: string) => path || '/'
|
||||||
|
const getMainScroller = () => document.querySelector('main') as HTMLElement | null
|
||||||
|
|
||||||
|
const restoreScroll = (path: string) => {
|
||||||
|
const scroller = getMainScroller()
|
||||||
|
if (!scroller) return
|
||||||
|
const top = folderScrollTop.get(scrollKey(path)) ?? 0
|
||||||
|
scroller.scrollTop = top
|
||||||
|
}
|
||||||
|
|
||||||
|
const onAfterEnter = () => {
|
||||||
|
store.transitionDirection = 'none'
|
||||||
|
restoreScroll(folderPath.value)
|
||||||
|
}
|
||||||
|
|
||||||
// Handle route-based search changes (back/forward navigation, direct URL)
|
// Handle route-based search changes (back/forward navigation, direct URL)
|
||||||
// Skip if store.query already matches (means we triggered this via typing)
|
// Skip if store.query already matches (means we triggered this via typing)
|
||||||
@@ -87,6 +115,19 @@ watchEffect(() => {
|
|||||||
store.fileExplorer = fileExplorer.value
|
store.fileExplorer = fileExplorer.value
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
folderPath,
|
||||||
|
async (path, oldPath) => {
|
||||||
|
const scroller = getMainScroller()
|
||||||
|
if (scroller && oldPath !== undefined) {
|
||||||
|
folderScrollTop.set(scrollKey(oldPath), scroller.scrollTop)
|
||||||
|
}
|
||||||
|
await nextTick()
|
||||||
|
requestAnimationFrame(() => restoreScroll(path))
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
// Only auto-switch gallery mode when entering a new folder or on initial file list load
|
// Only auto-switch gallery mode when entering a new folder or on initial file list load
|
||||||
watch(
|
watch(
|
||||||
[() => props.path.join('/'), () => store.documentCount],
|
[() => props.path.join('/'), () => store.documentCount],
|
||||||
@@ -100,16 +141,6 @@ watch(
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.empty-container {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
height: 100%;
|
|
||||||
font-size: 2rem;
|
|
||||||
text-shadow: 0 0 .3rem #000, 0 0 2rem #0008;
|
|
||||||
color: var(--accent-color);
|
|
||||||
}
|
|
||||||
.search-loading {
|
.search-loading {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 1rem;
|
bottom: 1rem;
|
||||||
|
|||||||
@@ -0,0 +1,255 @@
|
|||||||
|
<template>
|
||||||
|
<div class="text-editor">
|
||||||
|
<div class="editor-body">
|
||||||
|
<div v-if="loading" class="status">Loading…</div>
|
||||||
|
<div v-else-if="error" class="status error">{{ error }}</div>
|
||||||
|
<div v-else ref="editorHost" class="editor-host"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { apiFetch } from '@/repositories/Client'
|
||||||
|
import { useMainStore } from '@/stores/main'
|
||||||
|
import { indentWithTab } from '@codemirror/commands'
|
||||||
|
import { LanguageDescription } from '@codemirror/language'
|
||||||
|
import { languages } from '@codemirror/language-data'
|
||||||
|
import { Compartment, EditorState } from '@codemirror/state'
|
||||||
|
import { oneDark } from '@codemirror/theme-one-dark'
|
||||||
|
import { EditorView, keymap } from '@codemirror/view'
|
||||||
|
import { basicSetup } from 'codemirror'
|
||||||
|
import {
|
||||||
|
computed,
|
||||||
|
nextTick,
|
||||||
|
onActivated,
|
||||||
|
onDeactivated,
|
||||||
|
onMounted,
|
||||||
|
onUnmounted,
|
||||||
|
ref
|
||||||
|
} from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const store = useMainStore()
|
||||||
|
|
||||||
|
const MAX_SIZE = 1024 * 1024 // 1 MiB
|
||||||
|
|
||||||
|
const filePath = computed(() => {
|
||||||
|
const raw = decodeURIComponent(route.path).split('//')[0] ?? ''
|
||||||
|
return raw.replace(/^\//, '').replace(/\/$/, '')
|
||||||
|
})
|
||||||
|
const filename = computed(() => filePath.value.split('/').pop() || '')
|
||||||
|
|
||||||
|
const filesUrl = computed(() => {
|
||||||
|
return (
|
||||||
|
'/files/' +
|
||||||
|
filePath.value
|
||||||
|
.split('/')
|
||||||
|
.map(part => encodeURIComponent(part))
|
||||||
|
.join('/')
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const content = ref('')
|
||||||
|
const original = ref('')
|
||||||
|
const loading = ref(true)
|
||||||
|
const saving = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
const editorHost = ref<HTMLDivElement | null>(null)
|
||||||
|
let editorView: EditorView | null = null
|
||||||
|
const languageCompartment = new Compartment()
|
||||||
|
|
||||||
|
const dirty = computed(() => content.value !== original.value)
|
||||||
|
|
||||||
|
const beforeUnload = (event: BeforeUnloadEvent) => {
|
||||||
|
if (!dirty.value) return
|
||||||
|
event.preventDefault()
|
||||||
|
event.returnValue = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
let beforeUnloadActive = false
|
||||||
|
|
||||||
|
const activateEditorBindings = () => {
|
||||||
|
store.editorSave = save
|
||||||
|
if (!beforeUnloadActive) {
|
||||||
|
window.addEventListener('beforeunload', beforeUnload)
|
||||||
|
beforeUnloadActive = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deactivateEditorBindings = () => {
|
||||||
|
if (store.editorSave === save) {
|
||||||
|
store.editorSave = null
|
||||||
|
}
|
||||||
|
if (beforeUnloadActive) {
|
||||||
|
window.removeEventListener('beforeunload', beforeUnload)
|
||||||
|
beforeUnloadActive = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const detectLanguage = async () => {
|
||||||
|
const language = LanguageDescription.matchFilename(languages, filename.value)
|
||||||
|
if (!language) return []
|
||||||
|
try {
|
||||||
|
return [await language.load()]
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const initEditor = async (text: string) => {
|
||||||
|
if (!editorHost.value) return
|
||||||
|
const languageExtensions = await detectLanguage()
|
||||||
|
const state = EditorState.create({
|
||||||
|
doc: text,
|
||||||
|
extensions: [
|
||||||
|
basicSetup,
|
||||||
|
oneDark,
|
||||||
|
languageCompartment.of(languageExtensions),
|
||||||
|
EditorView.updateListener.of(update => {
|
||||||
|
if (update.docChanged) {
|
||||||
|
content.value = update.state.doc.toString()
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
keymap.of([
|
||||||
|
{
|
||||||
|
key: 'Mod-s',
|
||||||
|
run: () => {
|
||||||
|
void save()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
indentWithTab
|
||||||
|
])
|
||||||
|
]
|
||||||
|
})
|
||||||
|
editorView = new EditorView({ state, parent: editorHost.value })
|
||||||
|
editorView.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
if (saving.value || loading.value) return
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
const res = await apiFetch(filesUrl.value, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: content.value,
|
||||||
|
headers: { 'Content-Type': 'text/plain; charset=utf-8' }
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}))
|
||||||
|
throw new Error(data.message || data.detail || `${res.status} ${res.statusText}`)
|
||||||
|
}
|
||||||
|
original.value = content.value
|
||||||
|
store.showToast(`Saved ${filename.value}`)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Save failed', err)
|
||||||
|
store.showToast(err instanceof Error ? err.message : 'Save failed')
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
activateEditorBindings()
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
const res = await fetch(filesUrl.value, { method: 'HEAD' })
|
||||||
|
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`)
|
||||||
|
const size = Number(res.headers.get('content-length') || '0')
|
||||||
|
if (size > MAX_SIZE) {
|
||||||
|
throw new Error(
|
||||||
|
`File is too large to edit (${(size / 1024 / 1024).toFixed(1)} MB)`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const textRes = await fetch(filesUrl.value)
|
||||||
|
if (!textRes.ok) throw new Error(`${textRes.status} ${textRes.statusText}`)
|
||||||
|
const text = await textRes.text()
|
||||||
|
content.value = text
|
||||||
|
original.value = text
|
||||||
|
loading.value = false
|
||||||
|
await nextTick()
|
||||||
|
await initEditor(text)
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err instanceof Error ? err.message : 'Failed to load file'
|
||||||
|
} finally {
|
||||||
|
if (loading.value) loading.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onActivated(() => {
|
||||||
|
activateEditorBindings()
|
||||||
|
})
|
||||||
|
|
||||||
|
onDeactivated(() => {
|
||||||
|
deactivateEditorBindings()
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
deactivateEditorBindings()
|
||||||
|
editorView?.destroy()
|
||||||
|
editorView = null
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.text-editor {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
background: #1a1a1a;
|
||||||
|
color: #ddd;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.editor-body {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.editor-host {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
.editor-host :deep(.cm-editor) {
|
||||||
|
flex: 1;
|
||||||
|
height: 100%;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.editor-host :deep(.cm-scroller) {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.editor-host :deep(.cm-content) {
|
||||||
|
padding: 1rem;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.editor-host :deep(.cm-selectionBackground) {
|
||||||
|
background: var(--soft-color, #146) !important;
|
||||||
|
}
|
||||||
|
.editor-host :deep(.cm-focused .cm-selectionBackground) {
|
||||||
|
background: var(--soft-color, #146) !important;
|
||||||
|
}
|
||||||
|
.editor-host :deep(.cm-content ::selection) {
|
||||||
|
background: var(--soft-color, #146);
|
||||||
|
}
|
||||||
|
.editor-host :deep(.cm-content, .cm-gutter) {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
|
||||||
|
}
|
||||||
|
.editor-host :deep(.cm-line, .cm-gutters, .cm-gutterElement) {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.status {
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
.status.error {
|
||||||
|
color: #f55;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import errno
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import NamedTuple
|
||||||
|
from unittest.mock import patch
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import pytest_asyncio
|
||||||
|
from sanic import Sanic
|
||||||
|
|
||||||
|
from cista import config, watching
|
||||||
|
from cista.api import fileserver
|
||||||
|
from cista.fileserver import bp as fileserver_bp
|
||||||
|
|
||||||
|
|
||||||
|
class Usage(NamedTuple):
|
||||||
|
total: int
|
||||||
|
used: int
|
||||||
|
free: int
|
||||||
|
|
||||||
|
|
||||||
|
def _low_disk_usage(*args, **kwargs):
|
||||||
|
return Usage(total=1000, used=900, free=10)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def setup_storage(tmp_path: Path):
|
||||||
|
config.config = config.Config(path=tmp_path, listen=":0", public=True)
|
||||||
|
watching.state.root = []
|
||||||
|
watching.rootpath = tmp_path
|
||||||
|
yield tmp_path
|
||||||
|
watching.state.root = []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture()
|
||||||
|
async def client(setup_storage: Path):
|
||||||
|
app = Sanic(f"disk-space-test-{uuid4().hex}", strict_slashes=True)
|
||||||
|
app.router.ALLOWED_METHODS = (
|
||||||
|
*app.router.ALLOWED_METHODS,
|
||||||
|
"MKCOL",
|
||||||
|
"MOVE",
|
||||||
|
"COPY",
|
||||||
|
"PROPFIND",
|
||||||
|
)
|
||||||
|
app.blueprint(fileserver_bp)
|
||||||
|
await fileserver.start()
|
||||||
|
yield app.asgi_client
|
||||||
|
await fileserver.stop()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_upload_rejected_when_disk_low(client):
|
||||||
|
with patch("cista.util.diskspace.shutil.disk_usage", side_effect=_low_disk_usage):
|
||||||
|
_, res = await client.put("/files/test.txt", data=b"hello world")
|
||||||
|
assert res.status_code == 507
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_upload_rejected_on_enospc(client):
|
||||||
|
with patch(
|
||||||
|
"cista.fileio.os.write",
|
||||||
|
side_effect=OSError(errno.ENOSPC, "No space left on device"),
|
||||||
|
):
|
||||||
|
_, res = await client.put("/files/test.txt", data=b"hello world")
|
||||||
|
assert res.status_code == 507
|
||||||
@@ -115,6 +115,12 @@ def setup_storage(tmp_path: Path):
|
|||||||
mode="rw",
|
mode="rw",
|
||||||
share_paths=["docs"],
|
share_paths=["docs"],
|
||||||
)
|
)
|
||||||
|
share_anon = config.Token(
|
||||||
|
key="share_anon_123",
|
||||||
|
kind="share",
|
||||||
|
mode="ro",
|
||||||
|
share_paths=["docs"],
|
||||||
|
)
|
||||||
config.config = config.Config(
|
config.config = config.Config(
|
||||||
path=tmp_path,
|
path=tmp_path,
|
||||||
listen=":0",
|
listen=":0",
|
||||||
@@ -124,6 +130,7 @@ def setup_storage(tmp_path: Path):
|
|||||||
"test_token_123": token,
|
"test_token_123": token,
|
||||||
"share_ro_123": share_ro,
|
"share_ro_123": share_ro,
|
||||||
"share_rw_123": share_rw,
|
"share_rw_123": share_rw,
|
||||||
|
"share_anon_123": share_anon,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
watching.state.root = []
|
watching.state.root = []
|
||||||
@@ -285,3 +292,20 @@ async def test_share_token_rw_allows_writes_in_scope_only(client):
|
|||||||
"/files/secret.txt", headers=_basic_auth("token", "share_rw_123")
|
"/files/secret.txt", headers=_basic_auth("token", "share_rw_123")
|
||||||
)
|
)
|
||||||
assert res.status_code == 404
|
assert res.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_anonymous_share_token_requires_public_mode(client):
|
||||||
|
config.config.public = True
|
||||||
|
|
||||||
|
_, res = await client.get(
|
||||||
|
"/files/docs/a.txt", headers=_basic_auth("token", "share_anon_123")
|
||||||
|
)
|
||||||
|
assert res.status_code == 200
|
||||||
|
assert res.body == b"A"
|
||||||
|
|
||||||
|
config.config.public = False
|
||||||
|
_, res = await client.get(
|
||||||
|
"/files/docs/a.txt", headers=_basic_auth("token", "share_anon_123")
|
||||||
|
)
|
||||||
|
assert res.status_code == 401
|
||||||
|
|||||||
@@ -215,3 +215,18 @@ async def test_create_share_token(client):
|
|||||||
share_tokens = [t for t in res.json["tokens"] if t.get("kind") == "share"]
|
share_tokens = [t for t in res.json["tokens"] if t.get("kind") == "share"]
|
||||||
assert len(share_tokens) == 1
|
assert len(share_tokens) == 1
|
||||||
assert share_tokens[0]["mode"] == "ro"
|
assert share_tokens[0]["mode"] == "ro"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_share_token_public_anonymous(client):
|
||||||
|
config.config = msgspec.structs.replace(config.config, public=True)
|
||||||
|
|
||||||
|
_, res = await client.post(
|
||||||
|
"/api/share-tokens",
|
||||||
|
json={"paths": ["hello.txt"], "mode": "ro", "name": "public-share"},
|
||||||
|
)
|
||||||
|
assert res.status_code == 200
|
||||||
|
data = res.json
|
||||||
|
assert data["kind"] == "share"
|
||||||
|
assert data["username"] == ""
|
||||||
|
assert data["sso_user_id"] == ""
|
||||||
|
|||||||
Reference in New Issue
Block a user