Compare commits

...
9 Commits
Author SHA1 Message Date
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
15 changed files with 361 additions and 197 deletions
+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: def _is_anonymous_share_token(token: config.Token) -> bool:
return ( return (
sharefs.is_share_token(token) sharefs.is_share_token(token) and not token.username and not token.sso_user_id
and not token.username
and not token.sso_user_id
) )
+52 -14
View File
@@ -29,7 +29,13 @@
@after-enter="store.transitionDirection = 'none'" @after-enter="store.transitionDirection = 'none'"
> >
<div :key="routeViewKey" class="explorer-content"> <div :key="routeViewKey" class="explorer-content">
<RouterView :path="path.pathList" :query="path.query" /> <KeepAlive>
<component
:is="routeViewComponent"
:key="routeViewKey"
v-bind="routeViewProps"
/>
</KeepAlive>
</div> </div>
</Transition> </Transition>
</main> </main>
@@ -43,10 +49,10 @@
<script setup lang="ts"> <script setup lang="ts">
import type HeaderMain from '@/components/HeaderMain.vue' import type HeaderMain from '@/components/HeaderMain.vue'
import { loadSession, watchConnect, watchDisconnect } from '@/repositories/WS' import { loadSession, watchConnect, watchDisconnect } from '@/repositories/WS'
import { getDocuments } from '@/stores/documentStore'
import { useMainStore } from '@/stores/main' import { useMainStore } from '@/stores/main'
import type { ComputedRef } from 'vue' import type { ComputedRef } from 'vue'
import { onMounted, onUnmounted, ref, watch } from 'vue' import { onMounted, onUnmounted, ref, watch } from 'vue'
import { RouterView } from 'vue-router'
import Router from '@/router/index' import Router from '@/router/index'
import { computed } from 'vue' import { computed } from 'vue'
@@ -57,9 +63,12 @@ import type SettingsModalVue from './components/SettingsModal.vue'
import UserManagementModal from './components/UserManagementModal.vue' import UserManagementModal from './components/UserManagementModal.vue'
import UserTokensModal from './components/UserTokensModal.vue' import UserTokensModal from './components/UserTokensModal.vue'
import type { SortOrder } from './utils/docsort' import type { SortOrder } from './utils/docsort'
import ExplorerView from './views/ExplorerView.vue'
import TextEditorView from './views/TextEditorView.vue'
interface Path { interface Path {
path: string path: string
canonicalPath: string
isEditorPath: boolean isEditorPath: boolean
pathList: string[] pathList: string[]
breadcrumbPathList: string[] breadcrumbPathList: string[]
@@ -67,26 +76,40 @@ interface Path {
query: string query: string
} }
const store = useMainStore() const store = useMainStore()
const getDocByPath = (fullPath: string) =>
getDocuments().find(
doc => (doc.loc ? `${doc.loc}/${doc.name}` : doc.name) === fullPath
)
const path: ComputedRef<Path> = computed(() => { const path: ComputedRef<Path> = computed(() => {
const p = decodeURIComponent(Router.currentRoute.value.path).split('//') const p = decodeURIComponent(Router.currentRoute.value.path).split('//')
const 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 query = p.slice(1).join('//')
const isEditorPath = routePathList[0] === 'edit' const fullPath = routePathList.join('/')
const pathList = isEditorPath ? routePathList.slice(1, -1) : routePathList // Access docVersion to make route mode reactive to tree updates
const breadcrumbPathList = isEditorPath void store.docVersion
? routePathList.slice(1) const doc = fullPath ? getDocByPath(fullPath) : null
: routePathList 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 const breadcrumbLinks = isEditorPath
? [ ? [
'/', '/',
...routePathList ...routePathList
.slice(1, -1) .slice(0, -1)
.map((_, index) => `/${routePathList.slice(1, index + 2).join('/')}/`), .map((_, index) => `/${routePathList.slice(0, index + 1).join('/')}/`),
`/${routePathList.join('/')}` `/${fullPath}`
] ]
: undefined : undefined
return { return {
path: p[0] ?? '', path: rawPath,
canonicalPath,
isEditorPath, isEditorPath,
pathList, pathList,
breadcrumbPathList, breadcrumbPathList,
@@ -99,10 +122,25 @@ const routeTransitionName = computed(() => {
if (store.transitionDirection === 'backward') return 'slide-backward' if (store.transitionDirection === 'backward') return 'slide-backward'
return '' return ''
}) })
const routeViewComponent = computed(() =>
path.value.isEditorPath ? TextEditorView : ExplorerView
)
const routeViewKey = computed(() => { const routeViewKey = computed(() => {
const route = Router.currentRoute.value return path.value.isEditorPath ? `editor:${path.value.path}` : 'explorer'
return route.name === 'editor' ? route.path : String(route.name ?? route.path)
}) })
const routeViewProps = computed(() =>
path.value.isEditorPath ? {} : { path: path.value.pathList, query: path.value.query }
)
watch(
() => path.value.canonicalPath,
canonical => {
const current = decodeURIComponent(Router.currentRoute.value.path)
if (canonical && current !== canonical) {
Router.replace(canonical.replaceAll('?', '%3F').replaceAll('#', '%23'))
}
},
{ immediate: true }
)
watch( watch(
() => path.value.path, () => path.value.path,
() => { () => {
+2
View File
@@ -62,10 +62,12 @@
display: grid; display: grid;
grid-template-columns: 1fr; grid-template-columns: 1fr;
grid-template-rows: 1fr; grid-template-rows: 1fr;
height: 100%;
} }
.explorer-content { .explorer-content {
grid-area: 1 / 1; grid-area: 1 / 1;
height: 100%;
} }
.slide-forward-enter-active, .slide-forward-enter-active,
+1 -2
View File
@@ -122,8 +122,7 @@ watchEffect(() => {
if (!same) { if (!same) {
longest.value = props.path longest.value = props.path
longestLinks.value = currentLinks 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)) longest.value = longcut.concat(props.path.slice(longcut.length))
longestLinks.value.splice(0, currentLinks.length, ...currentLinks) longestLinks.value.splice(0, currentLinks.length, ...currentLinks)
} else { } else {
+8 -2
View File
@@ -104,7 +104,9 @@ const showOtherCategory = computed(() => {
return !!s.disk && otherBytes.value / s.disk >= 0.01 return !!s.disk && otherBytes.value / s.disk >= 0.01
}) })
const freeSliceBytes = computed(() => 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 // Calculate max label length based on angular gap to neighbor labels
@@ -295,7 +297,11 @@ const freeLabelPath = computed(() =>
createArcPath(adjustedLabelAngles.value.free!, 'free', 4) createArcPath(adjustedLabelAngles.value.free!, 'free', 4)
) )
const otherLabelPath = computed(() => const otherLabelPath = computed(() =>
createArcPath(adjustedLabelAngles.value.other ?? sectorInfo.value.other.angle, 'other', 5) createArcPath(
adjustedLabelAngles.value.other ?? sectorInfo.value.other.angle,
'other',
5
)
) )
const handleClick = () => (isExpanded.value ? collapse() : expand()) const handleClick = () => (isExpanded.value ? collapse() : expand())
+22 -1
View File
@@ -1,5 +1,5 @@
<template> <template>
<div v-if="!props.path || documents.length === 0" class="empty-container"> <div v-if="showEmpty" class="empty-container">
<component :is="cog" :class="['cog', { stopped: store.dialog === 'accessdenied' || store.authInProgress }]"/> <component :is="cog" :class="['cog', { stopped: store.dialog === 'accessdenied' || store.authInProgress }]"/>
<p v-if="store.dialog === 'accessdenied'">Access Denied</p> <p v-if="store.dialog === 'accessdenied'">Access Denied</p>
<p v-else-if="!store.connected">No Connection</p> <p v-else-if="!store.connected">No Connection</p>
@@ -14,6 +14,7 @@
import { Cog } from '@/assets/svg' import { Cog } from '@/assets/svg'
import { useMainStore } from '@/stores/main' import { useMainStore } from '@/stores/main'
import { exists } from '@/utils/fileutil' import { exists } from '@/utils/fileutil'
import { computed } from 'vue'
const cog = Cog const cog = Cog
const store = useMainStore() const store = useMainStore()
@@ -21,9 +22,29 @@ const props = defineProps<{
path: string[] path: string[]
documents: Document[] documents: Document[]
}>() }>()
const showEmpty = computed(() => {
const loc = props.path.join('/')
const hasVisibleGhost = store.ghosts.some(g => {
const full = g.loc ? `${g.loc}/${g.name}` : g.name
return g.loc === loc && !store.hiddenPaths.has(full)
})
return !props.path || (props.documents.length === 0 && !hasVisibleGhost)
})
</script> </script>
<style scoped> <style scoped>
.empty-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
font-size: 2rem;
text-shadow: 0 0 .3rem #000, 0 0 2rem #0008;
color: var(--accent-color);
}
@keyframes rotate { @keyframes rotate {
0% { transform: rotate(0deg); } 0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); } 100% { transform: rotate(360deg); }
+113 -91
View File
@@ -1,74 +1,77 @@
<template> <template>
<table v-if="props.documents.length || editing"> <div class="file-explorer">
<thead> <table v-if="props.documents.length || editing">
<tr> <thead>
<th class="selection"> <tr>
<input type="checkbox" tabindex="-1" v-model="allSelected" :indeterminate="selectionIndeterminate"> <th class="selection">
</th> <input type="checkbox" tabindex="-1" v-model="allSelected" :indeterminate="selectionIndeterminate">
<th class="sortcolumn" :class="{ sortactive: store.sortOrder === 'name' }" @click="store.toggleSort('name')">Name</th> </th>
<th class="sortcolumn modified right" :class="{ sortactive: store.sortOrder === 'modified' }" @click="store.toggleSort('modified')">Modified</th> <th class="sortcolumn" :class="{ sortactive: store.sortOrder === 'name' }" @click="store.toggleSort('name')">Name</th>
<th class="sortcolumn size right" :class="{ sortactive: store.sortOrder === 'size' }" @click="store.toggleSort('size')">Size</th> <th class="sortcolumn modified right" :class="{ sortactive: store.sortOrder === 'modified' }" @click="store.toggleSort('modified')">Modified</th>
<th class="menu"></th> <th class="sortcolumn size right" :class="{ sortactive: store.sortOrder === 'size' }" @click="store.toggleSort('size')">Size</th>
</tr> <th class="menu"></th>
</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>
</tr> </tr>
</thead>
<tr <tbody>
:id="`file-${doc.key}`" <tr v-if="editing?.key === 'new'" :class="editing.dir ? 'folder' : 'file'">
:class="{ file: !doc.dir, folder: doc.dir, cursor: store.cursor === doc.key, ghost: doc.ghost }" <td class="selection"></td>
@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"> <td class="name">
<template v-if="editing === doc"> <FileRenameInput :doc="editing" :rename="createItem" :exit="exitEditing" />
<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>
</td> </td>
<FileModified :doc=editing :now=nowkey />
<FileSize :doc=editing />
<td class="menu"></td>
</tr> </tr>
</template> <template v-for="(doc, index) in documents" :key="doc.key">
<tr class="summary" v-if="props.documents.length > 1"> <tr class="folder-change" v-if="showFolderBreadcrumb(index)">
<td colspan="3" class="right">{{props.documents.length}} items</td> <th colspan="5"><BreadCrumb :path="doc.loc ? doc.loc.split('/') : []" /></th>
<td class="size right">{{ formatSize(props.documents.reduce((a, b) => a + b.size, 0)) }}</td> </tr>
<td class="menu"></td>
</tr> <tr
</tbody> :id="`file-${doc.key}`"
</table> :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 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>
</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> </template>
<script setup lang="ts"> <script setup lang="ts">
@@ -81,11 +84,12 @@ import ContextMenu from '@imengyu/vue3-context-menu'
import { import {
computed, computed,
nextTick, nextTick,
onDeactivated,
onMounted, onMounted,
onUnmounted, onUnmounted,
ref, ref,
shallowRef, shallowRef,
watchEffect watch
} from 'vue' } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import FileRenameInput from './FileRenameInput.vue' import FileRenameInput from './FileRenameInput.vue'
@@ -189,6 +193,9 @@ const pageMove = (direction: 1 | -1, ev: KeyboardEvent) => {
// File rename // File rename
const editing = shallowRef<Doc | null>(null) const editing = shallowRef<Doc | null>(null)
const exitEditing = () => {
editing.value = null
}
const rename = async (doc: Doc, newName: string) => { const rename = async (doc: Doc, newName: string) => {
const oldName = doc.name const oldName = doc.name
doc.name = newName // We should get an update from watch but this is quicker doc.name = newName // We should get an update from watch but this is quicker
@@ -243,7 +250,7 @@ defineExpose({
const docs = props.documents const docs = props.documents
if (docs.length > 0) { if (docs.length > 0) {
store.cursor = docs[0]!.key store.cursor = docs[0]!.key
// Also focus the element directly (watchEffect won't trigger if cursor unchanged) // Also focus the element directly (post-flush watcher won't trigger if cursor unchanged)
nextTick(() => { nextTick(() => {
const a = document.querySelector( const a = document.querySelector(
`#file-${store.cursor} .name a` `#file-${store.cursor} .name a`
@@ -329,22 +336,35 @@ const focusBreadcrumb = () => {
const keyboardFollowScroll = createKeyboardFollowScroll() const keyboardFollowScroll = createKeyboardFollowScroll()
const markKeyboardFollow = keyboardFollowScroll.markKeyboardFollow const markKeyboardFollow = keyboardFollowScroll.markKeyboardFollow
const keepCursorVisibleSmooth = keyboardFollowScroll.keepVisible const keepCursorVisibleSmooth = keyboardFollowScroll.keepVisible
watchEffect(() => { watch(
if (store.cursor && store.cursor !== editing.value?.key) editing.value = null () => store.cursor,
if (editing.value) store.cursor = editing.value?.key cursor => {
if (store.cursor) { if (cursor && editing.value && cursor !== editing.value.key) {
const a = document.querySelector( exitEditing()
`#file-${store.cursor} .name a` }
) as HTMLAnchorElement | null
if (a) a.focus({ preventScroll: true })
} }
}) )
watchEffect(() => { watch(
if (!props.documents.length && store.cursor && !store.query) { () => store.cursor,
store.cursor = '' cursor => {
focusBreadcrumb() 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 (!len && cursor && !query && !editingDoc) {
store.cursor = ''
focusBreadcrumb()
}
} }
}) )
let nowkey = ref(0) let nowkey = ref(0)
let modifiedTimer: any = null let modifiedTimer: any = null
const updateModified = () => { const updateModified = () => {
@@ -358,12 +378,15 @@ onMounted(() => {
active.focus({ preventScroll: true }) active.focus({ preventScroll: true })
} }
}) })
onDeactivated(() => {
if (editing.value) exitEditing()
})
onUnmounted(() => { onUnmounted(() => {
keyboardFollowScroll.cancel() keyboardFollowScroll.cancel()
clearInterval(modifiedTimer) clearInterval(modifiedTimer)
}) })
const editRoute = (path: string) => const editRoute = (path: string) =>
'/edit/' + '/' +
path path
.split('/') .split('/')
.map(part => encodeURIComponent(part)) .map(part => encodeURIComponent(part))
@@ -373,7 +396,8 @@ const createItem = async (doc: Doc, name: string) => {
doc.name = name doc.name = name
doc.key = crypto.randomUUID() doc.key = crypto.randomUUID()
store.addGhost(doc) store.addGhost(doc)
editing.value = null store.cursor = doc.key
exitEditing()
const path = doc.loc ? `${doc.loc}/${name}` : name const path = doc.loc ? `${doc.loc}/${name}` : name
try { try {
const res = doc.dir const res = doc.dir
@@ -525,9 +549,14 @@ const contextMenu = (ev: MouseEvent, doc: Doc) => {
</script> </script>
<style scoped> <style scoped>
.file-explorer {
height: 100%;
width: 100%;
}
table { table {
width: 100%; width: 100%;
table-layout: fixed; table-layout: fixed;
height: auto;
} }
thead tr { thead tr {
position: sticky; position: sticky;
@@ -658,12 +687,6 @@ tbody .selection input {
content: '📁'; content: '📁';
font-size: 1.5rem; font-size: 1.5rem;
} }
.empty-container {
padding-top: 3rem;
text-align: center;
font-size: 3rem;
color: var(--accent-color);
}
.folder-change { .folder-change {
margin-left: -.5rem; margin-left: -.5rem;
} }
@@ -674,4 +697,3 @@ tbody .selection input {
color: #888; color: #888;
} }
</style> </style>
@/stores/main
@@ -60,6 +60,7 @@ input#FileRenameInput {
padding: .75em; padding: .75em;
font-weight: 600; font-weight: 600;
width: auto; width: auto;
text-align: center;
} }
</style> </style>
+62 -28
View File
@@ -13,6 +13,7 @@
/> />
</template> </template>
</div> </div>
<EmptyFolder v-else :documents="documents" :path="props.path" />
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@@ -25,12 +26,13 @@ import ContextMenu from '@imengyu/vue3-context-menu'
import { import {
computed, computed,
nextTick, nextTick,
onActivated,
onDeactivated,
onMounted, onMounted,
onUnmounted, onUnmounted,
ref, ref,
shallowRef, shallowRef,
watch, watch
watchEffect
} from 'vue' } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
@@ -170,6 +172,7 @@ const onImgLoad = (e: Event) => {
} }
const updateColumns = () => { const updateColumns = () => {
if (!gallery.value) return if (!gallery.value) return
if (gallery.value.getBoundingClientRect().width <= 0) return
const style = getComputedStyle(gallery.value) const style = getComputedStyle(gallery.value)
const templates = style.gridTemplateColumns const templates = style.gridTemplateColumns
.split(' ') .split(' ')
@@ -301,7 +304,7 @@ defineExpose({
const docs = props.documents const docs = props.documents
if (docs.length > 0) { if (docs.length > 0) {
store.cursor = docs[0]!.key store.cursor = docs[0]!.key
// Also focus the element directly (watchEffect won't trigger if cursor unchanged) // Also focus the element directly (post-flush watcher won't trigger if cursor unchanged)
nextTick(() => { nextTick(() => {
const a = document.querySelector( const a = document.querySelector(
`#file-${store.cursor}` `#file-${store.cursor}`
@@ -393,25 +396,49 @@ const focusBreadcrumb = () => {
const keyboardFollowScroll = createKeyboardFollowScroll() const keyboardFollowScroll = createKeyboardFollowScroll()
const markKeyboardFollow = keyboardFollowScroll.markKeyboardFollow const markKeyboardFollow = keyboardFollowScroll.markKeyboardFollow
const keepCursorVisibleSmooth = keyboardFollowScroll.keepVisible const keepCursorVisibleSmooth = keyboardFollowScroll.keepVisible
watchEffect(() => { watch(
if (store.cursor && store.cursor !== editing.value?.key) editing.value = null () => store.cursor,
if (editing.value) store.cursor = editing.value.key cursor => {
if (store.cursor && !editing.value) { if (cursor && editing.value && cursor !== editing.value.key) {
const a = document.querySelector( exit()
`#file-${store.cursor}`
) as HTMLAnchorElement | null
if (a) {
a.focus({ preventScroll: true })
} }
} }
}) )
watchEffect(() => { watch(
if (!props.documents.length && store.cursor && !store.query) { () => store.cursor,
store.cursor = '' cursor => {
focusBreadcrumb() 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 (!len && cursor && !query && !editingDoc) {
store.cursor = ''
focusBreadcrumb()
}
} }
}) )
let resizeObserver: ResizeObserver | null = null let resizeObserver: ResizeObserver | null = null
const attachGalleryObservers = () => {
if (!gallery.value || resizeObserver) return
resizeObserver = new ResizeObserver(updateColumns)
resizeObserver.observe(gallery.value)
gallery.value.addEventListener('load', onImgLoad, { capture: true })
}
const detachGalleryObservers = () => {
resizeObserver?.disconnect()
resizeObserver = null
gallery.value?.removeEventListener('load', onImgLoad, { capture: true })
}
onMounted(() => { onMounted(() => {
const active = document.querySelector('.cursor') as HTMLElement | null const active = document.querySelector('.cursor') as HTMLElement | null
if (active) { if (active) {
@@ -419,22 +446,27 @@ onMounted(() => {
} }
updateColumns() updateColumns()
seedFromDocs() seedFromDocs()
if (gallery.value) { attachGalleryObservers()
resizeObserver = new ResizeObserver(updateColumns) })
resizeObserver.observe(gallery.value) onActivated(() => {
gallery.value.addEventListener('load', onImgLoad, { capture: true }) nextTick(() => {
} updateColumns()
attachGalleryObservers()
})
})
onDeactivated(() => {
detachGalleryObservers()
if (editing.value) exit()
}) })
onUnmounted(() => { onUnmounted(() => {
keyboardFollowScroll.cancel() keyboardFollowScroll.cancel()
resizeObserver?.disconnect() detachGalleryObservers()
gallery.value?.removeEventListener('load', onImgLoad, { capture: true })
}) })
// Re-seed aspect ratios whenever docs update (e.g., ar patch from server) // Re-seed aspect ratios whenever docs update (e.g., ar patch from server)
watch(() => props.documents, seedFromDocs) watch(() => props.documents, seedFromDocs)
const editRoute = (path: string) => const editRoute = (path: string) =>
'/edit/' + '/' +
path path
.split('/') .split('/')
.map(part => encodeURIComponent(part)) .map(part => encodeURIComponent(part))
@@ -444,7 +476,8 @@ const createItem = async (doc: Doc, name: string) => {
doc.name = name doc.name = name
doc.key = crypto.randomUUID() doc.key = crypto.randomUUID()
store.addGhost(doc) store.addGhost(doc)
editing.value = null store.cursor = doc.key
exit()
const path = doc.loc ? `${doc.loc}/${name}` : name const path = doc.loc ? `${doc.loc}/${name}` : name
try { try {
const res = doc.dir const res = doc.dir
@@ -592,7 +625,8 @@ const contextMenu = (ev: MouseEvent, doc: Doc) => {
display: grid; display: grid;
gap: .5em; gap: .5em;
grid-template-columns: repeat(auto-fill, minmax(15em, 1fr)); grid-template-columns: repeat(auto-fill, minmax(15em, 1fr));
align-items: end; align-items: start;
align-content: start;
} }
.folder-indicator { .folder-indicator {
grid-column: 1 / -1; grid-column: 1 / -1;
+1 -1
View File
@@ -95,7 +95,7 @@ export class Doc {
get editurl(): string { get editurl(): string {
if (!this.text) return '' if (!this.text) return ''
const p = this.loc ? `${this.loc}/${this.name}` : this.name 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 { get complete(): boolean {
return !this.ghost && (this.dir || this.size <= this.allocated) return !this.ghost && (this.dir || this.size <= this.allocated)
-6
View File
@@ -1,6 +1,5 @@
import { useMainStore } from '@/stores/main' import { useMainStore } from '@/stores/main'
import ExplorerView from '@/views/ExplorerView.vue' import ExplorerView from '@/views/ExplorerView.vue'
import TextEditorView from '@/views/TextEditorView.vue'
import { createRouter, createWebHashHistory } from 'vue-router' import { createRouter, createWebHashHistory } from 'vue-router'
function getPathDepth(path: string): number { function getPathDepth(path: string): number {
@@ -11,11 +10,6 @@ function getPathDepth(path: string): number {
const router = createRouter({ const router = createRouter({
history: createWebHashHistory(import.meta.env.BASE_URL), history: createWebHashHistory(import.meta.env.BASE_URL),
routes: [ routes: [
{
path: '/edit/:pathMatch(.*)*',
name: 'editor',
component: TextEditorView
},
{ {
path: '/:pathMatch(.*)*', path: '/:pathMatch(.*)*',
name: 'explorer', name: 'explorer',
+10 -3
View File
@@ -7,9 +7,16 @@ export const exists = (path: string[]) => {
void store.docVersion void store.docVersion
if (path.length === 0) return true if (path.length === 0) return true
const p = path.join('/') const p = path.join('/')
return getDocuments().some( const hidden = store.hiddenPaths
doc => (doc.loc ? `${doc.loc}/${doc.name}` : doc.name) === p const inDocs = getDocuments().some(doc => {
) const full = doc.loc ? `${doc.loc}/${doc.name}` : doc.name
return full === p && !hidden.has(full)
})
if (inDocs) return true
return store.ghosts.some(g => {
const full = g.loc ? `${g.loc}/${g.name}` : g.name
return full === p && !hidden.has(full)
})
} }
/** Strip file extension intelligently (handles .tar.gz, name.with.dots.pdf, etc.) */ /** Strip file extension intelligently (handles .tar.gz, name.with.dots.pdf, etc.) */
+40 -23
View File
@@ -2,23 +2,18 @@
<div class="transition-wrapper"> <div class="transition-wrapper">
<Transition <Transition
:name="transitionName" :name="transitionName"
@after-enter="store.transitionDirection = 'none'" @after-enter="onAfterEnter"
> >
<div :key="folderPath" class="explorer-content"> <KeepAlive>
<Gallery <component
v-if="store.prefs.gallery" :is="store.prefs.gallery ? Gallery : FileExplorer"
:key="cacheKey"
ref="fileExplorer" ref="fileExplorer"
class="explorer-content"
:path="props.path" :path="props.path"
:documents="documents" :documents="documents"
/> />
<FileExplorer </KeepAlive>
v-else
ref="fileExplorer"
:path="props.path"
:documents="documents"
/>
<EmptyFolder :documents="documents" :path="props.path" />
</div>
</Transition> </Transition>
</div> </div>
<div v-if="store.searchLoading" class="search-loading">Searching...</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 { useMainStore } from '@/stores/main'
import { collator } from '@/utils' import { collator } from '@/utils'
import { sorted, sortedGrouped } from '@/utils/docsort' import { sorted, sortedGrouped } from '@/utils/docsort'
import { computed, ref, watch, watchEffect } from 'vue' import { computed, nextTick, ref, watch, watchEffect } from 'vue'
const store = useMainStore() const store = useMainStore()
const fileExplorer = ref() const fileExplorer = ref()
@@ -42,6 +37,9 @@ const props = defineProps<{
// Folder path for component keys - only recreate component when folder changes, not search // Folder path for component keys - only recreate component when folder changes, not search
const folderPath = computed(() => props.path.join('/')) const folderPath = computed(() => props.path.join('/'))
const cacheKey = computed(
() => `${store.prefs.gallery ? 'gallery' : 'list'}:${folderPath.value}`
)
const transitionName = computed(() => { const transitionName = computed(() => {
if (store.transitionDirection === 'forward') return 'slide-forward' if (store.transitionDirection === 'forward') return 'slide-forward'
@@ -49,6 +47,22 @@ const transitionName = computed(() => {
return '' return ''
}) })
const folderScrollTop = new Map<string, number>()
const scrollKey = (path: string) => path || '/'
const getMainScroller = () => document.querySelector('main') as HTMLElement | null
const restoreScroll = (path: string) => {
const scroller = getMainScroller()
if (!scroller) return
const top = folderScrollTop.get(scrollKey(path)) ?? 0
scroller.scrollTop = top
}
const onAfterEnter = () => {
store.transitionDirection = 'none'
restoreScroll(folderPath.value)
}
// Handle route-based search changes (back/forward navigation, direct URL) // Handle route-based search changes (back/forward navigation, direct URL)
// Skip if store.query already matches (means we triggered this via typing) // Skip if store.query already matches (means we triggered this via typing)
watch( watch(
@@ -101,6 +115,19 @@ watchEffect(() => {
store.fileExplorer = fileExplorer.value store.fileExplorer = fileExplorer.value
}) })
watch(
folderPath,
async (path, oldPath) => {
const scroller = getMainScroller()
if (scroller && oldPath !== undefined) {
folderScrollTop.set(scrollKey(oldPath), scroller.scrollTop)
}
await nextTick()
requestAnimationFrame(() => restoreScroll(path))
},
{ immediate: true }
)
// Only auto-switch gallery mode when entering a new folder or on initial file list load // Only auto-switch gallery mode when entering a new folder or on initial file list load
watch( watch(
[() => props.path.join('/'), () => store.documentCount], [() => props.path.join('/'), () => store.documentCount],
@@ -114,16 +141,6 @@ watch(
</script> </script>
<style scoped> <style scoped>
.empty-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
font-size: 2rem;
text-shadow: 0 0 .3rem #000, 0 0 2rem #0008;
color: var(--accent-color);
}
.search-loading { .search-loading {
position: fixed; position: fixed;
bottom: 1rem; bottom: 1rem;
+47 -22
View File
@@ -9,6 +9,8 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { apiFetch } from '@/repositories/Client'
import { useMainStore } from '@/stores/main'
import { indentWithTab } from '@codemirror/commands' import { indentWithTab } from '@codemirror/commands'
import { LanguageDescription } from '@codemirror/language' import { LanguageDescription } from '@codemirror/language'
import { languages } from '@codemirror/language-data' import { languages } from '@codemirror/language-data'
@@ -16,17 +18,26 @@ import { Compartment, EditorState } from '@codemirror/state'
import { oneDark } from '@codemirror/theme-one-dark' import { oneDark } from '@codemirror/theme-one-dark'
import { EditorView, keymap } from '@codemirror/view' import { EditorView, keymap } from '@codemirror/view'
import { basicSetup } from 'codemirror' import { basicSetup } from 'codemirror'
import { apiFetch } from '@/repositories/Client' import {
import { useMainStore } from '@/stores/main' computed,
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue' nextTick,
import { onBeforeRouteLeave, useRoute } from 'vue-router' onActivated,
onDeactivated,
onMounted,
onUnmounted,
ref
} from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute() const route = useRoute()
const store = useMainStore() const store = useMainStore()
const MAX_SIZE = 1024 * 1024 // 1 MiB 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 filename = computed(() => filePath.value.split('/').pop() || '')
const filesUrl = computed(() => { const filesUrl = computed(() => {
@@ -50,21 +61,32 @@ const languageCompartment = new Compartment()
const dirty = computed(() => content.value !== original.value) 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) => { const beforeUnload = (event: BeforeUnloadEvent) => {
if (!dirty.value) return if (!dirty.value) return
event.preventDefault() event.preventDefault()
event.returnValue = '' 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 detectLanguage = async () => {
const language = LanguageDescription.matchFilename(languages, filename.value) const language = LanguageDescription.matchFilename(languages, filename.value)
if (!language) return [] if (!language) return []
@@ -129,8 +151,7 @@ const save = async () => {
} }
onMounted(async () => { onMounted(async () => {
store.editorSave = save activateEditorBindings()
window.addEventListener('beforeunload', beforeUnload)
loading.value = true loading.value = true
error.value = '' error.value = ''
try { try {
@@ -152,19 +173,23 @@ onMounted(async () => {
await initEditor(text) await initEditor(text)
} catch (err) { } catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to load file' error.value = err instanceof Error ? err.message : 'Failed to load file'
} } finally {
finally {
if (loading.value) loading.value = false if (loading.value) loading.value = false
} }
}) })
onActivated(() => {
activateEditorBindings()
})
onDeactivated(() => {
deactivateEditorBindings()
})
onUnmounted(() => { onUnmounted(() => {
if (store.editorSave === save) { deactivateEditorBindings()
store.editorSave = null
}
editorView?.destroy() editorView?.destroy()
editorView = null editorView = null
window.removeEventListener('beforeunload', beforeUnload)
}) })
</script> </script>