Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 112 additions & 2 deletions internal/live/inject.go
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,17 @@ const versionDrawerCSS = `<style>
.sth-version-loading,.sth-version-empty,.sth-version-error{font-size:13px!important;color:#999!important;padding:8px 0!important;margin:0!important}
.sth-version-error{color:#dc2626!important}
.sth-version-retry{margin-top:8px!important;padding:6px 12px!important;border:1px solid #d6d1c6!important;border-radius:6px!important;background:#f2efe6!important;cursor:pointer!important;font-size:13px!important}
.sth-version-diffbtn{margin-top:6px!important;padding:4px 10px!important;border:1px solid #d6d1c6!important;border-radius:6px!important;background:#f2efe6!important;cursor:pointer!important;font-size:12px!important;color:#444!important;font-family:inherit!important}
.sth-version-diffbtn:hover{background:#e8e2d2!important}
.sth-version-diffbtn .badge{color:#15803d!important;font-weight:600!important;margin-left:2px!important}
.sth-version-diffbtn .badge .rm{color:#b91c1c!important}
.sth-version-htmldiff{margin-top:8px!important;border:1px solid #eee!important;border-radius:6px!important;overflow:auto!important;max-height:320px!important;font-family:ui-monospace,SFMono-Regular,Menlo,monospace!important;font-size:11px!important;line-height:1.5!important;background:#fafafa!important}
.sth-version-htmldiff-empty{padding:8px 10px!important;color:#999!important;font-size:12px!important;font-family:-apple-system,"Segoe UI",sans-serif!important}
.sth-version-htmldiff .hunk{display:block!important;white-space:pre!important;padding:0 8px!important}
.sth-version-htmldiff .hunk.add{background:#dcfce7!important;color:#15803d!important}
.sth-version-htmldiff .hunk.del{background:#fee2e2!important;color:#b91c1c!important;text-decoration:line-through!important;text-decoration-color:#b91c1c66!important}
.sth-version-htmldiff .hunk.ctx{color:#999!important}
.sth-version-htmldiff .hunk.skip{color:#bbb!important;font-style:italic!important;background:#f5f5f5!important}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
</style>`

