Compare commits

..
11 Commits
Author SHA1 Message Date
LeoVasanko 3bad311e35 Tell Paskia SSO not to renew session on WebSocket connections where we cannot pass back the refreshed cookie. 2026-08-11 02:08:01 +00:00
LeoVasanko fdc4fe0a3e Forward client user-agent to SSO backend on validation refreshes. Matches function of existing proxy_auth_request (copies all headers) and proxy_auth_websocket (copies user-agent when present). 2026-08-10 14:08:17 +00:00
LeoVasanko 5df2308bdb Silence CPY copyright rule; make format_access_log tail args keyword-only
Newer ruff flagged CPY001 across the codebase (copyright notices are not
wanted here, rule disabled) and PLR0917 on format_access_log. duration_ms
and extra are now keyword-only at the single call site.
2026-07-28 02:37:12 +00:00
LeoVasanko f4c44ce1aa Remove unused frontend test framework
vitest, @vue/test-utils, jsdom and @types/jsdom were installed but no
frontend tests exist or are planned. Removing them also drops the
deprecated glob@10 dependency chain (js-beautify). type-check now uses
tsconfig.app.json.
2026-07-28 02:32:26 +00:00
LeoVasanko 49232f11cc Fix rename flow: KeepAlive-cached view watchers cleared cursor on stale props
Deactivated FileExplorer/Gallery instances stay alive in KeepAlive with
frozen, potentially empty document props. Their empty-folder watcher
cleared store.cursor and yanked focus to the breadcrumb on every cursor
change, breaking rename via gallery pen and keyboard entry into the
file list, and hiding the explorer rename button.

- Guard cursor watchers in FileExplorer/Gallery with an isActive flag
  (set on activated, cleared on deactivated)
- Declare emits in GalleryFigure (rename/menu fell through to the root
  anchor as native listeners)
- Show the explorer rename button on row hover with a delayed fade-in
  instead of only on the keyboard-focused row
2026-07-28 02:16:23 +00:00
LeoVasanko 1258eff42d Fix preview worker pool leak: ffmpeg must not inherit worker stdin
The ffmpeg fallback in the preview worker inherited the worker's stdin
pipe (the framed request protocol). When a slow conversion was killed
at the 10s timeout, the orphaned ffmpeg grandchild kept that pipe open,
so the parent's proc.wait() blocked forever waiting for pipe EOF —
permanently sticking one dispatcher per event until the whole pool
starved and every preview request (pdf, image, office) returned 503.

- Run ffmpeg with stdin=DEVNULL (also stops it eating protocol bytes)
- Drop start_new_session (only needed for group kills, POSIX-only)
- Stop logging the master secret at worker startup
2026-07-28 01:14:15 +00:00
LeoVasanko 718d46e3f9 Fix search in subdirectories (problem saving search field in URL). 2026-06-17 04:01:21 +00:00
LeoVasanko 92d9c40a28 Center file rename input in gallery mode to be more consistent with normal titles. 2026-06-17 03:43:01 +00:00
LeoVasanko 4f646fb344 Fix layout when there is more space than needed to display file explorer (don't scale larger) or gallery (don't bottom align). 2026-06-17 03:30:20 +00:00
LeoVasanko d6304d0029 Frontend linter changes. 2026-06-16 22:13:27 +00:00
LeoVasanko 77e35cf0fc fix(frontend): new file and folder creation hang, empty folder UX
- Replace circular watchEffects in FileExplorer/Gallery with explicit watchers

  to stop recursive Vue updates when creating items in empty folders.

- Move EmptyFolder rendering inside FileExplorer/Gallery so empty/list swaps

  no longer trigger folder slide transitions.

