Compare commits

...
5 Commits
10 changed files with 141 additions and 25 deletions
+1
View File
@@ -31,6 +31,7 @@ bp = Blueprint("preview", url_prefix="/preview")
@dataclass(slots=True) @dataclass(slots=True)
class CachedPreview: class CachedPreview:
"""Cached preview with headers and body.""" """Cached preview with headers and body."""
headers: dict[str, str] headers: dict[str, str]
body: bytes body: bytes
+12 -2
View File
@@ -48,6 +48,8 @@ async def get_client() -> httpx.AsyncClient:
global _client global _client
if _client is None or _client.is_closed: if _client is None or _client.is_closed:
_client = httpx.AsyncClient(timeout=1.0) _client = httpx.AsyncClient(timeout=1.0)
if "user-agent" in _client.headers:
del _client.headers["user-agent"] # No httpx UA
return _client return _client
@@ -171,10 +173,10 @@ async def proxy_auth_request(request):
"upgrade", "upgrade",
"proxy-authorization", "proxy-authorization",
"proxy-authenticate", "proxy-authenticate",
"forwarded",
"x-forwarded-for", "x-forwarded-for",
"x-forwarded-host", "x-forwarded-host",
"x-forwarded-proto", "x-forwarded-proto",
"forwarded",
} }
headers = [ headers = [
@@ -182,9 +184,17 @@ async def proxy_auth_request(request):
for key, value in request.headers.items() for key, value in request.headers.items()
if key.lower() not in skip_headers 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-host", request.host))
headers.append(("x-forwarded-proto", request.scheme)) headers.append(("x-forwarded-proto", request.scheme))
headers.append(
(
"forwarded",
f"by=cista;for={request.client_ip};host={request.host};proto={request.scheme}",
)
)
try: try:
async with client.stream( async with client.stream(
+57 -8
View File
@@ -63,6 +63,7 @@ onUnmounted(watchDisconnect)
const headerMain = ref<typeof HeaderMain | null>(null) const headerMain = ref<typeof HeaderMain | null>(null)
let vert = 0 let vert = 0
let timer: any = null let timer: any = null
const globalShortcutHandler = (event: KeyboardEvent) => { const globalShortcutHandler = (event: KeyboardEvent) => {
if (store.dialog) { if (store.dialog) {
if (timer) { if (timer) {
@@ -76,6 +77,13 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
const c = fileExplorer.isCursor() const c = fileExplorer.isCursor()
const input = (event.target as HTMLElement).tagName === 'INPUT' const input = (event.target as HTMLElement).tagName === 'INPUT'
const keyup = event.type === 'keyup' 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.repeat) {
if ( if (
event.key === 'ArrowUp' || event.key === 'ArrowUp' ||
@@ -91,13 +99,32 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
//console.log("key pressed", event) //console.log("key pressed", event)
/// Long if-else machina for all keys we handle here /// Long if-else machina for all keys we handle here
let arrow = '' 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 // 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 (!keyup && event.key === 'f' && (event.ctrlKey || event.metaKey)) {
headerMain.value!.toggleSearchInput() headerMain.value!.toggleSearchInput()
} }
// Search also on / (UNIX style) // Search also on / (UNIX style) - use code to support any keyboard layout
else if (!input && keyup && event.key === '/') { 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() headerMain.value!.toggleSearchInput()
} }
// Globally close search, clear errors on Escape // Globally close search, clear errors on Escape
@@ -143,13 +170,34 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
timer = null timer = null
} }
let f: any let f: any
// 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) { switch (arrow) {
case 'up': f = () => fileExplorer.up(event); break case 'up': f = () => fileExplorer.up(event); break
case 'down': f = () => fileExplorer.down(event); break case 'down': f = () => fileExplorer.down(event); break
case 'left': f = () => fileExplorer.left(event); break case 'left': f = () => fileExplorer.left(event); break
case 'right': f = () => fileExplorer.right(event); break case 'right': f = () => fileExplorer.right(event); break
} }
if (f && !keyup) { }
}
if (f) {
// Initial move, then t0 delay until repeats at tr intervals // Initial move, then t0 delay until repeats at tr intervals
const t0 = 200, tr = event.altKey ? 20 : 100 const t0 = 200, tr = event.altKey ? 20 : 100
f() f()
@@ -157,12 +205,13 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
} }
} }
onMounted(() => { onMounted(() => {
window.addEventListener('keydown', globalShortcutHandler) // Use capture phase to handle events before they reach target elements
window.addEventListener('keyup', globalShortcutHandler) window.addEventListener('keydown', globalShortcutHandler, true)
window.addEventListener('keyup', globalShortcutHandler, true)
}) })
onUnmounted(() => { onUnmounted(() => {
window.removeEventListener('keydown', globalShortcutHandler) window.removeEventListener('keydown', globalShortcutHandler, true)
window.removeEventListener('keyup', globalShortcutHandler) window.removeEventListener('keyup', globalShortcutHandler, true)
}) })
export type { Path } export type { Path }
</script> </script>
+6 -1
View File
@@ -1,5 +1,5 @@
<template> <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 <div
ref="widgetRef" ref="widgetRef"
class="disk-space-widget" class="disk-space-widget"
@@ -352,6 +352,11 @@ onUnmounted(() => {
position: relative; position: relative;
width: 3em; width: 3em;
height: 3em; height: 3em;
outline: none;
}
.disk-space-container:focus .disk-space-widget:not(.expanded) {
filter: brightness(1);
} }
.disk-space-widget { .disk-space-widget {
+28 -4
View File
@@ -72,7 +72,7 @@
</template> </template>
<script setup lang="ts"> <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 { useMainStore } from '@/stores/main'
import { Doc } from '@/repositories/Document' import { Doc } from '@/repositories/Document'
import FileRenameInput from './FileRenameInput.vue' import FileRenameInput from './FileRenameInput.vue'
@@ -135,6 +135,17 @@ defineExpose({
isCursor() { isCursor() {
return store.cursor && editing.value === null 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() { cursorRename() {
editing.value = props.documents.find(doc => doc.key === store.cursor) ?? null editing.value = props.documents.find(doc => doc.key === store.cursor) ?? null
}, },
@@ -150,7 +161,12 @@ defineExpose({
}, },
up(ev: KeyboardEvent) { this.cursorMove(-1, ev) }, up(ev: KeyboardEvent) { this.cursorMove(-1, ev) },
down(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) { right(ev: KeyboardEvent) {
const a = document.querySelector(`#file-${store.cursor} a`) as HTMLAnchorElement | null const a = document.querySelector(`#file-${store.cursor} a`) as HTMLAnchorElement | null
if (a) a.click() if (a) a.click()
@@ -190,9 +206,17 @@ defineExpose({
scrolltimer = null scrolltimer = null
}, 300) }, 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 focusBreadcrumb = () => {
const el = document.querySelector('.breadcrumb') as HTMLElement | null const el = document.querySelector('.breadcrumb') as HTMLElement | null
if (el) el.focus() if (el) el.focus()
@@ -210,7 +234,7 @@ watchEffect(() => {
} }
}) })
watchEffect(() => { watchEffect(() => {
if (!props.documents.length && store.cursor) { if (!props.documents.length && store.cursor && !store.query) {
store.cursor = '' store.cursor = ''
focusBreadcrumb() focusBreadcrumb()
} }
+22 -3
View File
@@ -9,7 +9,7 @@
</template> </template>
<script setup lang="ts"> <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 { useMainStore } from '@/stores/main'
import { Doc } from '@/repositories/Document' import { Doc } from '@/repositories/Document'
import { connect, controlUrl } from '@/repositories/WS' import { connect, controlUrl } from '@/repositories/WS'
@@ -82,6 +82,17 @@ defineExpose({
isCursor() { isCursor() {
return store.cursor && editing.value === null 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() { cursorRename() {
editing.value = props.documents.find(doc => doc.key === store.cursor) ?? null editing.value = props.documents.find(doc => doc.key === store.cursor) ?? null
}, },
@@ -144,9 +155,17 @@ defineExpose({
scrolltimer = null scrolltimer = null
}, 300) }, 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 focusBreadcrumb = () => {
const el = document.querySelector('.breadcrumb') as HTMLElement | null const el = document.querySelector('.breadcrumb') as HTMLElement | null
if (el) el.focus() if (el) el.focus()
@@ -162,7 +181,7 @@ watchEffect(() => {
} }
}) })
watchEffect(() => { watchEffect(() => {
if (!props.documents.length && store.cursor) { if (!props.documents.length && store.cursor && !store.query) {
store.cursor = '' store.cursor = ''
focusBreadcrumb() focusBreadcrumb()
} }
+5 -2
View File
@@ -9,7 +9,7 @@
<div class="smallgap"></div> <div class="smallgap"></div>
<SvgButton name="eye" @click="store.prefs.gallery = !store.prefs.gallery" tooltip="Details/Gallery" /> <SvgButton name="eye" @click="store.prefs.gallery = !store.prefs.gallery" tooltip="Details/Gallery" />
<div class="search-group"> <div class="search-group">
<SvgButton name="find" @click="focusSearch" tooltip="Search" /> <SvgButton name="find" tabindex="-1" @click="focusSearch" tooltip="Search" />
<input <input
ref="search" ref="search"
type="search" type="search"
@@ -17,7 +17,7 @@
@input="updateSearch" @input="updateSearch"
@keydown.escape="clearSearch" @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>
<div class="spacer smallgap"></div> <div class="spacer smallgap"></div>
<DiskSpace v-if="store.space.disk" /> <DiskSpace v-if="store.space.disk" />
@@ -159,6 +159,9 @@ defineExpose({
.search-group:focus-within { .search-group:focus-within {
background: rgba(255, 255, 255, 0.2); 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:hover :deep(button.action-button),
.search-group:focus-within :deep(button.action-button) { .search-group:focus-within :deep(button.action-button) {
transform: scale(1.1); transform: scale(1.1);
+2
View File
@@ -1,6 +1,7 @@
<template> <template>
<button <button
class="action-button" class="action-button"
:tabindex="tabindex"
@mouseenter="tooltip?.startHover" @mouseenter="tooltip?.startHover"
@mousemove="tooltip?.updatePosition" @mousemove="tooltip?.updatePosition"
@mouseleave="tooltip?.endHover" @mouseleave="tooltip?.endHover"
@@ -19,6 +20,7 @@ import CursorTooltip from './CursorTooltip.vue'
const props = defineProps<{ const props = defineProps<{
name: IconName name: IconName
tooltip?: string tooltip?: string
tabindex?: string | number
}>() }>()
const tooltip = ref<InstanceType<typeof CursorTooltip> | null>(null) const tooltip = ref<InstanceType<typeof CursorTooltip> | null>(null)
+2
View File
@@ -87,6 +87,7 @@ export const useMainStore = defineStore('main', {
gallery: false, gallery: false,
sortListing: '' as SortOrder, sortListing: '' as SortOrder,
sortFiltered: '' as SortOrder, sortFiltered: '' as SortOrder,
searchHotkey: '/', // Character shown for search hotkey (Slash key)
}, },
user: { user: {
username: '' as string, username: '' as string,
@@ -221,6 +222,7 @@ export const useMainStore = defineStore('main', {
name: doc.name, name: doc.name,
key: doc.key, key: doc.key,
size: doc.size, size: doc.size,
allocated: doc.allocated,
mtime: doc.mtime, mtime: doc.mtime,
dir: doc.dir, dir: doc.dir,
})) }))
+1
View File
@@ -6,6 +6,7 @@ interface DocData {
name: string name: string
key: string key: string
size: number size: number
allocated: number
mtime: number mtime: number
dir: boolean dir: boolean
} }