Compare commits

..

5 Commits

5 changed files with 195 additions and 48 deletions

View File

@ -120,6 +120,9 @@ class FileEntry(msgspec.Struct, array_like=True):
size: int
isfile: int
def __repr__(self):
return self.key or "FileEntry()"
class Update(msgspec.Struct, array_like=True):
...
@ -137,6 +140,10 @@ class UpdIns(Update, tag="i"):
items: list[FileEntry]
class UpdateMessage(msgspec.Struct):
update: list[UpdKeep | UpdDel | UpdIns]
class Space(msgspec.Struct):
disk: int
free: int

View File

@ -50,37 +50,42 @@ class State:
begin, end = 0, len(self._listing)
level = 0
isfile = 0
while level < len(relpath.parts):
# Enter a subdirectory
level += 1
# Special case for root
if not relpath.parts:
return slice(begin, end)
begin += 1
for part in relpath.parts:
level += 1
found = False
while begin < end:
entry = self._listing[begin]
if entry.level < level:
break
if entry.level == level:
if entry.name == part:
found = True
if level == len(relpath.parts):
isfile = relfile
name = relpath.parts[level - 1]
namesort = sortkey(name)
r = self._listing[begin]
assert r.level == level
# Iterate over items at this level
while (
begin < end
and r.name != name
and r.isfile <= isfile
and sortkey(r.name) < namesort
):
# Skip contents
else:
begin += 1
while begin < end and self._listing[begin].level > level:
break
cmp = entry.isfile - isfile or sortkey(entry.name) > sortkey(part)
if cmp > 0:
break
begin += 1
# Not found?
if begin == end or self._listing[begin].level < level:
if not found:
return slice(begin, begin)
r = self._listing[begin]
# Not found?
if begin == end or r.name != name:
return slice(begin, begin)
# Found an item, now find its end
for end in range(begin + 1, len(self._listing)):
if self._listing[end].level <= level:
# Found the starting point, now find the end of the slice
for end in range(begin + 1, len(self._listing) + 1):
if end == len(self._listing) or self._listing[end].level <= level:
break
return slice(begin, end)
@ -148,11 +153,12 @@ def watcher_thread(loop):
rootpath = config.config.path
i = inotify.adapters.InotifyTree(rootpath.as_posix())
# Initialize the tree from filesystem
old, new = state.root, walk()
if old != new:
new = walk()
with state.lock:
old = state.root
if old != new:
state.root = new
broadcast(format_root(new), loop)
broadcast(format_update(old, new), loop)
# The watching is not entirely reliable, so do a full refresh every minute
refreshdl = time.monotonic() + 60.0
@ -190,10 +196,10 @@ def watcher_thread_poll(loop):
while not quit:
rootpath = config.config.path
old = state.root
new = walk()
if old != new:
with state.lock:
old = state.root
if old != new:
state.root = new
broadcast(format_update(old, new), loop)
@ -283,13 +289,11 @@ def format_update(old, new):
del_count = 0
rest = new[nidx:]
while old[oidx] not in rest:
while oidx < len(old) and old[oidx] not in rest:
del_count += 1
oidx += 1
if del_count:
update.append(UpdDel(del_count))
oidx += 1
continue
insert_items = []
@ -333,8 +337,9 @@ async def abroadcast(msg):
async def start(app, loop):
config.load_config()
use_inotify = False and sys.platform == "linux"
app.ctx.watcher = threading.Thread(
target=watcher_thread if sys.platform == "linux" else watcher_thread_poll,
target=watcher_thread if use_inotify else watcher_thread_poll,
args=[loop],
)
app.ctx.watcher.start()

View File

@ -135,7 +135,7 @@ function handleUpdateMessage(updateData: { update: UpdateEntry[] }) {
else console.log("Unknown update action", action, arg)
}
if (oidx != tree.length)
throw Error(`Tree update out of sync, number of entries mismatch: got ${oidx}, expected ${tree.length}`)
throw Error(`Tree update out of sync, number of entries mismatch: got ${oidx}, expected ${tree.length}, new tree ${newtree.length}`)
store.updateRoot(newtree)
tree = newtree
saveSession()

View File

