Add Time Travel workspace history

Add the _time_travel core plugin with Agent Zero-owned shadow Git snapshots, history/diff/preview/travel/revert APIs, capture hooks, and canvas plus floating window UI surfaces for /a0/usr workspaces.

Wire generic file-browser mutation hooks for UI edits, update modal backdrop handling, remove the legacy _diff_viewer plugin, and replace Diff Viewer tests with focused Time Travel coverage.

Inspired by Space Agent :-)
This commit is contained in:
Alessandro 2026-04-27 01:27:20 +02:00
parent e92e518ab2
commit c5ea678052
35 changed files with 3152 additions and 1518 deletions

View file

@ -2,7 +2,7 @@ from helpers.api import ApiHandler, Input, Output, Request, Response
from helpers.file_browser import FileBrowser
from helpers import files, runtime
from helpers import files, runtime, extension
from api import get_work_dir_files
@ -19,6 +19,16 @@ class DeleteWorkDirFile(ApiHandler):
res = await runtime.call_development_function(delete_file, file_path)
if res:
await extension.call_extensions_async(
"workdir_file_mutation_after",
agent=None,
data={
"action": "delete",
"path": file_path,
"paths": [file_path],
"current_path": current_path,
},
)
# Get updated file list
# result = browser.get_files(current_path)
result = await runtime.call_development_function(get_work_dir_files.get_files, current_path)

View file

@ -3,7 +3,7 @@ import os
from helpers.api import ApiHandler, Input, Output, Request
from helpers.file_browser import FileBrowser
from helpers import runtime, files
from helpers import runtime, files, extension
MAX_EDIT_FILE_SIZE = 1024 * 1024
BINARY_SAMPLE_SIZE = 10 * 1024
@ -51,6 +51,15 @@ class EditWorkDirFile(ApiHandler):
if not res:
return {"error": "Failed to save file"}
await extension.call_extensions_async(
"workdir_file_mutation_after",
agent=None,
data={
"action": "edit",
"path": file_path,
"paths": [file_path],
},
)
return {"ok": True}
except Exception as e:
# Extract clean error message from exception

View file

@ -1,7 +1,8 @@
from helpers.api import ApiHandler, Input, Output, Request
from helpers.file_browser import FileBrowser
from helpers import runtime
from helpers import runtime, extension
from api import get_work_dir_files
import posixpath
class RenameWorkDirFile(ApiHandler):
@ -21,6 +22,7 @@ class RenameWorkDirFile(ApiHandler):
res = await runtime.call_development_function(
create_folder, parent_path, new_name
)
changed_paths = [posixpath.join(str(parent_path).rstrip("/"), new_name)]
else:
file_path = input.get("path", "")
if not file_path:
@ -30,8 +32,22 @@ class RenameWorkDirFile(ApiHandler):
res = await runtime.call_development_function(
rename_item, file_path, new_name
)
changed_paths = [
file_path,
posixpath.join(posixpath.dirname(file_path), new_name),
]
if res:
await extension.call_extensions_async(
"workdir_file_mutation_after",
agent=None,
data={
"action": action,
"path": changed_paths[-1],
"paths": changed_paths,
"current_path": current_path,
},
)
result = await runtime.call_development_function(
get_work_dir_files.get_files, current_path
)

View file

@ -2,9 +2,10 @@ import base64
from werkzeug.datastructures import FileStorage
from helpers.api import ApiHandler, Request, Response
from helpers.file_browser import FileBrowser
from helpers import files, runtime
from helpers import files, runtime, extension
from api import get_work_dir_files
import os
import posixpath
class UploadWorkDirFiles(ApiHandler):
@ -23,6 +24,21 @@ class UploadWorkDirFiles(ApiHandler):
if not successful and failed:
raise Exception("All uploads failed")
if successful:
await extension.call_extensions_async(
"workdir_file_mutation_after",
agent=None,
data={
"action": "upload",
"path": current_path,
"paths": [
posixpath.join(str(current_path).rstrip("/"), name)
for name in successful
],
"current_path": current_path,
},
)
# result = browser.get_files(current_path)
result = await runtime.call_development_function(get_work_dir_files.get_files, current_path)
@ -61,4 +77,3 @@ async def upload_files(uploaded_files: list[FileStorage], current_path: str):
async def upload_file(current_path: str, filename: str, base64_content: str):
browser = FileBrowser()
return browser.save_file_b64(current_path, filename, base64_content)

View file

@ -1,29 +0,0 @@
from __future__ import annotations
from helpers import files, projects, settings
from helpers.api import ApiHandler, Request, Response
from plugins._diff_viewer.helpers.diff import collect_workspace_diff
class Diff(ApiHandler):
async def process(self, input: dict, request: Request) -> dict | Response:
context_id = str(input.get("context_id") or "").strip()
workspace_path, display_path = self._resolve_workspace(context_id)
return collect_workspace_diff(
workspace_path,
context_id=context_id,
display_path=display_path,
)
def _resolve_workspace(self, context_id: str) -> tuple[str, str]:
if context_id:
context = self.use_context(context_id)
project_name = projects.get_context_project_name(context)
if project_name:
project_path = projects.get_project_folder(project_name)
display_path = files.normalize_a0_path(project_path)
return files.fix_dev_path(display_path), display_path
configured = str(settings.get_settings().get("workdir_path") or "")
display_path = configured or files.normalize_a0_path(files.get_abs_path("usr/workdir"))
return files.fix_dev_path(display_path), display_path

View file

@ -1,9 +0,0 @@
export default function refreshDiffViewerOnContextChange(ctx) {
const diffViewer = globalThis.Alpine?.store?.("diffViewer");
const canvas = globalThis.Alpine?.store?.("rightCanvas");
if (!diffViewer || !canvas?.isOpen || canvas.activeSurfaceId !== "diff") return;
const nextContextId = String(ctx?.snapshot?.context || "");
if (nextContextId && nextContextId !== diffViewer.contextId) {
diffViewer.scheduleRefresh({ contextId: nextContextId, reason: "context-change" });
}
}

View file

@ -1,8 +0,0 @@
<div
class="right-canvas-surface-panel diff-viewer-canvas-surface"
data-surface-id="diff"
x-show="$store.rightCanvas && $store.rightCanvas.isOpen && $store.rightCanvas.activeSurfaceId === 'diff'"
style="display: none;"
>
<x-component path="/plugins/_diff_viewer/webui/diff-viewer-panel.html" mode="canvas"></x-component>
</div>

View file

@ -1 +0,0 @@
"""Helpers for the built-in diff viewer plugin."""

View file

@ -1,347 +0,0 @@
from __future__ import annotations
import os
import subprocess
from pathlib import Path
from typing import Any
from helpers import files
MAX_PATCH_LINES = 2500
MAX_PATCH_BYTES = 240_000
MAX_UNTRACKED_BYTES = 180_000
GIT_TIMEOUT_SECONDS = 8
GROUP_ORDER = ("staged", "unstaged", "untracked")
STATUS_LABELS = {
"A": "added",
"C": "copied",
"D": "deleted",
"M": "modified",
"R": "renamed",
"T": "type_changed",
"U": "unmerged",
"?": "untracked",
}
class GitDiffError(RuntimeError):
pass
def collect_workspace_diff(
workspace_path: str,
*,
context_id: str = "",
display_path: str | None = None,
) -> dict[str, Any]:
workspace = Path(workspace_path).expanduser().resolve()
display = display_path or str(workspace)
if not workspace.exists() or not workspace.is_dir():
return {
"ok": False,
"context_id": context_id,
"workspace_path": display,
"is_git_repo": False,
"error": "Workspace path does not exist or is not a directory.",
"branch": "",
"totals": {"files": 0, "additions": 0, "deletions": 0},
"groups": _empty_groups(),
}
if not _is_git_repo(workspace):
return {
"ok": True,
"context_id": context_id,
"workspace_path": display,
"is_git_repo": False,
"branch": "",
"totals": {"files": 0, "additions": 0, "deletions": 0},
"groups": _empty_groups(),
}
groups = [
{"kind": "staged", "files": _collect_diff_group(workspace, "staged")},
{"kind": "unstaged", "files": _collect_diff_group(workspace, "unstaged")},
{"kind": "untracked", "files": _collect_untracked_group(workspace)},
]
seen_paths: set[str] = set()
additions = 0
deletions = 0
for group in groups:
for item in group["files"]:
seen_paths.add(str(item.get("path") or item.get("old_path") or ""))
additions += int(item.get("additions") or 0)
deletions += int(item.get("deletions") or 0)
return {
"ok": True,
"context_id": context_id,
"workspace_path": display,
"is_git_repo": True,
"branch": _branch_name(workspace),
"totals": {
"files": len([path for path in seen_paths if path]),
"additions": additions,
"deletions": deletions,
},
"groups": groups,
}
def _empty_groups() -> list[dict[str, Any]]:
return [{"kind": kind, "files": []} for kind in GROUP_ORDER]
def _git(workspace: Path, *args: str, check: bool = True) -> subprocess.CompletedProcess[str]:
env = os.environ.copy()
env["GIT_TERMINAL_PROMPT"] = "0"
env["GIT_OPTIONAL_LOCKS"] = "0"
completed = subprocess.run(
["git", "-C", str(workspace), *args],
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
env=env,
timeout=GIT_TIMEOUT_SECONDS,
)
if check and completed.returncode != 0:
message = (completed.stderr or completed.stdout or "Git command failed.").strip()
raise GitDiffError(message)
return completed
def _is_git_repo(workspace: Path) -> bool:
return _git(workspace, "rev-parse", "--is-inside-work-tree", check=False).returncode == 0
def _branch_name(workspace: Path) -> str:
branch = _git(workspace, "branch", "--show-current", check=False).stdout.strip()
if branch:
return branch
return _git(workspace, "rev-parse", "--short", "HEAD", check=False).stdout.strip()
def _collect_diff_group(workspace: Path, kind: str) -> list[dict[str, Any]]:
cached_args = ["--cached"] if kind == "staged" else []
status_output = _git(
workspace,
"diff",
*cached_args,
"--name-status",
"-z",
"--find-renames",
"--relative",
"--",
".",
).stdout
entries = _parse_name_status(status_output)
result: list[dict[str, Any]] = []
for entry in entries:
path = entry["path"]
old_path = entry.get("old_path", "")
if _is_a0_metadata_path(path) and (not old_path or _is_a0_metadata_path(old_path)):
continue
stat_paths = [candidate for candidate in (old_path, path) if candidate]
additions, deletions, binary = _diff_numstat(workspace, kind, stat_paths)
if _is_zero_line_gitkeep_change(path, old_path, additions, deletions):
continue
too_large = additions + deletions > MAX_PATCH_LINES
patch = ""
if not binary and not too_large:
patch = _diff_patch(workspace, kind, stat_paths)
if len(patch.encode("utf-8", errors="replace")) > MAX_PATCH_BYTES:
patch = ""
too_large = True
result.append(
{
"path": path,
"old_path": old_path,
"status": STATUS_LABELS.get(entry["status"], entry["status"].lower()),
"additions": additions,
"deletions": deletions,
"binary": binary,
"too_large": too_large,
"patch": patch,
}
)
return result
def _collect_untracked_group(workspace: Path) -> list[dict[str, Any]]:
output = _git(
workspace,
"ls-files",
"--others",
"--exclude-standard",
"-z",
"--",
".",
).stdout
result: list[dict[str, Any]] = []
for path in [part for part in output.split("\0") if part]:
path = path.replace("\\", "/")
if _is_a0_metadata_path(path):
continue
item = _untracked_file_diff(workspace, path)
if _is_zero_line_gitkeep_change(item["path"], item.get("old_path", ""), item["additions"], item["deletions"]):
continue
result.append(item)
result.sort(key=lambda item: item["path"])
return result
def _parse_name_status(output: str) -> list[dict[str, str]]:
parts = [part for part in output.split("\0") if part]
entries: list[dict[str, str]] = []
index = 0
while index < len(parts):
raw_status = parts[index]
index += 1
status = raw_status[:1]
if status in {"R", "C"} and index + 1 < len(parts):
old_path = parts[index].replace("\\", "/")
new_path = parts[index + 1].replace("\\", "/")
index += 2
entries.append({"status": status, "old_path": old_path, "path": new_path})
continue
if index < len(parts):
path = parts[index].replace("\\", "/")
index += 1
entries.append({"status": status, "path": path, "old_path": ""})
entries.sort(key=lambda item: item.get("path") or item.get("old_path") or "")
return entries
def _diff_numstat(workspace: Path, kind: str, paths: list[str]) -> tuple[int, int, bool]:
args = ["diff"]
if kind == "staged":
args.append("--cached")
output = _git(
workspace,
*args,
"--numstat",
"--find-renames",
"--relative",
"--",
*paths,
).stdout
first = next((line for line in output.splitlines() if line.strip()), "")
if not first:
return 0, 0, False
parts = first.split("\t")
if len(parts) < 2:
return 0, 0, False
if parts[0] == "-" or parts[1] == "-":
return 0, 0, True
return _safe_int(parts[0]), _safe_int(parts[1]), False
def _diff_patch(workspace: Path, kind: str, paths: list[str]) -> str:
args = ["diff"]
if kind == "staged":
args.append("--cached")
return _git(
workspace,
*args,
"--patch",
"--find-renames",
"--relative",
"--",
*paths,
).stdout
def _untracked_file_diff(workspace: Path, path: str) -> dict[str, Any]:
file_path = (workspace / path).resolve()
additions = 0
binary = False
too_large = False
patch = ""
try:
with open(file_path, "rb") as handle:
data = handle.read(MAX_UNTRACKED_BYTES + 1)
too_large = len(data) > MAX_UNTRACKED_BYTES
sample = data[: min(len(data), 10 * 1024)]
binary = files.is_probably_binary_bytes(sample)
if not binary and not too_large:
text = data.decode("utf-8", errors="replace")
lines = text.splitlines()
additions = len(lines)
if len(lines) > MAX_PATCH_LINES:
too_large = True
else:
patch = _synthetic_untracked_patch(path, lines, text.endswith("\n"))
elif not binary:
additions = _count_newlines(data[:MAX_UNTRACKED_BYTES])
except OSError:
too_large = True
return {
"path": path,
"old_path": "",
"status": "untracked",
"additions": additions,
"deletions": 0,
"binary": binary,
"too_large": too_large,
"patch": patch,
}
def _synthetic_untracked_patch(path: str, lines: list[str], has_trailing_newline: bool) -> str:
escaped = path.replace("\t", "\\t")
header = [
f"diff --git a/{escaped} b/{escaped}",
"new file mode 100644",
"index 0000000..0000000",
"--- /dev/null",
f"+++ b/{escaped}",
]
if not lines:
return "\n".join(header) + "\n"
body = [f"@@ -0,0 +1,{len(lines)} @@"]
body.extend(f"+{line}" for line in lines)
if not has_trailing_newline:
body.append("\\ No newline at end of file")
return "\n".join(header + body) + "\n"
def _count_newlines(data: bytes) -> int:
if not data:
return 0
count = data.count(b"\n")
return count if data.endswith(b"\n") else count + 1
def _safe_int(value: str) -> int:
try:
return max(0, int(value))
except (TypeError, ValueError):
return 0
def _is_a0_metadata_path(path: str) -> bool:
normalized = path.replace("\\", "/").lstrip("/")
return normalized == ".a0proj" or normalized.startswith(".a0proj/")
def _is_zero_line_gitkeep_change(path: str, old_path: str, additions: int, deletions: int) -> bool:
if additions != 0 or deletions != 0:
return False
candidates = [path, old_path]
return any(
candidate.replace("\\", "/").rstrip("/").split("/")[-1] == ".gitkeep"
for candidate in candidates
if candidate
)