- Keep EmptyFolder text size consistent across list and gallery views.
2026-06-16 22:09:53 +00:00
23 changed files with 276 additions and 190 deletions
+3 -1
View File
@@ -40,7 +40,9 @@ async def watch(req, ws):
if sso.paskia_enabled():
# SSO auth: call validation to get user info (don't enforce auth in public mode)
try:
await sso.validate_sso_request(req)
# WebSocket cannot forward Set-Cookie, so ask the auth backend not to
# renew the session here; renewal happens on the HTTP side instead.
await sso.validate_sso_request(req, renew=False)
except Exception as e:
logger.debug("watch SSO validation failed: %s", e)
if sso_user := getattr(req.ctx, "sso_user", None):
+1 -1
View File
@@ -86,7 +86,7 @@ async def log_access(req, res):
path = f"{path}?{qs}"
extra = getattr(req.ctx, "log_extra", None)
line = format_access_log(
client, res.status, req.method, host, path, duration_ms, extra=extra
client, res.status, req.method, host, path, duration_ms=duration_ms, extra=extra
)
access_logger.info(line)
return res
+1 -3
View File
@@ -1293,9 +1293,7 @@ def _token_belongs_to_user(token, username, 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
sharefs.is_share_token(token) and not token.username and not token.sso_user_id
)
+3 -1
View File
@@ -145,6 +145,9 @@ class _PreviewWorker:
async def kill(self) -> None:
if self.proc.returncode is None:
# Safe to hard-kill: the worker is stateless per request, and its
# subprocesses (ffmpeg) use stdin=DEVNULL so they never hold the
# worker's pipes open — proc.wait() cannot hang on pipe EOF.
with contextlib.suppress(ProcessLookupError):
self.proc.kill()
await self.proc.wait()
@@ -179,7 +182,6 @@ class _PreviewWorkerPool:
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
start_new_session=True,
)
_active_procs.add(proc)
try:
+13 -6
View File
@@ -219,7 +219,18 @@ def _image_via_ffmpeg(path: Path, maxsize: int, quality: int) -> bytes:
cmd.insert(5, f"{new_w}x{new_h}")
try:
try:
subprocess.run(cmd, capture_output=True, check=True, shell=False) # noqa: S603
# stdin=DEVNULL is critical: ffmpeg must not inherit the worker's
# stdin, which carries the framed request protocol. An inherited
# stdin lets ffmpeg eat protocol bytes and, if the worker is
# killed mid-conversion, keeps the orphaned ffmpeg holding the
# pipe open so the parent's proc.wait() hangs forever.
subprocess.run( # noqa: S603
cmd,
capture_output=True,
check=True,
shell=False,
stdin=subprocess.DEVNULL,
)
except subprocess.CalledProcessError as e:
shell_cmd = shlex.join(cmd)
stderr = (e.stderr or b"").decode(errors="replace").strip()
@@ -540,11 +551,7 @@ def main() -> None:
logging.basicConfig(stream=sys.stderr, level=logging.INFO)
try:
config.load_config()
logger.warning(
"preview-worker config=%s master_secret=%s",
config.conffile,
config.config.secret,
)
logger.info("preview-worker config=%s", config.conffile)
except Exception:
logger.exception("preview-worker failed to load config at startup")
if len(sys.argv) > 1:
+1
View File
@@ -156,6 +156,7 @@ def format_access_log(
method: str,
host: str,
path: str,
*,
duration_ms: float,
extra: str | None = None,
) -> str:
+11 -1
View File
@@ -62,12 +62,18 @@ async def close_client():
_client = None
async def validate_sso_request(request, *, perm: str = "cista:login") -> dict | None:
async def validate_sso_request(
request, *, perm: str = "cista:login", renew: bool = True
) -> dict | None:
"""Validate an SSO request against the auth backend.
Args:
request: The Sanic request object
perm: Permission to validate (default: cista:login, privileged also cista:admin)
renew: Whether to allow the auth backend to renew the session cookie.
Use ``False`` for WebSocket validation where Set-Cookie cannot be
forwarded to the client; this makes the request read-only and avoids
resetting the backend renewal timeout.
Returns:
User info dict if valid, None if validation fails with auth required response
@@ -88,12 +94,16 @@ async def validate_sso_request(request, *, perm: str = "cista:login") -> dict |
headers["cookie"] = request.headers["cookie"]
if "authorization" in request.headers:
headers["authorization"] = request.headers["authorization"]
if "user-agent" in request.headers:
headers["user-agent"] = request.headers["user-agent"]
headers["accept"] = "application/json"
headers["x-forwarded-for"] = request.client_ip
headers["x-forwarded-host"] = request.host
headers["x-forwarded-proto"] = request.scheme
url = f"{PASKIA_BACKEND_URL}/auth/api/validate?perm={perm}"
if not renew:
url += "&renew=0"
try:
response = await client.post(
+1 -6
View File
@@ -6,9 +6,8 @@
"dev": "vite",
"build": "run-p type-check \"build-only {@}\" --",
"preview": "vite preview",
"test:unit": "vitest",
"build-only": "vite build",
"type-check": "vue-tsc --noEmit -p tsconfig.vitest.json --composite false",
"type-check": "vue-tsc --noEmit -p tsconfig.app.json --composite false",
"lint": "biome lint .",
"format": "biome format --write .",
"format:check": "biome format --check .",
@@ -37,17 +36,13 @@
"devDependencies": {
"@biomejs/biome": "^1.9.4",
"@tsconfig/node18": "^18.2.6",
"@types/jsdom": "^27.0.0",
"@types/lodash-es": "^4.17.12",
"@types/node": "^25.1.0",
"@vitejs/plugin-vue": "^6.0.3",
"@vue/test-utils": "^2.4.6",
"@vue/tsconfig": "^0.8.1",
"jsdom": "^27.4.0",
"npm-run-all2": "^8.0.4",
"typescript": "~5.9.3",
"vite": "^7.3.1",
"vitest": "^4.0.18",
"vue-tsc": "^3.2.4"
}
}
+9 -15
View File
@@ -58,13 +58,13 @@ import Router from '@/router/index'
import { computed } from 'vue'
import AboutModal from './components/AboutModal.vue'
import AccessDeniedModal from './components/AccessDeniedModal.vue'
import ExplorerView from './views/ExplorerView.vue'
import SelectionToolbar from './components/SelectionToolbar.vue'
import TextEditorView from './views/TextEditorView.vue'
import type SettingsModalVue from './components/SettingsModal.vue'
import UserManagementModal from './components/UserManagementModal.vue'
import UserTokensModal from './components/UserTokensModal.vue'
import type { SortOrder } from './utils/docsort'
import ExplorerView from './views/ExplorerView.vue'
import TextEditorView from './views/TextEditorView.vue'
interface Path {
path: string
@@ -78,7 +78,9 @@ interface Path {
const store = useMainStore()
const getDocByPath = (fullPath: string) =>
getDocuments().find(doc => (doc.loc ? `${doc.loc}/${doc.name}` : doc.name) === fullPath)
getDocuments().find(
doc => (doc.loc ? `${doc.loc}/${doc.name}` : doc.name) === fullPath
)
const path: ComputedRef<Path> = computed(() => {
const p = decodeURIComponent(Router.currentRoute.value.path).split('//')
@@ -90,13 +92,9 @@ const path: ComputedRef<Path> = computed(() => {
void store.docVersion
const doc = fullPath ? getDocByPath(fullPath) : null
const isEditorPath = !!(doc && !doc.dir && doc.text)
const canonicalBase = !fullPath
? '/'
: doc?.dir
? `/${fullPath}/`
: `/${fullPath}`
const canonicalBase = !fullPath ? '/' : doc?.dir ? `/${fullPath}/` : `/${fullPath}`
const canonicalPath = query
? rawPath // keep search URL shape untouched
? `${rawPath}//${query}` // keep search URL shape untouched
: canonicalBase
const pathList = isEditorPath ? routePathList.slice(0, -1) : routePathList
const breadcrumbPathList = routePathList
@@ -128,14 +126,10 @@ const routeViewComponent = computed(() =>
path.value.isEditorPath ? TextEditorView : ExplorerView
)
const routeViewKey = computed(() => {
return path.value.isEditorPath
? `editor:${path.value.path}`
: 'explorer'
return path.value.isEditorPath ? `editor:${path.value.path}` : 'explorer'
})
const routeViewProps = computed(() =>
path.value.isEditorPath
? {}
: { path: path.value.pathList, query: path.value.query }
path.value.isEditorPath ? {} : { path: path.value.pathList, query: path.value.query }
)
watch(
() => path.value.canonicalPath,
+1 -2
View File
@@ -122,8 +122,7 @@ watchEffect(() => {
if (!same) {
longest.value = props.path
longestLinks.value = currentLinks
}
else if (props.path.length > longcut.length) {
} else if (props.path.length > longcut.length) {
longest.value = longcut.concat(props.path.slice(longcut.length))
longestLinks.value.splice(0, currentLinks.length, ...currentLinks)
} else {
+8 -2
View File
@@ -104,7 +104,9 @@ const showOtherCategory = computed(() => {
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)
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
@@ -295,7 +297,11 @@ const freeLabelPath = computed(() =>
createArcPath(adjustedLabelAngles.value.free!, 'free', 4)
)
const otherLabelPath = computed(() =>
createArcPath(adjustedLabelAngles.value.other ?? sectorInfo.value.other.angle, 'other', 5)
createArcPath(
adjustedLabelAngles.value.other ?? sectorInfo.value.other.angle,
'other',
5
)
)
const handleClick = () => (isExpanded.value ? collapse() : expand())
+22 -1
View File
@@ -1,5 +1,5 @@
<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 }]"/>
<p v-if="store.dialog === 'accessdenied'">Access Denied</p>
<p v-else-if="!store.connected">No Connection</p>
@@ -14,6 +14,7 @@
import { Cog } from '@/assets/svg'
import { useMainStore } from '@/stores/main'
import { exists } from '@/utils/fileutil'
import { computed } from 'vue'
const cog = Cog
const store = useMainStore()
@@ -21,9 +22,29 @@ const props = defineProps<{
path: string[]
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>
<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 {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
+129 -90
View File
@@ -1,74 +1,77 @@
<template>
<table v-if="props.documents.length || editing">
<thead>
<tr>
<th class="selection">
<input type="checkbox" tabindex="-1" v-model="allSelected" :indeterminate="selectionIndeterminate">
</th>
<th class="sortcolumn" :class="{ sortactive: store.sortOrder === 'name' }" @click="store.toggleSort('name')">Name</th>
<th class="sortcolumn modified right" :class="{ sortactive: store.sortOrder === 'modified' }" @click="store.toggleSort('modified')">Modified</th>
<th class="sortcolumn size right" :class="{ sortactive: store.sortOrder === 'size' }" @click="store.toggleSort('size')">Size</th>
<th class="menu"></th>
</tr>
</thead>
<tbody>
<tr v-if="editing?.key === 'new'" :class="editing.dir ? 'folder' : 'file'">
<td class="selection"></td>
<td class="name">
<FileRenameInput :doc="editing" :rename="createItem" :exit="() => {editing = null}" />
</td>
<FileModified :doc=editing :now=nowkey />
<FileSize :doc=editing />
<td class="menu"></td>
</tr>
<template v-for="(doc, index) in documents" :key="doc.key">
<tr class="folder-change" v-if="showFolderBreadcrumb(index)">
<th colspan="5"><BreadCrumb :path="doc.loc ? doc.loc.split('/') : []" /></th>
<div class="file-explorer">
<table v-if="props.documents.length || editing">
<thead>
<tr>
<th class="selection">
<input type="checkbox" tabindex="-1" v-model="allSelected" :indeterminate="selectionIndeterminate">
</th>
<th class="sortcolumn" :class="{ sortactive: store.sortOrder === 'name' }" @click="store.toggleSort('name')">Name</th>
<th class="sortcolumn modified right" :class="{ sortactive: store.sortOrder === 'modified' }" @click="store.toggleSort('modified')">Modified</th>
<th class="sortcolumn size right" :class="{ sortactive: store.sortOrder === 'size' }" @click="store.toggleSort('size')">Size</th>
<th class="menu"></th>
</tr>
<tr
:id="`file-${doc.key}`"
:class="{ file: !doc.dir, folder: doc.dir, cursor: store.cursor === doc.key, ghost: doc.ghost }"
@click="store.cursor = store.cursor === doc.key ? '' : doc.key"
@contextmenu.prevent="contextMenu($event, doc)"
>
<td class="selection" @click.up.stop="store.cursor = store.cursor === doc.key ? doc.key : ''">
<input
type="checkbox"
tabindex="-1"
:checked="store.selected.has(doc.key)"
@change="
($event.target as HTMLInputElement).checked
? store.selected.add(doc.key)
: store.selected.delete(doc.key)
"
/>
</td>
</thead>
<tbody>
<tr v-if="editing?.key === 'new'" :class="editing.dir ? 'folder' : 'file'">
<td class="selection"></td>
<td class="name">
<template v-if="editing === doc">
<FileRenameInput :doc="doc" :rename="rename" :exit="() => {editing = null}" />
</template>
<template v-else>
<a :href="doc.text ? doc.editurl : doc.url" tabindex=-1 @contextmenu.stop @focus.stop="store.cursor = doc.key">
{{ doc.name }}
</a>
<button tabindex=-1 v-if="store.cursor == doc.key" class="rename-button" @click="() => (editing = doc)">🖊</button>
</template>
</td>
<FileModified :doc=doc :now=nowkey />
<FileSize :doc=doc />
<td class="menu">
<button tabindex=-1 @click.stop="contextMenu($event, doc)"></button>
<FileRenameInput :doc="editing" :rename="createItem" :exit="exitEditing" />
</td>
<FileModified :doc=editing :now=nowkey />
<FileSize :doc=editing />
<td class="menu"></td>
</tr>
</template>
<tr class="summary" v-if="props.documents.length > 1">
<td colspan="3" class="right">{{props.documents.length}} items</td>
<td class="size right">{{ formatSize(props.documents.reduce((a, b) => a + b.size, 0)) }}</td>
<td class="menu"></td>
</tr>
</tbody>
</table>
<template v-for="(doc, index) in documents" :key="doc.key">
<tr class="folder-change" v-if="showFolderBreadcrumb(index)">
<th colspan="5"><BreadCrumb :path="doc.loc ? doc.loc.split('/') : []" /></th>
</tr>
<tr
:id="`file-${doc.key}`"
:class="{ file: !doc.dir, folder: doc.dir, cursor: store.cursor === doc.key, ghost: doc.ghost }"
@click="store.cursor = store.cursor === doc.key ? '' : doc.key"
@contextmenu.prevent="contextMenu($event, doc)"
>
<td class="selection" @click.up.stop="store.cursor = store.cursor === doc.key ? doc.key : ''">
<input
type="checkbox"
tabindex="-1"
:checked="store.selected.has(doc.key)"
@change="
($event.target as HTMLInputElement).checked
? store.selected.add(doc.key)
: store.selected.delete(doc.key)
"
/>
</td>
<td class="name">
<template v-if="editing === doc">
<FileRenameInput :doc="doc" :rename="rename" :exit="exitEditing" />
</template>
<template v-else>
<a :href="doc.text ? doc.editurl : doc.url" tabindex=-1 @contextmenu.stop @focus.stop="store.cursor = doc.key">
{{ doc.name }}
</a>
<button tabindex=-1 class="rename-button" @click="() => (editing = doc)">🖊</button>
</template>
</td>
<FileModified :doc=doc :now=nowkey />
<FileSize :doc=doc />
<td class="menu">
<button tabindex=-1 @click.stop="contextMenu($event, doc)"></button>
</td>
</tr>
</template>
<tr class="summary" v-if="props.documents.length > 1">
<td colspan="3" class="right">{{props.documents.length}} items</td>
<td class="size right">{{ formatSize(props.documents.reduce((a, b) => a + b.size, 0)) }}</td>
<td class="menu"></td>
</tr>
</tbody>
</table>
<EmptyFolder v-else :documents="documents" :path="props.path" />
</div>
</template>
<script setup lang="ts">
@@ -81,11 +84,13 @@ import ContextMenu from '@imengyu/vue3-context-menu'
import {
computed,
nextTick,
onActivated,
onDeactivated,
onMounted,
onUnmounted,
ref,
shallowRef,
watchEffect
watch
} from 'vue'
import { useRouter } from 'vue-router'
import FileRenameInput from './FileRenameInput.vue'
@@ -189,6 +194,9 @@ const pageMove = (direction: 1 | -1, ev: KeyboardEvent) => {
// File rename
const editing = shallowRef<Doc | null>(null)
const exitEditing = () => {
editing.value = null
}
const rename = async (doc: Doc, newName: string) => {
const oldName = doc.name
doc.name = newName // We should get an update from watch but this is quicker
@@ -243,7 +251,7 @@ defineExpose({
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)
// Also focus the element directly (post-flush watcher won't trigger if cursor unchanged)
nextTick(() => {
const a = document.querySelector(
`#file-${store.cursor} .name a`
@@ -329,22 +337,41 @@ const focusBreadcrumb = () => {
const keyboardFollowScroll = createKeyboardFollowScroll()
const markKeyboardFollow = keyboardFollowScroll.markKeyboardFollow
const keepCursorVisibleSmooth = keyboardFollowScroll.keepVisible
watchEffect(() => {
if (store.cursor && store.cursor !== editing.value?.key) editing.value = null
if (editing.value) store.cursor = editing.value?.key
if (store.cursor) {
const a = document.querySelector(
`#file-${store.cursor} .name a`
) as HTMLAnchorElement | null
if (a) a.focus({ preventScroll: true })
// Deactivated (KeepAlive-cached) instances stay alive with frozen, potentially
// stale props - their watchers must not react to global store changes.
let isActive = true
watch(
() => store.cursor,
cursor => {
if (!isActive) return
if (cursor && editing.value && cursor !== editing.value.key) {
exitEditing()
}
}
})
watchEffect(() => {
if (!props.documents.length && store.cursor && !store.query) {
store.cursor = ''
focusBreadcrumb()
)
watch(
() => store.cursor,
cursor => {
if (!isActive) return
if (cursor && !editing.value) {
const a = document.querySelector(
`#file-${cursor} .name a`
) as HTMLAnchorElement | null
if (a) a.focus({ preventScroll: true })
}
},
{ flush: 'post' }
)
watch(
() => [props.documents.length, store.cursor, store.query, editing.value] as const,
([len, cursor, query, editingDoc]) => {
if (!isActive) return
if (!len && cursor && !query && !editingDoc) {
store.cursor = ''
focusBreadcrumb()
}
}
})
)
let nowkey = ref(0)
let modifiedTimer: any = null
const updateModified = () => {
@@ -358,6 +385,13 @@ onMounted(() => {
active.focus({ preventScroll: true })
}
})
onActivated(() => {
isActive = true
})
onDeactivated(() => {
isActive = false
if (editing.value) exitEditing()
})
onUnmounted(() => {
keyboardFollowScroll.cancel()
clearInterval(modifiedTimer)
@@ -373,7 +407,8 @@ const createItem = async (doc: Doc, name: string) => {
doc.name = name
doc.key = crypto.randomUUID()
store.addGhost(doc)
editing.value = null
store.cursor = doc.key
exitEditing()
const path = doc.loc ? `${doc.loc}/${name}` : name
try {
const res = doc.dir
@@ -525,9 +560,14 @@ const contextMenu = (ev: MouseEvent, doc: Doc) => {
</script>
<style scoped>
.file-explorer {
height: 100%;
width: 100%;
}
table {
width: 100%;
table-layout: fixed;
height: auto;
}
thead tr {
position: sticky;
@@ -588,6 +628,12 @@ table td {
.name .rename-button {
position: absolute;
right: 0;
opacity: 0;
visibility: hidden;
}
tbody tr:hover .name .rename-button {
opacity: 1;
visibility: visible;
animation: appear calc(5 * var(--transition-time)) linear;
}
@keyframes appear {
@@ -658,12 +704,6 @@ tbody .selection input {
content: '📁';
font-size: 1.5rem;
}
.empty-container {
padding-top: 3rem;
text-align: center;
font-size: 3rem;
color: var(--accent-color);
}
.folder-change {
margin-left: -.5rem;
}
@@ -674,4 +714,3 @@ tbody .selection input {
color: #888;
}
</style>
@/stores/main
@@ -60,6 +60,7 @@ input#FileRenameInput {
padding: .75em;
font-weight: 600;
width: auto;
text-align: center;
}
</style>
+49 -23
View File
@@ -8,11 +8,12 @@
:editing="editing === doc ? {rename, exit} : null"
:style="{ '--gallery-figure-height': rowHeightsByKey[doc.key] ?? '15em' }"
@menu="contextMenu($event, doc)"
@rename="editing = doc; store.cursor = doc.key"
@rename="onFigureRename(doc)"
:class="{ 'folder-start': showFolderBreadcrumb(index) }"
/>
</template>
</div>
<EmptyFolder v-else :documents="documents" :path="props.path" />
</template>
<script setup lang="ts">
@@ -23,16 +24,15 @@ import type { SortOrder } from '@/utils/docsort'
import { createKeyboardFollowScroll } from '@/utils/keyboardFollowScroll'
import ContextMenu from '@imengyu/vue3-context-menu'
import {
onActivated,
onDeactivated,
computed,
nextTick,
onActivated,
onDeactivated,
onMounted,
onUnmounted,
ref,
shallowRef,
watch,
watchEffect
watch
} from 'vue'
import { useRouter } from 'vue-router'
@@ -64,6 +64,10 @@ const editing = shallowRef<Doc | null>(null)
const exit = () => {
editing.value = null
}
const onFigureRename = (doc: Doc) => {
editing.value = doc
store.cursor = doc.key
}
const rename = async (doc: Doc, newName: string) => {
const oldName = doc.name
doc.name = newName // We should get an update from watch but this is quicker
@@ -304,7 +308,7 @@ defineExpose({
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)
// Also focus the element directly (post-flush watcher won't trigger if cursor unchanged)
nextTick(() => {
const a = document.querySelector(
`#file-${store.cursor}`
@@ -396,24 +400,41 @@ const focusBreadcrumb = () => {
const keyboardFollowScroll = createKeyboardFollowScroll()
const markKeyboardFollow = keyboardFollowScroll.markKeyboardFollow
const keepCursorVisibleSmooth = keyboardFollowScroll.keepVisible
watchEffect(() => {
if (store.cursor && store.cursor !== editing.value?.key) editing.value = null
if (editing.value) store.cursor = editing.value.key
if (store.cursor && !editing.value) {
const a = document.querySelector(
`#file-${store.cursor}`
) as HTMLAnchorElement | null
if (a) {
a.focus({ preventScroll: true })
// Deactivated (KeepAlive-cached) instances stay alive with frozen, potentially
// stale props - their watchers must not react to global store changes.
let isActive = true
watch(
() => store.cursor,
cursor => {
if (!isActive) return
if (cursor && editing.value && cursor !== editing.value.key) {
exit()
}
}
})
watchEffect(() => {
if (!props.documents.length && store.cursor && !store.query) {
store.cursor = ''
focusBreadcrumb()
)
watch(
() => store.cursor,
cursor => {
if (!isActive) return
if (cursor && !editing.value) {
const a = document.querySelector(`#file-${cursor}`) as HTMLAnchorElement | null
if (a) {
a.focus({ preventScroll: true })
}
}
},
{ flush: 'post' }
)
watch(
() => [props.documents.length, store.cursor, store.query, editing.value] as const,
([len, cursor, query, editingDoc]) => {
if (!isActive) return
if (!len && cursor && !query && !editingDoc) {
store.cursor = ''
focusBreadcrumb()
}
}
})
)
let resizeObserver: ResizeObserver | null = null
const attachGalleryObservers = () => {
if (!gallery.value || resizeObserver) return
@@ -438,13 +459,16 @@ onMounted(() => {
attachGalleryObservers()
})
onActivated(() => {
isActive = true
nextTick(() => {
updateColumns()
attachGalleryObservers()
})
})
onDeactivated(() => {
isActive = false
detachGalleryObservers()
if (editing.value) exit()
})
onUnmounted(() => {
keyboardFollowScroll.cancel()
@@ -464,7 +488,8 @@ const createItem = async (doc: Doc, name: string) => {
doc.name = name
doc.key = crypto.randomUUID()
store.addGhost(doc)
editing.value = null
store.cursor = doc.key
exit()
const path = doc.loc ? `${doc.loc}/${name}` : name
try {
const res = doc.dir
@@ -612,7 +637,8 @@ const contextMenu = (ev: MouseEvent, doc: Doc) => {
display: grid;
gap: .5em;
grid-template-columns: repeat(auto-fill, minmax(15em, 1fr));
align-items: end;
align-items: start;
align-content: start;
}
.folder-indicator {
grid-column: 1 / -1;
+5 -1
View File
@@ -29,7 +29,7 @@
<span class="filename">{{ snap.displayName }}<SparseIndicator :doc="doc" class="after-name" /></span>
<span v-if="snap.ext" class="file-ext">.{{ snap.ext }}</span>
</span>
<button class="rename-btn" @click="$emit('rename')" title="Rename"></button>
<button class="rename-btn" @click="emit('rename')" title="Rename"></button>
</div>
<div class=namespacer></div>
</template>
@@ -64,6 +64,10 @@ const props = defineProps<{
doc: Doc
editing?: EditingProp
}>()
const emit = defineEmits<{
(e: 'rename'): void
(e: 'menu', ev: MouseEvent): void
}>()
const m = ref<typeof MediaPreview | null>(null)
const tooltip = ref<InstanceType<typeof CursorTooltip> | null>(null)
+10 -3
View File
@@ -7,9 +7,16 @@ export const exists = (path: string[]) => {
void store.docVersion
if (path.length === 0) return true
const p = path.join('/')
return getDocuments().some(
doc => (doc.loc ? `${doc.loc}/${doc.name}` : doc.name) === p
)
const hidden = store.hiddenPaths
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.) */
+3 -12
View File
@@ -15,7 +15,6 @@
/>
</KeepAlive>
</Transition>
<EmptyFolder :documents="documents" :path="props.path" />
</div>
<div v-if="store.searchLoading" class="search-loading">Searching...</div>
</template>
@@ -38,7 +37,9 @@ const props = defineProps<{
// Folder path for component keys - only recreate component when folder changes, not search
const folderPath = computed(() => props.path.join('/'))
const cacheKey = computed(() => `${store.prefs.gallery ? 'gallery' : 'list'}:${folderPath.value}`)
const cacheKey = computed(
() => `${store.prefs.gallery ? 'gallery' : 'list'}:${folderPath.value}`
)
const transitionName = computed(() => {
if (store.transitionDirection === 'forward') return 'slide-forward'
@@ -140,16 +141,6 @@ watch(
</script>
<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 {
position: fixed;
bottom: 1rem;
+3 -4
View File
@@ -9,6 +9,8 @@
</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'
@@ -16,8 +18,6 @@ import { Compartment, EditorState } from '@codemirror/state'
import { oneDark } from '@codemirror/theme-one-dark'
import { EditorView, keymap } from '@codemirror/view'
import { basicSetup } from 'codemirror'
import { apiFetch } from '@/repositories/Client'
import { useMainStore } from '@/stores/main'
import {
computed,
nextTick,
@@ -173,8 +173,7 @@ onMounted(async () => {
await initEditor(text)
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to load file'
}
finally {
} finally {
if (loading.value) loading.value = false
}
})
-3
View File
@@ -6,9 +6,6 @@
},
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.vitest.json"
}
]
}
+1 -7
View File
@@ -1,12 +1,6 @@
{
"extends": "@tsconfig/node18/tsconfig.json",
"include": [
"vite.config.*",
"vitest.config.*",
"cypress.config.*",
"nightwatch.conf.*",
"playwright.config.*"
],
"include": ["vite.config.*"],
"compilerOptions": {
"composite": true,
"module": "ESNext",
-8
View File
@@ -1,8 +0,0 @@
{
"extends": "./tsconfig.app.json",
"exclude": [],
"compilerOptions": {
"composite": true,
"types": ["node", "jsdom"]
}
}
+1
View File
@@ -132,6 +132,7 @@ ignore = [
"ANN205", # legacy codebase: no full runtime annotation coverage yet
"BLE001", # broad catch remains in boundary/proxy/error-handling paths
"C901", # legacy complexity; keep other correctness rules enabled
"CPY", # copyright notices not wanted in this codebase
"D100", # legacy docs not yet standardized
"D101", # legacy docs not yet standardized
"D102", # legacy docs not yet standardized