Compare commits

...
13 Commits
Author SHA1 Message Date
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
LeoVasanko bf8a049b92 Use canonical paths for editor and fix route transitions 2026-05-07 02:18:55 +00:00
LeoVasanko 6d7f44bd88 Add view caching and preserve folder/editor UI state 2026-05-07 02:12:03 +00:00
LeoVasanko e2097a1563 Fix empty state vertical centering in explorer 2026-05-07 01:53:16 +00:00
LeoVasanko b864936eaa Lint 2026-05-07 01:44:27 +00:00
25 changed files with 417 additions and 231 deletions
+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:
+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"
}
}
+52 -14
View File
@@ -29,7 +29,13 @@
@after-enter="store.transitionDirection = 'none'"
>
<div :key="routeViewKey" class="explorer-content">
<RouterView :path="path.pathList" :query="path.query" />
<KeepAlive>
<component
:is="routeViewComponent"
:key="routeViewKey"
v-bind="routeViewProps"
/>
</KeepAlive>
</div>
</Transition>
</main>
@@ -43,10 +49,10 @@
<script setup lang="ts">
import type HeaderMain from '@/components/HeaderMain.vue'
import { loadSession, watchConnect, watchDisconnect } from '@/repositories/WS'
import { getDocuments } from '@/stores/documentStore'
import { useMainStore } from '@/stores/main'
import type { ComputedRef } from 'vue'
import { onMounted, onUnmounted, ref, watch } from 'vue'
import { RouterView } from 'vue-router'
import Router from '@/router/index'
import { computed } from 'vue'
@@ -57,9 +63,12 @@ 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
canonicalPath: string
isEditorPath: boolean
pathList: string[]
breadcrumbPathList: string[]
@@ -67,26 +76,40 @@ interface Path {
query: string
}
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 p = decodeURIComponent(Router.currentRoute.value.path).split('//')
const routePathList = (p[0] ?? '').split('/').filter(value => value !== '')
const rawPath = p[0] ?? ''
const routePathList = rawPath.split('/').filter(value => value !== '')
const query = p.slice(1).join('//')
const isEditorPath = routePathList[0] === 'edit'
const pathList = isEditorPath ? routePathList.slice(1, -1) : routePathList
const breadcrumbPathList = isEditorPath
? routePathList.slice(1)
: routePathList
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}//${query}` // keep search URL shape untouched
: canonicalBase
const pathList = isEditorPath ? routePathList.slice(0, -1) : routePathList
const breadcrumbPathList = routePathList
const breadcrumbLinks = isEditorPath
? [
'/',
...routePathList
.slice(1, -1)
.map((_, index) => `/${routePathList.slice(1, index + 2).join('/')}/`),
`/${routePathList.join('/')}`
.slice(0, -1)
.map((_, index) => `/${routePathList.slice(0, index + 1).join('/')}/`),
`/${fullPath}`
]
: undefined
return {
path: p[0] ?? '',
path: rawPath,
canonicalPath,
isEditorPath,
pathList,
breadcrumbPathList,
@@ -99,10 +122,25 @@ const routeTransitionName = computed(() => {
if (store.transitionDirection === 'backward') return 'slide-backward'
return ''
})
const routeViewComponent = computed(() =>
path.value.isEditorPath ? TextEditorView : ExplorerView
)
const routeViewKey = computed(() => {
const route = Router.currentRoute.value
return route.name === 'editor' ? route.path : String(route.name ?? route.path)
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(
() => path.value.path,
() => {
+2
View File
@@ -62,10 +62,12 @@
display: grid;
grid-template-columns: 1fr;
grid-template-rows: 1fr;
height: 100%;
}
.explorer-content {
grid-area: 1 / 1;
height: 100%;
}
.slide-forward-enter-active,
+1 -1
View File
@@ -1,3 +1,3 @@
<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>
</svg>

Before

Width:  |  Height:  |  Size: 451 B

After

Width:  |  Height:  |  Size: 452 B

+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); }
+130 -91
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,12 +385,19 @@ onMounted(() => {
active.focus({ preventScroll: true })
}
})
onActivated(() => {
isActive = true
})
onDeactivated(() => {
isActive = false
if (editing.value) exitEditing()
})
onUnmounted(() => {
keyboardFollowScroll.cancel()
clearInterval(modifiedTimer)
})
const editRoute = (path: string) =>
'/edit/' +
'/' +
path
.split('/')
.map(part => encodeURIComponent(part))
@@ -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>
+75 -29
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">
@@ -25,12 +26,13 @@ import ContextMenu from '@imengyu/vue3-context-menu'
import {
computed,
nextTick,
onActivated,
onDeactivated,
onMounted,
onUnmounted,
ref,
shallowRef,
watch,
watchEffect
watch
} from 'vue'
import { useRouter } from 'vue-router'
@@ -62,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
@@ -170,6 +176,7 @@ const onImgLoad = (e: Event) => {
}
const updateColumns = () => {
if (!gallery.value) return
if (gallery.value.getBoundingClientRect().width <= 0) return
const style = getComputedStyle(gallery.value)
const templates = style.gridTemplateColumns
.split(' ')
@@ -301,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}`
@@ -393,25 +400,55 @@ 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
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(() => {
const active = document.querySelector('.cursor') as HTMLElement | null
if (active) {
@@ -419,22 +456,29 @@ onMounted(() => {
}
updateColumns()
seedFromDocs()
if (gallery.value) {
resizeObserver = new ResizeObserver(updateColumns)
resizeObserver.observe(gallery.value)
gallery.value.addEventListener('load', onImgLoad, { capture: true })
}
attachGalleryObservers()
})
onActivated(() => {
isActive = true
nextTick(() => {
updateColumns()
attachGalleryObservers()
})
})
onDeactivated(() => {
isActive = false
detachGalleryObservers()
if (editing.value) exit()
})
onUnmounted(() => {
keyboardFollowScroll.cancel()
resizeObserver?.disconnect()
gallery.value?.removeEventListener('load', onImgLoad, { capture: true })
detachGalleryObservers()
})
// Re-seed aspect ratios whenever docs update (e.g., ar patch from server)
watch(() => props.documents, seedFromDocs)
const editRoute = (path: string) =>
'/edit/' +
'/' +
path
.split('/')
.map(part => encodeURIComponent(part))
@@ -444,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
@@ -592,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)
+1 -1
View File
@@ -95,7 +95,7 @@ export class Doc {
get editurl(): string {
if (!this.text) return ''
const p = this.loc ? `${this.loc}/${this.name}` : this.name
return '/#/edit/' + p.replaceAll('?', '%3F').replaceAll('#', '%23')
return '/#/' + p.replaceAll('?', '%3F').replaceAll('#', '%23')
}
get complete(): boolean {
return !this.ghost && (this.dir || this.size <= this.allocated)
-6
View File
@@ -1,6 +1,5 @@
import { useMainStore } from '@/stores/main'
import ExplorerView from '@/views/ExplorerView.vue'
import TextEditorView from '@/views/TextEditorView.vue'
import { createRouter, createWebHashHistory } from 'vue-router'
function getPathDepth(path: string): number {
@@ -11,11 +10,6 @@ function getPathDepth(path: string): number {
const router = createRouter({
history: createWebHashHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/edit/:pathMatch(.*)*',
name: 'editor',
component: TextEditorView
},
{
path: '/:pathMatch(.*)*',
name: 'explorer',
+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.) */
+40 -23
View File
@@ -2,23 +2,18 @@
<div class="transition-wrapper">
<Transition
:name="transitionName"
@after-enter="store.transitionDirection = 'none'"
@after-enter="onAfterEnter"
>
<div :key="folderPath" class="explorer-content">
<Gallery
v-if="store.prefs.gallery"
<KeepAlive>
<component
:is="store.prefs.gallery ? Gallery : FileExplorer"
:key="cacheKey"
ref="fileExplorer"
class="explorer-content"
:path="props.path"
:documents="documents"
/>
<FileExplorer
v-else
ref="fileExplorer"
:path="props.path"
:documents="documents"
/>
<EmptyFolder :documents="documents" :path="props.path" />
</div>
</KeepAlive>
</Transition>
</div>
<div v-if="store.searchLoading" class="search-loading">Searching...</div>
@@ -31,7 +26,7 @@ import { getDocuments } from '@/stores/documentStore'
import { useMainStore } from '@/stores/main'
import { collator } from '@/utils'
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 fileExplorer = ref()
@@ -42,6 +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 transitionName = computed(() => {
if (store.transitionDirection === 'forward') return 'slide-forward'
@@ -49,6 +47,22 @@ const transitionName = computed(() => {
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)
// Skip if store.query already matches (means we triggered this via typing)
watch(
@@ -101,6 +115,19 @@ watchEffect(() => {
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
watch(
[() => props.path.join('/'), () => store.documentCount],
@@ -114,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;
+47 -22
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,17 +18,26 @@ 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, onMounted, onUnmounted, ref } from 'vue'
import { onBeforeRouteLeave, useRoute } from 'vue-router'
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(() => decodeURIComponent(route.path.slice(6))) // strip /edit/
const filePath = computed(() => {
const raw = decodeURIComponent(route.path).split('//')[0] ?? ''
return raw.replace(/^\//, '').replace(/\/$/, '')
})
const filename = computed(() => filePath.value.split('/').pop() || '')
const filesUrl = computed(() => {
@@ -50,21 +61,32 @@ const languageCompartment = new Compartment()
const dirty = computed(() => content.value !== original.value)
onBeforeRouteLeave((_to, _from, next) => {
if (!dirty.value) {
next()
return
}
const discard = window.confirm('You have unsaved changes. Discard them?')
next(discard)
})
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 []
@@ -129,8 +151,7 @@ const save = async () => {
}
onMounted(async () => {
store.editorSave = save
window.addEventListener('beforeunload', beforeUnload)
activateEditorBindings()
loading.value = true
error.value = ''
try {
@@ -152,19 +173,23 @@ 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
}
})
onActivated(() => {
activateEditorBindings()
})
onDeactivated(() => {
deactivateEditorBindings()
})
onUnmounted(() => {
if (store.editorSave === save) {
store.editorSave = null
}
deactivateEditorBindings()
editorView?.destroy()
editorView = null
window.removeEventListener('beforeunload', beforeUnload)
})
</script>
-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