Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
497de296f2 | ||
|
|
134b216f4c | ||
|
|
06759b3c12 | ||
|
|
c51552ea29 | ||
|
|
00645fc8ff | ||
|
|
760f7bc35d | ||
|
|
8480a73839 | ||
|
|
302ed684e7 | ||
|
|
af35e0480a | ||
|
|
5717486197 | ||
|
|
0061fc54ae | ||
|
|
4eefe83072 | ||
|
|
f578a50007 |
@@ -20,6 +20,11 @@ Experience Cista by visiting [Cista Demo](https://drop.zi.fi) for a test run and
|
||||
|
||||
We recommend using [UV](https://docs.astral.sh/uv/getting-started/installation/) to directly run Cista:
|
||||
|
||||
Try it out locally at http://localhost:8000 (serves the current directory):
|
||||
```fish
|
||||
uvx cista
|
||||
```
|
||||
|
||||
Create an account: (otherwise the server is public for all)
|
||||
```fish
|
||||
uvx cista --user yourname --privileged
|
||||
|
||||
+4
-2
@@ -61,10 +61,12 @@ doc = """\
|
||||
Usage:
|
||||
cista [-c <confdir>] [-l <host>] [--import-droppy] [--dev] [<path>]
|
||||
cista [-c <confdir>] --user <name> [--privileged] [--password]
|
||||
cista --version
|
||||
|
||||
Options:
|
||||
-c CONFDIR Custom config directory
|
||||
-l LISTEN-ADDR Listen on
|
||||
-l, --listen LISTEN-ADDR
|
||||
Listen on
|
||||
:8989 (localhost port, plain http)
|
||||
<addr>:3000 (bind another address, port)
|
||||
/path/to/unix.sock (unix socket)
|
||||
@@ -117,7 +119,7 @@ def _main():
|
||||
args = docopt(doc)
|
||||
if args["--user"]:
|
||||
return _user(args)
|
||||
listen = args["-l"]
|
||||
listen = args["--listen"]
|
||||
# Validate arguments first
|
||||
if args["<path>"]:
|
||||
path = Path(args["<path>"]).resolve()
|
||||
|
||||
@@ -31,6 +31,7 @@ bp = Blueprint("preview", url_prefix="/preview")
|
||||
@dataclass(slots=True)
|
||||
class CachedPreview:
|
||||
"""Cached preview with headers and body."""
|
||||
|
||||
headers: dict[str, str]
|
||||
body: bytes
|
||||
|
||||
|
||||
+1
-6
@@ -62,11 +62,6 @@ def parse_listen(listen):
|
||||
return "http://localhost", {"unix": unix.as_posix()}
|
||||
|
||||
host, port = ep["host"], ep["port"]
|
||||
# When binding all interfaces, use single_listener=False for Sanic
|
||||
if len(endpoints) > 1:
|
||||
return f"http://localhost:{port}", {
|
||||
"host": host,
|
||||
"port": port,
|
||||
"single_listener": False,
|
||||
}
|
||||
return f"http://localhost:{port}", {"host": host, "port": port}
|
||||
return f"http://{host}:{port}", {"host": host, "port": port}
|
||||
|
||||
+30
-38
@@ -15,7 +15,8 @@ import re
|
||||
|
||||
import httpx
|
||||
import websockets
|
||||
from sanic import Blueprint
|
||||
from sanic import Blueprint, json
|
||||
from sanic import raw as raw_response
|
||||
from sanic.exceptions import Forbidden, SanicException, Unauthorized
|
||||
from sanic.log import logger
|
||||
|
||||
@@ -48,6 +49,8 @@ async def get_client() -> httpx.AsyncClient:
|
||||
global _client
|
||||
if _client is None or _client.is_closed:
|
||||
_client = httpx.AsyncClient(timeout=1.0)
|
||||
if "user-agent" in _client.headers:
|
||||
del _client.headers["user-agent"] # No httpx UA
|
||||
return _client
|
||||
|
||||
|
||||
@@ -171,10 +174,10 @@ async def proxy_auth_request(request):
|
||||
"upgrade",
|
||||
"proxy-authorization",
|
||||
"proxy-authenticate",
|
||||
"forwarded",
|
||||
"x-forwarded-for",
|
||||
"x-forwarded-host",
|
||||
"x-forwarded-proto",
|
||||
"forwarded",
|
||||
}
|
||||
|
||||
headers = [
|
||||
@@ -182,9 +185,17 @@ async def proxy_auth_request(request):
|
||||
for key, value in request.headers.items()
|
||||
if key.lower() not in skip_headers
|
||||
]
|
||||
headers.append(("x-forwarded-for", request.client_ip))
|
||||
|
||||
# Set Forwarded headers (strip IPv6 brackets for x-forwarded-for)
|
||||
headers.append(("x-forwarded-for", request.client_ip.strip("[]")))
|
||||
headers.append(("x-forwarded-host", request.host))
|
||||
headers.append(("x-forwarded-proto", request.scheme))
|
||||
headers.append(
|
||||
(
|
||||
"forwarded",
|
||||
f"by=cista;for={request.client_ip};host={request.host};proto={request.scheme}",
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
async with client.stream(
|
||||
@@ -210,8 +221,6 @@ async def proxy_auth_request(request):
|
||||
if key.lower() not in resp_hop_by_hop
|
||||
]
|
||||
|
||||
from sanic import raw as raw_response
|
||||
|
||||
return raw_response(
|
||||
raw_content,
|
||||
status=response.status_code,
|
||||
@@ -221,35 +230,31 @@ async def proxy_auth_request(request):
|
||||
|
||||
except httpx.RequestError as e:
|
||||
logger.error(f"Auth proxy request failed: {e}")
|
||||
from sanic import json
|
||||
|
||||
return json(
|
||||
{"detail": "Authentication service unavailable", "error": str(e)},
|
||||
{"detail": "Authentication service unavailable"},
|
||||
status=503,
|
||||
)
|
||||
|
||||
|
||||
async def proxy_auth_websocket(request, ws):
|
||||
"""Proxy a WebSocket connection to the auth backend."""
|
||||
path = request.path
|
||||
query_string = request.query_string
|
||||
ws_backend = PASKIA_BACKEND_URL.replace("http://", "ws://").replace(
|
||||
"https://", "wss://"
|
||||
)
|
||||
url = f"{ws_backend}{path}"
|
||||
if query_string:
|
||||
url = f"{url}?{query_string}"
|
||||
url = f"ws{PASKIA_BACKEND_URL.removeprefix('http')}{request.path}"
|
||||
if request.query_string:
|
||||
url = f"{url}?{request.query_string}"
|
||||
|
||||
additional_headers = {}
|
||||
if "cookie" in request.headers:
|
||||
additional_headers["cookie"] = request.headers["cookie"]
|
||||
if "authorization" in request.headers:
|
||||
additional_headers["authorization"] = request.headers["authorization"]
|
||||
if "host" in request.headers:
|
||||
additional_headers["host"] = request.headers["host"]
|
||||
if "origin" in request.headers:
|
||||
additional_headers["origin"] = request.headers["origin"]
|
||||
if "user-agent" in request.headers:
|
||||
additional_headers["user-agent"] = request.headers["user-agent"]
|
||||
additional_headers["x-forwarded-for"] = request.ip
|
||||
additional_headers["x-forwarded-for"] = request.client_ip.strip("[]")
|
||||
additional_headers["x-forwarded-host"] = request.host
|
||||
additional_headers["x-forwarded-proto"] = request.scheme
|
||||
|
||||
@@ -281,23 +286,20 @@ async def proxy_auth_websocket(request, ws):
|
||||
logger.error(f"WebSocket proxy to {url} failed: {e}")
|
||||
|
||||
|
||||
def _is_websocket_request(request) -> bool:
|
||||
"""Check if the request is a WebSocket upgrade request."""
|
||||
connection = request.headers.get("connection", "").lower()
|
||||
upgrade = request.headers.get("upgrade", "").lower()
|
||||
connection_tokens = [t.strip() for t in connection.split(",")]
|
||||
return "upgrade" in connection_tokens and upgrade == "websocket"
|
||||
# Blueprint for auth proxy routes (only registered when paskia_enabled())
|
||||
bp = Blueprint("sso", url_prefix="/auth")
|
||||
|
||||
|
||||
async def _handle_websocket_upgrade(request):
|
||||
"""Handle WebSocket upgrade and proxy the connection."""
|
||||
protocol = request.transport.get_protocol()
|
||||
ws = await protocol.websocket_handshake(request, subprotocols=None)
|
||||
@bp.websocket("/ws/<path:path>")
|
||||
async def auth_websocket_proxy(request, ws, path=""):
|
||||
"""Proxy WebSocket connections to the auth backend."""
|
||||
await proxy_auth_websocket(request, ws)
|
||||
|
||||
|
||||
# Blueprint for auth proxy routes (only registered when paskia_enabled())
|
||||
bp = Blueprint("sso", url_prefix="/auth")
|
||||
@bp.websocket("/ws/")
|
||||
async def auth_websocket_proxy_root(request, ws):
|
||||
"""Proxy root WebSocket connections to the auth backend."""
|
||||
await proxy_auth_websocket(request, ws)
|
||||
|
||||
|
||||
@bp.route(
|
||||
@@ -305,20 +307,10 @@ bp = Blueprint("sso", url_prefix="/auth")
|
||||
)
|
||||
async def auth_proxy(request, path=""):
|
||||
"""Proxy all auth requests to the auth backend."""
|
||||
if _is_websocket_request(request):
|
||||
await _handle_websocket_upgrade(request)
|
||||
from sanic import empty
|
||||
|
||||
return empty()
|
||||
return await proxy_auth_request(request)
|
||||
|
||||
|
||||
@bp.route("/", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"])
|
||||
async def auth_proxy_root(request):
|
||||
"""Proxy root auth requests to the auth backend."""
|
||||
if _is_websocket_request(request):
|
||||
await _handle_websocket_upgrade(request)
|
||||
from sanic import empty
|
||||
|
||||
return empty()
|
||||
return await proxy_auth_request(request)
|
||||
|
||||
+32
-2
@@ -17,6 +17,33 @@ from cista import config
|
||||
from cista.fileio import fuid
|
||||
from cista.protocol import FileEntry, Space, UpdDel, UpdIns, UpdKeep
|
||||
|
||||
# Platform-specific allocated size calculation
|
||||
if sys.platform == "win32":
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
|
||||
kernel32 = ctypes.windll.kernel32
|
||||
GetCompressedFileSizeW = kernel32.GetCompressedFileSizeW
|
||||
GetCompressedFileSizeW.argtypes = [wintypes.LPCWSTR, ctypes.POINTER(wintypes.DWORD)]
|
||||
GetCompressedFileSizeW.restype = wintypes.DWORD
|
||||
INVALID_FILE_SIZE = 0xFFFFFFFF
|
||||
|
||||
def get_allocated_size(path: Path, st: stat_result) -> int:
|
||||
"""Get actual disk allocation on Windows using GetCompressedFileSizeW."""
|
||||
high = wintypes.DWORD()
|
||||
low = GetCompressedFileSizeW(str(path), ctypes.byref(high))
|
||||
if low == INVALID_FILE_SIZE and ctypes.get_last_error() != 0:
|
||||
raise OSError(f"GetCompressedFileSizeW failed for {path}")
|
||||
return (high.value << 32) + low
|
||||
|
||||
else:
|
||||
|
||||
def get_allocated_size(path: Path, st: stat_result) -> int:
|
||||
"""Get actual disk allocation on Unix using st_blocks."""
|
||||
# st_blocks is in 512-byte units
|
||||
return st.st_blocks * 512
|
||||
|
||||
|
||||
pubsub = {}
|
||||
sortkey = natsort_keygen(alg=ns.LOCALE)
|
||||
|
||||
@@ -148,8 +175,11 @@ def walk(rel: PurePosixPath, stat: stat_result | None = None) -> list[FileEntry]
|
||||
try:
|
||||
st = stat or path.stat()
|
||||
isfile = int(not S_ISDIR(st.st_mode))
|
||||
# st_blocks is in 512-byte units
|
||||
allocated = st.st_blocks * 512 if isfile else 0
|
||||
try:
|
||||
allocated = get_allocated_size(path, st) if isfile else 0
|
||||
except Exception:
|
||||
logger.exception(f"get_allocated_size failed for {path}")
|
||||
allocated = st.st_size if isfile else 0
|
||||
entry = FileEntry(
|
||||
level=len(rel.parts),
|
||||
name=rel.name,
|
||||
|
||||
+62
-13
@@ -63,6 +63,7 @@ onUnmounted(watchDisconnect)
|
||||
const headerMain = ref<typeof HeaderMain | null>(null)
|
||||
let vert = 0
|
||||
let timer: any = null
|
||||
|
||||
const globalShortcutHandler = (event: KeyboardEvent) => {
|
||||
if (store.dialog) {
|
||||
if (timer) {
|
||||
@@ -76,6 +77,13 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
|
||||
const c = fileExplorer.isCursor()
|
||||
const input = (event.target as HTMLElement).tagName === 'INPUT'
|
||||
const keyup = event.type === 'keyup'
|
||||
|
||||
// Always clear repeat timer on arrow keyup, even if focus moved to input
|
||||
if (keyup && event.key.startsWith('Arrow') && timer) {
|
||||
clearTimeout(timer)
|
||||
timer = null
|
||||
}
|
||||
|
||||
if (event.repeat) {
|
||||
if (
|
||||
event.key === 'ArrowUp' ||
|
||||
@@ -91,13 +99,32 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
|
||||
//console.log("key pressed", event)
|
||||
/// Long if-else machina for all keys we handle here
|
||||
let arrow = ''
|
||||
if (!input && event.key.startsWith("Arrow")) arrow = event.key.slice(5).toLowerCase()
|
||||
const inHeader = !!(event.target as HTMLElement).closest('.headermain')
|
||||
const inBreadcrumb = !!(event.target as HTMLElement).closest('.breadcrumb')
|
||||
// Handle arrows: in search input with text, only up/down; otherwise all arrows
|
||||
const searchInput = inHeader && input
|
||||
const searchHasText = searchInput && (event.target as HTMLInputElement).value
|
||||
if (event.key.startsWith("Arrow")) {
|
||||
const dir = event.key.slice(5).toLowerCase()
|
||||
// In search with text: left/right move cursor, up/down navigate
|
||||
if (searchHasText && (dir === 'left' || dir === 'right')) {
|
||||
return // Let browser handle cursor movement
|
||||
}
|
||||
arrow = dir
|
||||
}
|
||||
if (arrow) {
|
||||
// Arrow key handling - fall through to bottom
|
||||
}
|
||||
// Find: process on keydown so that we can bypass the built-in search hotkey
|
||||
else if (!keyup && event.key === 'f' && (event.ctrlKey || event.metaKey)) {
|
||||
headerMain.value!.toggleSearchInput()
|
||||
}
|
||||
// Search also on / (UNIX style)
|
||||
else if (!input && keyup && event.key === '/') {
|
||||
// Search also on / (UNIX style) - use code to support any keyboard layout
|
||||
else if (!input && keyup && event.code === 'Slash') {
|
||||
// Record the actual character for display (varies by keyboard layout)
|
||||
if (event.key.length === 1 && event.key !== store.prefs.searchHotkey) {
|
||||
store.prefs.searchHotkey = event.key
|
||||
}
|
||||
headerMain.value!.toggleSearchInput()
|
||||
}
|
||||
// Globally close search, clear errors on Escape
|
||||
@@ -143,13 +170,34 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
|
||||
timer = null
|
||||
}
|
||||
let f: any
|
||||
switch (arrow) {
|
||||
case 'up': f = () => fileExplorer.up(event); break
|
||||
case 'down': f = () => fileExplorer.down(event); break
|
||||
case 'left': f = () => fileExplorer.left(event); break
|
||||
case 'right': f = () => fileExplorer.right(event); break
|
||||
// Arrow navigation - always use fileExplorer for repeatable movement
|
||||
if (arrow && !keyup) {
|
||||
const focusSearch = () => (document.querySelector('.headermain input[type="search"]') as HTMLElement)?.focus()
|
||||
const focusBreadcrumb = () => (document.querySelector('.breadcrumb') as HTMLElement)?.focus()
|
||||
|
||||
if (inBreadcrumb) {
|
||||
// Breadcrumb: up→header (no repeat), down→files (with repeat)
|
||||
if (arrow === 'up') { focusSearch(); f = null }
|
||||
else if (arrow === 'down') { fileExplorer.focusFirst?.(); f = null }
|
||||
} else if (inHeader) {
|
||||
// Header: left/right navigate focusable items (buttons without tabindex=-1, search input, disk space)
|
||||
const items = Array.from(document.querySelectorAll('.headermain button:not([tabindex=\"-1\"]), .headermain input[type=\"search\"], .headermain [tabindex=\"0\"]')) as HTMLElement[]
|
||||
const idx = items.indexOf(document.activeElement as HTMLElement)
|
||||
if (arrow === 'left' && idx > 0) { items[idx - 1]?.focus(); f = null }
|
||||
else if (arrow === 'right' && idx < items.length - 1) { items[idx + 1]?.focus(); f = null }
|
||||
else if (arrow === 'up') f = () => fileExplorer.up({ shiftKey: false })
|
||||
else if (arrow === 'down') { focusBreadcrumb(); f = null }
|
||||
} else {
|
||||
// File explorer: normal navigation with repeat
|
||||
switch (arrow) {
|
||||
case 'up': f = () => fileExplorer.up(event); break
|
||||
case 'down': f = () => fileExplorer.down(event); break
|
||||
case 'left': f = () => fileExplorer.left(event); break
|
||||
case 'right': f = () => fileExplorer.right(event); break
|
||||
}
|
||||
}
|
||||
}
|
||||
if (f && !keyup) {
|
||||
if (f) {
|
||||
// Initial move, then t0 delay until repeats at tr intervals
|
||||
const t0 = 200, tr = event.altKey ? 20 : 100
|
||||
f()
|
||||
@@ -157,12 +205,13 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
|
||||
}
|
||||
}
|
||||
onMounted(() => {
|
||||
window.addEventListener('keydown', globalShortcutHandler)
|
||||
window.addEventListener('keyup', globalShortcutHandler)
|
||||
// Use capture phase to handle events before they reach target elements
|
||||
window.addEventListener('keydown', globalShortcutHandler, true)
|
||||
window.addEventListener('keyup', globalShortcutHandler, true)
|
||||
})
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('keydown', globalShortcutHandler)
|
||||
window.removeEventListener('keyup', globalShortcutHandler)
|
||||
window.removeEventListener('keydown', globalShortcutHandler, true)
|
||||
window.removeEventListener('keyup', globalShortcutHandler, true)
|
||||
})
|
||||
export type { Path }
|
||||
</script>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="disk-space-container" ref="containerRef">
|
||||
<div class="disk-space-container" ref="containerRef" tabindex="0" @keydown.enter="handleClick" @keydown.space.prevent="handleClick">
|
||||
<div
|
||||
ref="widgetRef"
|
||||
class="disk-space-widget"
|
||||
@@ -352,6 +352,11 @@ onUnmounted(() => {
|
||||
position: relative;
|
||||
width: 3em;
|
||||
height: 3em;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.disk-space-container:focus .disk-space-widget:not(.expanded) {
|
||||
filter: brightness(1);
|
||||
}
|
||||
|
||||
.disk-space-widget {
|
||||
|
||||
@@ -72,7 +72,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watchEffect, shallowRef, onMounted, onUnmounted } from 'vue'
|
||||
import { ref, computed, watchEffect, shallowRef, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import { Doc } from '@/repositories/Document'
|
||||
import FileRenameInput from './FileRenameInput.vue'
|
||||
@@ -135,6 +135,17 @@ defineExpose({
|
||||
isCursor() {
|
||||
return store.cursor && editing.value === null
|
||||
},
|
||||
focusFirst() {
|
||||
const docs = props.documents
|
||||
if (docs.length > 0) {
|
||||
store.cursor = docs[0]!.key
|
||||
// Also focus the element directly (watchEffect won't trigger if cursor unchanged)
|
||||
nextTick(() => {
|
||||
const a = document.querySelector(`#file-${store.cursor} .name a`) as HTMLAnchorElement | null
|
||||
if (a) a.focus()
|
||||
})
|
||||
}
|
||||
},
|
||||
cursorRename() {
|
||||
editing.value = props.documents.find(doc => doc.key === store.cursor) ?? null
|
||||
},
|
||||
@@ -150,7 +161,12 @@ defineExpose({
|
||||
},
|
||||
up(ev: KeyboardEvent) { this.cursorMove(-1, ev) },
|
||||
down(ev: KeyboardEvent) { this.cursorMove(1, ev) },
|
||||
left(ev: KeyboardEvent) { router.back() },
|
||||
left(ev: KeyboardEvent) {
|
||||
// Only go back if we're in a subfolder (not at root)
|
||||
if (props.path.length > 0) {
|
||||
router.back()
|
||||
}
|
||||
},
|
||||
right(ev: KeyboardEvent) {
|
||||
const a = document.querySelector(`#file-${store.cursor} a`) as HTMLAnchorElement | null
|
||||
if (a) a.click()
|
||||
@@ -190,9 +206,17 @@ defineExpose({
|
||||
scrolltimer = null
|
||||
}, 300)
|
||||
}
|
||||
if (moveto === N) focusBreadcrumb()
|
||||
// When leaving the file list: up goes to breadcrumbs, down goes to header
|
||||
if (moveto === N) {
|
||||
if (d < 0) focusBreadcrumb()
|
||||
else focusHeader()
|
||||
}
|
||||
}
|
||||
})
|
||||
const focusHeader = () => {
|
||||
const el = document.querySelector('.headermain input[type="search"]') as HTMLElement | null
|
||||
if (el) el.focus()
|
||||
}
|
||||
const focusBreadcrumb = () => {
|
||||
const el = document.querySelector('.breadcrumb') as HTMLElement | null
|
||||
if (el) el.focus()
|
||||
@@ -210,7 +234,7 @@ watchEffect(() => {
|
||||
}
|
||||
})
|
||||
watchEffect(() => {
|
||||
if (!props.documents.length && store.cursor) {
|
||||
if (!props.documents.length && store.cursor && !store.query) {
|
||||
store.cursor = ''
|
||||
focusBreadcrumb()
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watchEffect, shallowRef, onMounted, onUnmounted } from 'vue'
|
||||
import { ref, computed, watchEffect, shallowRef, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import { Doc } from '@/repositories/Document'
|
||||
import { connect, controlUrl } from '@/repositories/WS'
|
||||
@@ -82,6 +82,17 @@ defineExpose({
|
||||
isCursor() {
|
||||
return store.cursor && editing.value === null
|
||||
},
|
||||
focusFirst() {
|
||||
const docs = props.documents
|
||||
if (docs.length > 0) {
|
||||
store.cursor = docs[0]!.key
|
||||
// Also focus the element directly (watchEffect won't trigger if cursor unchanged)
|
||||
nextTick(() => {
|
||||
const a = document.querySelector(`#file-${store.cursor}`) as HTMLAnchorElement | null
|
||||
if (a) a.focus()
|
||||
})
|
||||
}
|
||||
},
|
||||
cursorRename() {
|
||||
editing.value = props.documents.find(doc => doc.key === store.cursor) ?? null
|
||||
},
|
||||
@@ -144,9 +155,17 @@ defineExpose({
|
||||
scrolltimer = null
|
||||
}, 300)
|
||||
}
|
||||
if (moveto === N) focusBreadcrumb()
|
||||
// When leaving the file list: up goes to breadcrumbs, down goes to header
|
||||
if (moveto === N) {
|
||||
if (d < 0) focusBreadcrumb()
|
||||
else focusHeader()
|
||||
}
|
||||
}
|
||||
})
|
||||
const focusHeader = () => {
|
||||
const el = document.querySelector('.headermain input[type="search"]') as HTMLElement | null
|
||||
if (el) el.focus()
|
||||
}
|
||||
const focusBreadcrumb = () => {
|
||||
const el = document.querySelector('.breadcrumb') as HTMLElement | null
|
||||
if (el) el.focus()
|
||||
@@ -162,7 +181,7 @@ watchEffect(() => {
|
||||
}
|
||||
})
|
||||
watchEffect(() => {
|
||||
if (!props.documents.length && store.cursor) {
|
||||
if (!props.documents.length && store.cursor && !store.query) {
|
||||
store.cursor = ''
|
||||
focusBreadcrumb()
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<div class="smallgap"></div>
|
||||
<SvgButton name="eye" @click="store.prefs.gallery = !store.prefs.gallery" tooltip="Details/Gallery" />
|
||||
<div class="search-group">
|
||||
<SvgButton name="find" @click="focusSearch" tooltip="Search" />
|
||||
<SvgButton name="find" tabindex="-1" @click="focusSearch" tooltip="Search" />
|
||||
<input
|
||||
ref="search"
|
||||
type="search"
|
||||
@@ -17,7 +17,7 @@
|
||||
@input="updateSearch"
|
||||
@keydown.escape="clearSearch"
|
||||
/>
|
||||
<span v-if="!query" class="search-hint" @click="focusSearch">/</span>
|
||||
<span v-if="!query" class="search-hint" @click="focusSearch">{{ store.prefs.searchHotkey }}</span>
|
||||
</div>
|
||||
<div class="spacer smallgap"></div>
|
||||
<DiskSpace v-if="store.space.disk" />
|
||||
@@ -159,6 +159,9 @@ defineExpose({
|
||||
.search-group:focus-within {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
.search-group:focus-within {
|
||||
box-shadow: 0 0 0 2px var(--accent-color, #f80);
|
||||
}
|
||||
.search-group:hover :deep(button.action-button),
|
||||
.search-group:focus-within :deep(button.action-button) {
|
||||
transform: scale(1.1);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<template>
|
||||
<button
|
||||
class="action-button"
|
||||
:tabindex="tabindex"
|
||||
@mouseenter="tooltip?.startHover"
|
||||
@mousemove="tooltip?.updatePosition"
|
||||
@mouseleave="tooltip?.endHover"
|
||||
@@ -19,6 +20,7 @@ import CursorTooltip from './CursorTooltip.vue'
|
||||
const props = defineProps<{
|
||||
name: IconName
|
||||
tooltip?: string
|
||||
tabindex?: string | number
|
||||
}>()
|
||||
|
||||
const tooltip = ref<InstanceType<typeof CursorTooltip> | null>(null)
|
||||
|
||||
@@ -87,6 +87,7 @@ export const useMainStore = defineStore('main', {
|
||||
gallery: false,
|
||||
sortListing: '' as SortOrder,
|
||||
sortFiltered: '' as SortOrder,
|
||||
searchHotkey: '/', // Character shown for search hotkey (Slash key)
|
||||
},
|
||||
user: {
|
||||
username: '' as string,
|
||||
@@ -221,6 +222,7 @@ export const useMainStore = defineStore('main', {
|
||||
name: doc.name,
|
||||
key: doc.key,
|
||||
size: doc.size,
|
||||
allocated: doc.allocated,
|
||||
mtime: doc.mtime,
|
||||
dir: doc.dir,
|
||||
}))
|
||||
|
||||
@@ -6,6 +6,7 @@ interface DocData {
|
||||
name: string
|
||||
key: string
|
||||
size: number
|
||||
allocated: number
|
||||
mtime: number
|
||||
dir: boolean
|
||||
}
|
||||
|
||||
+3
-5
@@ -114,6 +114,7 @@ filterwarnings = [
|
||||
]
|
||||
|
||||
[tool.ruff.lint]
|
||||
extend-select = ["E402"]
|
||||
isort.known-first-party = ["cista"]
|
||||
per-file-ignores."tests/*" = ["S", "ANN", "D", "INP", "PLR2004"]
|
||||
per-file-ignores."scripts/*" = ["T20"]
|
||||
@@ -121,16 +122,13 @@ per-file-ignores."scripts/*" = ["T20"]
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"pytest>=8.4.1",
|
||||
"pytest-asyncio>=0.25.0",
|
||||
"pytest-cov>=7.0.0",
|
||||
"ruff>=0.8.0",
|
||||
"mypy>=1.13.0",
|
||||
"pre-commit>=4.0.0",
|
||||
"httpx>=0.28.1",
|
||||
]
|
||||
test = [
|
||||
"pytest>=8.4.1",
|
||||
"pytest-cov>=6.0.0",
|
||||
"pytest-asyncio>=0.25.0",
|
||||
]
|
||||
|
||||
[tool.coverage.run]
|
||||
source = ["cista"]
|
||||
|
||||
@@ -134,7 +134,7 @@ def find_dev_tool() -> list[str]:
|
||||
Raises RuntimeError if no runtime is found.
|
||||
"""
|
||||
dev_args = {
|
||||
"deno": ("run", "dev", "--"),
|
||||
"deno": ("run", "-A", "npm:vite"),
|
||||
"npm": ("--silent", "run", "dev", "--"),
|
||||
"bun": ("run", "dev", "--"),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user