const versionDrawerHTML = `<div id="sth-version-btn" title="Version timeline" style="display:none">v?</div>
Expand Down Expand Up @@ -239,12 +250,23 @@ const versionDrawerJS = `<script>
// Newest first for the timeline UI.
var versions=data.versions.slice().sort(function(a,b){return b.versionNo-a.versionNo;});
for(var j=0;j<versions.length;j++){
html+=item(versions[j],byVersion[versions[j].versionNo]);
// Predecessor (by version_no, not array index): the largest version_no
// strictly smaller than this one among live versions. In the
// newest-first array that is the next entry, but compute it explicitly
// so soft-delete gaps (v1, v3) still link v3 -> v1.
var prevNo=0;
for(var k=0;k<versions.length;k++){
if(versions[k].versionNo<versions[j].versionNo && versions[k].versionNo>prevNo){
prevNo=versions[k].versionNo;
}
}
html+=item(versions[j],byVersion[versions[j].versionNo],prevNo);
}
html+='</div>';
content.innerHTML=html;
wireDiffButtons();
}
function item(v,diff){
function item(v,diff,prevVersionNo){
var cls=v.current?'sth-version-item sth-version-current':'sth-version-item';
var h='<div class="'+cls+'">';
h+='<a class="sth-version-link" href="/s/'+encodeURIComponent(v.sessionId)+'/">v'+v.versionNo+(v.current?' (current)':'')+'</a>';
Expand All @@ -253,9 +275,97 @@ const versionDrawerJS = `<script>
}
h+='<div class="sth-version-created">'+escapeHtml(v.createdAt||'')+'</div>';
h+=diffHtml(diff);
// HTML content diff button: only when this version has a live predecessor.
if(prevVersionNo>0){
h+='<button class="sth-version-diffbtn" data-from="'+prevVersionNo+'" data-to="'+v.versionNo+'">Show HTML diff <span class="badge"></span></button>';
h+='<div class="sth-version-htmldiff" data-target="v'+v.versionNo+'" style="display:none"></div>';
}
h+='</div>';
return h;
}
// wireDiffButtons attaches click handlers to every Show HTML diff button in
// the rendered timeline. Clicking fetches the diff once and expands it into
// the sibling .sth-version-htmldiff container; clicking again collapses it.
function wireDiffButtons(){
var buttons=content.querySelectorAll('.sth-version-diffbtn');
for(var i=0;i<buttons.length;i++){
(function(b){
b.addEventListener('click',function(){
var container=b.nextElementSibling;
if(!container||!container.classList.contains('sth-version-htmldiff')){return;}
if(container.getAttribute('data-loaded')==='1'){
// Toggle collapse/expand.
var open=container.style.display!=='none';
container.style.display=open?'none':'block';
b.firstChild.nodeValue=open?'Show HTML diff ':'Hide HTML diff ';
return;
}
container.style.display='block';
container.innerHTML='<div class="sth-version-htmldiff-empty">Diffing...</div>';
var from=b.getAttribute('data-from'),to=b.getAttribute('data-to');
fetch('/api/sessions/'+encodeURIComponent(sid)+'/diff?from='+encodeURIComponent(from)+'&to='+encodeURIComponent(to),{credentials:'same-origin'})
.then(function(r){if(!r.ok){throw new Error('HTTP '+r.status);}return r.json();})
.then(function(data){
container.setAttribute('data-loaded','1');
container.innerHTML=renderHtmlDiff(data);
b.firstChild.nodeValue='Hide HTML diff ';
var badge=b.querySelector('.badge');
if(badge){badge.innerHTML=formatBadge(data.summary);}
})
.catch(function(err){
container.innerHTML='<div class="sth-version-htmldiff-empty">Failed: '+escapeHtml(String(err))+'</div>';
});
});
})(buttons[i]);
}
}
// renderHtmlDiff turns a diffResponse into collapsed HTML: equal runs are
// elided into a single "… N unchanged …" skip line so additions/deletions
// stay visible without scrolling through hundreds of context lines.
function renderHtmlDiff(data){
if(!data||!data.lines||data.lines.length===0){
return '<div class="sth-version-htmldiff-empty">No changes.</div>';
}
// Explicit "diff skipped: input too large" signal from the server. Render
// the sentinel verbatim as a skip hunk rather than letting the generic
// equal-line collapsing logic swallow it into "N unchanged lines".
if(data.tooLarge){
return '<span class="hunk skip">'+escapeHtml(data.lines[0].text)+'</span>';
}
var out=[];
var ctxRun=0;
function flushCtx(){
if(ctxRun>0){
out.push('<span class="hunk skip"> ⋯ '+ctxRun+' unchanged line'+(ctxRun===1?'':'s')+' ⋯</span>');
ctxRun=0;
}
}
for(var i=0;i<data.lines.length;i++){
var l=data.lines[i];
if(l.kind==='equal'){
ctxRun++;
}else{
flushCtx();
if(l.kind==='add'){
out.push('<span class="hunk add">+ '+escapeHtml(l.text)+'</span>');
}else if(l.kind==='delete'){
out.push('<span class="hunk del">- '+escapeHtml(l.text)+'</span>');
}
}
}
flushCtx();
if(out.length===0){
return '<div class="sth-version-htmldiff-empty">No changes.</div>';
}
return out.join('');
}
function formatBadge(summary){
if(!summary){return '';}
var parts=[];
if(summary.added){parts.push('+'+summary.added);}
if(summary.removed){parts.push('<span class="rm">−'+summary.removed+'</span>');}
return parts.length?' '+parts.join(' '):'';
}
function diffHtml(diff){
if(!diff){return '';}
var parts=[];
Expand Down
158 changes: 158 additions & 0 deletions internal/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -740,6 +740,8 @@ func (s *Server) routes() http.Handler {
s.handleGetPeers(w, r)
case r.Method == http.MethodGet && hasPrefixSuffix(r.URL.Path, "/api/sessions/", "/chain"):
s.handleGetChain(w, r)
case r.Method == http.MethodGet && hasPrefixSuffix(r.URL.Path, "/api/sessions/", "/diff"):
s.handleGetDiff(w, r)
case r.Method == http.MethodGet && hasPrefixSuffix(r.URL.Path, "/api/sessions/", "/download"):
s.handleDownloadSession(w, r)
case r.Method == http.MethodDelete && isExactSessionPath(r.URL.Path):
Expand Down Expand Up @@ -1931,6 +1933,162 @@ func (s *Server) handleGetChain(w http.ResponseWriter, r *http.Request) {
})
}

// diffLine is one line of an HTML content diff, with a stable kind token for
// JSON consumers ("equal" / "add" / "delete") alongside the 1-based line
// numbers in each document (0 when N/A).
type diffLine struct {
Kind string `json:"kind"`
Text string `json:"text"`
OldNo int `json:"oldNo"`
NewNo int `json:"newNo"`
}

// diffResponse is the payload of GET /api/sessions/{id}/diff?from=vN&to=vM.
// Lines is the full line-level diff in reading order; Summary gives additive
// counts so the UI can render a "+a −b" hint without scanning the slice.
// TooLarge is true when either input exceeded MaxDiffLines and the diff was
// skipped: Lines then carries a single explanatory sentinel line and Summary
// is zeroed. The UI branches on TooLarge rather than sniffing the sentinel
// text, so the contract is explicit and locale-independent.
type diffResponse struct {
FromVersion int `json:"fromVersion"`
ToVersion int `json:"toVersion"`
Lines []diffLine `json:"lines"`
Summary diffSummary `json:"summary"`
TooLarge bool `json:"tooLarge"`
}

type diffSummary struct {
Added int `json:"added"`
Removed int `json:"removed"`
}

// handleGetDiff computes a line-level HTML content diff between two versions
// of the same chain. The {id} in the path identifies any session in the chain
// (used to resolve chain membership + ownership); from / to are 1-based
// version numbers of the same chain. Cross-chain or out-of-range version
// numbers yield 400; a missing or soft-deleted session yields 404.
func (s *Server) handleGetDiff(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
sessionID, ok := extractSessionIDFromMetaPath(r.URL.Path, "/api/sessions/", "/diff")
if !ok {
writeJSONError(w, http.StatusBadRequest, "Invalid session ID.")
return
}

_, found, err := s.requireSession(sessionID)
if err != nil {
writeJSONError(w, http.StatusInternalServerError, err.Error())
return
}
if !found {
writeJSONError(w, http.StatusNotFound, "Session not found.")
return
}
if !s.requireOwner(w, r, sessionID) {
return
}

fromVer, toVer, bad := parseDiffVersions(r)
if bad != "" {
writeJSONError(w, http.StatusBadRequest, bad)
return
}

// Resolve the chain via the requested session; this also enforces the
// soft-delete visibility rule (404 on deleted sessions).
_, versions, err := s.store.GetChainOfSession(sessionID)
if err != nil {
if errors.Is(err, session.ErrSessionNotFound) {
writeJSONError(w, http.StatusNotFound, "Session not found.")
return
}
writeJSONError(w, http.StatusInternalServerError, err.Error())
return
}

fromSID, ok := versionSessionID(versions, fromVer)
if !ok {
writeJSONError(w, http.StatusBadRequest, "from version not found in this chain.")
return
}
toSID, ok := versionSessionID(versions, toVer)
if !ok {
writeJSONError(w, http.StatusBadRequest, "to version not found in this chain.")
return
}
if fromSID == toSID {
writeJSONError(w, http.StatusBadRequest, "from and to versions must differ.")
return
}

result, err := s.store.DiffSessionHTML(fromSID, toSID)
if err != nil {
if errors.Is(err, session.ErrSessionNotFound) {
writeJSONError(w, http.StatusNotFound, "Session not found.")
return
}
writeJSONError(w, http.StatusInternalServerError, err.Error())
return
}

lines := make([]diffLine, 0, len(result.Ops))
for _, op := range result.Ops {
lines = append(lines, diffLine{
Kind: op.KindString(),
Text: op.Text,
OldNo: op.OldNo,
NewNo: op.NewNo,
})
}
summary := session.Summarize(result.Ops)

writeJSON(w, http.StatusOK, diffResponse{
FromVersion: fromVer,
ToVersion: toVer,
Lines: lines,
Summary: diffSummary{
Added: summary.Added,
Removed: summary.Removed,
},
// TooLarge is propagated verbatim from the diff layer's explicit
// signal rather than inferred from op text, so a legitimate document
// whose content equals DiffTooLargeText is not misclassified.
TooLarge: result.TooLarge,
})
}

// parseDiffVersions reads the from / to query params as positive integers.
// Returns a human-readable error string (empty when OK).
func parseDiffVersions(r *http.Request) (from, to int, errMsg string) {
q := r.URL.Query()
fromStr := strings.TrimSpace(q.Get("from"))
toStr := strings.TrimSpace(q.Get("to"))
if fromStr == "" || toStr == "" {
return 0, 0, "Both 'from' and 'to' version numbers are required."
}
f, err := strconv.Atoi(fromStr)
if err != nil || f < 1 {
return 0, 0, "'from' must be a positive integer version number."
}
t, err := strconv.Atoi(toStr)
if err != nil || t < 1 {
return 0, 0, "'to' must be a positive integer version number."
}
return f, t, ""
}

// versionSessionID finds the session id for a given version number within a
// chain's version list. Returns ok=false when no live version matches.
func versionSessionID(versions []session.ChainVersion, versionNo int) (string, bool) {
for _, v := range versions {
if v.VersionNo == versionNo {
return v.SessionID, true
}
}
return "", false
}

func (s *Server) handleDeleteSession(w http.ResponseWriter, r *http.Request) {
sessionID := strings.TrimPrefix(r.URL.Path, "/api/sessions/")

Expand Down
Loading
Loading