Fix free/used disk space handling. Implement file disk usage tracking. Display indicators on FileExplorer for incomplete files (sparse allocation, upload in progress).
This commit is contained in:
+3
-1
@@ -146,6 +146,7 @@ class FileEntry(msgspec.Struct, array_like=True, frozen=True):
|
||||
key: str
|
||||
mtime: int
|
||||
size: int
|
||||
allocated: int
|
||||
isfile: int
|
||||
|
||||
def __str__(self):
|
||||
@@ -177,5 +178,6 @@ class UpdateMessage(msgspec.Struct):
|
||||
class Space(msgspec.Struct):
|
||||
disk: int
|
||||
free: int
|
||||
usage: int
|
||||
used: int
|
||||
storage: int
|
||||
allocated: int
|
||||
|
||||
+72
-3
@@ -24,7 +24,7 @@ sortkey = natsort_keygen(alg=ns.LOCALE)
|
||||
class State:
|
||||
def __init__(self):
|
||||
self.lock = threading.RLock()
|
||||
self._space = Space(0, 0, 0, 0)
|
||||
self._space = Space(0, 0, 0, 0, 0)
|
||||
self.root: list[FileEntry] = []
|
||||
|
||||
@property
|
||||
@@ -148,12 +148,15 @@ def walk(rel: PurePosixPath, stat: stat_result | None = None) -> list[FileEntry]
|
||||
try:
|
||||
st = stat or path.stat()
|
||||
isfile = int(not S_ISDIR(st.st_mode))
|
||||
# st_blocks is in 512-byte units
|
||||
allocated = st.st_blocks * 512 if isfile else 0
|
||||
entry = FileEntry(
|
||||
level=len(rel.parts),
|
||||
name=rel.name,
|
||||
key=fuid(st),
|
||||
mtime=int(st.st_mtime),
|
||||
size=st.st_size if isfile else 0,
|
||||
allocated=allocated,
|
||||
isfile=isfile,
|
||||
)
|
||||
if isfile:
|
||||
@@ -181,8 +184,9 @@ def walk(rel: PurePosixPath, stat: stat_result | None = None) -> list[FileEntry]
|
||||
level=entry.level,
|
||||
name=entry.name,
|
||||
key=entry.key,
|
||||
size=entry.size + child.size,
|
||||
mtime=max(entry.mtime, child.mtime),
|
||||
size=entry.size + child.size,
|
||||
allocated=entry.allocated + child.allocated,
|
||||
isfile=entry.isfile,
|
||||
)
|
||||
ret.extend(sub)
|
||||
@@ -227,7 +231,14 @@ def update_path(rootmod: list[FileEntry], relpath: PurePosixPath, loop):
|
||||
def update_space(loop):
|
||||
"""Called periodically to update the disk usage."""
|
||||
du = shutil.disk_usage(rootpath)
|
||||
space = Space(*du, storage=state.root[0].size)
|
||||
root = state.root[0]
|
||||
space = Space(
|
||||
disk=du.total,
|
||||
free=du.free,
|
||||
used=du.used,
|
||||
storage=root.size,
|
||||
allocated=root.allocated,
|
||||
)
|
||||
# Update only on difference above 1 MB
|
||||
tol = 10**6
|
||||
old = msgspec.structs.astuple(state.space)
|
||||
@@ -504,8 +515,66 @@ class PathIndex:
|
||||
|
||||
self.root = new_root
|
||||
self._rebuild()
|
||||
|
||||
# Recalculate sizes for ancestor folders (including root)
|
||||
self._recalculate_ancestors(path)
|
||||
|
||||
return new_root
|
||||
|
||||
def _recalculate_ancestors(self, path: PurePosixPath):
|
||||
"""Recalculate size/allocated for all ancestors of path, including root."""
|
||||
# Build list of ancestors from deepest to root
|
||||
ancestors = []
|
||||
current = path.parent if path.parts else PurePosixPath()
|
||||
while True:
|
||||
ancestors.append(current)
|
||||
if not current.parts:
|
||||
break
|
||||
current = current.parent
|
||||
|
||||
# Process from deepest ancestor to root
|
||||
for ancestor_path in ancestors:
|
||||
if ancestor_path not in self._index:
|
||||
continue
|
||||
start, count = self._index[ancestor_path]
|
||||
if count == 0:
|
||||
continue
|
||||
|
||||
ancestor = self.root[start]
|
||||
if ancestor.isfile:
|
||||
continue # Files don't aggregate
|
||||
|
||||
# Sum size/allocated of direct children
|
||||
total_size = 0
|
||||
total_allocated = 0
|
||||
i = start + 1
|
||||
while i < start + count:
|
||||
child = self.root[i]
|
||||
if child.level == ancestor.level + 1:
|
||||
total_size += child.size
|
||||
total_allocated += child.allocated
|
||||
# Skip child's subtree
|
||||
child_path = ancestor_path / child.name
|
||||
if child_path in self._index:
|
||||
_, child_count = self._index[child_path]
|
||||
i += child_count
|
||||
else:
|
||||
i += 1
|
||||
else:
|
||||
i += 1
|
||||
|
||||
# Update ancestor entry if changed
|
||||
if ancestor.size != total_size or ancestor.allocated != total_allocated:
|
||||
self.root[start] = FileEntry(
|
||||
level=ancestor.level,
|
||||
name=ancestor.name,
|
||||
key=ancestor.key,
|
||||
mtime=ancestor.mtime,
|
||||
size=total_size,
|
||||
allocated=total_allocated,
|
||||
isfile=ancestor.isfile,
|
||||
)
|
||||
|
||||
|
||||
def collapse_paths(paths: set[PurePosixPath]) -> set[PurePosixPath]:
|
||||
"""Remove child paths if parent is in set."""
|
||||
|
||||
@@ -36,9 +36,9 @@
|
||||
</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="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.allocated, 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>
|
||||
<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.used - store.space.allocated, sectorInfo.other.angle) }}</text>
|
||||
|
||||
<defs>
|
||||
<path :id="storageLabelPath.id" :d="storageLabelPath.d" fill="none" />
|
||||
@@ -113,7 +113,7 @@ const CIRC = TAU * midRadius
|
||||
const pieStorageDash = computed(() => {
|
||||
const s = store.space
|
||||
if (!s.disk) return `0 ${CIRC}`
|
||||
return `${(s.storage / s.disk) * CIRC} ${CIRC}`
|
||||
return `${(s.allocated / s.disk) * CIRC} ${CIRC}`
|
||||
})
|
||||
|
||||
const pieFreeDash = computed(() => {
|
||||
@@ -125,7 +125,7 @@ const pieFreeDash = computed(() => {
|
||||
const pieFreeOffsetVal = computed(() => {
|
||||
const s = store.space
|
||||
if (!s.disk) return 0
|
||||
return -(s.storage / s.disk) * CIRC
|
||||
return -(s.allocated / s.disk) * CIRC
|
||||
})
|
||||
|
||||
const freeColor = computed(() => {
|
||||
@@ -153,9 +153,9 @@ const sectorInfo = computed(() => {
|
||||
other: { angle: 270, pct: 0.25 }
|
||||
}
|
||||
|
||||
const storagePct = s.storage / s.disk
|
||||
const storagePct = s.allocated / s.disk
|
||||
const freePct = s.free / s.disk
|
||||
const otherPct = (s.usage - s.storage) / s.disk
|
||||
const otherPct = (s.used - s.allocated) / s.disk
|
||||
|
||||
const storageAngle = storagePct * 180 // midpoint of storage sector
|
||||
const freeStart = storagePct * 360
|
||||
|
||||
@@ -124,6 +124,7 @@ defineExpose({
|
||||
dir: true,
|
||||
mtime: now,
|
||||
size: 0,
|
||||
allocated: 0,
|
||||
})
|
||||
store.cursor = editing.value.key
|
||||
},
|
||||
|
||||
@@ -1,22 +1,44 @@
|
||||
<template>
|
||||
<td class="size right" :class=sizeClass>{{ doc.sizedisp }}</td>
|
||||
<td
|
||||
class="size right"
|
||||
:class="sizeClass"
|
||||
@mouseenter="doc.sparseIndicator && tooltip?.startHover($event)"
|
||||
@mousemove="doc.sparseIndicator && tooltip?.updatePosition($event)"
|
||||
@mouseleave="doc.sparseIndicator && tooltip?.endHover()"
|
||||
>
|
||||
<SparseIndicator :doc="doc" class="before-size" />{{ doc.sizedisp }}
|
||||
<CursorTooltip v-if="doc.sparseIndicator" ref="tooltip" :text="tooltipText">{{ tooltipText }}</CursorTooltip>
|
||||
</td>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Doc } from '@/repositories/Document'
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { formatSize } from '@/utils'
|
||||
import SparseIndicator from './SparseIndicator.vue'
|
||||
import CursorTooltip from './CursorTooltip.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
doc: Doc
|
||||
}>()
|
||||
|
||||
const tooltip = ref<InstanceType<typeof CursorTooltip> | null>(null)
|
||||
|
||||
const sizeClass = computed(() => {
|
||||
const unit = props.doc.sizedisp.split('\u202F').slice(-1)[0]!
|
||||
return +unit ? "bytes" : unit
|
||||
})
|
||||
|
||||
const props = defineProps<{
|
||||
doc: Doc
|
||||
}>()
|
||||
const tooltipText = computed(() => {
|
||||
const { size, allocated } = props.doc
|
||||
return `${formatSize(allocated)} allocated of ${formatSize(size)}`
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.before-size {
|
||||
margin-right: 0.2em;
|
||||
}
|
||||
.size.empty { color: #555 }
|
||||
.size.bytes { color: #77a }
|
||||
.size.kB { color: #474 }
|
||||
|
||||
@@ -67,6 +67,7 @@ defineExpose({
|
||||
dir: true,
|
||||
mtime: now,
|
||||
size: 0,
|
||||
allocated: 0,
|
||||
})
|
||||
store.cursor = editing.value.key
|
||||
},
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
</template>
|
||||
<template v-else>
|
||||
<SelectBox :doc=doc @click="store.cursor = doc.key"/>
|
||||
<span>{{ doc.name }}</span>
|
||||
<span>{{ doc.name }}<SparseIndicator :doc="doc" class="after-name" /></span>
|
||||
<div class=namespacer></div>
|
||||
</template>
|
||||
</figcaption>
|
||||
@@ -26,6 +26,7 @@
|
||||
<CursorTooltip ref="tooltip" :text="tooltipText">
|
||||
<div class="tooltip-name">{{ doc.name }}</div>
|
||||
<div class="tooltip-details">{{ doc.modified }} — {{ doc.sizedisp }}</div>
|
||||
<div v-if="doc.sparseIndicator" class="tooltip-sparse">{{ sparseText }}</div>
|
||||
</CursorTooltip>
|
||||
</a>
|
||||
</template>
|
||||
@@ -34,8 +35,10 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import { Doc } from '@/repositories/Document'
|
||||
import { formatSize } from '@/utils'
|
||||
import MediaPreview from '@/components/MediaPreview.vue'
|
||||
import CursorTooltip from './CursorTooltip.vue'
|
||||
import SparseIndicator from './SparseIndicator.vue'
|
||||
|
||||
const store = useMainStore()
|
||||
type EditingProp = {
|
||||
@@ -52,6 +55,11 @@ const tooltip = ref<InstanceType<typeof CursorTooltip> | null>(null)
|
||||
|
||||
const tooltipText = computed(() => props.doc.key)
|
||||
|
||||
const sparseText = computed(() => {
|
||||
const { size, allocated } = props.doc
|
||||
return `${formatSize(allocated)} allocated of ${formatSize(size)}`
|
||||
})
|
||||
|
||||
const onclick = (ev: Event) => {
|
||||
if (m.value!.play()) ev.preventDefault()
|
||||
store.cursor = props.doc.key
|
||||
@@ -66,6 +74,13 @@ const onclick = (ev: Event) => {
|
||||
.tooltip-details {
|
||||
text-align: center;
|
||||
}
|
||||
.tooltip-sparse {
|
||||
text-align: center;
|
||||
opacity: 0.8;
|
||||
}
|
||||
.after-name {
|
||||
margin-left: 0.3em;
|
||||
}
|
||||
figure {
|
||||
max-height: 15em;
|
||||
position: relative;
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
<template>
|
||||
<span v-if="doc.sparseIndicator" class="sparse-indicator">{{ doc.sparseIndicator }}</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Doc } from '@/repositories/Document'
|
||||
|
||||
defineProps<{
|
||||
doc: Doc
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.sparse-indicator {
|
||||
opacity: 0.7;
|
||||
}
|
||||
</style>
|
||||
@@ -115,13 +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.addGhost(new Doc({ loc: parts.slice(0, i).join('/'), name: parts[i], key: crypto.randomUUID(), size: 0, mtime: now, dir: true }))
|
||||
store.addGhost(new Doc({ loc: parts.slice(0, i).join('/'), name: parts[i], key: crypto.randomUUID(), size: 0, allocated: 0, mtime: now, dir: true }))
|
||||
added.add(folderPath)
|
||||
}
|
||||
}
|
||||
// Ghost file or update existing (overwrite case doesn't need ghost, file already visible)
|
||||
const existing = byPath.get(f.cloudName)
|
||||
if (!existing) store.addGhost(new Doc({ loc, name, key: crypto.randomUUID(), size: f.file.size, mtime: now, dir: false }))
|
||||
if (!existing) store.addGhost(new Doc({ loc, name, key: crypto.randomUUID(), size: f.file.size, allocated: 0, mtime: now, dir: false }))
|
||||
}
|
||||
// @ts-ignore
|
||||
upqueue = [...upqueue, ...files]
|
||||
|
||||
@@ -7,6 +7,7 @@ export type DocProps = {
|
||||
name: string
|
||||
key: FUID
|
||||
size: number
|
||||
allocated: number
|
||||
mtime: number
|
||||
dir: boolean
|
||||
ghost?: boolean
|
||||
@@ -17,6 +18,7 @@ export class Doc {
|
||||
public loc: string = ""
|
||||
public key: FUID = ""
|
||||
public size: number = 0
|
||||
public allocated: number = 0
|
||||
public mtime: number = 0
|
||||
public dir: boolean = false
|
||||
public ghost: boolean = false
|
||||
@@ -35,6 +37,15 @@ export class Doc {
|
||||
this._name = name
|
||||
}
|
||||
get sizedisp(): string { return formatSize(this.size) }
|
||||
/** Returns a sparse allocation indicator symbol, or empty string if fully allocated */
|
||||
get sparseIndicator(): string {
|
||||
if (this.dir || this.size <= this.allocated) return ''
|
||||
if (this.allocated === 0) return '⭕' // exactly zero
|
||||
const ratio = this.allocated / this.size
|
||||
// Round to nearest 25%: ◔◑◕⬤
|
||||
const rounded = Math.round(ratio * 4) // 0,1,2,3,4
|
||||
return ['◔', '◔', '◑', '◕', '⬤'][rounded]! // 0 maps to ◔ since we handled exact 0 above
|
||||
}
|
||||
get modified(): string { return formatUnixDate(this.mtime) }
|
||||
get url(): string {
|
||||
const p = this.loc ? `${this.loc}/${this.name}` : this.name
|
||||
@@ -78,9 +89,10 @@ export type FileEntry = [
|
||||
number, // level
|
||||
string, // name
|
||||
FUID,
|
||||
number, //mtime
|
||||
number, // size
|
||||
number, // isfile
|
||||
number, // mtime
|
||||
number, // size
|
||||
number, // allocated (actual disk usage)
|
||||
number, // isfile
|
||||
]
|
||||
|
||||
export type UpdateEntry = ['k', number] | ['d', number] | ['i', Array<FileEntry>]
|
||||
|
||||
@@ -96,8 +96,9 @@ export const useMainStore = defineStore('main', {
|
||||
space: {
|
||||
disk: 0,
|
||||
free: 0,
|
||||
usage: 0,
|
||||
used: 0,
|
||||
storage: 0,
|
||||
allocated: 0,
|
||||
}
|
||||
}),
|
||||
persist: {
|
||||
@@ -118,13 +119,14 @@ export const useMainStore = defineStore('main', {
|
||||
updateRoot(root: FileEntry[]) {
|
||||
const docs = []
|
||||
let loc = [] as string[]
|
||||
for (const [level, name, key, mtime, size, isfile] of root) {
|
||||
for (const [level, name, key, mtime, size, allocated, isfile] of root) {
|
||||
loc = loc.slice(0, level - 1)
|
||||
docs.push(new Doc({
|
||||
name,
|
||||
loc: level ? loc.join('/') : '/',
|
||||
key,
|
||||
size,
|
||||
allocated,
|
||||
mtime,
|
||||
dir: !isfile,
|
||||
}))
|
||||
|
||||
@@ -10,7 +10,9 @@ def decode(data: str):
|
||||
|
||||
# Helper function to create a list of FileEntry objects
|
||||
def f(count, start=0):
|
||||
return [FileEntry(i, str(i), str(i), 0, 0, 0) for i in range(start, start + count)]
|
||||
return [
|
||||
FileEntry(i, str(i), str(i), 0, 0, 0, 0) for i in range(start, start + count)
|
||||
]
|
||||
|
||||
|
||||
def test_identical_lists():
|
||||
@@ -35,8 +37,8 @@ def test_insertions():
|
||||
|
||||
|
||||
def test_insertion_at_end():
|
||||
old_list = [*f(3), FileEntry(1, "xxx", "xxx", 0, 0, 1)]
|
||||
newfile = FileEntry(1, "yyy", "yyy", 0, 0, 1)
|
||||
old_list = [*f(3), FileEntry(1, "xxx", "xxx", 0, 0, 0, 1)]
|
||||
newfile = FileEntry(1, "yyy", "yyy", 0, 0, 0, 1)
|
||||
new_list = [*old_list, newfile]
|
||||
expected = [UpdKeep(4), UpdIns([newfile])]
|
||||
assert decode(format_update(old_list, new_list)) == expected
|
||||
|
||||
Reference in New Issue
Block a user