feat(app): Versions-Historie pro Datei im App-Datei-Manager

Step 3 vom File-Versioning-Feature (Step 1+2 lief schon in Diagnostic).
App kann jetzt:
  - pro Datei via 🕒-Button die Versions-Liste anzeigen
  - alte Versionen als '<name>@<short-hash>.<ext>' nach Downloads schreiben
  - per ⟲ eine alte Version als neue aktive setzen (non-destructive,
    macht im Backend einen 'restore:'-Commit, die bisherige Version
    bleibt in der Historie)

Drei neue RVS-Message-Type-Paare:
  file_version_list_request    / _response
  file_version_download_request / _response (base64)
  file_version_restore_request  / _response

rvs/server.js: alle sechs Typen in die ALLOWED_TYPES-Whitelist.

bridge/aria_bridge.py: handler proxen die Anfragen an diagnostic
(http://localhost:3001/api/files-versions / -version-content / -restore).
Diagnostic ist eh schon der Owner der git-Repository-Logik. Bridge
wrappt die Binary-Antwort als base64 fuer den RVS-Transport.

android/src/screens/SettingsScreen.tsx:
  - State versionsOpen/versionsList/-Loading/-Error
  - Drei rvs.onMessage-Branches fuer die neuen *_response Types
  - 🕒-Button in jeder Datei-Zeile (zwischen Auswahl-Checkbox und Mülltonne)
  - Neues Modal mit Versions-Liste (AKTIV-Badge, short-hash, subject,
    formatiertes Datum, ⬇ Download + ⟲ Restore-Button pro Eintrag)
  - Restore-Button hat Confirm-Alert
  - Bei file_version_restore_response: list refresh + file-list refresh
This commit is contained in:
2026-06-02 13:50:35 +02:00
parent 6464dbe28c
commit 20e623dc37
3 changed files with 322 additions and 0 deletions
+123
View File
@@ -2405,6 +2405,129 @@ class ARIABridge:
logger.warning("[rvs] file_delete_request: %s", e)
return
elif msg_type == "file_version_list_request":
# Versions-Historie einer Datei (App-Side Dateimanager).
# Pfad ist relativ-zu-/shared/uploads, kommt vom App-File-Manager
# der eh nur diesen flachen Bereich anzeigt. Diagnostic hat die
# git-Logik — wir proxien.
req_path = payload.get("path", "")
logger.info("[rvs] file_version_list_request: %s", req_path)
try:
qs = urllib.parse.urlencode({"path": req_path})
req = urllib.request.Request(
f"http://localhost:3001/api/files-versions?{qs}",
method="GET",
)
def _do_list():
try:
with urllib.request.urlopen(req, timeout=10) as resp:
return json.loads(resp.read().decode("utf-8", errors="ignore"))
except Exception as e:
return {"ok": False, "error": str(e)}
d = await asyncio.get_event_loop().run_in_executor(None, _do_list)
await self._send_to_rvs({
"type": "file_version_list_response",
"payload": d,
"timestamp": int(asyncio.get_event_loop().time() * 1000),
})
except Exception as e:
logger.warning("[rvs] file_version_list_request: %s", e)
return
elif msg_type == "file_version_download_request":
# Inhalt einer alten Version holen, base64 zurueck. Diagnostic
# liefert Binary, wir wrappen als base64 in der Response damit
# die App's RVS-WS damit umgehen kann.
req_path = payload.get("path", "")
req_hash = payload.get("hash", "")
req_id = payload.get("requestId", "")
logger.info("[rvs] file_version_download_request: %s @ %s",
req_path, req_hash[:7])
try:
qs = urllib.parse.urlencode({"path": req_path, "hash": req_hash})
req = urllib.request.Request(
f"http://localhost:3001/api/files-version-content?{qs}",
method="GET",
)
def _do_dl():
try:
with urllib.request.urlopen(req, timeout=30) as resp:
return resp.status, resp.read()
except urllib.error.HTTPError as e:
return e.code, e.read()
except Exception as e:
return None, str(e).encode("utf-8")
status, body = await asyncio.get_event_loop().run_in_executor(None, _do_dl)
if status == 200 and isinstance(body, (bytes, bytearray)):
await self._send_to_rvs({
"type": "file_version_download_response",
"payload": {
"ok": True,
"requestId": req_id,
"path": req_path,
"hash": req_hash,
"base64": base64.b64encode(body).decode("ascii"),
"size": len(body),
"name": (req_path.rsplit("/", 1)[-1] or "file"),
},
"timestamp": int(asyncio.get_event_loop().time() * 1000),
})
else:
err = body.decode("utf-8", "ignore") if isinstance(body, (bytes, bytearray)) else str(body)
await self._send_to_rvs({
"type": "file_version_download_response",
"payload": {
"ok": False,
"requestId": req_id,
"path": req_path,
"hash": req_hash,
"error": f"HTTP {status}: {err[:200]}",
},
"timestamp": int(asyncio.get_event_loop().time() * 1000),
})
except Exception as e:
logger.warning("[rvs] file_version_download_request: %s", e)
return
elif msg_type == "file_version_restore_request":
# Eine Version als neue aktive setzen — non-destructive
# (diagnostic schreibt den alten Inhalt + macht einen neuen Commit).
req_path = payload.get("path", "")
req_hash = payload.get("hash", "")
logger.warning("[rvs] file_version_restore_request: %s <- %s",
req_path, req_hash[:7])
try:
body_bytes = json.dumps({"path": req_path, "hash": req_hash}).encode("utf-8")
req = urllib.request.Request(
"http://localhost:3001/api/files-version-restore",
data=body_bytes,
method="POST",
headers={"Content-Type": "application/json"},
)
def _do_restore():
try:
with urllib.request.urlopen(req, timeout=15) as resp:
return resp.status, resp.read().decode("utf-8", errors="ignore")
except urllib.error.HTTPError as e:
return e.code, e.read().decode("utf-8", errors="ignore")
except Exception as e:
return None, str(e)
status, body = await asyncio.get_event_loop().run_in_executor(None, _do_restore)
try:
parsed = json.loads(body) if body else {"ok": False, "error": "leer"}
except Exception:
parsed = {"ok": False, "error": body[:200]}
if status != 200 and "ok" not in parsed:
parsed = {"ok": False, "error": f"HTTP {status}"}
await self._send_to_rvs({
"type": "file_version_restore_response",
"payload": parsed,
"timestamp": int(asyncio.get_event_loop().time() * 1000),
})
except Exception as e:
logger.warning("[rvs] file_version_restore_request: %s", e)
return
elif msg_type == "location_update":
# Live-GPS-Update von der App (nicht an Chat gekoppelt). Wird in
# /shared/state/location.json geschrieben, damit Watcher-Trigger