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.
This commit is contained in:
@@ -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); }
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
<tr v-if="editing?.key === 'new'" :class="editing.dir ? 'folder' : 'file'">
|
<tr v-if="editing?.key === 'new'" :class="editing.dir ? 'folder' : 'file'">
|
||||||
<td class="selection"></td>
|
<td class="selection"></td>
|
||||||
<td class="name">
|
<td class="name">
|
||||||
<FileRenameInput :doc="editing" :rename="createItem" :exit="() => {editing = null}" />
|
<FileRenameInput :doc="editing" :rename="createItem" :exit="exitEditing" />
|
||||||
</td>
|
</td>
|
||||||
<FileModified :doc=editing :now=nowkey />
|
<FileModified :doc=editing :now=nowkey />
|
||||||
<FileSize :doc=editing />
|
<FileSize :doc=editing />
|
||||||
@@ -46,7 +46,7 @@
|
|||||||
</td>
|
</td>
|
||||||
<td class="name">
|
<td class="name">
|
||||||
<template v-if="editing === doc">
|
<template v-if="editing === doc">
|
||||||
<FileRenameInput :doc="doc" :rename="rename" :exit="() => {editing = null}" />
|
<FileRenameInput :doc="doc" :rename="rename" :exit="exitEditing" />
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<a :href="doc.text ? doc.editurl : doc.url" tabindex=-1 @contextmenu.stop @focus.stop="store.cursor = doc.key">
|
<a :href="doc.text ? doc.editurl : doc.url" tabindex=-1 @contextmenu.stop @focus.stop="store.cursor = doc.key">
|
||||||
@@ -69,6 +69,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
<EmptyFolder v-else :documents="documents" :path="props.path" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
@@ -81,11 +82,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 +191,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 +248,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 +334,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) {
|
||||||
|
exitEditing()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
watch(
|
||||||
|
() => store.cursor,
|
||||||
|
cursor => {
|
||||||
|
if (cursor && !editing.value) {
|
||||||
const a = document.querySelector(
|
const a = document.querySelector(
|
||||||
`#file-${store.cursor} .name a`
|
`#file-${cursor} .name a`
|
||||||
) as HTMLAnchorElement | null
|
) as HTMLAnchorElement | null
|
||||||
if (a) a.focus({ preventScroll: true })
|
if (a) a.focus({ preventScroll: true })
|
||||||
}
|
}
|
||||||
})
|
},
|
||||||
watchEffect(() => {
|
{ flush: 'post' }
|
||||||
if (!props.documents.length && store.cursor && !store.query) {
|
)
|
||||||
|
watch(
|
||||||
|
() => [props.documents.length, store.cursor, store.query, editing.value] as const,
|
||||||
|
([len, cursor, query, editingDoc]) => {
|
||||||
|
if (!len && cursor && !query && !editingDoc) {
|
||||||
store.cursor = ''
|
store.cursor = ''
|
||||||
focusBreadcrumb()
|
focusBreadcrumb()
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
|
)
|
||||||
let nowkey = ref(0)
|
let nowkey = ref(0)
|
||||||
let modifiedTimer: any = null
|
let modifiedTimer: any = null
|
||||||
const updateModified = () => {
|
const updateModified = () => {
|
||||||
@@ -358,6 +376,9 @@ onMounted(() => {
|
|||||||
active.focus({ preventScroll: true })
|
active.focus({ preventScroll: true })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
onDeactivated(() => {
|
||||||
|
if (editing.value) exitEditing()
|
||||||
|
})
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
keyboardFollowScroll.cancel()
|
keyboardFollowScroll.cancel()
|
||||||
clearInterval(modifiedTimer)
|
clearInterval(modifiedTimer)
|
||||||
@@ -373,7 +394,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
|
||||||
@@ -658,12 +680,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 +690,3 @@ tbody .selection input {
|
|||||||
color: #888;
|
color: #888;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@/stores/main
|
|
||||||
|
|||||||
@@ -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">
|
||||||
@@ -23,16 +24,15 @@ import type { SortOrder } from '@/utils/docsort'
|
|||||||
import { createKeyboardFollowScroll } from '@/utils/keyboardFollowScroll'
|
import { createKeyboardFollowScroll } from '@/utils/keyboardFollowScroll'
|
||||||
import ContextMenu from '@imengyu/vue3-context-menu'
|
import ContextMenu from '@imengyu/vue3-context-menu'
|
||||||
import {
|
import {
|
||||||
onActivated,
|
|
||||||
onDeactivated,
|
|
||||||
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'
|
||||||
|
|
||||||
@@ -304,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}`
|
||||||
@@ -396,24 +396,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 && !editing.value) {
|
if (cursor && editing.value && cursor !== editing.value.key) {
|
||||||
const a = document.querySelector(
|
exit()
|
||||||
`#file-${store.cursor}`
|
}
|
||||||
) as HTMLAnchorElement | null
|
}
|
||||||
|
)
|
||||||
|
watch(
|
||||||
|
() => store.cursor,
|
||||||
|
cursor => {
|
||||||
|
if (cursor && !editing.value) {
|
||||||
|
const a = document.querySelector(`#file-${cursor}`) as HTMLAnchorElement | null
|
||||||
if (a) {
|
if (a) {
|
||||||
a.focus({ preventScroll: true })
|
a.focus({ preventScroll: true })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
},
|
||||||
watchEffect(() => {
|
{ flush: 'post' }
|
||||||
if (!props.documents.length && store.cursor && !store.query) {
|
)
|
||||||
|
watch(
|
||||||
|
() => [props.documents.length, store.cursor, store.query, editing.value] as const,
|
||||||
|
([len, cursor, query, editingDoc]) => {
|
||||||
|
if (!len && cursor && !query && !editingDoc) {
|
||||||
store.cursor = ''
|
store.cursor = ''
|
||||||
focusBreadcrumb()
|
focusBreadcrumb()
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
|
)
|
||||||
let resizeObserver: ResizeObserver | null = null
|
let resizeObserver: ResizeObserver | null = null
|
||||||
const attachGalleryObservers = () => {
|
const attachGalleryObservers = () => {
|
||||||
if (!gallery.value || resizeObserver) return
|
if (!gallery.value || resizeObserver) return
|
||||||
@@ -445,6 +456,7 @@ onActivated(() => {
|
|||||||
})
|
})
|
||||||
onDeactivated(() => {
|
onDeactivated(() => {
|
||||||
detachGalleryObservers()
|
detachGalleryObservers()
|
||||||
|
if (editing.value) exit()
|
||||||
})
|
})
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
keyboardFollowScroll.cancel()
|
keyboardFollowScroll.cancel()
|
||||||
@@ -464,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
|
||||||
|
|||||||
@@ -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.) */
|
||||||
|
|||||||
@@ -15,7 +15,6 @@
|
|||||||
/>
|
/>
|
||||||
</KeepAlive>
|
</KeepAlive>
|
||||||
</Transition>
|
</Transition>
|
||||||
<EmptyFolder :documents="documents" :path="props.path" />
|
|
||||||
</div>
|
</div>
|
||||||
<div v-if="store.searchLoading" class="search-loading">Searching...</div>
|
<div v-if="store.searchLoading" class="search-loading">Searching...</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -38,7 +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 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'
|
||||||
@@ -140,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;
|
||||||
|
|||||||
Reference in New Issue
Block a user