Compare commits

..
9 Commits
15 changed files with 629 additions and 163 deletions
+3 -2
View File
@@ -13,8 +13,8 @@
--transition-time: 0.2s;
/* The following are overridden by responsive layouts */
--root-font-size: 1rem;
--header-font-size: 1rem;
--header-height: 4rem;
--header-font-size: clamp(0.7rem, 2.5vw, 1rem);
--header-height: clamp(2.8rem, 10vw, 4rem);
}
@media (prefers-color-scheme: dark) {
:root {
@@ -36,6 +36,7 @@
@media screen and (min-width: 1000px) {
:root {
--root-font-size: calc(8px + 8 * 100vw / 1000);
--header-font-size: 16px;
}
}
@media screen and (min-width: 2000px) {
+396
View File
@@ -0,0 +1,396 @@
<template>
<div class="disk-space-container" ref="containerRef">
<div
ref="widgetRef"
class="disk-space-widget"
:class="{ expanded: isExpanded }"
>
<svg viewBox="0 0 150 150" class="pie-svg" preserveAspectRatio="xMidYMid meet">
<defs>
<filter id="pieShadow" x="-50%" y="-50%" width="200%" height="200%">
<feDropShadow dx="0" dy="4" stdDeviation="8" flood-color="rgba(0,0,0,0.4)" />
</filter>
<radialGradient id="storageGradient" cx="30%" cy="30%" r="70%">
<stop offset="0%" stop-color="#93e" />
<stop offset="100%" stop-color="#82d" />
</radialGradient>
<radialGradient id="otherGradient" cx="30%" cy="30%" r="70%">
<stop offset="0%" stop-color="#d9f" />
<stop offset="100%" stop-color="#c8e" />
</radialGradient>
<radialGradient id="highlightOverlay" cx="35%" cy="35%" r="65%">
<stop offset="0%" stop-color="rgba(255,255,255,0.15)" />
<stop offset="60%" stop-color="rgba(255,255,255,0)" />
<stop offset="100%" stop-color="rgba(0,0,0,0.08)" />
</radialGradient>
</defs>
<g :filter="isExpanded ? 'url(#pieShadow)' : 'none'">
<circle :r="midRadius" :cx="pieCx" :cy="pieCy" fill="transparent" stroke="url(#otherGradient)" :stroke-width="ringWidth" />
<circle :r="midRadius" :cx="pieCx" :cy="pieCy" fill="transparent" :stroke="freeColor" :stroke-width="ringWidth" :stroke-dasharray="pieFreeDash" :stroke-dashoffset="pieFreeOffsetVal" :transform="`rotate(-90 ${pieCx} ${pieCy})`" />
<circle :r="midRadius" :cx="pieCx" :cy="pieCy" fill="transparent" stroke="url(#storageGradient)" :stroke-width="ringWidth" :stroke-dasharray="pieStorageDash" :transform="`rotate(-90 ${pieCx} ${pieCy})`" />
<circle :r="midRadius" :cx="pieCx" :cy="pieCy" fill="transparent" stroke="url(#highlightOverlay)" :stroke-width="ringWidth" />
<circle :r="holeRadius" :cx="pieCx" :cy="pieCy" fill="rgba(0,0,0,0.5)" />
<text ref="centerLabelRef" :x="pieCx" :y="pieCy" dy="0.35em" class="pie-center-label" text-anchor="middle">GB</text>
<circle :r="pieRadius" :cx="pieCx" :cy="pieCy" fill="transparent" class="pie-hitarea" @click="handleClick" />
</g>
<g ref="labelsRef" class="pie-labels">
<text :x="storageInnerPos.x" :y="storageInnerPos.y" class="pie-label-inner" :text-anchor="getSizeAnchor(sectorInfo.storage.angle)" dominant-baseline="middle" :transform="`rotate(${getSizeRotation(sectorInfo.storage.angle)} ${storageInnerPos.x} ${storageInnerPos.y})`">{{ fmtSize(store.space.storage, sectorInfo.storage.angle) }}</text>
<text :x="freeInnerPos.x" :y="freeInnerPos.y" class="pie-label-inner" :text-anchor="getSizeAnchor(sectorInfo.free.angle)" dominant-baseline="middle" :transform="`rotate(${getSizeRotation(sectorInfo.free.angle)} ${freeInnerPos.x} ${freeInnerPos.y})`">{{ fmtSize(store.space.free, sectorInfo.free.angle) }}</text>
<text :x="otherInnerPos.x" :y="otherInnerPos.y" class="pie-label-inner" :text-anchor="getSizeAnchor(sectorInfo.other.angle)" dominant-baseline="middle" :transform="`rotate(${getSizeRotation(sectorInfo.other.angle)} ${otherInnerPos.x} ${otherInnerPos.y})`">{{ fmtSize(store.space.usage - store.space.storage, sectorInfo.other.angle) }}</text>
<defs>
<path :id="storageLabelPath.id" :d="storageLabelPath.d" fill="none" />
<path :id="freeLabelPath.id" :d="freeLabelPath.d" fill="none" />
<path :id="otherLabelPath.id" :d="otherLabelPath.d" fill="none" />
</defs>
<text class="pie-label-sub" fill="#93e">
<textPath :href="'#' + storageLabelPath.id" startOffset="50%" text-anchor="middle" dominant-baseline="middle">{{ storageName }}</textPath>
</text>
<text class="pie-label-sub" :fill="freeColor">
<textPath :href="'#' + freeLabelPath.id" startOffset="50%" text-anchor="middle" dominant-baseline="middle">free</textPath>
</text>
<text class="pie-label-sub" fill="#d9f">
<textPath :href="'#' + otherLabelPath.id" startOffset="50%" text-anchor="middle" dominant-baseline="middle">other</textPath>
</text>
</g>
</svg>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useMainStore } from '@/stores/main'
const store = useMainStore()
const containerRef = ref<HTMLDivElement | null>(null)
const widgetRef = ref<HTMLDivElement | null>(null)
const labelsRef = ref<SVGGElement | null>(null)
const centerLabelRef = ref<SVGTextElement | null>(null)
const isExpanded = ref(false)
let animationFrame: number | null = null
const BASE_SIZE = 48
const EXPANDED_SCALE = 320 / 48
const ANIM_DURATION = 200
const containerPos = ref({ top: 0, left: 0, width: 0 })
const formatGB = (bytes: number) => {
const gb = bytes / (1024 * 1024 * 1024)
return gb < 10 ? gb.toFixed(1) : `${Math.round(gb)}`
}
// Add dot suffix for ambiguous angles (within 15° of horizontal) on numbers that look same upside down
const fmtSize = (bytes: number, angle: number) => {
const s = formatGB(bytes)
const a = Math.abs(angle % 180)
return (Math.min(a, 180 - a) < 15 && /^[0689]+$/.test(s)) ? `${s}.` : s
}
const truncateLabel = (name: string, maxLen = 10): string => {
if (name.length <= maxLen) return name
const parts = name.split(/[\s\-_.,;:!?()\[\]{}]+/)
if (parts[0] && parts[0].length <= maxLen) return parts[0]
return name.slice(0, maxLen - 1) + '…'
}
const storageName = computed(() => truncateLabel(store.server.name || 'stored'))
const TAU = 2 * Math.PI
const pieCx = 75
const pieCy = 75
const pieRadius = 55
const holeRadius = pieRadius * 0.38
const ringWidth = pieRadius - holeRadius
const midRadius = (pieRadius + holeRadius) / 2
const CIRC = TAU * midRadius
const pieStorageDash = computed(() => {
const s = store.space
if (!s.disk) return `0 ${CIRC}`
return `${(s.storage / s.disk) * CIRC} ${CIRC}`
})
const pieFreeDash = computed(() => {
const s = store.space
if (!s.disk) return `0 ${CIRC}`
return `${(s.free / s.disk) * CIRC} ${CIRC}`
})
const pieFreeOffsetVal = computed(() => {
const s = store.space
if (!s.disk) return 0
return -(s.storage / s.disk) * CIRC
})
const freeColor = computed(() => {
const s = store.space
if (!s.disk) return '#6c6'
const freePct = s.free / s.disk
if (freePct > 0.25) return '#5b5'
if (freePct > 0.10) return '#db3'
return '#d44'
})
const PIE_RADIUS = 55
const LABEL_RADIUS = 62
const getPoint = (angle: number, radius: number) => {
const rad = TAU * (angle - 90) / 360
return { x: pieCx + radius * Math.cos(rad), y: pieCy + radius * Math.sin(rad) }
}
const sectorInfo = computed(() => {
const s = store.space
if (!s.disk) return {
storage: { angle: 45, pct: 0.25 },
free: { angle: 180, pct: 0.5 },
other: { angle: 270, pct: 0.25 }
}
const storagePct = s.storage / s.disk
const freePct = s.free / s.disk
const otherPct = (s.usage - s.storage) / s.disk
const storageAngle = storagePct * 180 // midpoint of storage sector
const freeStart = storagePct * 360
const freeAngle = freeStart + freePct * 180
const otherStart = (storagePct + freePct) * 360
const otherAngle = otherStart + otherPct * 180
return {
storage: { angle: storageAngle, pct: storagePct },
free: { angle: freeAngle, pct: freePct },
other: { angle: otherAngle, pct: otherPct }
}
})
const rawAngles = computed(() => ({
storage: sectorInfo.value.storage.angle,
free: sectorInfo.value.free.angle,
other: sectorInfo.value.other.angle
}))
const getSizeRotation = (angle: number) => angle < 180 ? angle - 90 : angle + 90
const getSizeAnchor = (angle: number) => angle < 180 ? 'end' : 'start'
const INNER_LABEL_RADIUS = PIE_RADIUS * 0.95
const storageInnerPos = computed(() => getPoint(sectorInfo.value.storage.angle, INNER_LABEL_RADIUS))
const freeInnerPos = computed(() => getPoint(sectorInfo.value.free.angle, INNER_LABEL_RADIUS))
const otherInnerPos = computed(() => getPoint(sectorInfo.value.other.angle, INNER_LABEL_RADIUS))
// Collision avoidance for curved name labels
const labelLengths = computed(() => ({
storage: storageName.value.length,
free: 4,
other: 5
}))
const getGapForPair = (len1: number, len2: number) => {
return 35 + Math.max(0, len1 + len2 - 8) * 2.5
}
const adjustedLabelAngles = computed(() => {
const angles = rawAngles.value
const lens = labelLengths.value
const labels = [
{ id: 'storage', angle: angles.storage, len: lens.storage },
{ id: 'free', angle: angles.free, len: lens.free },
{ id: 'other', angle: angles.other, len: lens.other }
]
labels.sort((a, b) => a.angle - b.angle)
for (let iterations = 0; iterations < 15; iterations++) {
let moved = false
for (let i = 0; i < labels.length; i++) {
const current = labels[i]!
const next = labels[(i + 1) % labels.length]!
let angleDiff = next.angle - current.angle
if (angleDiff < 0) angleDiff += 360
const requiredGap = getGapForPair(current.len, next.len)
if (angleDiff < requiredGap) {
const push = (requiredGap - angleDiff) / 2
current.angle = (current.angle - push + 360) % 360
next.angle = (next.angle + push) % 360
moved = true
}
}
if (!moved) break
}
const result: Record<string, number> = {}
for (const l of labels) result[l.id] = l.angle
return result
})
// Arc path for curved text labels (CW for top half, CCW for bottom half)
const createArcPath = (centerAngle: number, id: string) => {
const radius = LABEL_RADIUS
const arcSpan = 60
const isBottom = centerAngle > 90 && centerAngle <= 270
const startAngle = isBottom ? centerAngle + arcSpan / 2 : centerAngle - arcSpan / 2
const endAngle = isBottom ? centerAngle - arcSpan / 2 : centerAngle + arcSpan / 2
const start = getPoint(startAngle, radius)
const end = getPoint(endAngle, radius)
const sweep = isBottom ? 0 : 1
return {
id: `label-path-${id}`,
d: `M ${start.x} ${start.y} A ${radius} ${radius} 0 0 ${sweep} ${end.x} ${end.y}`
}
}
const storageLabelPath = computed(() => createArcPath(adjustedLabelAngles.value.storage!, 'storage'))
const freeLabelPath = computed(() => createArcPath(adjustedLabelAngles.value.free!, 'free'))
const otherLabelPath = computed(() => createArcPath(adjustedLabelAngles.value.other!, 'other'))
const handleClick = () => isExpanded.value ? collapse() : expand()
const applyAnimState = (t: number, opacity: number) => {
const widget = widgetRef.value
const labels = labelsRef.value
const centerLabel = centerLabelRef.value
if (!widget) return
const scale = 1 + (EXPANDED_SCALE - 1) * t
// Move top-right corner of widget to top-right corner of viewport
const targetX = window.innerWidth - containerPos.value.left - containerPos.value.width
const targetY = -containerPos.value.top
widget.style.transform = `translate(${targetX * t}px, ${targetY * t}px) scale(${scale})`
if (labels) labels.style.opacity = String(opacity)
if (centerLabel) centerLabel.style.opacity = String(opacity)
}
const animate = (duration: number, expanding: boolean, onComplete?: () => void) => {
const startTime = performance.now()
const tick = (now: number) => {
const elapsed = now - startTime
const progress = Math.min(elapsed / duration, 1)
const eased = 1 - Math.pow(1 - progress, 3) // easeOutCubic
const t = expanding ? eased : 1 - eased
applyAnimState(t, t) // opacity follows position
if (progress < 1) {
animationFrame = requestAnimationFrame(tick)
} else {
animationFrame = null
onComplete?.()
}
}
animationFrame = requestAnimationFrame(tick)
}
const expand = () => {
if (animationFrame) cancelAnimationFrame(animationFrame)
if (containerRef.value) {
const rect = containerRef.value.getBoundingClientRect()
containerPos.value = { top: rect.top, left: rect.left, width: rect.width }
}
isExpanded.value = true
animate(ANIM_DURATION, true)
}
const collapse = () => {
if (animationFrame) cancelAnimationFrame(animationFrame)
if (containerRef.value) {
const rect = containerRef.value.getBoundingClientRect()
containerPos.value = { top: rect.top, left: rect.left, width: rect.width }
}
animate(ANIM_DURATION, false, () => {
isExpanded.value = false
})
}
const handleKeydown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isExpanded.value) collapse()
}
onMounted(() => {
document.addEventListener('keydown', handleKeydown)
// Initialize labels as hidden
if (labelsRef.value) labelsRef.value.style.opacity = '0'
if (centerLabelRef.value) centerLabelRef.value.style.opacity = '0'
})
onUnmounted(() => {
document.removeEventListener('keydown', handleKeydown)
if (animationFrame) cancelAnimationFrame(animationFrame)
})
</script>
<style scoped>
.disk-space-container {
position: relative;
width: 3em;
height: 3em;
}
.disk-space-widget {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
cursor: pointer;
will-change: transform;
filter: brightness(0.85);
transition: filter 0.2s ease;
transform-origin: top right;
}
.disk-space-widget:hover,
.disk-space-widget:focus {
filter: brightness(1);
}
.disk-space-widget.expanded {
pointer-events: none;
filter: none;
}
.disk-space-widget.expanded:hover,
.disk-space-widget.expanded:focus {
filter: none;
}
.pie-svg {
width: 100%;
height: 100%;
overflow: visible;
pointer-events: none;
}
.pie-hitarea {
pointer-events: auto;
cursor: pointer;
}
.pie-label-inner {
fill: #eee;
font-size: 12px;
font-weight: 700;
stroke: #000;
stroke-width: 0.5px;
paint-order: stroke fill;
}
.pie-center-label {
fill: #eee;
font-size: 12px;
font-weight: 600;
}
.pie-label-sub {
font-size: 14px;
font-weight: 600;
font-variant: small-caps;
text-transform: lowercase;
stroke: #000;
stroke-width: 1px;
paint-order: stroke fill;
}
</style>
+1 -1
View File
@@ -3,7 +3,7 @@
<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>
<p v-else-if="store.document.length === 0">Waiting for File List</p>
<p v-else-if="store.documentCount === 0">Waiting for File List</p>
<p v-else-if="store.query">No matches!</p>
<p v-else-if="!exists(props.path)">Folder not found</p>
<p v-else>Empty folder</p>
+3 -4
View File
@@ -251,8 +251,7 @@ const mkdir = (doc: Doc, name: string) => {
})
doc.name = name
doc.key = crypto.randomUUID()
doc.ghost = true
store.document.push(doc)
store.addGhost(doc)
editing.value = null
}
const showFolderBreadcrumb = (i: number) => {
@@ -351,13 +350,13 @@ const copyImage = async (doc: Doc) => {
const deleteFile = (doc: Doc) => {
const path = doc.loc ? `${doc.loc}/${doc.name}` : doc.name
doc.ghost = true
store.hideDoc(path)
const control = connect(controlUrl, {
message(ev: MessageEvent) {
const res = JSON.parse(ev.data)
if ('error' in res) {
console.error('Delete failed', res.error)
doc.ghost = false
store.unhideDoc(path)
store.showToast(res.error.message || 'Delete failed')
} else if (res.status === 'ack') {
store.showToast(`🗑️ Deleted ${doc.name}`)
+3 -4
View File
@@ -205,8 +205,7 @@ const mkdir = (doc: Doc, name: string) => {
})
doc.name = name
doc.key = crypto.randomUUID()
doc.ghost = true
store.document.push(doc)
store.addGhost(doc)
editing.value = null
}
const showFolderBreadcrumb = (i: number) => {
@@ -295,13 +294,13 @@ const copyImage = async (doc: Doc) => {
const deleteFile = (doc: Doc) => {
const path = doc.loc ? `${doc.loc}/${doc.name}` : doc.name
doc.ghost = true
store.hideDoc(path)
const control = connect(controlUrl, {
message(ev: MessageEvent) {
const res = JSON.parse(ev.data)
if ('error' in res) {
console.error('Delete failed', res.error)
doc.ghost = false
store.unhideDoc(path)
store.showToast(res.error.message || 'Delete failed')
} else if (res.status === 'ack') {
store.showToast(`🗑️ Deleted ${doc.name}`)
+24 -114
View File
@@ -20,40 +20,7 @@
<span v-if="!query" class="search-hint" @click="focusSearch">/</span>
</div>
<div class="spacer smallgap"></div>
<div v-if="store.space.disk" class="disk-space"
@mouseenter="diskTooltip?.startHover"
@mousemove="diskTooltip?.updatePosition"
@mouseleave="diskTooltip?.endHover"
>
<svg viewBox="0 0 32 32" class="pie-mini">
<!-- Base: 'other' usage (light purple - appears on left, before 12 o'clock) -->
<circle r="16" cx="16" cy="16" fill="#c8e" />
<!-- Middle ring: free space (dynamic color - appears at bottom) -->
<circle r="8" cx="16" cy="16" fill="transparent" :stroke="freeColor" stroke-width="16" :stroke-dasharray="pieFree" :stroke-dashoffset="pieFreeOffset" transform="rotate(-90 16 16)" />
<!-- Top ring: storage (deep purple - appears on right after 12 o'clock) -->
<circle r="8" cx="16" cy="16" fill="transparent" stroke="#82d" stroke-width="16" :stroke-dasharray="pieStorage" transform="rotate(-90 16 16)" />
<!-- Subtle inner circle for depth -->
<circle r="2" cx="16" cy="16" fill="rgba(255,255,255,0.2)" />
</svg>
<CursorTooltip ref="diskTooltip" text="Disk space">
<div class="disk-tooltip">
<svg viewBox="0 0 160 80" width="160" height="80" class="pie-tooltip">
<!-- Pie chart centered at 40,40 -->
<circle r="32" cx="40" cy="40" fill="#c8e" />
<circle r="16" cx="40" cy="40" fill="transparent" :stroke="freeColor" stroke-width="32" :stroke-dasharray="pieFreeLg" :stroke-dashoffset="pieFreeOffsetLg" transform="rotate(-90 40 40)" />
<circle r="16" cx="40" cy="40" fill="transparent" stroke="#82d" stroke-width="32" :stroke-dasharray="pieStorageLg" transform="rotate(-90 40 40)" />
<circle r="4" cx="40" cy="40" fill="rgba(255,255,255,0.25)" />
<!-- Labels on the right -->
<rect x="78" y="10" width="10" height="10" fill="#82d" rx="2"/>
<text x="92" y="19" class="pie-label">{{ formatSize(store.space.storage) }} stored</text>
<rect x="78" y="30" width="10" height="10" fill="#c8e" rx="2"/>
<text x="92" y="39" class="pie-label">{{ formatSize(store.space.usage - store.space.storage) }} other</text>
<rect x="78" y="50" width="10" height="10" :fill="freeColor" rx="2"/>
<text x="92" y="59" class="pie-label">{{ formatSize(store.space.free) }} free</text>
</svg>
</div>
</CursorTooltip>
</div>
<DiskSpace v-if="store.space.disk" />
<SvgButton name="cog" @click="settingsMenu" />
</nav>
</template>
@@ -61,74 +28,18 @@
<script setup lang="ts">
import { useMainStore } from '@/stores/main'
import { useSsoAuthStore } from '@/stores/ssoAuth'
import { ref, nextTick, watchEffect, computed } from 'vue'
import { ref } from 'vue'
import ContextMenu from '@imengyu/vue3-context-menu'
import { showAuthIframe } from 'paskia'
import { resumeWatching } from '@/repositories/WS'
import router from '@/router';
import { formatSize } from '@/utils'
import CursorTooltip from './CursorTooltip.vue'
import DiskSpace from './DiskSpace.vue'
const store = useMainStore()
const ssoStore = useSsoAuthStore()
const search = ref<HTMLInputElement | null>()
const diskTooltip = ref<InstanceType<typeof CursorTooltip> | null>(null)
const CIRC = 50.27 // 2π×8
// Storage segment (starts at top, -90°)
const pieStorage = computed(() => {
const s = store.space
if (!s.disk) return `0 ${CIRC}`
const pct = s.storage / s.disk
return `${pct * CIRC} ${CIRC}`
})
// Free segment (starts after storage, goes clockwise to bottom area)
const pieFree = computed(() => {
const s = store.space
if (!s.disk) return `0 ${CIRC}`
const pct = s.free / s.disk
return `${pct * CIRC} ${CIRC}`
})
const pieFreeOffset = computed(() => {
const s = store.space
if (!s.disk) return 0
// Start after storage segment
const storagePct = s.storage / s.disk
return -storagePct * CIRC
})
// Free space color: green when plenty, yellow when moderate, red when low
const freeColor = computed(() => {
const s = store.space
if (!s.disk) return '#6c6'
const freePct = s.free / s.disk
if (freePct > 0.25) return '#5b5' // Green: > 25% free
if (freePct > 0.10) return '#db3' // Yellow: 10-25% free
return '#d44' // Red: < 10% free
})
// Large pie for tooltip (circumference = 2π×16 ≈ 100.53)
const CIRC_LG = 100.53
const pieStorageLg = computed(() => {
const s = store.space
if (!s.disk) return `0 ${CIRC_LG}`
return `${(s.storage / s.disk) * CIRC_LG} ${CIRC_LG}`
})
const pieFreeLg = computed(() => {
const s = store.space
if (!s.disk) return `0 ${CIRC_LG}`
return `${(s.free / s.disk) * CIRC_LG} ${CIRC_LG}`
})
const pieFreeOffsetLg = computed(() => {
const s = store.space
if (!s.disk) return 0
return -(s.storage / s.disk) * CIRC_LG
})
const props = defineProps<{
const props = defineProps<{
path: Array<string>
query: string
}>()
@@ -240,12 +151,21 @@ defineExpose({
padding: 0 0.3em;
transition: background 0.2s ease;
flex: 1 1 auto;
min-width: 5.5em;
min-width: 2.5em;
max-width: 20em;
}
.search-group:hover,
.search-group:focus-within {
background: rgba(255, 255, 255, 0.2);
}
.search-group:hover :deep(button.action-button),
.search-group:focus-within :deep(button.action-button) {
transform: scale(1.1);
}
.search-group:hover :deep(button.action-button svg),
.search-group:focus-within :deep(button.action-button svg) {
fill: #fff;
}
.search-group:focus-within .search-hint {
opacity: 0;
pointer-events: none;
@@ -253,6 +173,8 @@ defineExpose({
.search-group :deep(.action-button) {
width: 2.2em;
height: 2.2em;
min-width: 1.5em;
min-height: 1.5em;
flex-shrink: 0;
}
.search-group input[type='search'] {
@@ -261,9 +183,10 @@ defineExpose({
border: none;
outline: none;
padding: 0.2em 0.5em 0.2em 0;
font-size: var(--header-font-size);
font-size: inherit;
flex: 1 1 3em;
min-width: 3em;
min-width: 0;
width: 100%;
}
.search-hint {
position: absolute;
@@ -279,24 +202,11 @@ defineExpose({
line-height: 1.4;
cursor: pointer;
transition: opacity 0.15s ease;
display: none;
}
.disk-space {
display: flex;
align-items: center;
cursor: default;
}
.pie-mini {
width: 1.4em;
height: 1.4em;
}
.disk-tooltip {
line-height: 1.5;
}
.pie-tooltip {
display: block;
}
.pie-tooltip .pie-label {
fill: #fff;
font-size: 9px;
@media (hover: hover) and (pointer: fine) {
.search-hint {
display: block;
}
}
</style>
+13 -6
View File
@@ -31,23 +31,30 @@ const props = defineProps({
const dst = computed(() => props.path!.join('/'))
const op = (opName: string, dst?: string) => {
const sel = store.selectedFiles
const paths = sel.keys.map(key => {
const doc = sel.docs[key]!
return doc.loc ? `${doc.loc}/${doc.name}` : doc.name
})
const msg = {
op: opName,
sel: sel.keys.map(key => {
const doc = sel.docs[key]!
return doc.loc ? `${doc.loc}/${doc.name}` : doc.name
})
sel: paths
}
// @ts-ignore
if (dst !== undefined) msg.dst = dst
if (opName === 'rm' || opName === 'mv')
for (const key of sel.keys) sel.docs[key]!.ghost = true
// Hide items being deleted or moved (optimistic update)
if (opName === 'rm' || opName === 'mv') {
for (const path of paths) store.hideDoc(path)
}
const control = connect(controlUrl, {
message(ev: MessageEvent) {
const res = JSON.parse(ev.data)
if ('error' in res) {
console.error('Control socket error', msg, res.error)
store.error = res.error.message
// Restore hidden items on error
if (opName === 'rm' || opName === 'mv') {
for (const path of paths) store.unhideDoc(path)
}
return
} else if (res.status === 'ack') {
console.log('Control ack OK', res)
+16 -5
View File
@@ -32,18 +32,29 @@ const tooltipText = props.tooltip ?? ''
color: #ccc;
cursor: pointer;
transition: all 0.2s ease;
padding: 0.2em;
width: 3em;
height: 3em;
margin: 0 0.2em;
padding: 0;
width: 2.7em;
height: 2.7em;
min-width: 1.9em;
min-height: 1.9em;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
}
.action-button:hover,
.action-button:focus {
color: #fff;
transform: scale(1.1);
}
svg {
.action-button svg {
fill: #ccc;
transform: fill 0.2s ease;
transition: fill 0.2s ease;
width: auto;
height: auto;
max-width: 100%;
max-height: 100%;
}
.action-button:hover svg,
.action-button:focus svg {
+10 -5
View File
@@ -10,6 +10,7 @@
<script setup lang="ts">
import { connect, uploadUrl } from '@/repositories/WS';
import { useMainStore } from '@/stores/main'
import { getDocuments } from '@/stores/documentStore'
import { Doc } from '@/repositories/Document'
import { collator } from '@/utils';
import { onMounted, onUnmounted, reactive, ref } from 'vue'
@@ -98,7 +99,12 @@ const uploadCloudFiles = (files: CloudFile[]) => {
files.sort((a, b) => collator.compare(a.cloudName, b.cloudName))
// Optimistic update: ghost folders and files
const now = Math.floor(Date.now() / 1000)
const byPath = new Map(store.document.map(d => [d.loc ? `${d.loc}/${d.name}` : d.name, d]))
const docs = getDocuments()
const byPath = new Map(docs.map(d => [d.loc ? `${d.loc}/${d.name}` : d.name, d]))
// Also check existing ghosts
for (const g of store.ghosts) {
byPath.set(g.loc ? `${g.loc}/${g.name}` : g.name, g)
}
const added = new Set<string>()
for (const f of files) {
const lastSlash = f.cloudName.lastIndexOf('/')
@@ -109,14 +115,13 @@ const uploadCloudFiles = (files: CloudFile[]) => {
for (let i = 0; i < parts.length; i++) {
const folderPath = parts.slice(0, i + 1).join('/')
if (folderPath && !byPath.has(folderPath) && !added.has(folderPath)) {
store.document.push(new Doc({ loc: parts.slice(0, i).join('/'), name: parts[i], key: crypto.randomUUID(), size: 0, mtime: now, dir: true, ghost: true }))
store.addGhost(new Doc({ loc: parts.slice(0, i).join('/'), name: parts[i], key: crypto.randomUUID(), size: 0, mtime: now, dir: true }))
added.add(folderPath)
}
}
// Ghost file or update existing
// Ghost file or update existing (overwrite case doesn't need ghost, file already visible)
const existing = byPath.get(f.cloudName)
if (existing) { existing.size = f.file.size; existing.mtime = now; existing.ghost = true }
else store.document.push(new Doc({ loc, name, key: crypto.randomUUID(), size: f.file.size, mtime: now, dir: false, ghost: true }))
if (!existing) store.addGhost(new Doc({ loc, name, key: crypto.randomUUID(), size: f.file.size, mtime: now, dir: false }))
}
// @ts-ignore
upqueue = [...upqueue, ...files]
+2
View File
@@ -10,6 +10,7 @@ export type DocProps = {
mtime: number
dir: boolean
ghost?: boolean
expires?: number // Unix timestamp for ghost expiry
}
export class Doc {
@@ -19,6 +20,7 @@ export class Doc {
public mtime: number = 0
public dir: boolean = false
public ghost: boolean = false
public expires: number = 0 // Unix timestamp for ghost expiry (0 = no expiry)
/** @internal Use the name getter/setter instead */
public _name: string = ""
+33
View File
@@ -0,0 +1,33 @@
// Non-reactive document storage for the full file list
// This avoids Vue reactivity overhead on large arrays
import type { Doc } from '@/repositories/Document'
import { shallowRef, triggerRef } from 'vue'
// The main document list - shallowRef means only the reference is reactive, not the contents
const documents = shallowRef<Doc[]>([])
// Version counter for manual reactivity triggering
let version = 0
export function getDocuments(): Doc[] {
return documents.value
}
export function setDocuments(docs: Doc[]): void {
documents.value = docs
version++
}
export function getVersion(): number {
return version
}
// Trigger reactivity manually (e.g., after modifications)
export function triggerUpdate(): void {
version++
triggerRef(documents)
}
// For computed dependencies that need to react to document changes
export const documentRef = documents
+93 -7
View File
@@ -5,6 +5,7 @@ import { collator } from '@/utils'
import { watchConnect, resumeWatching } from '@/repositories/WS'
import { sorted, type SortOrder } from '@/utils/docsort'
import SearchWorker from '@/workers/searchWorker?worker'
import { getDocuments, setDocuments, documentRef } from './documentStore'
// Singleton search worker instance
let searchWorker: Worker | null = null
@@ -52,9 +53,20 @@ function getSearchWorker(): Worker {
return searchWorker
}
// Ghost expiry time in seconds
const GHOST_TTL = 30
// Periodic cleanup interval
let cleanupInterval: ReturnType<typeof setInterval> | null = null
export const useMainStore = defineStore('main', {
state: () => ({
document: [] as Doc[],
// Ghosts are temporary optimistic-update files/folders shown until server confirms
ghosts: [] as Doc[],
// Hidden paths for optimistic delete (path -> expiry timestamp)
hiddenPaths: new Map<string, number>(),
// Version counter to trigger reactivity when external document list changes
docVersion: 0,
selected: new Set<FUID>([]),
query: '' as string,
searchResults: [] as Doc[],
@@ -118,10 +130,66 @@ export const useMainStore = defineStore('main', {
}))
loc.push(name)
}
this.document = docs
// Store in non-reactive external storage
setDocuments(docs)
// Clear ghosts that now exist in the real list
const realPaths = new Set(docs.map(d => d.loc ? `${d.loc}/${d.name}` : d.name))
this.ghosts = this.ghosts.filter(g => !realPaths.has(g.loc ? `${g.loc}/${g.name}` : g.name))
// Clear hidden paths that no longer exist (deletion confirmed)
for (const path of this.hiddenPaths.keys()) {
if (!realPaths.has(path)) this.hiddenPaths.delete(path)
}
// Start cleanup timer if not running
this.startCleanupTimer()
// Bump version to trigger reactive updates
this.docVersion++
// Sync documents to search worker
this.syncSearchWorker()
},
/** Add a ghost file/folder for optimistic UI updates */
addGhost(doc: Doc) {
doc.ghost = true
doc.expires = Math.floor(Date.now() / 1000) + GHOST_TTL
this.ghosts.push(doc)
},
/** Clear all ghosts (e.g., on navigation or refresh) */
clearGhosts() {
this.ghosts = []
},
/** Hide a document path (optimistic delete) */
hideDoc(path: string) {
this.hiddenPaths.set(path, Math.floor(Date.now() / 1000) + GHOST_TTL)
},
/** Unhide a document path (delete failed, restore visibility) */
unhideDoc(path: string) {
this.hiddenPaths.delete(path)
},
/** Start the periodic cleanup timer */
startCleanupTimer() {
if (cleanupInterval) return
cleanupInterval = setInterval(() => this.cleanupExpired(), 5000)
},
/** Stop the cleanup timer */
stopCleanupTimer() {
if (cleanupInterval) {
clearInterval(cleanupInterval)
cleanupInterval = null
}
},
/** Remove expired ghosts and hidden paths */
cleanupExpired() {
const now = Math.floor(Date.now() / 1000)
const ghostsBefore = this.ghosts.length
const hiddenBefore = this.hiddenPaths.size
this.ghosts = this.ghosts.filter(g => g.expires > now)
for (const [path, expires] of this.hiddenPaths) {
if (expires <= now) this.hiddenPaths.delete(path)
}
// Stop timer if nothing to clean up
if (this.ghosts.length === 0 && this.hiddenPaths.size === 0) {
this.stopCleanupTimer()
}
},
/** Show a temporary toast message that auto-dismisses */
showToast(message: string, duration = 3000) {
if (this.toastTimeout) {
@@ -145,7 +213,8 @@ export const useMainStore = defineStore('main', {
syncSearchWorker() {
const worker = getSearchWorker()
// Send plain data to worker (no class instances)
const docData = this.document.map(doc => ({
const docs = getDocuments()
const docData = docs.map(doc => ({
loc: doc.loc,
name: doc.name,
key: doc.key,
@@ -209,7 +278,11 @@ export const useMainStore = defineStore('main', {
clearSensitiveData() {
// Clear all sensitive state on logout or auth failure
localStorage.removeItem('cista-files')
this.document = []
setDocuments([])
this.ghosts = []
this.hiddenPaths.clear()
this.stopCleanupTimer()
this.docVersion++
this.selected.clear()
this.user.username = ''
this.user.privileged = false
@@ -268,8 +341,21 @@ export const useMainStore = defineStore('main', {
getters: {
sortOrder(): SortOrder { return this.query ? this.prefs.sortFiltered : this.prefs.sortListing },
isUserLogged(): boolean { return this.user.isLoggedIn },
recentDocuments(): Doc[] { return sorted(this.document, 'modified') },
/** Get documents count (triggers on docVersion change) */
documentCount(): number {
// Access docVersion to make this reactive
void this.docVersion
return getDocuments().length
},
recentDocuments(): Doc[] {
// Access docVersion to make this reactive
void this.docVersion
return sorted(getDocuments(), 'modified')
},
selectedFiles(): SelectedItems {
// Access docVersion to make this reactive
void this.docVersion
const docs = getDocuments()
const selected = this.selected
const found = new Set<FUID>()
const ret: SelectedItems = {
@@ -278,7 +364,7 @@ export const useMainStore = defineStore('main', {
keys: [],
recursive: [],
}
for (const doc of this.document) {
for (const doc of docs) {
if (selected.has(doc.key)) {
found.add(doc.key)
ret.keys.push(doc.key)
@@ -299,7 +385,7 @@ export const useMainStore = defineStore('main', {
const basepath = base.loc ? `${base.loc}/${base.name}` : base.name
const nremove = base.loc.length
add(base.name, basepath, base)
for (const doc of this.document) {
for (const doc of docs) {
if (doc.loc === basepath || doc.loc.startsWith(basepath) && doc.loc[basepath.length] === '/') {
const full = doc.loc ? `${doc.loc}/${doc.name}` : doc.name
const rel = full.slice(nremove)
+4 -1
View File
@@ -1,10 +1,13 @@
import { useMainStore } from '@/stores/main'
import { getDocuments } from '@/stores/documentStore'
export const exists = (path: string[]) => {
const store = useMainStore()
// Access docVersion to make this reactive
void store.docVersion
const p = path.join('/')
return store.document.some(doc => (doc.loc ? `${doc.loc}/${doc.name}` : doc.name) === p)
return getDocuments().some(doc => (doc.loc ? `${doc.loc}/${doc.name}` : doc.name) === p)
}
/** Strip file extension intelligently (handles .tar.gz, name.with.dots.pdf, etc.) */
+17 -7
View File
@@ -20,6 +20,7 @@
<script setup lang="ts">
import { watchEffect, ref, computed, watch } from 'vue'
import { useMainStore } from '@/stores/main'
import { getDocuments } from '@/stores/documentStore'
import { collator } from '@/utils'
import { sorted, sortedGrouped } from '@/utils/docsort'
import FileExplorer from '@/components/FileExplorer.vue'
@@ -50,13 +51,22 @@ const documents = computed(() => {
const query = props.query
// List the current location (no search)
if (!query) return sorted(
store.document.filter(doc => doc.loc === loc),
store.prefs.sortListing,
)
if (!query) {
// Access docVersion to make this reactive to document changes
void store.docVersion
const hidden = store.hiddenPaths
const docs = getDocuments().filter(doc => doc.loc === loc && !hidden.has(doc.loc ? `${doc.loc}/${doc.name}` : doc.name))
// Overlay ghosts for this location (excluding hidden ones)
const ghosts = store.ghosts.filter(g => g.loc === loc && !hidden.has(g.loc ? `${g.loc}/${g.name}` : g.name))
// Merge: ghosts that don't conflict with real docs
const realNames = new Set(docs.map(d => d.name))
const merged = [...docs, ...ghosts.filter(g => !realNames.has(g.name))]
return sorted(merged, store.prefs.sortListing)
}
// Search results from worker
const docs = store.searchResults
// Search results from worker (also filter hidden)
const hidden = store.hiddenPaths
const docs = store.searchResults.filter(doc => !hidden.has(doc.loc ? `${doc.loc}/${doc.name}` : doc.name))
// Custom sort override in effect? Use grouped sorting to keep folders together
const order = store.prefs.sortFiltered
@@ -71,7 +81,7 @@ watchEffect(() => {
})
// Only auto-switch gallery mode when entering a new folder or on initial file list load
watch([() => props.path.join('/'), () => store.document.length], ([path, len], [oldPath, oldLen]) => {
watch([() => props.path.join('/'), () => store.documentCount], ([path, len], [oldPath, oldLen]) => {
// React to path change or initial document load (0 → non-zero)
if (path === oldPath && oldLen !== undefined && oldLen > 0) return
store.prefs.gallery = documents.value.some(d => d.previewable)
+11 -7
View File
@@ -2,12 +2,14 @@
"""Run Vite development server for frontend and Cista backend with auto-reload.
Usage:
uv run scripts/devserver.py [frontend] [--backend backend]
uv run scripts/devserver.py [frontend] [--backend backend] [cista_args...]
Options:
frontend Vite frontend endpoint (default: localhost:8989)
--backend Cista backend endpoint (default: from config, or :8999)
Any additional arguments are passed to the cista command.
Environment:
JS_RUNTIME Path or name of JS runtime to use (deno, npm/node or bun).
"""
@@ -29,7 +31,7 @@ from cista.serve import parse_listen
DEFAULT_BACKEND_PORT = 8999
def setup_sanic_backend(listen: str | None) -> tuple[str, list[str]]:
def setup_sanic_backend(listen: str | None, extra_args: list[str]) -> tuple[str, list[str]]:
"""Parse backend listen address and build cista dev command.
Returns (url, cmd).
@@ -40,11 +42,11 @@ def setup_sanic_backend(listen: str | None) -> tuple[str, list[str]]:
port = opts.get("port", DEFAULT_BACKEND_PORT)
host = opts.get("host", "localhost") or "localhost"
cmd = ["cista", "--dev", "-l", listen]
cmd = ["cista", "--dev", "-l", listen] + extra_args
return f"http://{host}:{port}", cmd
async def run_devserver(frontend: str | None, backend: str | None) -> None:
async def run_devserver(frontend: str | None, backend: str | None, extra_args: list[str]) -> None:
reporoot = Path(__file__).parent.parent
front = reporoot / "frontend"
if not (front / "package.json").exists():
@@ -52,7 +54,7 @@ async def run_devserver(frontend: str | None, backend: str | None) -> None:
raise SystemExit(1)
frontend_url, npm_install, vite = setup_vite(frontend or "")
backend_url, sanic_cmd = setup_sanic_backend(backend)
backend_url, sanic_cmd = setup_sanic_backend(backend, extra_args)
# Tell vite where to proxy API requests
os.environ["FASTAPI_VUE_BACKEND_URL"] = backend_url
@@ -89,9 +91,9 @@ def main():
metavar="host:port",
help="Cista backend endpoint (default: from config, or :8999)",
)
args = parser.parse_args()
args, unknown = parser.parse_known_args()
with contextlib.suppress(KeyboardInterrupt):
asyncio.run(run_devserver(args.frontend, args.backend))
asyncio.run(run_devserver(args.frontend, args.backend, unknown))
HELP_EPILOG = """
@@ -99,6 +101,8 @@ HELP_EPILOG = """
scripts/devserver.py 3000 # Vite on localhost:3000
scripts/devserver.py :3000 --backend 8080 # Vite on *:3000, backend on :8080
Additional arguments are passed to the cista backend command.
JS_RUNTIME environment variable can be used to select the JS runtime
"""