@ -4,7 +4,6 @@ import { defineStore } from 'pinia'
import { collator } from '@/utils'
import { logoutUser } from '@/repositories/User'
import { watchConnect } from '@/repositories/WS'
import { format } from 'path'
type FileData = { id: string; mtime: number; size: number; dir: DirectoryData }
type DirectoryData = {

136
tests/test_watching.py Normal file
View File

@ -0,0 +1,136 @@
from pathlib import PurePosixPath
import msgspec
import pytest
from cista.protocol import FileEntry, UpdateMessage, UpdDel, UpdIns, UpdKeep
from cista.watching import State, format_update
def decode(data: str):
return msgspec.json.decode(data, type=UpdateMessage).update
# 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)]
def test_identical_lists():
old_list = f(3)
new_list = old_list.copy()
expected = [UpdKeep(3)]
assert decode(format_update(old_list, new_list)) == expected
def test_completely_different_lists():
old_list = f(3)
new_list = f(3, 3) # Different entries
expected = [UpdDel(3), UpdIns(new_list)]
assert decode(format_update(old_list, new_list)) == expected
def test_insertions():
old_list = f(3)
new_list = old_list[:2] + f(1, 10) + old_list[2:]
expected = [UpdKeep(2), UpdIns(f(1, 10)), UpdKeep(1)]
assert decode(format_update(old_list, new_list)) == expected
def test_deletions():
old_list = f(3)
new_list = [old_list[0], old_list[2]]
expected = [UpdKeep(1), UpdDel(1), UpdKeep(1)]
assert decode(format_update(old_list, new_list)) == expected
def test_mixed_operations():
old_list = f(4)
new_list = [old_list[0], old_list[2], *f(1, 10)]
expected = [UpdKeep(1), UpdDel(1), UpdKeep(1), UpdDel(1), UpdIns(f(1, 10))]
assert decode(format_update(old_list, new_list)) == expected
def test_empty_old_list():
old_list = []
new_list = f(3)
expected = [UpdIns(new_list)]
assert decode(format_update(old_list, new_list)) == expected
def test_empty_new_list():
old_list = f(3)
new_list = []
expected = [UpdDel(3)]
assert decode(format_update(old_list, new_list)) == expected
def test_longer_lists():
old_list = f(6)
new_list = f(1, 6) + old_list[1:3] + old_list[4:5] + f(2, 7)
expected = [
UpdDel(1),
UpdIns(f(1, 6)),
UpdKeep(2),
UpdDel(1),
UpdKeep(1),
UpdDel(1),
UpdIns(f(2, 7)),
]
assert decode(format_update(old_list, new_list)) == expected
def sortkey(name):
# Define the sorting key for names here
return name.lower()
@pytest.fixture()
def state():
entries = [
FileEntry(0, "", "root", 0, 0, 0),
FileEntry(1, "bar", "bar", 0, 0, 0),
FileEntry(2, "baz", "bar/baz", 0, 0, 0),
FileEntry(1, "foo", "foo", 0, 0, 0),
FileEntry(1, "xxx", "xxx", 0, 0, 0),
FileEntry(2, "yyy", "xxx/yyy", 0, 0, 1),
]
s = State()
s._listing = entries
return s
def test_existing_directory(state):
path = PurePosixPath("bar")
expected_slice = slice(1, 3) # Includes 'bar' and 'baz'
assert state._slice(path) == expected_slice
def test_existing_file(state):
path = PurePosixPath("xxx/yyy")
expected_slice = slice(5, 6) # Only includes 'yyy'
assert state._slice(path) == expected_slice
def test_nonexistent_directory(state):
path = PurePosixPath("zzz")
expected_slice = slice(6, 6) # 'zzz' would be inserted at end
assert state._slice(path) == expected_slice
def test_nonexistent_file(state):
path = (PurePosixPath("bar/mmm"), 1)
expected_slice = slice(3, 3) # A file would be inserted after 'baz' under 'bar'
assert state._slice(path) == expected_slice
def test_root_directory(state):
path = PurePosixPath()
expected_slice = slice(0, 6) # Entire tree
assert state._slice(path) == expected_slice
def test_directory_with_subdirs_and_files(state):
path = PurePosixPath("xxx")
expected_slice = slice(4, 6) # Includes 'xxx' and 'yyy'
assert state._slice(path) == expected_slice