A guest sees the conversations they started or were shared (list, detail, summary, search index, subagents, raw meta, diffs), the forms, asks and pushes of those, and the projects they were granted. Their ui-state lives under u.<id>.<key>; the SSE hub filters per subscriber. Forms record answeredBy. Pushes from a guest's session skip the owner's webhooks and go to the guest's channels (in-app, email). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
171 lines
7.2 KiB
Python
171 lines
7.2 KiB
Python
"""The diff view: a conversation's commits, their diffs and the uncommitted changes."""
|
||
|
||
import pathlib
|
||
import threading
|
||
|
||
from fastapi import APIRouter, HTTPException
|
||
from fastapi.responses import Response
|
||
|
||
import schemas
|
||
from conversations.catalog import _resolve_transcript, _summary_and_meta
|
||
from core.http import _r
|
||
from core.state import State
|
||
from diff import editsdiff, gitdiff, worktreediff
|
||
from users import access
|
||
from users.caller import Caller, CallerDep
|
||
|
||
router = APIRouter()
|
||
|
||
|
||
def _repo_allowed(caller: Caller, repo: str) -> None:
|
||
"""A guest reads diffs of their projects' repos only (``project:<slug>``)."""
|
||
if caller.is_owner:
|
||
return
|
||
kind, _, slug = (repo or "").partition(":")
|
||
if kind != "project" or not caller.can(slug, "view"):
|
||
raise HTTPException(404, "commit not found")
|
||
|
||
|
||
@router.get("/api/conversation-commits", responses=_r(schemas.ConversationCommits))
|
||
def conversation_commits(deps: State, caller: CallerDep, id: str):
|
||
"""The commits a conversation produced (cached), tagged with their repo."""
|
||
access.ensure_visible(deps, caller, id)
|
||
sid, summary, meta = _summary_and_meta(deps, id)
|
||
data = gitdiff.conversation_commits(deps.store, summary, meta, sid)
|
||
return {"id": id, **data}
|
||
|
||
|
||
@router.get("/api/commit-diff", responses=_r(schemas.DiffResult))
|
||
def commit_diff(caller: CallerDep, repo: str, sha: str):
|
||
"""Structured per-file hunk diff of a single commit within a repo token."""
|
||
_repo_allowed(caller, repo)
|
||
out = gitdiff.commit_diff(repo, sha)
|
||
if out is None:
|
||
raise HTTPException(404, "commit not found")
|
||
return out
|
||
|
||
|
||
# Content-type for image blobs served in the diff view (the only kind the diff
|
||
# viewer requests). Anything else falls back to octet-stream.
|
||
_IMAGE_CT = {
|
||
".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
|
||
".gif": "image/gif", ".webp": "image/webp", ".bmp": "image/bmp",
|
||
".avif": "image/avif", ".svg": "image/svg+xml", ".ico": "image/x-icon",
|
||
}
|
||
|
||
|
||
@router.get("/api/diff-blob")
|
||
def diff_blob(caller: CallerDep, repo: str, path: str, sha: str | None = None,
|
||
wt: str | None = None):
|
||
"""Raw bytes of a file inside a diff — the image side rendered in the viewer.
|
||
|
||
``repo`` is a diff repo token (``project:`` / ``service:`` / ``super:_``),
|
||
``path`` is repo-relative. With ``sha`` the file is read at that rev (e.g. the
|
||
commit's tip for an added image, or ``<sha>^`` for a deleted one); without it
|
||
the current working-tree copy is served (conversation / uncommitted diffs).
|
||
``wt`` names a worktree directory — the file is then read from that worktree
|
||
instead of the canonical checkout (see worktreediff)."""
|
||
_repo_allowed(caller, repo)
|
||
if wt and not caller.is_owner:
|
||
raise HTTPException(404, "blob not found")
|
||
data = (worktreediff.blob_bytes(wt, path) if wt
|
||
else gitdiff.blob_bytes(repo, path, sha=sha))
|
||
if data is None:
|
||
raise HTTPException(404, "blob not found")
|
||
ext = pathlib.PurePosixPath(path).suffix.lower()
|
||
ct = _IMAGE_CT.get(ext, "application/octet-stream")
|
||
# SVG can carry scripts; serving it inline as image/svg+xml is fine for <img>
|
||
# but nosniff + no inline-disposition keeps it from being treated as a page.
|
||
return Response(content=data, media_type=ct,
|
||
headers={"X-Content-Type-Options": "nosniff",
|
||
"Cache-Control": "public, max-age=3600"})
|
||
|
||
|
||
# Replaying a transcript's edits re-reads the whole file; during a live run the
|
||
# panel refetches on every SSE ping, so cache per (path, mtime, size, commits).
|
||
_udiff_cache: dict[str, tuple[tuple, dict]] = {}
|
||
_UDIFF_CACHE_CAP = 8
|
||
_udiff_lock = threading.Lock()
|
||
|
||
|
||
@router.get("/api/conversation-uncommitted-diff", responses=_r(schemas.DiffResult))
|
||
def conversation_uncommitted_diff(deps: State, caller: CallerDep, id: str,
|
||
stats: bool = False):
|
||
"""The conversation's current not-yet-committed changes.
|
||
|
||
When it worked in a git worktree that still exists, this is a **real**
|
||
``git diff HEAD`` (+ untracked files) read from that worktree — see
|
||
worktreediff. Otherwise (or for edits made outside it) the changes are
|
||
replayed from the transcript's accumulated edits/creates/deletes (editsdiff).
|
||
``stats`` strips the hunks — the detail panel only needs paths and +/− counts.
|
||
"""
|
||
access.ensure_visible(deps, caller, id)
|
||
sid, summary, meta = _summary_and_meta(deps, id)
|
||
ap = _resolve_transcript(id)
|
||
data = gitdiff.conversation_commits(deps.store, summary, meta, sid)
|
||
wt_out = worktreediff.worktree_diff(summary, meta,
|
||
summary.get("title") or "", stats=stats)
|
||
try:
|
||
st = ap.stat()
|
||
sig = (st.st_mtime, st.st_size,
|
||
tuple(c.get("sha") for c in data["commits"]))
|
||
except OSError:
|
||
sig = None
|
||
out = None
|
||
with _udiff_lock:
|
||
hit = _udiff_cache.get(str(ap))
|
||
if hit and sig and hit[0] == sig:
|
||
out = hit[1]
|
||
if out is None:
|
||
out = editsdiff.uncommitted_diff(ap, summary.get("title") or "",
|
||
data["commits"])
|
||
if sig:
|
||
with _udiff_lock:
|
||
_udiff_cache[str(ap)] = (sig, out)
|
||
while len(_udiff_cache) > _UDIFF_CACHE_CAP:
|
||
_udiff_cache.pop(next(iter(_udiff_cache)))
|
||
if stats:
|
||
out = {**out, "files": [{**f, "hunks": []} for f in out["files"]]}
|
||
if wt_out is None:
|
||
return out
|
||
# A live worktree is authoritative for its own repo, so the replay only
|
||
# contributes what it saw *elsewhere* (e.g. a stray edit in the main
|
||
# checkout) — otherwise reverted or already-committed files would linger.
|
||
covered = {w["repo"] for w in wt_out["worktrees"]}
|
||
extra = [f for f in out["files"] if f.get("repo") not in covered]
|
||
if not extra:
|
||
return wt_out
|
||
files = wt_out["files"] + extra
|
||
wts = wt_out["worktrees"]
|
||
return {**wt_out, "files": files, "approx": True,
|
||
"additions": sum(f["additions"] for f in files),
|
||
"deletions": sum(f["deletions"] for f in files),
|
||
"subtitle": worktreediff.subtitle(wts, len(files), len(extra))}
|
||
|
||
|
||
@router.get("/api/conversation-diff", responses=_r(schemas.DiffResult))
|
||
def conversation_diff(deps: State, caller: CallerDep, id: str):
|
||
"""Aggregated net diff across all of a conversation's commits."""
|
||
access.ensure_visible(deps, caller, id)
|
||
sid, summary, meta = _summary_and_meta(deps, id)
|
||
data = gitdiff.conversation_commits(deps.store, summary, meta, sid)
|
||
by_repo: dict[str, list[str]] = {}
|
||
for c in data["commits"]:
|
||
by_repo.setdefault(c["repo"], []).append(c["sha"])
|
||
files: list[dict] = []
|
||
add = dele = 0
|
||
for token, shas in by_repo.items():
|
||
agg = gitdiff.aggregate_diff(token, shas, title="", subtitle="")
|
||
if agg:
|
||
files += agg["files"]
|
||
add += agg["additions"]
|
||
dele += agg["deletions"]
|
||
title = summary.get("title") or "Conversation changes"
|
||
n = len(data["commits"])
|
||
return {
|
||
"title": title,
|
||
"subtitle": f"{n} commit{'s' if n != 1 else ''} · {len(files)} file{'s' if len(files) != 1 else ''}",
|
||
"files": files, "additions": add, "deletions": dele,
|
||
"commits": data["commits"], "approx": True,
|
||
}
|