diff --git a/cista/protocol.py b/cista/protocol.py index 7ebd221..c04147a 100644 --- a/cista/protocol.py +++ b/cista/protocol.py @@ -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 diff --git a/cista/watching.py b/cista/watching.py index 9bdef50..b7456b5 100644 --- a/cista/watching.py +++ b/cista/watching.py @@ -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.""" diff --git a/frontend/src/components/DiskSpace.vue b/frontend/src/components/DiskSpace.vue index 429aa8a..8134db0 100644 --- a/frontend/src/components/DiskSpace.vue +++ b/frontend/src/components/DiskSpace.vue @@ -36,9 +36,9 @@ - {{ fmtSize(store.space.storage, sectorInfo.storage.angle) }} + {{ fmtSize(store.space.allocated, sectorInfo.storage.angle) }} {{ fmtSize(store.space.free, sectorInfo.free.angle) }} - {{ fmtSize(store.space.usage - store.space.storage, sectorInfo.other.angle) }} + {{ fmtSize(store.space.used - store.space.allocated, sectorInfo.other.angle) }} @@ -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 diff --git a/frontend/src/components/FileExplorer.vue b/frontend/src/components/FileExplorer.vue index ea1b5e0..b7691a9 100644 --- a/frontend/src/components/FileExplorer.vue +++ b/frontend/src/components/FileExplorer.vue @@ -124,6 +124,7 @@ defineExpose({ dir: true, mtime: now, size: 0, + allocated: 0, }) store.cursor = editing.value.key }, diff --git a/frontend/src/components/FileSize.vue b/frontend/src/components/FileSize.vue index 432d342..5375f7f 100644 --- a/frontend/src/components/FileSize.vue +++ b/frontend/src/components/FileSize.vue @@ -1,22 +1,44 @@ diff --git a/frontend/src/components/UploadButton.vue b/frontend/src/components/UploadButton.vue index 6deb4ab..68c90af 100644 --- a/frontend/src/components/UploadButton.vue +++ b/frontend/src/components/UploadButton.vue @@ -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] diff --git a/frontend/src/repositories/Document.ts b/frontend/src/repositories/Document.ts index da29d63..be5e704 100644 --- a/frontend/src/repositories/Document.ts +++ b/frontend/src/repositories/Document.ts @@ -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] diff --git a/frontend/src/stores/main.ts b/frontend/src/stores/main.ts index 4aa8bcd..5d740e9 100644 --- a/frontend/src/stores/main.ts +++ b/frontend/src/stores/main.ts @@ -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, })) diff --git a/tests/test_watching.py b/tests/test_watching.py index c358469..8034839 100644 --- a/tests/test_watching.py +++ b/tests/test_watching.py @@ -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