View file

@ -1,8 +0,0 @@
name: _diff_viewer
title: Diff Viewer
description: Right canvas Git working tree diff viewer for the active chat or task workspace.
version: 0.1.0
always_enabled: false
settings_sections: []
per_project_config: false
per_agent_config: false

View file

@ -1,572 +0,0 @@
<html>
<head>
<script type="module">
import { store } from "/plugins/_diff_viewer/webui/diff-viewer-store.js";
</script>
</head>
<body>
<div class="diff-viewer-panel" x-data x-create="$store.diffViewer.onMount($el, xAttrs($el) || {})" x-destroy="$store.diffViewer.cleanup()">
<template x-if="$store.diffViewer">
<div class="diff-viewer-shell">
<div class="diff-viewer-toolbar">
<div class="diff-viewer-title">
<span class="material-symbols-outlined">difference</span>
<span>Review</span>
</div>
<span class="diff-viewer-spacer"></span>
<button type="button" class="diff-viewer-icon-button" title="Expand all" aria-label="Expand all" @click="$store.diffViewer.expandAll()" :disabled="!$store.diffViewer.hasChanges()">
<span class="material-symbols-outlined">unfold_more</span>
</button>
<button type="button" class="diff-viewer-icon-button" title="Collapse all" aria-label="Collapse all" @click="$store.diffViewer.collapseAll()" :disabled="!$store.diffViewer.hasChanges()">
<span class="material-symbols-outlined">unfold_less</span>
</button>
<button type="button" class="diff-viewer-icon-button" title="Refresh" aria-label="Refresh" @click="$store.diffViewer.refresh()" :disabled="$store.diffViewer.loading">
<span class="material-symbols-outlined" :class="{ spinning: $store.diffViewer.loading }">refresh</span>
</button>
</div>
<div class="diff-viewer-summary">
<div class="diff-viewer-summary-main">
<strong x-text="$store.diffViewer.payload?.totals?.files || 0"></strong>
<span>files changed</span>
<span class="diff-add" x-text="$store.diffViewer.formatSigned($store.diffViewer.payload?.totals?.additions, '+')"></span>
<span class="diff-del" x-text="$store.diffViewer.formatSigned($store.diffViewer.payload?.totals?.deletions, '-')"></span>
</div>
<div class="diff-viewer-summary-meta">
<span x-text="$store.diffViewer.payload?.branch || 'no branch'"></span>
<span class="diff-viewer-dot"></span>
<span :title="$store.diffViewer.workspacePath" x-text="$store.diffViewer.workspacePath || 'workspace'"></span>
</div>
</div>
<div class="diff-viewer-status" x-show="$store.diffViewer.loading || $store.diffViewer.error" style="display: none;">
<span class="material-symbols-outlined" :class="{ spinning: $store.diffViewer.loading }" x-text="$store.diffViewer.loading ? 'progress_activity' : 'error'"></span>
<span x-text="$store.diffViewer.loading ? 'Loading changes...' : $store.diffViewer.error"></span>
</div>
<div class="diff-viewer-body">
<template x-if="$store.diffViewer.payload && !$store.diffViewer.payload.is_git_repo && !$store.diffViewer.loading && !$store.diffViewer.error">
<div class="diff-viewer-empty">
<span class="material-symbols-outlined">folder_off</span>
<strong>Not a Git workspace</strong>
<span x-text="$store.diffViewer.workspacePath"></span>
</div>
</template>
<template x-if="$store.diffViewer.payload?.is_git_repo && !$store.diffViewer.hasChanges() && !$store.diffViewer.loading && !$store.diffViewer.error">
<div class="diff-viewer-empty">
<span class="material-symbols-outlined">check_circle</span>
<strong>No changes</strong>
<span>The selected context workspace is clean.</span>
</div>
</template>
<div class="diff-viewer-groups" x-show="$store.diffViewer.hasChanges()" style="display: none;">
<template x-for="group in $store.diffViewer.visibleGroups()" :key="group.kind">
<section class="diff-viewer-group">
<header class="diff-viewer-group-header">
<span x-text="$store.diffViewer.groupTitle(group.kind)"></span>
<span class="diff-viewer-count" x-text="group.files.length"></span>
</header>
<template x-for="file in group.files" :key="$store.diffViewer.fileKey(group, file)">
<article class="diff-file">
<button type="button" class="diff-file-header" @click="$store.diffViewer.toggleFile(group, file)" :title="$store.diffViewer.fileTitle(file)">
<span class="material-symbols-outlined diff-file-chevron" x-text="$store.diffViewer.isExpanded(group, file) ? 'expand_less' : 'expand_more'"></span>
<span class="diff-file-name" x-text="$store.diffViewer.fileTitle(file)"></span>
<span class="diff-file-status" x-text="$store.diffViewer.statusLabel(file)"></span>
<span class="diff-file-counts">
<span class="diff-add" x-text="$store.diffViewer.formatSigned(file.additions, '+')"></span>
<span class="diff-del" x-text="$store.diffViewer.formatSigned(file.deletions, '-')"></span>
</span>
</button>
<div class="diff-file-tools" x-show="$store.diffViewer.isExpanded(group, file)" style="display: none;">
<button type="button" class="diff-viewer-tool-button" title="Open containing folder" @click="$store.diffViewer.openContainingFolder(file)">
<span class="material-symbols-outlined">folder_open</span>
<span>Folder</span>
</button>
<button type="button" class="diff-viewer-tool-button" title="Copy path" @click="$store.diffViewer.copyPath(file)">
<span class="material-symbols-outlined">content_copy</span>
<span>Path</span>
</button>
</div>
<div class="diff-file-body" x-show="$store.diffViewer.isExpanded(group, file)" style="display: none;">
<template x-if="file.binary">
<div class="diff-file-note">
<span class="material-symbols-outlined">data_object</span>
<span>Binary file changed.</span>
</div>
</template>
<template x-if="file.too_large && !file.binary">
<div class="diff-file-note">
<span class="material-symbols-outlined">text_snippet</span>
<span>Diff is too large to render inline.</span>
</div>
</template>
<template x-if="!file.binary && !file.too_large && file.patch">
<div class="diff-code" role="table" aria-label="Unified diff">
<template x-for="line in $store.diffViewer.patchLines(file)" :key="line.id">
<div class="diff-line" :class="`is-${line.type}`" role="row">
<span class="diff-line-marker" x-text="line.type === 'add' ? '+' : (line.type === 'del' ? '-' : '')"></span>
<code x-text="line.text"></code>
</div>
</template>
</div>
</template>
</div>
</article>
</template>
</section>
</template>
</div>
</div>
</div>
</template>
</div>
<style>
.diff-viewer-panel,
.diff-viewer-shell {
display: flex;
flex: 1 1 auto;
flex-direction: column;
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
background: color-mix(in srgb, var(--color-background) 96%, #000 4%);
color: var(--color-text);
}
.diff-viewer-panel {
container-type: inline-size;
}
.modal-inner.diff-viewer-modal {
box-sizing: border-box;
container-type: inline-size;
width: min(82vw, 1180px);
height: min(88vh, 900px);
min-width: min(340px, calc(100vw - 16px));
min-height: min(500px, calc(100vh - 16px));
max-width: calc(100vw - 16px);
max-height: calc(100vh - 16px);
resize: both;
border: 1px solid color-mix(in srgb, var(--color-border) 75%, transparent);
border-radius: 7px;
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.32);
background: color-mix(in srgb, var(--color-background) 94%, #000 6%);
}
.modal.modal-floating {
pointer-events: none;
}
.modal.modal-floating .modal-inner {
pointer-events: auto;
}
.modal-inner.diff-viewer-modal .modal-header {
min-height: 34px;
padding: 0.35rem 0.75rem 0.35rem 1rem;
cursor: move;
user-select: none;
background: color-mix(in srgb, var(--color-background) 92%, #000 8%);
border-bottom: 1px solid color-mix(in srgb, var(--color-border) 70%, transparent);
}
.modal-inner.diff-viewer-modal .modal-scroll {
display: flex;
flex-direction: column;
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
padding: 0;
}
.modal-inner.diff-viewer-modal .modal-bd.diff-viewer-modal-body {
box-sizing: border-box;
display: flex;
flex-direction: column;
flex: 1 1 auto;
width: 100%;
height: 100%;
min-height: 0;
padding: 0;
}
.modal-inner.diff-viewer-modal .modal-bd.diff-viewer-modal-body > x-component,
.modal-inner.diff-viewer-modal .modal-bd.diff-viewer-modal-body > div[x-data] {
display: flex;
flex: 1 1 auto;
width: 100%;
height: 100%;
min-height: 0;
}
.diff-viewer-toolbar {
display: flex;
align-items: center;
gap: 6px;
min-height: 44px;
padding: var(--spacing-xs) var(--spacing-md);
border-bottom: 1px solid color-mix(in srgb, var(--color-border) 66%, transparent);
background: color-mix(in srgb, var(--color-background) 92%, #000 8%);
}
.diff-viewer-title,
.diff-viewer-summary-main,
.diff-viewer-summary-meta,
.diff-viewer-status,
.diff-file-counts,
.diff-file-tools,
.diff-viewer-tool-button,
.diff-file-note {
display: flex;
align-items: center;
}
.diff-viewer-title {
gap: 7px;
min-width: 0;
font-weight: 700;
font-size: 0.9rem;
}
.diff-viewer-title .material-symbols-outlined {
font-size: 19px;
}
.diff-viewer-spacer {
flex: 1 1 auto;
min-width: 8px;
}
.diff-viewer-icon-button,
.diff-viewer-tool-button {
appearance: none;
border: 1px solid color-mix(in srgb, var(--color-border) 64%, transparent);
border-radius: 7px;
background: color-mix(in srgb, var(--color-panel) 80%, transparent);
color: var(--color-text);
font: inherit;
cursor: pointer;
}
.diff-viewer-icon-button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
min-width: 32px;
padding: 0;
}
.diff-viewer-tool-button {
gap: 5px;
min-height: 28px;
padding: 4px 8px;
font-size: 0.76rem;
}
.diff-viewer-icon-button:hover:not(:disabled),
.diff-viewer-tool-button:hover {
background: color-mix(in srgb, var(--color-background-hover) 70%, transparent);
border-color: color-mix(in srgb, var(--color-primary) 28%, var(--color-border));
}
.diff-viewer-icon-button:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.diff-viewer-icon-button .material-symbols-outlined,
.diff-viewer-tool-button .material-symbols-outlined {
font-size: 17px;
}
.diff-viewer-summary {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 3px;
padding: 9px 11px;
border-bottom: 1px solid color-mix(in srgb, var(--color-border) 54%, transparent);
background: color-mix(in srgb, var(--color-panel) 54%, transparent);
}
.diff-viewer-summary-main {
gap: 5px;
min-width: 0;
font-size: 0.86rem;
}
.diff-viewer-summary-meta {
gap: 7px;
min-width: 0;
color: var(--color-text-muted);
font-size: 0.74rem;
}
.diff-viewer-summary-meta span:last-child {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.diff-viewer-dot {
width: 4px;
height: 4px;
flex: 0 0 auto;
border-radius: 999px;
background: color-mix(in srgb, var(--color-text-muted) 50%, transparent);
}
.diff-add {
color: #31c48d;
}
.diff-del {
color: #f05252;
}
.diff-viewer-status {
gap: 8px;
min-height: 34px;
padding: 6px 11px;
border-bottom: 1px solid color-mix(in srgb, var(--color-border) 44%, transparent);
color: var(--color-text-muted);
font-size: 0.82rem;
}
.diff-viewer-body {
display: flex;
flex: 1 1 auto;
min-width: 0;
min-height: 0;
overflow: auto;
}
.diff-viewer-groups {
display: flex;
flex: 1 1 auto;
min-width: 0;
flex-direction: column;
gap: 12px;
padding: 12px;
}
.diff-viewer-group {
display: flex;
min-width: 0;
flex-direction: column;
gap: 7px;
}
.diff-viewer-group-header {
display: flex;
align-items: center;
gap: 7px;
min-height: 26px;
color: var(--color-text);
font-size: 0.82rem;
font-weight: 700;
}
.diff-viewer-count {
min-width: 22px;
padding: 2px 6px;
border-radius: 999px;
background: color-mix(in srgb, var(--color-panel) 78%, transparent);
color: var(--color-text-muted);
text-align: center;
font-size: 0.72rem;
}
.diff-file {
overflow: hidden;
border: 1px solid color-mix(in srgb, var(--color-border) 58%, transparent);
border-radius: 7px;
background: color-mix(in srgb, var(--color-panel) 72%, transparent);
}
.diff-file-header {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto auto;
align-items: center;
gap: 7px;
width: 100%;
min-height: 38px;
padding: 0 9px;
border: 0;
background: transparent;
color: var(--color-text);
font: inherit;
text-align: left;
cursor: pointer;
}
.diff-file-header:hover {
background: color-mix(in srgb, var(--color-background-hover) 56%, transparent);
}
.diff-file-chevron {
font-size: 18px;
color: var(--color-text-muted);
}
.diff-file-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--font-family-code);
font-size: 0.78rem;
font-weight: 650;
}
.diff-file-status {
padding: 2px 6px;
border-radius: 999px;
background: color-mix(in srgb, var(--color-background) 70%, transparent);
color: var(--color-text-muted);
font-size: 0.68rem;
text-transform: capitalize;
white-space: nowrap;
}
.diff-file-counts {
gap: 5px;
justify-content: end;
min-width: 72px;
font-family: var(--font-family-code);
font-size: 0.74rem;
}
.diff-file-tools {
justify-content: flex-end;
gap: 6px;
min-height: 34px;
padding: 4px 8px;
border-top: 1px solid color-mix(in srgb, var(--color-border) 34%, transparent);
background: color-mix(in srgb, var(--color-background) 55%, transparent);
}
.diff-file-body {
border-top: 1px solid color-mix(in srgb, var(--color-border) 44%, transparent);
}
.diff-file-note {
gap: 8px;
padding: 12px;
color: var(--color-text-muted);
font-size: 0.82rem;
}
.diff-code {
overflow: auto;
padding: 4px 0;
background: color-mix(in srgb, var(--color-background) 91%, #000 9%);
font-family: var(--font-family-code);
font-size: 0.74rem;
line-height: 1.45;
}
.diff-line {
display: grid;
grid-template-columns: 24px minmax(0, 1fr);
min-width: max-content;
}
.diff-line-marker {
position: sticky;
left: 0;
z-index: 1;
min-height: 1.45em;
padding-right: 5px;
background: inherit;
color: var(--color-text-muted);
text-align: right;
user-select: none;
}
.diff-line code {
display: block;
min-height: 1.45em;
padding: 0 10px 0 6px;
color: inherit;
font: inherit;
white-space: pre;
}
.diff-line.is-add {
background: rgba(39, 174, 96, 0.16);
color: color-mix(in srgb, #8df0b0 78%, var(--color-text));
}
.diff-line.is-del {
background: rgba(231, 76, 60, 0.17);
color: color-mix(in srgb, #ffaaa2 78%, var(--color-text));
}
.diff-line.is-hunk {
background: rgba(99, 102, 241, 0.16);
color: color-mix(in srgb, #b9c3ff 80%, var(--color-text));
}
.diff-line.is-meta,
.diff-line.is-note {
color: var(--color-text-muted);
}
.diff-viewer-empty {
display: grid;
flex: 1 1 auto;
place-items: center;
align-content: center;
gap: 7px;
min-width: 0;
padding: 24px;
color: var(--color-text-muted);
text-align: center;
}
.diff-viewer-empty .material-symbols-outlined {
font-size: 28px;
}
.diff-viewer-empty span:last-child {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.78rem;
}
.diff-viewer-panel .spinning {
display: inline-block;
animation: diff-viewer-spin 0.8s linear infinite;
}
@keyframes diff-viewer-spin {
to { transform: rotate(360deg); }
}
@container (max-width: 560px) {
.diff-file-header {
grid-template-columns: auto minmax(0, 1fr) auto;
}
.diff-file-status {
display: none;
}
.diff-viewer-tool-button span:last-child {
display: none;
}
}
</style>
</body>
</html>

View file

@ -1,321 +0,0 @@
import { createStore } from "/js/AlpineStore.js";
import { callJsonApi } from "/js/api.js";
import { getContext } from "/index.js";
import { store as fileBrowserStore } from "/components/modals/file-browser/file-browser-store.js";
const REFRESH_DEBOUNCE_MS = 180;
function lineType(text) {
if (text.startsWith("@@")) return "hunk";
if (text.startsWith("+++") || text.startsWith("---") || text.startsWith("diff --git") || text.startsWith("index ")) {
return "meta";
}
if (text.startsWith("+")) return "add";
if (text.startsWith("-")) return "del";
if (text.startsWith("\\ No newline")) return "note";
return "context";
}
function dirname(path) {
const clean = String(path || "").replace(/\/+$/, "");
const index = clean.lastIndexOf("/");
return index > 0 ? clean.slice(0, index) : "";
}
const model = {
loading: false,
error: "",
payload: null,
contextId: "",
workspacePath: "",
expanded: {},
_root: null,
_mode: "canvas",
_refreshTimer: null,
_floatingCleanup: null,
_requestSeq: 0,
async init(element = null) {
await this.onMount(element, { mode: "canvas" });
},
async onMount(element = null, options = {}) {
if (element) this._root = element;
this._mode = options?.mode === "modal" ? "modal" : "canvas";
if (this._mode === "modal") {
this.setupFloatingModal(element);
} else {
this.setupCanvasSurface(element);
}
this.contextId = this.resolveContextId();
if (!this.payload && !this.loading) {
await this.refresh({ contextId: this.contextId });
}
},
async onOpen(payload = {}) {
const nextContextId = String(payload.contextId || payload.context_id || this.resolveContextId() || "");
await this.refresh({ contextId: nextContextId });
},
cleanup() {
if (this._refreshTimer) {
clearTimeout(this._refreshTimer);
this._refreshTimer = null;
}
this._floatingCleanup?.();
this._floatingCleanup = null;
},
setupFloatingModal(element = null) {
this._floatingCleanup?.();
const root = element || globalThis.document?.querySelector(".diff-viewer-panel");
const modal = root?.closest?.(".modal");
const inner = modal?.querySelector?.(".modal-inner");
const body = modal?.querySelector?.(".modal-bd");
const header = modal?.querySelector?.(".modal-header");
if (!modal || !inner || !header) return;
modal.classList.add("modal-floating");
inner.classList.add("diff-viewer-modal", "modal-no-backdrop");
body?.classList?.add("diff-viewer-modal-body");
const rect = inner.getBoundingClientRect();
inner.style.left = `${Math.max(8, rect.left)}px`;
inner.style.top = `${Math.max(8, rect.top)}px`;
inner.style.transform = "none";
let drag = null;
let resizeObserver = null;
const viewportGap = 8;
const clampPosition = (left, top) => {
const bounds = inner.getBoundingClientRect();
const maxLeft = Math.max(viewportGap, globalThis.innerWidth - bounds.width - viewportGap);
const maxTop = Math.max(viewportGap, globalThis.innerHeight - bounds.height - viewportGap);
return {
left: Math.min(Math.max(viewportGap, left), maxLeft),
top: Math.min(Math.max(viewportGap, top), maxTop),
};
};
const clampGeometry = () => {
const bounds = inner.getBoundingClientRect();
const left = Math.max(viewportGap, bounds.left);
const top = Math.max(viewportGap, bounds.top);
const maxWidth = Math.max(340, globalThis.innerWidth - viewportGap * 2);
const maxHeight = Math.max(360, globalThis.innerHeight - viewportGap * 2);
if (bounds.width > maxWidth) inner.style.width = `${maxWidth}px`;
if (bounds.height > maxHeight) inner.style.height = `${maxHeight}px`;
const next = clampPosition(left, top);
inner.style.left = `${next.left}px`;
inner.style.top = `${next.top}px`;
inner.style.maxWidth = `${Math.max(340, globalThis.innerWidth - next.left - viewportGap)}px`;
inner.style.maxHeight = `${Math.max(360, globalThis.innerHeight - next.top - viewportGap)}px`;
};
clampGeometry();
globalThis.addEventListener("resize", clampGeometry);
if (globalThis.ResizeObserver) {
resizeObserver = new ResizeObserver(clampGeometry);
resizeObserver.observe(inner);
}
const onPointerMove = (event) => {
if (!drag) return;
const next = clampPosition(
drag.left + event.clientX - drag.x,
drag.top + event.clientY - drag.y,
);
inner.style.left = `${next.left}px`;
inner.style.top = `${next.top}px`;
clampGeometry();
};
const onPointerUp = () => {
drag = null;
globalThis.removeEventListener("pointermove", onPointerMove);
globalThis.removeEventListener("pointerup", onPointerUp);
try {
header.releasePointerCapture?.(header.__diffViewerPanelPointerId || 0);
} catch {}
};
const onPointerDown = (event) => {
if (event.button !== 0) return;
if (event.target?.closest?.("button, input, select, textarea, a")) return;
const current = inner.getBoundingClientRect();
drag = {
x: event.clientX,
y: event.clientY,
left: current.left,
top: current.top,
};
header.__diffViewerPanelPointerId = event.pointerId;
header.setPointerCapture?.(event.pointerId);
globalThis.addEventListener("pointermove", onPointerMove);
globalThis.addEventListener("pointerup", onPointerUp);
event.preventDefault();
};
header.addEventListener("pointerdown", onPointerDown);
this._floatingCleanup = () => {
header.removeEventListener("pointerdown", onPointerDown);
globalThis.removeEventListener("pointermove", onPointerMove);
globalThis.removeEventListener("pointerup", onPointerUp);
globalThis.removeEventListener("resize", clampGeometry);
resizeObserver?.disconnect?.();
};
},
setupCanvasSurface(element = null) {
this._floatingCleanup?.();
this._floatingCleanup = null;
if (element) this._root = element;
},
resolveContextId() {
const urlContext = new URLSearchParams(globalThis.location?.search || "").get("ctxid");
return getContext?.() || urlContext || globalThis.Alpine?.store?.("chats")?.selected || "";
},
scheduleRefresh(options = {}) {
if (this._refreshTimer) clearTimeout(this._refreshTimer);
this._refreshTimer = setTimeout(() => {
this._refreshTimer = null;
this.refresh(options).catch((error) => {
console.error("Diff refresh failed", error);
});
}, REFRESH_DEBOUNCE_MS);
},
async refresh(options = {}) {
const contextId = String(options.contextId || options.context_id || this.resolveContextId() || "");
const seq = ++this._requestSeq;
this.loading = true;
this.error = "";
try {
const response = await callJsonApi("/plugins/_diff_viewer/diff", { context_id: contextId });
if (seq !== this._requestSeq) return;
if (!response?.ok) {
throw new Error(response?.error || "Could not load diff.");
}
this.payload = response;
this.contextId = String(response.context_id || contextId || "");
this.workspacePath = String(response.workspace_path || "");
this.reconcileExpanded();
} catch (error) {
if (seq !== this._requestSeq) return;
this.error = error instanceof Error ? error.message : String(error);
} finally {
if (seq === this._requestSeq) this.loading = false;
}
},
reconcileExpanded() {
const next = {};
let index = 0;
for (const group of this.visibleGroups()) {
for (const file of group.files || []) {
const key = this.fileKey(group, file);
next[key] = this.expanded[key] ?? index < 4;
index += 1;
}
}
this.expanded = next;
},
visibleGroups() {
return (this.payload?.groups || []).filter((group) => Array.isArray(group.files) && group.files.length > 0);
},
hasChanges() {
return this.visibleGroups().length > 0;
},
groupTitle(kind) {
const labels = {
staged: "Staged",
unstaged: "Unstaged",
untracked: "Untracked",
};
return labels[kind] || kind;
},
statusLabel(file) {
return String(file?.status || "changed").replaceAll("_", " ");
},
fileKey(group, file) {
return `${group?.kind || "diff"}:${file?.old_path || ""}:${file?.path || ""}`;
},
isExpanded(group, file) {
return this.expanded[this.fileKey(group, file)] !== false;
},
toggleFile(group, file) {
const key = this.fileKey(group, file);
this.expanded[key] = !this.isExpanded(group, file);
},
expandAll() {
const next = {};
for (const group of this.visibleGroups()) {
for (const file of group.files || []) {
next[this.fileKey(group, file)] = true;
}
}
this.expanded = next;
},
collapseAll() {
const next = {};
for (const group of this.visibleGroups()) {
for (const file of group.files || []) {
next[this.fileKey(group, file)] = false;
}
}
this.expanded = next;
},
patchLines(file) {
const patch = String(file?.patch || "");
if (!patch) return [];
const textLines = patch.endsWith("\n") ? patch.slice(0, -1).split("\n") : patch.split("\n");
return textLines.map((text, index) => ({
id: `${index}-${text.slice(0, 20)}`,
text,
type: lineType(text),
}));
},
fileTitle(file) {
if (file?.old_path && file.old_path !== file.path) {
return `${file.old_path} -> ${file.path}`;
}
return file?.path || file?.old_path || "";
},
formatSigned(value, sign) {
const number = Number(value) || 0;
return `${sign}${number.toLocaleString()}`;
},
fullPath(file) {
const relativePath = String(file?.path || file?.old_path || "").replace(/^\/+/, "");
const base = String(this.workspacePath || "").replace(/\/+$/, "");
return relativePath ? `${base}/${relativePath}` : base;
},
async openContainingFolder(file) {
const parent = dirname(this.fullPath(file));
await fileBrowserStore.open(parent || this.workspacePath || "$WORK_DIR");
},
async copyPath(file) {
const path = this.fullPath(file);
try {
await navigator.clipboard.writeText(path);
globalThis.justToast?.("Path copied", "success", 1200, "diff-viewer-copy");
} catch (_error) {
globalThis.prompt?.("Copy path", path);
}
},
};
export const store = createStore("diffViewer", model);

View file

@ -1,14 +0,0 @@
<html
class="diff-viewer-modal modal-no-backdrop"
data-canvas-surface="diff"
data-canvas-modal-path="/plugins/_diff_viewer/webui/main.html"
data-canvas-dock-title="Open Diff in canvas"
data-canvas-dock-icon="dock_to_right"
>
<head>
<title>Diff</title>
</head>
<body class="diff-viewer-modal-body">
<x-component path="/plugins/_diff_viewer/webui/diff-viewer-panel.html" mode="modal"></x-component>
</body>
</html>

View file

@ -0,0 +1,25 @@
from __future__ import annotations
from helpers.api import ApiHandler, Request, Response
from plugins._time_travel.helpers.time_travel import (
TimeTravelError,
TimeTravelService,
WorkspaceRejectedError,
resolve_workspace,
)
class HistoryDiff(ApiHandler):
async def process(self, input: dict, request: Request) -> dict | Response:
context_id = str(input.get("context_id") or "").strip()
try:
workspace = resolve_workspace(context_id, context_loader=self.use_context)
return TimeTravelService(workspace).history_diff(
commit_hash=str(input.get("commit_hash") or ""),
path=str(input.get("path") or ""),
mode=str(input.get("mode") or "commit"),
)
except WorkspaceRejectedError as exc:
return {"ok": False, "locked": True, "error": str(exc)}
except TimeTravelError as exc:
return {"ok": False, "error": str(exc)}

View file

@ -0,0 +1,26 @@
from __future__ import annotations
from helpers.api import ApiHandler, Request, Response
from plugins._time_travel.helpers.time_travel import (
TimeTravelError,
TimeTravelService,
WorkspaceRejectedError,
resolve_workspace,
unavailable_payload,
)
class HistoryList(ApiHandler):
async def process(self, input: dict, request: Request) -> dict | Response:
context_id = str(input.get("context_id") or "").strip()
try:
workspace = resolve_workspace(context_id, context_loader=self.use_context)
return TimeTravelService(workspace).history_list(
limit=int(input.get("limit") or 100),
offset=int(input.get("offset") or 0),
file_filter=str(input.get("file_filter") or ""),
)
except WorkspaceRejectedError as exc:
return unavailable_payload(context_id, str(exc))
except TimeTravelError as exc:
return {"ok": False, "error": str(exc)}

View file

@ -0,0 +1,24 @@
from __future__ import annotations
from helpers.api import ApiHandler, Request, Response
from plugins._time_travel.helpers.time_travel import (
TimeTravelError,
TimeTravelService,
WorkspaceRejectedError,
resolve_workspace,
)
class HistoryPreview(ApiHandler):
async def process(self, input: dict, request: Request) -> dict | Response:
context_id = str(input.get("context_id") or "").strip()
try:
workspace = resolve_workspace(context_id, context_loader=self.use_context)
return TimeTravelService(workspace).preview(
operation=str(input.get("operation") or ""),
commit_hash=str(input.get("commit_hash") or ""),
)
except WorkspaceRejectedError as exc:
return {"ok": False, "locked": True, "error": str(exc)}
except TimeTravelError as exc:
return {"ok": False, "error": str(exc), "technical_details": getattr(exc, "stderr", "")}

View file

@ -0,0 +1,35 @@
from __future__ import annotations
from helpers.api import ApiHandler, Request, Response
from plugins._time_travel.helpers.time_travel import (
TimeTravelError,
TimeTravelService,
WorkspaceRejectedError,
resolve_workspace,
)
class HistoryRevert(ApiHandler):
async def process(self, input: dict, request: Request) -> dict | Response:
context_id = str(input.get("context_id") or "").strip()
try:
workspace = resolve_workspace(context_id, context_loader=self.use_context)
return TimeTravelService(workspace).revert(
commit_hash=str(input.get("commit_hash") or ""),
metadata=input.get("metadata") if isinstance(input.get("metadata"), dict) else {},
)
except WorkspaceRejectedError as exc:
return {"ok": False, "locked": True, "error": str(exc)}
except TimeTravelError as exc:
details = getattr(exc, "stderr", "") or str(exc)
return {"ok": False, "error": _human_conflict_summary(str(exc)), "technical_details": details}
def _human_conflict_summary(message: str) -> str:
text = str(message or "").strip()
if not text:
return "Revert could not be applied cleanly."
first = text.splitlines()[0]
if "does not match index" in text or "patch failed" in text.lower() or "error:" in text.lower():
return "Revert could not be applied cleanly because the current workspace has conflicting changes."
return first

View file

@ -0,0 +1,28 @@
from __future__ import annotations
from helpers.api import ApiHandler, Request, Response
from plugins._time_travel.helpers.time_travel import (
TimeTravelError,
TimeTravelService,
WorkspaceRejectedError,
_snapshot_public,
resolve_workspace,
)
class HistorySnapshot(ApiHandler):
async def process(self, input: dict, request: Request) -> dict | Response:
context_id = str(input.get("context_id") or "").strip()
try:
workspace = resolve_workspace(context_id, context_loader=self.use_context)
snapshot = TimeTravelService(workspace).snapshot(
trigger=str(input.get("trigger") or "manual"),
message=str(input.get("message") or ""),
metadata=input.get("metadata") if isinstance(input.get("metadata"), dict) else {},
changed_path_hints=input.get("changed_path_hints") if isinstance(input.get("changed_path_hints"), list) else None,
)
return {"ok": True, "snapshot": _snapshot_public(snapshot)}
except WorkspaceRejectedError as exc:
return {"ok": False, "locked": True, "error": str(exc)}
except TimeTravelError as exc:
return {"ok": False, "error": str(exc), "technical_details": getattr(exc, "stderr", "")}

View file

@ -0,0 +1,28 @@
from __future__ import annotations
from helpers.api import ApiHandler, Request, Response
from plugins._time_travel.helpers.time_travel import (
TimeTravelError,
TimeTravelService,
WorkspaceRejectedError,
resolve_workspace,
)
class HistoryTravel(ApiHandler):
async def process(self, input: dict, request: Request) -> dict | Response:
context_id = str(input.get("context_id") or "").strip()
try:
workspace = resolve_workspace(context_id, context_loader=self.use_context)
return TimeTravelService(workspace).travel(
commit_hash=str(input.get("commit_hash") or ""),
metadata=input.get("metadata") if isinstance(input.get("metadata"), dict) else {},
)
except WorkspaceRejectedError as exc:
return {"ok": False, "locked": True, "error": str(exc)}
except TimeTravelError as exc:
return {
"ok": False,
"error": str(exc),
"technical_details": getattr(exc, "stderr", "") or str(exc),
}

View file

@ -0,0 +1,18 @@
from __future__ import annotations
from typing import Any
from helpers.extension import Extension
from plugins._time_travel.helpers.time_travel import snapshot_for_agent
class TimeTravelTextEditorPatchSnapshot(Extension):
async def execute(self, data: dict[str, Any] | None = None, **kwargs: Any):
snapshot_for_agent(
self.agent,
trigger="text_editor_patch",
metadata={
"patch_mode": str((data or {}).get("mode") or "edits"),
"changed_path_hints": [str((data or {}).get("path") or "")],
},
)

View file

@ -0,0 +1,17 @@
from __future__ import annotations
from typing import Any
from helpers.extension import Extension
from plugins._time_travel.helpers.time_travel import snapshot_for_agent
class TimeTravelTextEditorWriteSnapshot(Extension):
async def execute(self, data: dict[str, Any] | None = None, **kwargs: Any):
snapshot_for_agent(
self.agent,
trigger="text_editor_write",
metadata={
"changed_path_hints": [str((data or {}).get("path") or "")],
},
)

View file

@ -0,0 +1,39 @@
from __future__ import annotations
import time
from typing import Any
from helpers.extension import Extension
from plugins._time_travel.helpers.time_travel import snapshot_for_agent
DEBOUNCE_SECONDS = 2.0
_LAST_SNAPSHOT_BY_CONTEXT: dict[str, float] = {}
class TimeTravelCodeExecutionSnapshot(Extension):
async def execute(self, tool_name: str = "", response: Any = None, **kwargs: Any):
if tool_name != "code_execution_tool" or not self.agent:
return
context_id = str(getattr(getattr(self.agent, "context", None), "id", "") or "")
now = time.monotonic()
if context_id and now - _LAST_SNAPSHOT_BY_CONTEXT.get(context_id, 0.0) < DEBOUNCE_SECONDS:
return
if context_id:
_LAST_SNAPSHOT_BY_CONTEXT[context_id] = now
tool = getattr(getattr(self.agent, "loop_data", None), "current_tool", None)
args = getattr(tool, "args", {}) if tool else {}
runtime = str(args.get("runtime") or "") if isinstance(args, dict) else ""
if runtime == "output":
return
snapshot_for_agent(
self.agent,
trigger="code_execution",
metadata={
"tool_name": tool_name,
"runtime": runtime,
},
)

View file

@ -0,0 +1,28 @@
from __future__ import annotations
from typing import Any
from helpers.extension import Extension
from plugins._time_travel.helpers.time_travel import snapshot_for_path_hint
class TimeTravelWorkdirFileMutationSnapshot(Extension):
async def execute(self, data: dict[str, Any] | None = None, **kwargs: Any):
payload = data or {}
paths = payload.get("paths")
if not isinstance(paths, list):
paths = [payload.get("path") or payload.get("current_path") or payload.get("parent_path")]
first_path = next((str(path) for path in paths if path), "")
if not first_path:
return
snapshot_for_path_hint(
first_path,
trigger=f"file_browser_{payload.get('action') or 'mutation'}",
metadata={
"source": "file_browser",
"action": payload.get("action") or "mutation",
"changed_path_hints": [str(path) for path in paths if path],
},
)

View file

@ -0,0 +1,9 @@
export default function refreshTimeTravelOnContextChange(ctx) {
const store = globalThis.Alpine?.store?.("timeTravel");
const canvas = globalThis.Alpine?.store?.("rightCanvas");
if (!store || !canvas?.isOpen || canvas.activeSurfaceId !== "time-travel") return;
const nextContextId = String(ctx?.snapshot?.context || "");
if (nextContextId && nextContextId !== store.contextId) {
store.scheduleRefresh({ contextId: nextContextId, reason: "context-change" });
}
}

View file

@ -0,0 +1,8 @@
<div
class="right-canvas-surface-panel time-travel-canvas-surface"
data-surface-id="time-travel"
x-show="$store.rightCanvas && $store.rightCanvas.isOpen && $store.rightCanvas.activeSurfaceId === 'time-travel'"
style="display: none;"
>
<x-component path="/plugins/_time_travel/webui/time-travel-panel.html" mode="canvas"></x-component>
</div>

View file

@ -17,21 +17,21 @@ function waitForElement(selector, timeoutMs = 3000) {
});
}
export default async function registerDiffViewerSurface(canvas) {
export default async function registerTimeTravelSurface(canvas) {
canvas.registerSurface({
id: "diff",
title: "Diff",
icon: "difference",
id: "time-travel",
title: "Time Travel",
icon: "history",
order: 30,
modalPath: "/plugins/_diff_viewer/webui/main.html",
modalPath: "/plugins/_time_travel/webui/main.html",
async open(payload = {}) {
await waitForElement('[data-surface-id="diff"] .diff-viewer-panel');
const diffViewer = globalThis.Alpine?.store?.("diffViewer");
await diffViewer?.onOpen?.(payload);
await waitForElement('[data-surface-id="time-travel"] .time-travel-panel');
const store = globalThis.Alpine?.store?.("timeTravel");
await store?.onOpen?.(payload);
},
async close() {
const diffViewer = globalThis.Alpine?.store?.("diffViewer");
diffViewer?.cleanup?.();
const store = globalThis.Alpine?.store?.("timeTravel");
store?.cleanup?.();
},
});
}

View file

@ -0,0 +1 @@

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,8 @@
name: _time_travel
title: Time Travel
description: Agent Zero-owned workspace history, diff inspection, travel, and revert for active /a0/usr workspaces.
version: 0.1.0
always_enabled: false
settings_sections: []
per_project_config: false
per_agent_config: false

View file

@ -0,0 +1,8 @@
<html>
<head>
<title>Time Travel</title>
</head>
<body>
<x-component path="/plugins/_time_travel/webui/time-travel-panel.html" mode="modal"></x-component>
</body>
</html>

View file

@ -0,0 +1,907 @@
<html>
<head>
<script type="module">
import { store } from "/plugins/_time_travel/webui/time-travel-store.js";
</script>
</head>
<body>
<div class="time-travel-panel" x-data x-create="$store.timeTravel.onMount($el, xAttrs($el) || {})" x-destroy="$store.timeTravel.cleanup()">
<template x-if="$store.timeTravel">
<div class="time-travel-shell">
<div class="time-travel-toolbar">
<div class="time-travel-title">
<span class="material-symbols-outlined">history</span>
<span>Time Travel</span>
</div>
<div class="time-travel-workspace" :title="$store.timeTravel.workspacePath" x-text="$store.timeTravel.workspacePath || 'workspace'"></div>
<span class="time-travel-spacer"></span>
<button type="button" class="time-travel-icon-button" title="Snapshot" aria-label="Snapshot" @click="$store.timeTravel.manualSnapshot()" :disabled="$store.timeTravel.busy || $store.timeTravel.loading || $store.timeTravel.isLocked()">
<span class="material-symbols-outlined">add_a_photo</span>
</button>
<button type="button" class="time-travel-icon-button" title="Refresh" aria-label="Refresh" @click="$store.timeTravel.refresh({ keepSelection: true })" :disabled="$store.timeTravel.loading">
<span class="material-symbols-outlined" :class="{ spinning: $store.timeTravel.loading }">refresh</span>
</button>
</div>
<div class="time-travel-status" x-show="$store.timeTravel.loading || $store.timeTravel.error || $store.timeTravel.busy" style="display: none;">
<span class="material-symbols-outlined" :class="{ spinning: $store.timeTravel.loading || $store.timeTravel.busy }" x-text="$store.timeTravel.error ? 'error' : 'progress_activity'"></span>
<span x-text="$store.timeTravel.error || ($store.timeTravel.busy ? 'Working...' : 'Loading history...')"></span>
</div>
<template x-if="$store.timeTravel.isLocked() && !$store.timeTravel.loading">
<div class="time-travel-locked">
<span class="material-symbols-outlined">lock</span>
<strong>Unavailable</strong>
<span x-text="$store.timeTravel.payload?.workspace?.error || 'Time Travel is available only inside /a0/usr.'"></span>
</div>
</template>
<template x-if="!$store.timeTravel.isLocked()">
<div class="time-travel-body">
<aside class="time-travel-timeline">
<div class="time-travel-filter">
<span class="material-symbols-outlined">filter_list</span>
<input
type="search"
placeholder="Filter files"
x-model="$store.timeTravel.fileFilter"
@input="$store.timeTravel.scheduleFilterRefresh()"
/>
</div>
<div class="time-travel-rows">
<template x-for="row in $store.timeTravel.timelineRows()" :key="row.key">
<button
type="button"
class="time-travel-row"
:class="{ 'is-active': $store.timeTravel.selectedHash === row.key, 'is-present': row.kind === 'present', 'is-current': row.is_current }"
@click="$store.timeTravel.selectRow(row)"
:title="row.message"
>
<span class="time-travel-row-mark">
<span class="material-symbols-outlined" x-text="row.kind === 'present' ? (row.dirty ? 'edit_note' : 'check_circle') : (row.is_current ? 'radio_button_checked' : 'commit')"></span>
</span>
<span class="time-travel-row-main">
<span class="time-travel-row-title" x-text="row.message"></span>
<span class="time-travel-row-meta" x-text="$store.timeTravel.rowMeta(row)"></span>
</span>
<span class="time-travel-row-count" x-text="row.files?.length || 0"></span>
</button>
</template>
</div>
<button type="button" class="time-travel-load-more" x-show="$store.timeTravel.payload?.has_more" style="display: none;" @click="$store.timeTravel.loadMore()" :disabled="$store.timeTravel.loading">
<span class="material-symbols-outlined">expand_more</span>
<span>More</span>
</button>
</aside>
<main class="time-travel-detail">
<div class="time-travel-detail-header">
<div class="time-travel-detail-title">
<strong x-text="$store.timeTravel.selectedRow()?.message || 'History'"></strong>
<span x-text="$store.timeTravel.rowMeta($store.timeTravel.selectedRow())"></span>
</div>
<div class="time-travel-actions" x-show="$store.timeTravel.selectedCommit()" style="display: none;">
<button type="button" class="time-travel-tool-button" title="Travel" @click="$store.timeTravel.openPreview('travel')" :disabled="$store.timeTravel.selectedCommit()?.is_current || $store.timeTravel.busy">
<span class="material-symbols-outlined">move_down</span>
<span>Travel</span>
</button>
<button type="button" class="time-travel-tool-button" title="Revert" @click="$store.timeTravel.openPreview('revert')" :disabled="$store.timeTravel.busy">
<span class="material-symbols-outlined">undo</span>
<span>Revert</span>
</button>
</div>
</div>
<div class="time-travel-detail-grid">
<section class="time-travel-files">
<template x-if="$store.timeTravel.selectedFiles().length === 0">
<div class="time-travel-empty">
<span class="material-symbols-outlined">hourglass_empty</span>
<span>No file changes</span>
</div>
</template>
<template x-for="file in $store.timeTravel.selectedFiles()" :key="$store.timeTravel.fileKey(file)">
<button
type="button"
class="time-travel-file-row"
:class="{ 'is-active': $store.timeTravel.fileKey(file) === $store.timeTravel.selectedPath }"
@click="$store.timeTravel.selectFile(file)"
:title="$store.timeTravel.fileTitle(file)"
>
<span class="time-travel-file-name" x-text="$store.timeTravel.fileTitle(file)"></span>
<span class="time-travel-file-status" x-text="$store.timeTravel.statusLabel(file)"></span>
<span class="time-travel-file-counts">
<span class="diff-add" x-text="$store.timeTravel.formatSigned(file.additions, '+')"></span>
<span class="diff-del" x-text="$store.timeTravel.formatSigned(file.deletions, '-')"></span>
</span>
</button>
</template>
</section>
<section class="time-travel-diff">
<div class="time-travel-diff-toolbar">
<span class="time-travel-diff-path" :title="$store.timeTravel.fileTitle($store.timeTravel.selectedFile())" x-text="$store.timeTravel.fileTitle($store.timeTravel.selectedFile()) || 'Diff'"></span>
<span class="time-travel-spacer"></span>
<button type="button" class="time-travel-icon-button is-small" title="Open containing folder" aria-label="Open containing folder" @click="$store.timeTravel.openContainingFolder($store.timeTravel.selectedFile())" :disabled="!$store.timeTravel.selectedFile()">
<span class="material-symbols-outlined">folder_open</span>
</button>
<button type="button" class="time-travel-icon-button is-small" title="Copy path" aria-label="Copy path" @click="$store.timeTravel.copyPath($store.timeTravel.selectedFile())" :disabled="!$store.timeTravel.selectedFile()">
<span class="material-symbols-outlined">content_copy</span>
</button>
</div>
<div class="time-travel-diff-status" x-show="$store.timeTravel.diffLoading || $store.timeTravel.diffError" style="display: none;">
<span class="material-symbols-outlined" :class="{ spinning: $store.timeTravel.diffLoading }" x-text="$store.timeTravel.diffError ? 'error' : 'progress_activity'"></span>
<span x-text="$store.timeTravel.diffError || 'Loading diff...'"></span>
</div>
<template x-if="$store.timeTravel.selectedDiff?.binary">
<div class="time-travel-empty">
<span class="material-symbols-outlined">data_object</span>
<span>Binary file changed</span>
</div>
</template>
<template x-if="$store.timeTravel.selectedDiff?.too_large && !$store.timeTravel.selectedDiff?.binary">
<div class="time-travel-empty">
<span class="material-symbols-outlined">text_snippet</span>
<span>Diff exceeds the 1 MB render limit</span>
</div>
</template>
<template x-if="$store.timeTravel.selectedDiff?.patch && !$store.timeTravel.selectedDiff?.binary">
<div class="time-travel-code" role="table" aria-label="Unified diff">
<template x-for="line in $store.timeTravel.patchLines()" :key="line.id">
<div class="time-travel-line" :class="`is-${line.type}`" role="row">
<span class="time-travel-line-marker" x-text="line.type === 'add' ? '+' : (line.type === 'del' ? '-' : '')"></span>
<code x-text="line.text"></code>
</div>
</template>
</div>
</template>
</section>
</div>
</main>
</div>
</template>
<div class="time-travel-preview-backdrop" x-show="$store.timeTravel.previewOpen" style="display: none;">
<section class="time-travel-preview" role="dialog" aria-modal="true" aria-label="Time Travel preview">
<header>
<span class="material-symbols-outlined" x-text="$store.timeTravel.preview?.operation === 'travel' ? 'move_down' : 'undo'"></span>
<strong x-text="$store.timeTravel.preview?.operation === 'travel' ? 'Travel Preview' : 'Revert Preview'"></strong>
<button type="button" class="time-travel-icon-button is-small" title="Close" aria-label="Close" @click="$store.timeTravel.closePreview()" :disabled="$store.timeTravel.busy">
<span class="material-symbols-outlined">close</span>
</button>
</header>
<div class="time-travel-preview-body">
<div class="time-travel-preview-status" x-show="$store.timeTravel.previewLoading || $store.timeTravel.previewError" style="display: none;">
<span class="material-symbols-outlined" :class="{ spinning: $store.timeTravel.previewLoading }" x-text="$store.timeTravel.previewError ? 'error' : 'progress_activity'"></span>
<span x-text="$store.timeTravel.previewError || 'Building preview...'"></span>
</div>
<div class="time-travel-preview-summary">
<span x-text="$store.timeTravel.preview?.short_hash || ''"></span>
<span class="time-travel-dot"></span>
<span x-text="`${$store.timeTravel.preview?.files?.length || 0} affected files`"></span>
</div>
<div class="time-travel-preview-files">
<template x-for="file in ($store.timeTravel.preview?.files || []).slice(0, 80)" :key="$store.timeTravel.fileKey(file)">
<div class="time-travel-preview-file">
<span x-text="$store.timeTravel.fileTitle(file)"></span>
<span x-text="$store.timeTravel.statusLabel(file)"></span>
</div>
</template>
</div>
<details x-show="$store.timeTravel.previewTechnicalDetails" style="display: none;">
<summary>Details</summary>
<pre x-text="$store.timeTravel.previewTechnicalDetails"></pre>
</details>
</div>
<footer>
<button type="button" class="time-travel-tool-button" @click="$store.timeTravel.closePreview()" :disabled="$store.timeTravel.busy">
<span class="material-symbols-outlined">close</span>
<span>Cancel</span>
</button>
<button type="button" class="time-travel-tool-button is-primary" @click="$store.timeTravel.confirmPreview()" :disabled="Boolean($store.timeTravel.busy || $store.timeTravel.previewLoading || $store.timeTravel.previewError)">
<span class="material-symbols-outlined" :class="{ spinning: $store.timeTravel.busy }" x-text="$store.timeTravel.busy ? 'progress_activity' : ($store.timeTravel.preview?.operation === 'travel' ? 'move_down' : 'undo')"></span>
<span x-text="$store.timeTravel.preview?.operation === 'travel' ? 'Travel' : 'Revert'"></span>
</button>
</footer>
</section>
</div>
</div>
</template>
</div>
<style>
.time-travel-panel,
.time-travel-shell {
display: flex;
flex: 1 1 auto;
flex-direction: column;
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
background: color-mix(in srgb, var(--color-background) 95%, #000 5%);
color: var(--color-text);
}
.time-travel-panel {
container-type: inline-size;
}
.modal-inner.time-travel-modal {
box-sizing: border-box;
container-type: inline-size;
width: min(86vw, 1240px);
height: min(88vh, 920px);
min-width: min(360px, calc(100vw - 16px));
min-height: min(520px, calc(100vh - 16px));
max-width: calc(100vw - 16px);
max-height: calc(100vh - 16px);
resize: both;
border: 1px solid color-mix(in srgb, var(--color-border) 75%, transparent);
border-radius: 7px;
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.32);
background: color-mix(in srgb, var(--color-background) 94%, #000 6%);
}
.modal.modal-floating {
pointer-events: none;
}
.modal.modal-floating .modal-inner {
pointer-events: auto;
}
.modal-inner.time-travel-modal .modal-header {
min-height: 34px;
padding: 0.35rem 0.75rem 0.35rem 1rem;
cursor: move;
user-select: none;
background: color-mix(in srgb, var(--color-background) 92%, #000 8%);
border-bottom: 1px solid color-mix(in srgb, var(--color-border) 70%, transparent);
}
.modal-inner.time-travel-modal .modal-scroll,
.modal-inner.time-travel-modal .modal-bd.time-travel-modal-body,
.modal-inner.time-travel-modal .modal-bd.time-travel-modal-body > x-component,
.modal-inner.time-travel-modal .modal-bd.time-travel-modal-body > div[x-data] {
display: flex;
flex: 1 1 auto;
width: 100%;
height: 100%;
min-height: 0;
overflow: hidden;
padding: 0;
}
.time-travel-toolbar,
.time-travel-status,
.time-travel-title,
.time-travel-filter,
.time-travel-actions,
.time-travel-tool-button,
.time-travel-diff-toolbar,
.time-travel-diff-status,
.time-travel-file-counts,
.time-travel-preview header,
.time-travel-preview footer,
.time-travel-preview-status,
.time-travel-preview-summary {
display: flex;
align-items: center;
}
.time-travel-toolbar {
gap: 8px;
min-height: 44px;
padding: var(--spacing-xs) var(--spacing-md);
border-bottom: 1px solid color-mix(in srgb, var(--color-border) 64%, transparent);
background: color-mix(in srgb, var(--color-background) 91%, #000 9%);
}
.time-travel-title {
gap: 7px;
font-weight: 750;
font-size: 0.9rem;
}
.time-travel-title .material-symbols-outlined {
font-size: 19px;
}
.time-travel-workspace {
min-width: 0;
max-width: 45%;
overflow: hidden;
color: var(--color-text-muted);
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--font-family-code);
font-size: 0.72rem;
}
.time-travel-spacer {
flex: 1 1 auto;
min-width: 8px;
}
.time-travel-icon-button,
.time-travel-tool-button,
.time-travel-load-more {
appearance: none;
border: 1px solid color-mix(in srgb, var(--color-border) 64%, transparent);
border-radius: 7px;
background: color-mix(in srgb, var(--color-panel) 80%, transparent);
color: var(--color-text);
font: inherit;
cursor: pointer;
}
.time-travel-icon-button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
min-width: 32px;
padding: 0;
}
.time-travel-icon-button.is-small {
width: 28px;
height: 28px;
min-width: 28px;
}
.time-travel-tool-button {
gap: 5px;
min-height: 30px;
padding: 4px 9px;
font-size: 0.76rem;
}
.time-travel-tool-button.is-primary {
border-color: color-mix(in srgb, var(--color-primary) 42%, var(--color-border));
background: color-mix(in srgb, var(--color-primary) 22%, var(--color-panel));
}
.time-travel-icon-button:hover:not(:disabled),
.time-travel-tool-button:hover:not(:disabled),
.time-travel-load-more:hover:not(:disabled) {
background: color-mix(in srgb, var(--color-background-hover) 70%, transparent);
border-color: color-mix(in srgb, var(--color-primary) 28%, var(--color-border));
}
.time-travel-icon-button:disabled,
.time-travel-tool-button:disabled,
.time-travel-load-more:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.time-travel-icon-button .material-symbols-outlined,
.time-travel-tool-button .material-symbols-outlined,
.time-travel-load-more .material-symbols-outlined {
font-size: 17px;
}
.time-travel-status,
.time-travel-diff-status,
.time-travel-preview-status {
gap: 8px;
min-height: 34px;
padding: 6px 11px;
border-bottom: 1px solid color-mix(in srgb, var(--color-border) 44%, transparent);
color: var(--color-text-muted);
font-size: 0.82rem;
}
.time-travel-body {
display: grid;
flex: 1 1 auto;
grid-template-columns: minmax(210px, 0.42fr) minmax(0, 1fr);
min-width: 0;
min-height: 0;
}
.time-travel-timeline {
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
border-right: 1px solid color-mix(in srgb, var(--color-border) 58%, transparent);
background: color-mix(in srgb, var(--color-panel) 48%, transparent);
}
.time-travel-filter {
gap: 6px;
min-height: 40px;
padding: 7px 9px;
border-bottom: 1px solid color-mix(in srgb, var(--color-border) 42%, transparent);
}
.time-travel-filter .material-symbols-outlined {
font-size: 17px;
color: var(--color-text-muted);
}
.time-travel-filter input {
width: 100%;
min-width: 0;
height: 28px;
border: 1px solid color-mix(in srgb, var(--color-border) 60%, transparent);
border-radius: 7px;
background: color-mix(in srgb, var(--color-background) 72%, transparent);
color: var(--color-text);
font-size: 0.78rem;
padding: 0 8px;
}
.time-travel-rows {
display: flex;
flex: 1 1 auto;
flex-direction: column;
min-width: 0;
min-height: 0;
overflow: auto;
padding: 7px;
}
.time-travel-row {
display: grid;
grid-template-columns: 24px minmax(0, 1fr) auto;
align-items: center;
gap: 7px;
width: 100%;
min-height: 50px;
padding: 6px 7px;
border: 1px solid transparent;
border-radius: 7px;
background: transparent;
color: var(--color-text);
font: inherit;
text-align: left;
cursor: pointer;
}
.time-travel-row:hover,
.time-travel-row.is-active {
border-color: color-mix(in srgb, var(--color-primary) 25%, var(--color-border));
background: color-mix(in srgb, var(--color-background-hover) 56%, transparent);
}
.time-travel-row-mark .material-symbols-outlined {
font-size: 18px;
color: var(--color-text-muted);
}
.time-travel-row.is-current .time-travel-row-mark .material-symbols-outlined {
color: var(--color-primary);
}
.time-travel-row-main {
display: grid;
min-width: 0;
gap: 2px;
}
.time-travel-row-title,
.time-travel-row-meta,
.time-travel-file-name,
.time-travel-diff-path {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.time-travel-row-title {
font-size: 0.79rem;
font-weight: 650;
}
.time-travel-row-meta {
color: var(--color-text-muted);
font-family: var(--font-family-code);
font-size: 0.69rem;
}
.time-travel-row-count {
min-width: 24px;
padding: 2px 6px;
border-radius: 999px;
background: color-mix(in srgb, var(--color-background) 70%, transparent);
color: var(--color-text-muted);
text-align: center;
font-size: 0.7rem;
}
.time-travel-load-more {
display: flex;
align-items: center;
justify-content: center;
gap: 5px;
min-height: 34px;
margin: 7px;
font-size: 0.76rem;
}
.time-travel-detail {
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
}
.time-travel-detail-header {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px;
align-items: center;
min-height: 48px;
padding: 8px 10px;
border-bottom: 1px solid color-mix(in srgb, var(--color-border) 50%, transparent);
}
.time-travel-detail-title {
display: grid;
min-width: 0;
gap: 2px;
}
.time-travel-detail-title strong,
.time-travel-detail-title span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.time-travel-detail-title strong {
font-size: 0.88rem;
}
.time-travel-detail-title span {
color: var(--color-text-muted);
font-size: 0.72rem;
}
.time-travel-actions {
gap: 6px;
}
.time-travel-detail-grid {
display: grid;
flex: 1 1 auto;
grid-template-columns: minmax(190px, 0.36fr) minmax(0, 1fr);
min-width: 0;
min-height: 0;
}
.time-travel-files {
min-width: 0;
min-height: 0;
overflow: auto;
border-right: 1px solid color-mix(in srgb, var(--color-border) 45%, transparent);
}
.time-travel-file-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 4px 7px;
width: 100%;
min-height: 46px;
padding: 7px 9px;
border: 0;
border-bottom: 1px solid color-mix(in srgb, var(--color-border) 26%, transparent);
background: transparent;
color: var(--color-text);
font: inherit;
text-align: left;
cursor: pointer;
}
.time-travel-file-row:hover,
.time-travel-file-row.is-active {
background: color-mix(in srgb, var(--color-background-hover) 54%, transparent);
}
.time-travel-file-name {
font-family: var(--font-family-code);
font-size: 0.74rem;
grid-column: 1 / -1;
}
.time-travel-file-status {
color: var(--color-text-muted);
font-size: 0.68rem;
text-transform: capitalize;
}
.time-travel-file-counts {
gap: 5px;
justify-content: end;
font-family: var(--font-family-code);
font-size: 0.7rem;
}
.time-travel-diff {
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
overflow: hidden;
}
.time-travel-diff-toolbar {
gap: 6px;
min-height: 38px;
padding: 5px 8px;
border-bottom: 1px solid color-mix(in srgb, var(--color-border) 42%, transparent);
background: color-mix(in srgb, var(--color-panel) 44%, transparent);
}
.time-travel-diff-path {
font-family: var(--font-family-code);
font-size: 0.74rem;
font-weight: 650;
}
.time-travel-code {
flex: 1 1 auto;
min-width: 0;
min-height: 0;
overflow: auto;
padding: 4px 0;
background: color-mix(in srgb, var(--color-background) 91%, #000 9%);
font-family: var(--font-family-code);
font-size: 0.73rem;
line-height: 1.45;
}
.time-travel-line {
display: grid;
grid-template-columns: 24px minmax(0, 1fr);
min-width: max-content;
}
.time-travel-line-marker {
position: sticky;
left: 0;
z-index: 1;
min-height: 1.45em;
padding-right: 5px;
background: inherit;
color: var(--color-text-muted);
text-align: right;
user-select: none;
}
.time-travel-line code {
display: block;
min-height: 1.45em;
padding: 0 10px 0 6px;
color: inherit;
font: inherit;
white-space: pre;
}
.time-travel-line.is-add {
background: rgba(39, 174, 96, 0.16);
color: color-mix(in srgb, #8df0b0 78%, var(--color-text));
}
.time-travel-line.is-del {
background: rgba(231, 76, 60, 0.17);
color: color-mix(in srgb, #ffaaa2 78%, var(--color-text));
}
.time-travel-line.is-hunk {
background: rgba(99, 102, 241, 0.16);
color: color-mix(in srgb, #b9c3ff 80%, var(--color-text));
}
.time-travel-line.is-meta,
.time-travel-line.is-note {
color: var(--color-text-muted);
}
.time-travel-empty,
.time-travel-locked {
display: grid;
flex: 1 1 auto;
place-items: center;
align-content: center;
gap: 7px;
min-width: 0;
padding: 24px;
color: var(--color-text-muted);
text-align: center;
}
.time-travel-empty .material-symbols-outlined,
.time-travel-locked .material-symbols-outlined {
font-size: 28px;
}
.time-travel-locked span:last-child {
max-width: 520px;
overflow-wrap: anywhere;
font-size: 0.8rem;
}
.diff-add {
color: #31c48d;
}
.diff-del {
color: #f05252;
}
.time-travel-dot {
width: 4px;
height: 4px;
flex: 0 0 auto;
border-radius: 999px;
background: color-mix(in srgb, var(--color-text-muted) 50%, transparent);
}
.time-travel-preview-backdrop {
position: absolute;
inset: 0;
z-index: 20;
display: grid;
place-items: center;
padding: 16px;
background: rgba(0, 0, 0, 0.34);
}
.time-travel-preview {
display: flex;
flex-direction: column;
width: min(640px, 100%);
max-height: min(680px, 100%);
min-height: 260px;
border: 1px solid color-mix(in srgb, var(--color-border) 72%, transparent);
border-radius: 8px;
background: color-mix(in srgb, var(--color-background) 95%, #000 5%);
box-shadow: 0 18px 46px rgba(0, 0, 0, 0.36);
overflow: hidden;
}
.time-travel-preview header,
.time-travel-preview footer {
gap: 8px;
min-height: 44px;
padding: 8px 10px;
border-bottom: 1px solid color-mix(in srgb, var(--color-border) 54%, transparent);
}
.time-travel-preview footer {
justify-content: flex-end;
border-top: 1px solid color-mix(in srgb, var(--color-border) 54%, transparent);
border-bottom: 0;
}
.time-travel-preview header strong {
flex: 1 1 auto;
min-width: 0;
font-size: 0.9rem;
}
.time-travel-preview-body {
display: flex;
flex: 1 1 auto;
flex-direction: column;
min-width: 0;
min-height: 0;
overflow: auto;
}
.time-travel-preview-summary {
gap: 8px;
padding: 9px 11px;
color: var(--color-text-muted);
font-family: var(--font-family-code);
font-size: 0.74rem;
}
.time-travel-preview-files {
display: grid;
gap: 4px;
padding: 0 11px 11px;
}
.time-travel-preview-file {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px;
min-height: 30px;
align-items: center;
border: 1px solid color-mix(in srgb, var(--color-border) 34%, transparent);
border-radius: 7px;
padding: 4px 7px;
background: color-mix(in srgb, var(--color-panel) 62%, transparent);
font-size: 0.73rem;
}
.time-travel-preview-file span:first-child {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--font-family-code);
}
.time-travel-preview-file span:last-child {
color: var(--color-text-muted);
text-transform: capitalize;
}
.time-travel-preview details {
margin: 0 11px 11px;
color: var(--color-text-muted);
font-size: 0.76rem;
}
.time-travel-preview pre {
max-height: 160px;
overflow: auto;
padding: 8px;
border-radius: 7px;
background: color-mix(in srgb, var(--color-background) 86%, #000 14%);
white-space: pre-wrap;
}
.time-travel-panel .spinning {
display: inline-block;
animation: time-travel-spin 0.8s linear infinite;
}
@keyframes time-travel-spin {
to { transform: rotate(360deg); }
}
@container (max-width: 720px) {
.time-travel-body,
.time-travel-detail-grid {
grid-template-columns: minmax(0, 1fr);
}
.time-travel-timeline,
.time-travel-files {
max-height: 34vh;
border-right: 0;
border-bottom: 1px solid color-mix(in srgb, var(--color-border) 48%, transparent);
}
.time-travel-detail-header {
grid-template-columns: minmax(0, 1fr);
}
.time-travel-actions {
justify-content: flex-start;
}
.time-travel-workspace {
display: none;
}
}
@container (max-width: 520px) {
.time-travel-tool-button span:last-child {
display: none;
}
.time-travel-row {
min-height: 46px;
}
}
</style>
</body>
</html>

View file

@ -0,0 +1,527 @@
import { createStore } from "/js/AlpineStore.js";
import { callJsonApi } from "/js/api.js";
import { getContext } from "/index.js";
import { store as fileBrowserStore } from "/components/modals/file-browser/file-browser-store.js";
const REFRESH_DEBOUNCE_MS = 180;
function lineType(text) {
if (text.startsWith("@@")) return "hunk";
if (text.startsWith("+++") || text.startsWith("---") || text.startsWith("diff --git") || text.startsWith("index ")) {
return "meta";
}
if (text.startsWith("+")) return "add";
if (text.startsWith("-")) return "del";
if (text.startsWith("\\ No newline")) return "note";
return "context";
}
function dirname(path) {
const clean = String(path || "").replace(/\/+$/, "");
const index = clean.lastIndexOf("/");
return index > 0 ? clean.slice(0, index) : "";
}
function apiPath(name) {
return `/plugins/_time_travel/${name}`;
}
const model = {
loading: false,
busy: false,
error: "",
payload: null,
contextId: "",
workspacePath: "",
fileFilter: "",
selectedHash: "",
selectedPath: "",
selectedDiff: null,
diffLoading: false,
diffError: "",
previewOpen: false,
previewLoading: false,
previewError: "",
previewTechnicalDetails: "",
previewDetailsOpen: false,
preview: null,
_root: null,
_mode: "canvas",
_refreshTimer: null,
_filterTimer: null,
_floatingCleanup: null,
_requestSeq: 0,
_diffSeq: 0,
async init(element = null) {
await this.onMount(element, { mode: "canvas" });
},
async onMount(element = null, options = {}) {
if (element) this._root = element;
this._mode = options?.mode === "modal" ? "modal" : "canvas";
if (this._mode === "modal") {
this.setupFloatingModal(element);
} else {
this.setupCanvasSurface(element);
}
this.contextId = this.resolveContextId();
if (!this.payload && !this.loading) {
await this.refresh({ contextId: this.contextId });
}
},
async onOpen(payload = {}) {
const nextContextId = String(payload.contextId || payload.context_id || this.resolveContextId() || "");
await this.refresh({ contextId: nextContextId });
},
cleanup() {
if (this._refreshTimer) {
clearTimeout(this._refreshTimer);
this._refreshTimer = null;
}
if (this._filterTimer) {
clearTimeout(this._filterTimer);
this._filterTimer = null;
}
this._floatingCleanup?.();
this._floatingCleanup = null;
},
setupFloatingModal(element = null) {
this._floatingCleanup?.();
const root = element || globalThis.document?.querySelector(".time-travel-panel");
const modal = root?.closest?.(".modal");
const inner = modal?.querySelector?.(".modal-inner");
const body = modal?.querySelector?.(".modal-bd");
const header = modal?.querySelector?.(".modal-header");
if (!modal || !inner || !header) return;
modal.classList.add("modal-floating");
inner.classList.add("time-travel-modal", "modal-no-backdrop");
body?.classList?.add("time-travel-modal-body");
const rect = inner.getBoundingClientRect();
inner.style.left = `${Math.max(8, rect.left)}px`;
inner.style.top = `${Math.max(8, rect.top)}px`;
inner.style.transform = "none";
let drag = null;
let resizeObserver = null;
const viewportGap = 8;
const clampPosition = (left, top) => {
const bounds = inner.getBoundingClientRect();
const maxLeft = Math.max(viewportGap, globalThis.innerWidth - bounds.width - viewportGap);
const maxTop = Math.max(viewportGap, globalThis.innerHeight - bounds.height - viewportGap);
return {
left: Math.min(Math.max(viewportGap, left), maxLeft),
top: Math.min(Math.max(viewportGap, top), maxTop),
};
};
const clampGeometry = () => {
const bounds = inner.getBoundingClientRect();
const maxWidth = Math.max(360, globalThis.innerWidth - viewportGap * 2);
const maxHeight = Math.max(420, globalThis.innerHeight - viewportGap * 2);
if (bounds.width > maxWidth) inner.style.width = `${maxWidth}px`;
if (bounds.height > maxHeight) inner.style.height = `${maxHeight}px`;
const next = clampPosition(bounds.left, bounds.top);
inner.style.left = `${next.left}px`;
inner.style.top = `${next.top}px`;
inner.style.maxWidth = `${Math.max(360, globalThis.innerWidth - next.left - viewportGap)}px`;
inner.style.maxHeight = `${Math.max(420, globalThis.innerHeight - next.top - viewportGap)}px`;
};
clampGeometry();
globalThis.addEventListener("resize", clampGeometry);
if (globalThis.ResizeObserver) {
resizeObserver = new ResizeObserver(clampGeometry);
resizeObserver.observe(inner);
}
const onPointerMove = (event) => {
if (!drag) return;
const next = clampPosition(drag.left + event.clientX - drag.x, drag.top + event.clientY - drag.y);
inner.style.left = `${next.left}px`;
inner.style.top = `${next.top}px`;
clampGeometry();
};
const onPointerUp = () => {
drag = null;
globalThis.removeEventListener("pointermove", onPointerMove);
globalThis.removeEventListener("pointerup", onPointerUp);
try {
header.releasePointerCapture?.(header.__timeTravelPanelPointerId || 0);
} catch {}
};
const onPointerDown = (event) => {
if (event.button !== 0) return;
if (event.target?.closest?.("button, input, select, textarea, a")) return;
const current = inner.getBoundingClientRect();
drag = {
x: event.clientX,
y: event.clientY,
left: current.left,
top: current.top,
};
header.__timeTravelPanelPointerId = event.pointerId;
header.setPointerCapture?.(event.pointerId);
globalThis.addEventListener("pointermove", onPointerMove);
globalThis.addEventListener("pointerup", onPointerUp);
event.preventDefault();
};
header.addEventListener("pointerdown", onPointerDown);
this._floatingCleanup = () => {
header.removeEventListener("pointerdown", onPointerDown);
globalThis.removeEventListener("pointermove", onPointerMove);
globalThis.removeEventListener("pointerup", onPointerUp);
globalThis.removeEventListener("resize", clampGeometry);
resizeObserver?.disconnect?.();
};
},
setupCanvasSurface(element = null) {
this._floatingCleanup?.();
this._floatingCleanup = null;
if (element) this._root = element;
},
resolveContextId() {
const urlContext = new URLSearchParams(globalThis.location?.search || "").get("ctxid");
return getContext?.() || urlContext || globalThis.Alpine?.store?.("chats")?.selected || "";
},
scheduleRefresh(options = {}) {
if (this._refreshTimer) clearTimeout(this._refreshTimer);
this._refreshTimer = setTimeout(() => {
this._refreshTimer = null;
this.refresh(options).catch((error) => console.error("Time Travel refresh failed", error));
}, REFRESH_DEBOUNCE_MS);
},
scheduleFilterRefresh() {
if (this._filterTimer) clearTimeout(this._filterTimer);
this._filterTimer = setTimeout(() => {
this._filterTimer = null;
this.refresh({ keepSelection: false });
}, 240);
},
async refresh(options = {}) {
const contextId = String(options.contextId || options.context_id || this.resolveContextId() || "");
const seq = ++this._requestSeq;
this.loading = true;
this.error = "";
try {
const response = await callJsonApi(apiPath("history_list"), {
context_id: contextId,
limit: 100,
offset: 0,
file_filter: this.fileFilter,
});
if (seq !== this._requestSeq) return;
if (!response?.ok) throw new Error(response?.error || "Could not load history.");
this.payload = response;
this.contextId = String(response.context_id || contextId || "");
this.workspacePath = String(response.workspace?.display_path || response.workspace?.path || "");
this.reconcileSelection(Boolean(options.keepSelection));
} catch (error) {
if (seq !== this._requestSeq) return;
this.error = error instanceof Error ? error.message : String(error);
} finally {
if (seq === this._requestSeq) this.loading = false;
}
},
async loadMore() {
if (this.loading || !this.payload?.has_more || this.isLocked()) return;
const seq = ++this._requestSeq;
this.loading = true;
try {
const response = await callJsonApi(apiPath("history_list"), {
context_id: this.contextId,
limit: 100,
offset: this.commits().length,
file_filter: this.fileFilter,
});
if (seq !== this._requestSeq) return;
if (!response?.ok) throw new Error(response?.error || "Could not load history.");
this.payload.commits = [...this.commits(), ...(response.commits || [])];
this.payload.has_more = Boolean(response.has_more);
} catch (error) {
this.error = error instanceof Error ? error.message : String(error);
} finally {
if (seq === this._requestSeq) this.loading = false;
}
},
reconcileSelection(keepSelection = false) {
if (this.isLocked()) {
this.selectedHash = "";
this.selectedPath = "";
this.selectedDiff = null;
return;
}
const rows = this.timelineRows();
let selected = keepSelection ? rows.find((row) => row.key === this.selectedHash) : null;
if (!selected) selected = rows[0] || null;
this.selectedHash = selected?.key || "";
const files = this.selectedFiles();
if (!files.some((file) => this.fileKey(file) === this.selectedPath)) {
this.selectedPath = files[0] ? this.fileKey(files[0]) : "";
}
void this.loadSelectedDiff();
},
commits() {
return Array.isArray(this.payload?.commits) ? this.payload.commits : [];
},
present() {
return this.payload?.present || {};
},
isLocked() {
return Boolean(this.payload?.workspace?.locked || this.payload?.workspace?.available === false);
},
hasHistory() {
return this.commits().length > 0;
},
hasPresentChanges() {
return Boolean(this.present()?.dirty);
},
timelineRows() {
const rows = [];
rows.push({
key: "present",
kind: "present",
hash: this.payload?.current_hash || "",
short_hash: "present",
message: this.hasPresentChanges() ? "Present changes" : "Present clean",
timestamp: "",
files: this.present()?.files || [],
is_current: false,
dirty: this.hasPresentChanges(),
});
for (const commit of this.commits()) {
rows.push({ key: commit.hash, kind: "commit", ...commit });
}
return rows;
},
selectedRow() {
return this.timelineRows().find((row) => row.key === this.selectedHash) || null;
},
selectedCommit() {
const row = this.selectedRow();
return row?.kind === "commit" ? row : null;
},
selectedFiles() {
const row = this.selectedRow();
return Array.isArray(row?.files) ? row.files : [];
},
selectedFile() {
return this.selectedFiles().find((file) => this.fileKey(file) === this.selectedPath) || null;
},
selectRow(row) {
this.selectedHash = row?.key || "";
const files = this.selectedFiles();
this.selectedPath = files[0] ? this.fileKey(files[0]) : "";
void this.loadSelectedDiff();
},
selectFile(file) {
this.selectedPath = this.fileKey(file);
void this.loadSelectedDiff();
},
fileKey(file) {
return `${file?.old_path || ""}:${file?.path || ""}`;
},
async loadSelectedDiff() {
const file = this.selectedFile();
const row = this.selectedRow();
this.selectedDiff = null;
this.diffError = "";
if (!file || !row || this.isLocked()) return;
const seq = ++this._diffSeq;
this.diffLoading = true;
try {
const response = await callJsonApi(apiPath("history_diff"), {
context_id: this.contextId,
commit_hash: row.kind === "present" ? this.payload?.current_hash || "" : row.hash,
path: file.path || file.old_path,
mode: row.kind === "present" ? "present" : "commit",
});
if (seq !== this._diffSeq) return;
if (!response?.ok) throw new Error(response?.error || "Could not load diff.");
this.selectedDiff = response;
} catch (error) {
if (seq !== this._diffSeq) return;
this.diffError = error instanceof Error ? error.message : String(error);
} finally {
if (seq === this._diffSeq) this.diffLoading = false;
}
},
async manualSnapshot() {
if (this.busy || this.isLocked()) return;
this.busy = true;
this.error = "";
try {
const response = await callJsonApi(apiPath("history_snapshot"), {
context_id: this.contextId,
trigger: "manual",
});
if (!response?.ok) throw new Error(response?.error || "Snapshot failed.");
globalThis.justToast?.(response.snapshot?.created ? "Snapshot captured" : "No changes to snapshot", "success", 1400, "time-travel-snapshot");
await this.refresh({ keepSelection: true });
} catch (error) {
this.error = error instanceof Error ? error.message : String(error);
} finally {
this.busy = false;
}
},
async openPreview(operation, commit = null) {
const target = commit || this.selectedCommit();
if (!target || this.busy || this.isLocked()) return;
if (operation === "travel" && target.is_current) return;
this.previewOpen = true;
this.previewLoading = true;
this.previewError = "";
this.previewTechnicalDetails = "";
this.previewDetailsOpen = false;
this.preview = { operation, commit_hash: target.hash, short_hash: target.short_hash, files: [], previews: [] };
try {
const response = await callJsonApi(apiPath("history_preview"), {
context_id: this.contextId,
operation,
commit_hash: target.hash,
});
if (!response?.ok) throw new Error(response?.error || "Preview failed.");
this.preview = response;
} catch (error) {
this.previewError = error instanceof Error ? error.message : String(error);
} finally {
this.previewLoading = false;
}
},
closePreview() {
if (this.busy) return;
this.previewOpen = false;
this.preview = null;
this.previewError = "";
this.previewTechnicalDetails = "";
this.previewDetailsOpen = false;
},
async confirmPreview() {
if (!this.preview || this.busy || this.previewLoading) return;
const operation = this.preview.operation;
const endpoint = operation === "travel" ? "history_travel" : "history_revert";
this.busy = true;
this.previewError = "";
this.previewDetailsOpen = false;
try {
const response = await callJsonApi(apiPath(endpoint), {
context_id: this.contextId,
commit_hash: this.preview.commit_hash,
metadata: { source: "time_travel_ui" },
});
if (!response?.ok) {
const error = new Error(response?.error || `${operation} failed.`);
error.technicalDetails = response?.technical_details || "";
throw error;
}
globalThis.justToast?.(operation === "travel" ? "Workspace traveled" : "Revert applied", "success", 1500, "time-travel-apply");
this.previewOpen = false;
this.preview = null;
await this.refresh({ keepSelection: false });
} catch (error) {
this.previewError = error instanceof Error ? error.message : String(error);
this.previewTechnicalDetails = error?.technicalDetails || "";
} finally {
this.busy = false;
}
},
patchLines(diff = null) {
const patch = String((diff || this.selectedDiff)?.patch || "");
if (!patch) return [];
const textLines = patch.endsWith("\n") ? patch.slice(0, -1).split("\n") : patch.split("\n");
return textLines.map((text, index) => ({
id: `${index}-${text.slice(0, 20)}`,
text,
type: lineType(text),
}));
},
fileTitle(file) {
if (file?.old_path && file.old_path !== file.path) {
return `${file.old_path} -> ${file.path}`;
}
return file?.path || file?.old_path || "";
},
statusLabel(file) {
return String(file?.action || file?.status || "changed").replaceAll("_", " ");
},
rowMeta(row) {
if (!row) return "";
const files = Array.isArray(row.files) ? row.files.length : 0;
if (row.kind === "present") return files ? `${files} file${files === 1 ? "" : "s"}` : "clean";
return `${row.short_hash || ""} · ${this.formatTime(row.timestamp)}`;
},
formatTime(value) {
if (!value) return "";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return String(value);
return date.toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
},
formatSigned(value, sign) {
const number = Number(value) || 0;
return `${sign}${number.toLocaleString()}`;
},
fullPath(file) {
const relativePath = String(file?.path || file?.old_path || "").replace(/^\/+/, "");
const base = String(this.workspacePath || "").replace(/\/+$/, "");
return relativePath ? `${base}/${relativePath}` : base;
},
async openContainingFolder(file) {
const parent = dirname(this.fullPath(file));
await fileBrowserStore.open(parent || this.workspacePath || "$WORK_DIR");
},
async copyPath(file) {
const path = this.fullPath(file);
try {
await navigator.clipboard.writeText(path);
globalThis.justToast?.("Path copied", "success", 1200, "time-travel-copy");
} catch (_error) {
globalThis.prompt?.("Copy path", path);
}
},
};
export const store = createStore("timeTravel", model);

View file

@ -1,192 +0,0 @@
from __future__ import annotations
import asyncio
import subprocess
import sys
import threading
from pathlib import Path
from types import SimpleNamespace
import pytest
from flask import Flask
PROJECT_ROOT = Path(__file__).resolve().parents[1]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from plugins._diff_viewer.helpers import diff as diff_helper
from plugins._diff_viewer.helpers.diff import collect_workspace_diff
def run_git(repo_dir: Path, *args: str) -> str:
completed = subprocess.run(
["git", "-C", str(repo_dir), *args],
check=True,
text=True,
capture_output=True,
)
return completed.stdout.strip()
def init_repo(repo_dir: Path) -> None:
run_git(repo_dir, "init")
run_git(repo_dir, "config", "user.name", "Test User")
run_git(repo_dir, "config", "user.email", "test@example.com")
(repo_dir / "tracked.txt").write_text("one\n", encoding="utf-8")
run_git(repo_dir, "add", "tracked.txt")
run_git(repo_dir, "commit", "-m", "initial")
def files_for_group(payload: dict, kind: str) -> list[dict]:
return next(group["files"] for group in payload["groups"] if group["kind"] == kind)
def test_collect_workspace_diff_returns_non_git_state(tmp_path: Path) -> None:
payload = collect_workspace_diff(str(tmp_path), context_id="ctx")
assert payload["ok"] is True
assert payload["context_id"] == "ctx"
assert payload["is_git_repo"] is False
assert payload["totals"] == {"files": 0, "additions": 0, "deletions": 0}
def test_collect_workspace_diff_groups_staged_unstaged_and_untracked(tmp_path: Path) -> None:
init_repo(tmp_path)
(tmp_path / "tracked.txt").write_text("one\nstaged\n", encoding="utf-8")
run_git(tmp_path, "add", "tracked.txt")
(tmp_path / "tracked.txt").write_text("one\nstaged\nunstaged\n", encoding="utf-8")
(tmp_path / "new.txt").write_text("hello\n", encoding="utf-8")
payload = collect_workspace_diff(str(tmp_path))
staged = files_for_group(payload, "staged")
unstaged = files_for_group(payload, "unstaged")
untracked = files_for_group(payload, "untracked")
assert staged[0]["path"] == "tracked.txt"
assert staged[0]["status"] == "modified"
assert "+staged" in staged[0]["patch"]
assert unstaged[0]["path"] == "tracked.txt"
assert "+unstaged" in unstaged[0]["patch"]
assert untracked[0]["path"] == "new.txt"
assert untracked[0]["status"] == "untracked"
assert "+hello" in untracked[0]["patch"]
assert payload["totals"]["files"] == 2
assert payload["totals"]["additions"] == 3
def test_collect_workspace_diff_ignores_zero_line_gitkeep(tmp_path: Path) -> None:
init_repo(tmp_path)
(tmp_path / ".gitkeep").write_text("", encoding="utf-8")
(tmp_path / "nested").mkdir()
(tmp_path / "nested" / ".gitkeep").write_text("", encoding="utf-8")
(tmp_path / "real.txt").write_text("real\n", encoding="utf-8")
run_git(tmp_path, "add", ".gitkeep")
payload = collect_workspace_diff(str(tmp_path))
paths = [
item["path"]
for group in payload["groups"]
for item in group["files"]
]
assert ".gitkeep" not in paths
assert "nested/.gitkeep" not in paths
assert paths == ["real.txt"]
assert payload["totals"] == {"files": 1, "additions": 1, "deletions": 0}
def test_collect_workspace_diff_deleted_renamed_binary_large_and_a0_exclusion(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
init_repo(tmp_path)
(tmp_path / "rename_me.txt").write_text("move\n", encoding="utf-8")
(tmp_path / "delete_me.txt").write_text("delete\n", encoding="utf-8")
run_git(tmp_path, "add", "rename_me.txt", "delete_me.txt")
run_git(tmp_path, "commit", "-m", "fixtures")
run_git(tmp_path, "mv", "rename_me.txt", "renamed.txt")
(tmp_path / "delete_me.txt").unlink()
(tmp_path / "binary.bin").write_bytes(b"\x00\x01data")
(tmp_path / ".a0proj").mkdir()
(tmp_path / ".a0proj" / "project.json").write_text("{}", encoding="utf-8")
monkeypatch.setattr(diff_helper, "MAX_UNTRACKED_BYTES", 10)
(tmp_path / "large.txt").write_text("0123456789\n" * 5, encoding="utf-8")
payload = collect_workspace_diff(str(tmp_path))
staged = files_for_group(payload, "staged")
unstaged = files_for_group(payload, "unstaged")
untracked = files_for_group(payload, "untracked")
git_changes = staged + unstaged
statuses = {(item["path"], item["status"]) for item in git_changes}
assert ("delete_me.txt", "deleted") in statuses
assert ("renamed.txt", "renamed") in statuses
renamed = next(item for item in git_changes if item["path"] == "renamed.txt")
assert renamed["old_path"] == "rename_me.txt"
assert renamed["additions"] == 0
assert renamed["deletions"] == 0
binary = next(item for item in untracked if item["path"] == "binary.bin")
assert binary["binary"] is True
large = next(item for item in untracked if item["path"] == "large.txt")
assert large["too_large"] is True
assert all(not item["path"].startswith(".a0proj") for item in untracked)
def test_collect_workspace_diff_limits_nested_workspace_to_pathspec(tmp_path: Path) -> None:
init_repo(tmp_path)
(tmp_path / "outside.txt").write_text("outside\n", encoding="utf-8")
(tmp_path / "nested").mkdir()
(tmp_path / "nested" / "inside.txt").write_text("inside\n", encoding="utf-8")
payload = collect_workspace_diff(str(tmp_path / "nested"))
untracked = files_for_group(payload, "untracked")
assert [item["path"] for item in untracked] == ["inside.txt"]
def test_diff_api_resolves_project_context_workspace(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
pytest.importorskip("whisper")
pytest.importorskip("langchain_core")
from plugins._diff_viewer.api import diff as diff_api
init_repo(tmp_path)
(tmp_path / "changed.txt").write_text("changed\n", encoding="utf-8")
handler = diff_api.Diff(Flask("diff-test"), threading.RLock())
monkeypatch.setattr(handler, "use_context", lambda context_id: SimpleNamespace(id=context_id))
monkeypatch.setattr(diff_api.projects, "get_context_project_name", lambda _context: "demo")
monkeypatch.setattr(diff_api.projects, "get_project_folder", lambda _name: str(tmp_path))
monkeypatch.setattr(diff_api.files, "normalize_a0_path", lambda path: path)
monkeypatch.setattr(diff_api.files, "fix_dev_path", lambda path: path)
payload = asyncio.run(handler.process({"context_id": "ctx-project"}, None))
assert isinstance(payload, dict)
assert payload["ok"] is True
assert payload["context_id"] == "ctx-project"
assert payload["workspace_path"] == str(tmp_path)
assert files_for_group(payload, "untracked")[0]["path"] == "changed.txt"
def test_diff_api_falls_back_to_default_workdir(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
pytest.importorskip("whisper")
pytest.importorskip("langchain_core")
from plugins._diff_viewer.api import diff as diff_api
init_repo(tmp_path)
(tmp_path / "workdir.txt").write_text("workdir\n", encoding="utf-8")
handler = diff_api.Diff(Flask("diff-test-default"), threading.RLock())
monkeypatch.setattr(diff_api.settings, "get_settings", lambda: {"workdir_path": str(tmp_path)})
monkeypatch.setattr(diff_api.files, "fix_dev_path", lambda path: path)
payload = asyncio.run(handler.process({}, None))
assert isinstance(payload, dict)
assert payload["workspace_path"] == str(tmp_path)
assert files_for_group(payload, "untracked")[0]["path"] == "workdir.txt"

262
tests/test_time_travel.py Normal file
View file

@ -0,0 +1,262 @@
from __future__ import annotations
import os
import shutil
import subprocess
import sys
import threading
import uuid
from pathlib import Path
from types import ModuleType, SimpleNamespace
import pytest
PROJECT_ROOT = Path(__file__).resolve().parents[1]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from plugins._time_travel.helpers import time_travel as tt
from plugins._time_travel.helpers.time_travel import (
TimeTravelConflictError,
TimeTravelError,
TimeTravelService,
WorkspaceRejectedError,
_workspace_from_display,
resolve_workspace,
)
def run_git(repo_dir: Path, *args: str, check: bool = True) -> str:
completed = subprocess.run(
["git", "-C", str(repo_dir), *args],
check=check,
text=True,
capture_output=True,
)
return completed.stdout.strip()
@pytest.fixture
def workspace():
name = f"tt-{uuid.uuid4().hex}"
root = PROJECT_ROOT / "usr" / "time-travel-tests" / name
root.mkdir(parents=True)
service = TimeTravelService(_workspace_from_display(f"/a0/usr/time-travel-tests/{name}"))
try:
yield root, service
finally:
shutil.rmtree(root, ignore_errors=True)
shutil.rmtree(service.workspace.shadow_path, ignore_errors=True)
def tracked_paths(service: TimeTravelService, commit_hash: str = "HEAD") -> set[str]:
output = service._git("ls-tree", "-r", "--name-only", commit_hash).stdout
return {line.strip() for line in output.splitlines() if line.strip()}
def test_shadow_history_snapshot_diff_travel_preserve_refs_and_root_revert(workspace):
root, service = workspace
(root / "a.txt").write_text("one\n", encoding="utf-8")
initial = service.snapshot(trigger="manual", metadata={"context_id": "ctx"})
duplicate = service.snapshot(trigger="manual")
assert initial.created is True
assert duplicate.created is False
assert duplicate.hash == initial.hash
assert (service.workspace.repo_git_path / "objects").is_dir()
(root / "a.txt").write_text("one\ntwo\n", encoding="utf-8")
present = service.present_summary()
assert present["dirty"] is True
assert present["files"][0]["path"] == "a.txt"
assert "+two" in service.history_diff(commit_hash=initial.hash, path="a.txt", mode="present")["patch"]
second = service.snapshot(trigger="tool", metadata={"tool_name": "code_execution_tool"})
history = service.history_list(limit=10)
assert [commit["hash"] for commit in history["commits"][:2]] == [second.hash, initial.hash]
assert history["commits"][0]["metadata"]["tool_name"] == "code_execution_tool"
assert "+two" in service.history_diff(commit_hash=second.hash, path="a.txt", mode="commit")["patch"]
service.travel(commit_hash=initial.hash)
assert (root / "a.txt").read_text(encoding="utf-8") == "one\n"
preserved = service._git(
"for-each-ref",
"--format=%(objectname)",
"refs/a0-time-travel/preserved",
).stdout
assert second.hash in preserved
assert second.hash in [commit["hash"] for commit in service.history_list(limit=10)["commits"]]
reverted = service.revert(commit_hash=initial.hash)
assert reverted["ok"] is True
assert not (root / "a.txt").exists()
assert reverted["snapshot"]["created"] is True
def test_revert_conflict_auto_snapshots_present_without_losing_changes(workspace):
root, service = workspace
(root / "a.txt").write_text("one\n", encoding="utf-8")
first = service.snapshot(trigger="manual")
(root / "a.txt").write_text("one\ntwo\n", encoding="utf-8")
second = service.snapshot(trigger="manual")
(root / "a.txt").write_text("custom\n", encoding="utf-8")
with pytest.raises(TimeTravelConflictError):
service.revert(commit_hash=second.hash)
assert (root / "a.txt").read_text(encoding="utf-8") == "custom\n"
assert service.current_hash() not in {first.hash, second.hash}
assert "custom" in service.history_diff(commit_hash=service.current_hash(), path="a.txt", mode="commit")["patch"]
def test_kernel_boundary_real_git_repo_and_git_dir_exclusion(workspace):
root, service = workspace
with pytest.raises(WorkspaceRejectedError):
_workspace_from_display("/tmp/outside")
run_git(root, "init")
run_git(root, "config", "user.name", "Test User")
run_git(root, "config", "user.email", "test@example.com")
(root / "tracked.txt").write_text("tracked\n", encoding="utf-8")
run_git(root, "add", "tracked.txt")
run_git(root, "commit", "-m", "real initial")
real_head = run_git(root, "rev-parse", "HEAD")
(root / "untracked.txt").write_text("shadow only\n", encoding="utf-8")
real_status_before = run_git(root, "status", "--short")
snapshot = service.snapshot(trigger="manual")
assert snapshot.created is True
assert run_git(root, "rev-parse", "HEAD") == real_head
assert run_git(root, "status", "--short") == real_status_before
assert all(not path.startswith(".git/") and path != ".git" for path in tracked_paths(service))
def test_metadata_policy_tracks_safe_project_files_and_preserves_exclusions(workspace):
root, service = workspace
(root / "src").mkdir()
(root / "src" / "app.py").write_text("print('one')\n", encoding="utf-8")
(root / ".a0proj" / "instructions").mkdir(parents=True)
(root / ".a0proj" / "knowledge").mkdir(parents=True)
(root / ".a0proj" / "skills" / "demo").mkdir(parents=True)
(root / ".a0proj" / "plugins" / "demo").mkdir(parents=True)
(root / ".a0proj" / "memory").mkdir(parents=True)
(root / "node_modules").mkdir()
(root / "dist").mkdir()
(root / "__pycache__").mkdir()
(root / ".a0proj" / "project.json").write_text("{}", encoding="utf-8")
(root / ".a0proj" / "agents.json").write_text("{}", encoding="utf-8")
(root / ".a0proj" / "instructions" / "one.md").write_text("i\n", encoding="utf-8")
(root / ".a0proj" / "knowledge" / "one.md").write_text("k\n", encoding="utf-8")
(root / ".a0proj" / "skills" / "demo" / "SKILL.md").write_text("s\n", encoding="utf-8")
(root / ".a0proj" / "plugins" / "demo" / "config.json").write_text("{}", encoding="utf-8")
(root / ".a0proj" / "plugins" / "demo" / "presets.yaml").write_text("[]\n", encoding="utf-8")
(root / ".a0proj" / "plugins" / "demo" / "state.json").write_text('{"state": true}\n', encoding="utf-8")
(root / ".a0proj" / "secrets.env").write_text("SECRET=one\n", encoding="utf-8")
(root / ".a0proj" / "variables.env").write_text("VAR=one\n", encoding="utf-8")
(root / ".a0proj" / "memory" / "index.faiss").write_bytes(b"memory")
(root / ".env").write_text("TOKEN=one\n", encoding="utf-8")
(root / "node_modules" / "pkg.js").write_text("pkg\n", encoding="utf-8")
(root / "dist" / "bundle.js").write_text("dist\n", encoding="utf-8")
(root / "__pycache__" / "app.pyc").write_bytes(b"pyc")
first = service.snapshot(trigger="manual")
paths = tracked_paths(service, first.hash)
assert "src/app.py" in paths
assert ".a0proj/project.json" in paths
assert ".a0proj/agents.json" in paths
assert ".a0proj/instructions/one.md" in paths
assert ".a0proj/knowledge/one.md" in paths
assert ".a0proj/skills/demo/SKILL.md" in paths
assert ".a0proj/plugins/demo/config.json" in paths
assert ".a0proj/plugins/demo/presets.yaml" in paths
assert ".a0proj/plugins/demo/state.json" not in paths
assert ".a0proj/secrets.env" not in paths
assert ".a0proj/variables.env" not in paths
assert ".a0proj/memory/index.faiss" not in paths
assert ".env" not in paths
assert "node_modules/pkg.js" not in paths
assert "dist/bundle.js" not in paths
assert "__pycache__/app.pyc" not in paths
(root / "src" / "app.py").write_text("print('two')\n", encoding="utf-8")
(root / ".a0proj" / "secrets.env").write_text("SECRET=two\n", encoding="utf-8")
service.snapshot(trigger="manual")
service.travel(commit_hash=first.hash)
assert (root / "src" / "app.py").read_text(encoding="utf-8") == "print('one')\n"
assert (root / ".a0proj" / "secrets.env").read_text(encoding="utf-8") == "SECRET=two\n"
def test_symlink_entries_are_snapshotted_and_deleted_without_following_targets(workspace, tmp_path: Path):
root, service = workspace
outside = tmp_path / "outside.txt"
outside.write_text("outside\n", encoding="utf-8")
os.symlink(outside, root / "outside-link")
first = service.snapshot(trigger="manual")
assert "outside-link" in tracked_paths(service, first.hash)
assert service._git("ls-tree", "HEAD", "outside-link").stdout.startswith("120000")
(root / "outside-link").unlink()
second = service.snapshot(trigger="manual")
assert outside.exists()
service.travel(commit_hash=first.hash)
assert (root / "outside-link").is_symlink()
assert outside.exists()
service.travel(commit_hash=second.hash)
assert not (root / "outside-link").exists()
assert outside.exists()
def test_pagination_large_diff_and_invalid_inputs(workspace, monkeypatch: pytest.MonkeyPatch):
root, service = workspace
(root / "file.txt").write_text("0\n", encoding="utf-8")
hashes = [service.snapshot(trigger="manual").hash]
for index in range(1, 4):
(root / "file.txt").write_text(("x\n" * index), encoding="utf-8")
hashes.append(service.snapshot(trigger="manual").hash)
page = service.history_list(limit=2)
assert len(page["commits"]) == 2
assert page["has_more"] is True
page2 = service.history_list(limit=2, offset=2)
assert page2["commits"][0]["hash"] == hashes[1]
monkeypatch.setattr(tt, "MAX_RENDERED_PATCH_BYTES", 30)
diff = service.history_diff(commit_hash=hashes[-1], path="file.txt", mode="commit")
assert diff["too_large"] is True
assert len(diff["patch"].encode("utf-8")) <= 30
with pytest.raises(TimeTravelError):
service.history_diff(commit_hash="not-a-commit", path="file.txt", mode="commit")
with pytest.raises(TimeTravelError):
service.history_diff(commit_hash=hashes[-1], path="../file.txt", mode="commit")
def test_workspace_resolution_prefers_project_and_rejects_external_paths(monkeypatch: pytest.MonkeyPatch, workspace):
root, _service = workspace
projects_mod = ModuleType("helpers.projects")
projects_mod.get_context_project_name = lambda _context: "demo"
projects_mod.get_project_folder = lambda _name: str(root)
settings_mod = ModuleType("helpers.settings")
settings_mod.get_settings = lambda: {"workdir_path": "/tmp/not-a0"}
import helpers
monkeypatch.setitem(sys.modules, "helpers.projects", projects_mod)
monkeypatch.setitem(sys.modules, "helpers.settings", settings_mod)
monkeypatch.setattr(helpers, "projects", projects_mod, raising=False)
monkeypatch.setattr(helpers, "settings", settings_mod, raising=False)
resolved = resolve_workspace("ctx", context_loader=lambda _ctxid: SimpleNamespace(id="ctx"))
assert resolved.project_name == "demo"
assert resolved.display_path.startswith("/a0/usr/time-travel-tests/")
projects_mod.get_context_project_name = lambda _context: ""
with pytest.raises(WorkspaceRejectedError):
resolve_workspace("ctx", context_loader=lambda _ctxid: SimpleNamespace(id="ctx"))

View file

@ -58,8 +58,8 @@ function modalSuppressesBackdrop(modal) {
|| path === "plugins/_browser/webui/main.html"
|| path === "/plugins/_office/webui/main.html"
|| path === "plugins/_office/webui/main.html"
|| path === "/plugins/_diff_viewer/webui/main.html"
|| path === "plugins/_diff_viewer/webui/main.html"
|| path === "/plugins/_time_travel/webui/main.html"
|| path === "plugins/_time_travel/webui/main.html"
|| modal?.element?.classList?.contains("modal-floating")
|| modal?.element?.classList?.contains("modal-no-backdrop")
|| modal?.inner?.classList?.contains("modal-no-backdrop");