From c1990b3e6e196ad41379aa432bf3ccca8a65d9f0 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Sun, 20 Sep 2026 00:36:50 -0300 Subject: Flatten the 9P control tree and move it out of fs.zig The served tree loses the self/ level: /index /ctl /new /log /screen /listeners /pane//... /os, with /src only in -Dembed-sources=true builds (default off, on for esp32p4). ctl speaks the editor's own language with two lowercase verbs, look TEXT and exec TEXT, plus acme's addr verbs; the new/ factory directory becomes one clone file; cons is gone (exec Msg); name and sel are files; stats report real lengths, modes and mtimes; /log streams pane new/del/rename/save events. The tree code lives in src/ninep/ (tree, pane, ctl, addr, pty, events, screen, sources); fs.zig keeps host access, mounts, resolution and find/grep. Same engine and transports. README (fs-help.txt) and docs rewritten; tests updated and extended. Co-Authored-By: Claude Fable 5.1 --- test/fs.py | 367 +++++++++++++++++++++++++++++++++++++------------------------ 1 file changed, 222 insertions(+), 145 deletions(-) (limited to 'test/fs.py') diff --git a/test/fs.py b/test/fs.py index 2ffd5098..819a78d6 100644 --- a/test/fs.py +++ b/test/fs.py @@ -72,7 +72,7 @@ def session(binary, root, name, *options, socket_name=None, file=None, tty=False time.sleep(.005) with Client(address) as client: if network is not None: - for dial in client.read('/self/listeners').decode().splitlines(): + for dial in client.read('/listeners').decode().splitlines(): parts = dial.split('!') if parts[0] in ['tcp', 'quic']: assert len(parts) == 3 and 0 < int(parts[2]) < 65536, dial @@ -101,60 +101,136 @@ def session(binary, root, name, *options, socket_name=None, file=None, tty=False def new_pane(client, contents): - serial = int(client.read('/self/new/ctl').split()[0]) - client.write(f'/self/pane/{serial}/body', contents) + serial = int(client.read('/new').split()[0]) + if contents: + client.write(f'/pane/{serial}/body', contents) return serial def look(client, path, source=None): + """A right click on `path` from a pane (a fresh scratch by default).""" serial = source if source is not None else new_pane(client, b'') - client.write(f'/self/pane/{serial}/body', path.encode(), truncate=True) - client.write(f'/self/pane/{serial}/event', f'ML0 {len(path.encode())}\n'.encode()) - rows = client.read('/self/index').splitlines() - opened = int(rows[-1].split()[0]) - assert opened != serial, f'Look did not open {path}' - return opened + client.write(f'/pane/{serial}/ctl', f'look {path}\n'.encode()) + results = [int(row) for row in client.read('/ctl').split()] + assert len(results) == 1 and results[0] != serial, (path, results) + return results[0] def execute(client, serial, command): - text = command.encode() - client.write(f'/self/pane/{serial}/body', text, truncate=True) - client.write(f'/self/pane/{serial}/event', f'MX0 {len(text)}\n'.encode()) + """A middle click on `command` from a pane.""" + client.write(f'/pane/{serial}/ctl', f'exec {command}\n'.encode()) -def discovery(binary): +def walk_tree(client, path='/', skip=('/new', '/log', '/os', '/screen')): + """Stat every entry below `path`, as `find` does, without opening anything.""" + seen = [] + for name in client.list(path): + child = (path.rstrip('/') + '/' + name) + info = client.stat(child) + seen.append((child, info)) + if info['dir'] and child not in skip: + seen.extend(walk_tree(client, child, skip)) + return seen + + +def discovery(binary, embedded=False): with tempfile.TemporaryDirectory(prefix='pardes-discovery-') as directory: - with session(binary, Path(directory), 'discovery') as (client, _): - before = client.read('/self/index') - entries = client.list('/self/new') - assert {'README', 'ctl', 'body', 'addr', 'data', 'event'} <= set(entries) - guide = client.read('/self/README') - assert guide == client.read('/self/new/README') - assert b'Each open creates a separate pane' in guide - # Walk and stat each entry, as a filesystem browser does, without open. - from ninep import string - for name in entries: - client.fid += 1 - fid = client.fid - client.rpc(110, struct.pack(' 0, (path, info) + assert client.read('/index') == before, 'browsing created a pane' + assert client.stat('/index')['length'] == len(before) > 0 + index = client.stat('/index') + assert index['mode'] == 0o444 and client.stat('/ctl')['mode'] == 0o666 + assert client.stat('/pane/1/body')['mode'] == 0o666 and client.stat('/pane')['mode'] == 0o755 + fixture = int(before.split()[0]) + assert before.split()[1:3] == [b'text', b'0'] and before.endswith(b'/discovery.txt\n'), before + # Before any command, /ctl reports the editor. + status = dict(line.split(maxsplit=1) for line in client.read('/ctl').decode().splitlines()) + assert int(status['pid']) > 0 and status['version'] and int(status['panes']) == 1, status + # /new: one pane per open, its serial is the file's content. + log = client.open('/log') + first = int(client.read('/new').split()[0]) + second = int(client.read('/new').split()[0]) + assert first != second and first != fixture, (first, second) + assert client.read_fid(log) == f'new {first} {root}/+New\n'.encode() + assert client.read_fid(log) == f'new {second} {root}/+New\n'.encode() + assert set(client.list('/pane')) == {str(fixture), str(first), str(second)} + client.write(f'/pane/{first}/body', b'first pane', truncate=True) + assert client.read(f'/pane/{first}/body') == b'first pane' + assert client.read(f'/pane/{second}/body') == b'' + assert client.stat(f'/pane/{first}/body')['length'] == len(b'first pane') + rows = {int(row.split()[0]): row.split() for row in client.read('/index').splitlines()} + assert rows[first][1:3] == [b'text', b'1'] and rows[second][1:3] == [b'text', b'0'], rows + # name: read the file name, write to rename; sel: the selection. + assert client.read(f'/pane/{first}/name') == f'{root}/+New\n'.encode() + client.write(f'/pane/{first}/name', b'notes.txt\n') + assert client.read(f'/pane/{first}/name') == f'{root}/notes.txt\n'.encode() + assert client.read_fid(log) == f'rename {first} {root}/notes.txt\n'.encode() + assert client.stat(f'/pane/{first}/name')['length'] == len(f'{root}/notes.txt\n') + client.write(f'/pane/{first}/addr', b'#0,#5') + client.write(f'/pane/{first}/ctl', b'dot=addr\n') + assert client.read(f'/pane/{first}/sel') == b'first' + assert client.stat(f'/pane/{first}/sel')['length'] == 5 + client.write(f'/pane/{first}/sel', b'FIRST') + assert client.read(f'/pane/{first}/body') == b'FIRST pane' + assert client.read(f'/pane/{first}/sel') == b'' + # exec Msg shows text; exec Save writes the named file; exec Del closes. + client.write(f'/pane/{first}/ctl', b'exec Msg hello from 9P\n') + assert 'hello from 9P' in ''.join(cell[0] for cell in client.screen()['cells']) + client.write(f'/pane/{first}/ctl', b'exec Save\n') + assert (root / 'notes.txt').read_bytes() == b'FIRST pane' + assert client.read_fid(log) == f'save {first} {root}/notes.txt\n'.encode() + assert client.read('/ctl') == f'{first}\n'.encode() + client.write('/ctl', b'exec Del\n') + assert client.read_fid(log) == f'del {second} {root}/+New\n'.encode() + assert str(second) not in client.list('/pane') + # look through /ctl opens a file; the serial reads back. + (root / 'opened.txt').write_bytes(b'one\ntwo\nthree\n') + client.write('/ctl', f'look {root}/opened.txt:2\n'.encode()) + opened = int(client.read('/ctl')) + assert opened not in (fixture, first, second), opened + assert client.read(f'/pane/{opened}/body') == b'one\ntwo\nthree\n' + assert client.read(f'/pane/{opened}/name') == f'{root}/opened.txt\n'.encode() + assert client.read_fid(log) == f'new {opened} {root}/opened.txt\n'.encode() + client.write(f'/pane/{first}/ctl', f'look {root}/opened.txt:3\n'.encode()) + assert client.read('/ctl') == f'{opened}\n'.encode(), 'a repeated look targets the open pane' + assert set(client.list('/pane')) == {str(fixture), str(first), str(opened)} + # Writing an event record back still performs the action it names. + client.write(f'/pane/{first}/body', b'Msg via event', truncate=True) + client.write(f'/pane/{first}/event', b'FX0 13\n') + assert 'via event' in ''.join(cell[0] for cell in client.screen()['cells']) + for bad in [b'Look x\n', b'Exec Msg x\n', b'put\n', b'name x\n', b'del\n']: try: - client.rpc(124, struct.pack(' [--quic | --discovery]') -- cgit v1.3