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 :-)

Alessandro committed Apr 27, 2026 at 01:27 UTC c5ea6780523a83ef6f7ccf8bc2037e80f7a55920
35 files changed +3152 -1518
api/delete_work_dir_file.py
+11 -1
@@ -2,7 +2,7 @@ from helpers.api import ApiHandler, Input, Output, Request, Response
2
3
4 from helpers.file_browser import FileBrowser
5 -from helpers import files, runtime
5 +from helpers import files, runtime, extension
6 from api import get_work_dir_files
7
8
@@ -19,6 +19,16 @@ class DeleteWorkDirFile(ApiHandler):
19 res = await runtime.call_development_function(delete_file, file_path)
20
21 if res:
22 + await extension.call_extensions_async(
23 + "workdir_file_mutation_after",
24 + agent=None,
25 + data={
26 + "action": "delete",
27 + "path": file_path,
28 + "paths": [file_path],
29 + "current_path": current_path,
30 + },
31 + )
32 # Get updated file list
33 # result = browser.get_files(current_path)
34 result = await runtime.call_development_function(get_work_dir_files.get_files, current_path)
api/edit_work_dir_file.py
+10 -1
@@ -3,7 +3,7 @@ import os
3
4 from helpers.api import ApiHandler, Input, Output, Request
5 from helpers.file_browser import FileBrowser
6 -from helpers import runtime, files
6 +from helpers import runtime, files, extension
7
8 MAX_EDIT_FILE_SIZE = 1024 * 1024
9 BINARY_SAMPLE_SIZE = 10 * 1024
@@ -51,6 +51,15 @@ class EditWorkDirFile(ApiHandler):
51 if not res:
52 return {"error": "Failed to save file"}
53
54 + await extension.call_extensions_async(
55 + "workdir_file_mutation_after",
56 + agent=None,
57 + data={
58 + "action": "edit",
59 + "path": file_path,
60 + "paths": [file_path],
61 + },
62 + )
63 return {"ok": True}
64 except Exception as e:
65 # Extract clean error message from exception
api/rename_work_dir_file.py
+17 -1
@@ -1,7 +1,8 @@
1 from helpers.api import ApiHandler, Input, Output, Request
2 from helpers.file_browser import FileBrowser
3 -from helpers import runtime
3 +from helpers import runtime, extension
4 from api import get_work_dir_files
5 +import posixpath
6
7
8 class RenameWorkDirFile(ApiHandler):
@@ -21,6 +22,7 @@ class RenameWorkDirFile(ApiHandler):
22 res = await runtime.call_development_function(
23 create_folder, parent_path, new_name
24 )
25 + changed_paths = [posixpath.join(str(parent_path).rstrip("/"), new_name)]
26 else:
27 file_path = input.get("path", "")
28 if not file_path:
@@ -30,8 +32,22 @@ class RenameWorkDirFile(ApiHandler):
32 res = await runtime.call_development_function(
33 rename_item, file_path, new_name
34 )
35 + changed_paths = [
36 + file_path,
37 + posixpath.join(posixpath.dirname(file_path), new_name),
38 + ]
39
40 if res:
41 + await extension.call_extensions_async(
42 + "workdir_file_mutation_after",
43 + agent=None,
44 + data={
45 + "action": action,
46 + "path": changed_paths[-1],
47 + "paths": changed_paths,
48 + "current_path": current_path,
49 + },
50 + )
51 result = await runtime.call_development_function(
52 get_work_dir_files.get_files, current_path
53 )
api/upload_work_dir_files.py
+17 -2
@@ -2,9 +2,10 @@ import base64
2 from werkzeug.datastructures import FileStorage
3 from helpers.api import ApiHandler, Request, Response
4 from helpers.file_browser import FileBrowser
5 -from helpers import files, runtime
5 +from helpers import files, runtime, extension
6 from api import get_work_dir_files
7 import os
8 +import posixpath
9
10
11 class UploadWorkDirFiles(ApiHandler):
@@ -23,6 +24,21 @@ class UploadWorkDirFiles(ApiHandler):
24 if not successful and failed:
25 raise Exception("All uploads failed")
26
27 + if successful:
28 + await extension.call_extensions_async(
29 + "workdir_file_mutation_after",
30 + agent=None,
31 + data={
32 + "action": "upload",
33 + "path": current_path,
34 + "paths": [
35 + posixpath.join(str(current_path).rstrip("/"), name)
36 + for name in successful
37 + ],
38 + "current_path": current_path,
39 + },
40 + )
41 +
42 # result = browser.get_files(current_path)
43 result = await runtime.call_development_function(get_work_dir_files.get_files, current_path)
44
@@ -61,4 +77,3 @@ async def upload_files(uploaded_files: list[FileStorage], current_path: str):
77 async def upload_file(current_path: str, filename: str, base64_content: str):
78 browser = FileBrowser()
79 return browser.save_file_b64(current_path, filename, base64_content)
64 -
plugins/_diff_viewer/api/diff.py deleted
-29
@@ -1,29 +0,0 @@
1 -from __future__ import annotations
2 -
3 -from helpers import files, projects, settings
4 -from helpers.api import ApiHandler, Request, Response
5 -from plugins._diff_viewer.helpers.diff import collect_workspace_diff
6 -
7 -
8 -class Diff(ApiHandler):
9 - async def process(self, input: dict, request: Request) -> dict | Response:
10 - context_id = str(input.get("context_id") or "").strip()
11 - workspace_path, display_path = self._resolve_workspace(context_id)
12 - return collect_workspace_diff(
13 - workspace_path,
14 - context_id=context_id,
15 - display_path=display_path,
16 - )
17 -
18 - def _resolve_workspace(self, context_id: str) -> tuple[str, str]:
19 - if context_id:
20 - context = self.use_context(context_id)
21 - project_name = projects.get_context_project_name(context)
22 - if project_name:
23 - project_path = projects.get_project_folder(project_name)
24 - display_path = files.normalize_a0_path(project_path)
25 - return files.fix_dev_path(display_path), display_path
26 -
27 - configured = str(settings.get_settings().get("workdir_path") or "")
28 - display_path = configured or files.normalize_a0_path(files.get_abs_path("usr/workdir"))
29 - return files.fix_dev_path(display_path), display_path
plugins/_diff_viewer/extensions/webui/apply_snapshot_before/refresh-diff-viewer.js deleted
-9
@@ -1,9 +0,0 @@
1 -export default function refreshDiffViewerOnContextChange(ctx) {
2 - const diffViewer = globalThis.Alpine?.store?.("diffViewer");
3 - const canvas = globalThis.Alpine?.store?.("rightCanvas");
4 - if (!diffViewer || !canvas?.isOpen || canvas.activeSurfaceId !== "diff") return;
5 - const nextContextId = String(ctx?.snapshot?.context || "");
6 - if (nextContextId && nextContextId !== diffViewer.contextId) {
7 - diffViewer.scheduleRefresh({ contextId: nextContextId, reason: "context-change" });
8 - }
9 -}
plugins/_diff_viewer/extensions/webui/right-canvas-panels/diff-viewer-panel.html deleted
-8
@@ -1,8 +0,0 @@
1 -<div
2 - class="right-canvas-surface-panel diff-viewer-canvas-surface"
3 - data-surface-id="diff"
4 - x-show="$store.rightCanvas && $store.rightCanvas.isOpen && $store.rightCanvas.activeSurfaceId === 'diff'"
5 - style="display: none;"
6 ->
7 - <x-component path="/plugins/_diff_viewer/webui/diff-viewer-panel.html" mode="canvas"></x-component>
8 -</div>
plugins/_diff_viewer/helpers/__init__.py deleted
-1
@@ -1 +0,0 @@
1 -"""Helpers for the built-in diff viewer plugin."""
plugins/_diff_viewer/helpers/diff.py deleted
-347
@@ -1,347 +0,0 @@
1 -from __future__ import annotations
2 -
3 -import os
4 -import subprocess
5 -from pathlib import Path
6 -from typing import Any
7 -
8 -from helpers import files
9 -
10 -
11 -MAX_PATCH_LINES = 2500
12 -MAX_PATCH_BYTES = 240_000
13 -MAX_UNTRACKED_BYTES = 180_000
14 -GIT_TIMEOUT_SECONDS = 8
15 -
16 -GROUP_ORDER = ("staged", "unstaged", "untracked")
17 -STATUS_LABELS = {
18 - "A": "added",
19 - "C": "copied",
20 - "D": "deleted",
21 - "M": "modified",
22 - "R": "renamed",
23 - "T": "type_changed",
24 - "U": "unmerged",
25 - "?": "untracked",
26 -}
27 -
28 -
29 -class GitDiffError(RuntimeError):
30 - pass
31 -
32 -
33 -def collect_workspace_diff(
34 - workspace_path: str,
35 - *,
36 - context_id: str = "",
37 - display_path: str | None = None,
38 -) -> dict[str, Any]:
39 - workspace = Path(workspace_path).expanduser().resolve()
40 - display = display_path or str(workspace)
41 -
42 - if not workspace.exists() or not workspace.is_dir():
43 - return {
44 - "ok": False,
45 - "context_id": context_id,
46 - "workspace_path": display,
47 - "is_git_repo": False,
48 - "error": "Workspace path does not exist or is not a directory.",
49 - "branch": "",
50 - "totals": {"files": 0, "additions": 0, "deletions": 0},
51 - "groups": _empty_groups(),
52 - }
53 -
54 - if not _is_git_repo(workspace):
55 - return {
56 - "ok": True,
57 - "context_id": context_id,
58 - "workspace_path": display,
59 - "is_git_repo": False,
60 - "branch": "",
61 - "totals": {"files": 0, "additions": 0, "deletions": 0},
62 - "groups": _empty_groups(),
63 - }
64 -
65 - groups = [
66 - {"kind": "staged", "files": _collect_diff_group(workspace, "staged")},
67 - {"kind": "unstaged", "files": _collect_diff_group(workspace, "unstaged")},
68 - {"kind": "untracked", "files": _collect_untracked_group(workspace)},
69 - ]
70 -
71 - seen_paths: set[str] = set()
72 - additions = 0
73 - deletions = 0
74 - for group in groups:
75 - for item in group["files"]:
76 - seen_paths.add(str(item.get("path") or item.get("old_path") or ""))
77 - additions += int(item.get("additions") or 0)
78 - deletions += int(item.get("deletions") or 0)
79 -
80 - return {
81 - "ok": True,
82 - "context_id": context_id,
83 - "workspace_path": display,
84 - "is_git_repo": True,
85 - "branch": _branch_name(workspace),
86 - "totals": {
87 - "files": len([path for path in seen_paths if path]),
88 - "additions": additions,
89 - "deletions": deletions,
90 - },
91 - "groups": groups,
92 - }
93 -
94 -
95 -def _empty_groups() -> list[dict[str, Any]]:
96 - return [{"kind": kind, "files": []} for kind in GROUP_ORDER]
97 -
98 -
99 -def _git(workspace: Path, *args: str, check: bool = True) -> subprocess.CompletedProcess[str]:
100 - env = os.environ.copy()
101 - env["GIT_TERMINAL_PROMPT"] = "0"
102 - env["GIT_OPTIONAL_LOCKS"] = "0"
103 - completed = subprocess.run(
104 - ["git", "-C", str(workspace), *args],
105 - capture_output=True,
106 - text=True,
107 - encoding="utf-8",
108 - errors="replace",
109 - env=env,
110 - timeout=GIT_TIMEOUT_SECONDS,
111 - )
112 - if check and completed.returncode != 0:
113 - message = (completed.stderr or completed.stdout or "Git command failed.").strip()
114 - raise GitDiffError(message)
115 - return completed
116 -
117 -
118 -def _is_git_repo(workspace: Path) -> bool:
119 - return _git(workspace, "rev-parse", "--is-inside-work-tree", check=False).returncode == 0
120 -
121 -
122 -def _branch_name(workspace: Path) -> str:
123 - branch = _git(workspace, "branch", "--show-current", check=False).stdout.strip()
124 - if branch:
125 - return branch
126 - return _git(workspace, "rev-parse", "--short", "HEAD", check=False).stdout.strip()
127 -
128 -
129 -def _collect_diff_group(workspace: Path, kind: str) -> list[dict[str, Any]]:
130 - cached_args = ["--cached"] if kind == "staged" else []
131 - status_output = _git(
132 - workspace,
133 - "diff",
134 - *cached_args,
135 - "--name-status",
136 - "-z",
137 - "--find-renames",
138 - "--relative",
139 - "--",
140 - ".",
141 - ).stdout
142 - entries = _parse_name_status(status_output)
143 - result: list[dict[str, Any]] = []
144 -
145 - for entry in entries:
146 - path = entry["path"]
147 - old_path = entry.get("old_path", "")
148 - if _is_a0_metadata_path(path) and (not old_path or _is_a0_metadata_path(old_path)):
149 - continue
150 -
151 - stat_paths = [candidate for candidate in (old_path, path) if candidate]
152 - additions, deletions, binary = _diff_numstat(workspace, kind, stat_paths)
153 - if _is_zero_line_gitkeep_change(path, old_path, additions, deletions):
154 - continue
155 -
156 - too_large = additions + deletions > MAX_PATCH_LINES
157 - patch = ""
158 - if not binary and not too_large:
159 - patch = _diff_patch(workspace, kind, stat_paths)
160 - if len(patch.encode("utf-8", errors="replace")) > MAX_PATCH_BYTES:
161 - patch = ""
162 - too_large = True
163 -
164 - result.append(
165 - {
166 - "path": path,
167 - "old_path": old_path,
168 - "status": STATUS_LABELS.get(entry["status"], entry["status"].lower()),
169 - "additions": additions,
170 - "deletions": deletions,
171 - "binary": binary,
172 - "too_large": too_large,
173 - "patch": patch,
174 - }
175 - )
176 -
177 - return result
178 -
179 -
180 -def _collect_untracked_group(workspace: Path) -> list[dict[str, Any]]:
181 - output = _git(
182 - workspace,
183 - "ls-files",
184 - "--others",
185 - "--exclude-standard",
186 - "-z",
187 - "--",
188 - ".",
189 - ).stdout
190 - result: list[dict[str, Any]] = []
191 - for path in [part for part in output.split("\0") if part]:
192 - path = path.replace("\\", "/")
193 - if _is_a0_metadata_path(path):
194 - continue
195 - item = _untracked_file_diff(workspace, path)
196 - if _is_zero_line_gitkeep_change(item["path"], item.get("old_path", ""), item["additions"], item["deletions"]):
197 - continue
198 - result.append(item)
199 - result.sort(key=lambda item: item["path"])
200 - return result
201 -
202 -
203 -def _parse_name_status(output: str) -> list[dict[str, str]]:
204 - parts = [part for part in output.split("\0") if part]
205 - entries: list[dict[str, str]] = []
206 - index = 0
207 - while index < len(parts):
208 - raw_status = parts[index]
209 - index += 1
210 - status = raw_status[:1]
211 - if status in {"R", "C"} and index + 1 < len(parts):
212 - old_path = parts[index].replace("\\", "/")
213 - new_path = parts[index + 1].replace("\\", "/")
214 - index += 2
215 - entries.append({"status": status, "old_path": old_path, "path": new_path})
216 - continue
217 - if index < len(parts):
218 - path = parts[index].replace("\\", "/")
219 - index += 1
220 - entries.append({"status": status, "path": path, "old_path": ""})
221 - entries.sort(key=lambda item: item.get("path") or item.get("old_path") or "")
222 - return entries
223 -
224 -
225 -def _diff_numstat(workspace: Path, kind: str, paths: list[str]) -> tuple[int, int, bool]:
226 - args = ["diff"]
227 - if kind == "staged":
228 - args.append("--cached")
229 - output = _git(
230 - workspace,
231 - *args,
232 - "--numstat",
233 - "--find-renames",
234 - "--relative",
235 - "--",
236 - *paths,
237 - ).stdout
238 - first = next((line for line in output.splitlines() if line.strip()), "")
239 - if not first:
240 - return 0, 0, False
241 - parts = first.split("\t")
242 - if len(parts) < 2:
243 - return 0, 0, False
244 - if parts[0] == "-" or parts[1] == "-":
245 - return 0, 0, True
246 - return _safe_int(parts[0]), _safe_int(parts[1]), False
247 -
248 -
249 -def _diff_patch(workspace: Path, kind: str, paths: list[str]) -> str:
250 - args = ["diff"]
251 - if kind == "staged":
252 - args.append("--cached")
253 - return _git(
254 - workspace,
255 - *args,
256 - "--patch",
257 - "--find-renames",
258 - "--relative",
259 - "--",
260 - *paths,
261 - ).stdout
262 -
263 -
264 -def _untracked_file_diff(workspace: Path, path: str) -> dict[str, Any]:
265 - file_path = (workspace / path).resolve()
266 - additions = 0
267 - binary = False
268 - too_large = False
269 - patch = ""
270 -
271 - try:
272 - with open(file_path, "rb") as handle:
273 - data = handle.read(MAX_UNTRACKED_BYTES + 1)
274 - too_large = len(data) > MAX_UNTRACKED_BYTES
275 - sample = data[: min(len(data), 10 * 1024)]
276 - binary = files.is_probably_binary_bytes(sample)
277 - if not binary and not too_large:
278 - text = data.decode("utf-8", errors="replace")
279 - lines = text.splitlines()
280 - additions = len(lines)
281 - if len(lines) > MAX_PATCH_LINES:
282 - too_large = True
283 - else:
284 - patch = _synthetic_untracked_patch(path, lines, text.endswith("\n"))
285 - elif not binary:
286 - additions = _count_newlines(data[:MAX_UNTRACKED_BYTES])
287 - except OSError:
288 - too_large = True
289 -
290 - return {
291 - "path": path,
292 - "old_path": "",
293 - "status": "untracked",
294 - "additions": additions,
295 - "deletions": 0,
296 - "binary": binary,
297 - "too_large": too_large,
298 - "patch": patch,
299 - }
300 -
301 -
302 -def _synthetic_untracked_patch(path: str, lines: list[str], has_trailing_newline: bool) -> str:
303 - escaped = path.replace("\t", "\\t")
304 - header = [
305 - f"diff --git a/{escaped} b/{escaped}",
306 - "new file mode 100644",
307 - "index 0000000..0000000",
308 - "--- /dev/null",
309 - f"+++ b/{escaped}",
310 - ]
311 - if not lines:
312 - return "\n".join(header) + "\n"
313 - body = [f"@@ -0,0 +1,{len(lines)} @@"]
314 - body.extend(f"+{line}" for line in lines)
315 - if not has_trailing_newline:
316 - body.append("\\ No newline at end of file")
317 - return "\n".join(header + body) + "\n"
318 -
319 -
320 -def _count_newlines(data: bytes) -> int:
321 - if not data:
322 - return 0
323 - count = data.count(b"\n")
324 - return count if data.endswith(b"\n") else count + 1
325 -
326 -
327 -def _safe_int(value: str) -> int:
328 - try:
329 - return max(0, int(value))
330 - except (TypeError, ValueError):
331 - return 0
332 -
333 -
334 -def _is_a0_metadata_path(path: str) -> bool:
335 - normalized = path.replace("\\", "/").lstrip("/")
336 - return normalized == ".a0proj" or normalized.startswith(".a0proj/")
337 -
338 -
339 -def _is_zero_line_gitkeep_change(path: str, old_path: str, additions: int, deletions: int) -> bool:
340 - if additions != 0 or deletions != 0:
341 - return False
342 - candidates = [path, old_path]
343 - return any(
344 - candidate.replace("\\", "/").rstrip("/").split("/")[-1] == ".gitkeep"
345 - for candidate in candidates
346 - if candidate
347 - )
plugins/_diff_viewer/plugin.yaml deleted
-8
@@ -1,8 +0,0 @@
1 -name: _diff_viewer
2 -title: Diff Viewer
3 -description: Right canvas Git working tree diff viewer for the active chat or task workspace.
4 -version: 0.1.0
5 -always_enabled: false
6 -settings_sections: []
7 -per_project_config: false
8 -per_agent_config: false
plugins/_diff_viewer/webui/diff-viewer-panel.html deleted
-572
@@ -1,572 +0,0 @@
1 -<html>
2 -<head>
3 - <script type="module">
4 - import { store } from "/plugins/_diff_viewer/webui/diff-viewer-store.js";
5 - </script>
6 -</head>
7 -<body>
8 - <div class="diff-viewer-panel" x-data x-create="$store.diffViewer.onMount($el, xAttrs($el) || {})" x-destroy="$store.diffViewer.cleanup()">
9 - <template x-if="$store.diffViewer">
10 - <div class="diff-viewer-shell">
11 - <div class="diff-viewer-toolbar">
12 - <div class="diff-viewer-title">
13 - <span class="material-symbols-outlined">difference</span>
14 - <span>Review</span>
15 - </div>
16 - <span class="diff-viewer-spacer"></span>
17 - <button type="button" class="diff-viewer-icon-button" title="Expand all" aria-label="Expand all" @click="$store.diffViewer.expandAll()" :disabled="!$store.diffViewer.hasChanges()">
18 - <span class="material-symbols-outlined">unfold_more</span>
19 - </button>
20 - <button type="button" class="diff-viewer-icon-button" title="Collapse all" aria-label="Collapse all" @click="$store.diffViewer.collapseAll()" :disabled="!$store.diffViewer.hasChanges()">
21 - <span class="material-symbols-outlined">unfold_less</span>
22 - </button>
23 - <button type="button" class="diff-viewer-icon-button" title="Refresh" aria-label="Refresh" @click="$store.diffViewer.refresh()" :disabled="$store.diffViewer.loading">
24 - <span class="material-symbols-outlined" :class="{ spinning: $store.diffViewer.loading }">refresh</span>
25 - </button>
26 - </div>
27 -
28 - <div class="diff-viewer-summary">
29 - <div class="diff-viewer-summary-main">
30 - <strong x-text="$store.diffViewer.payload?.totals?.files || 0"></strong>
31 - <span>files changed</span>
32 - <span class="diff-add" x-text="$store.diffViewer.formatSigned($store.diffViewer.payload?.totals?.additions, '+')"></span>
33 - <span class="diff-del" x-text="$store.diffViewer.formatSigned($store.diffViewer.payload?.totals?.deletions, '-')"></span>
34 - </div>
35 - <div class="diff-viewer-summary-meta">
36 - <span x-text="$store.diffViewer.payload?.branch || 'no branch'"></span>
37 - <span class="diff-viewer-dot"></span>
38 - <span :title="$store.diffViewer.workspacePath" x-text="$store.diffViewer.workspacePath || 'workspace'"></span>
39 - </div>
40 - </div>
41 -
42 - <div class="diff-viewer-status" x-show="$store.diffViewer.loading || $store.diffViewer.error" style="display: none;">
43 - <span class="material-symbols-outlined" :class="{ spinning: $store.diffViewer.loading }" x-text="$store.diffViewer.loading ? 'progress_activity' : 'error'"></span>
44 - <span x-text="$store.diffViewer.loading ? 'Loading changes...' : $store.diffViewer.error"></span>
45 - </div>
46 -
47 - <div class="diff-viewer-body">
48 - <template x-if="$store.diffViewer.payload && !$store.diffViewer.payload.is_git_repo && !$store.diffViewer.loading && !$store.diffViewer.error">
49 - <div class="diff-viewer-empty">
50 - <span class="material-symbols-outlined">folder_off</span>
51 - <strong>Not a Git workspace</strong>
52 - <span x-text="$store.diffViewer.workspacePath"></span>
53 - </div>
54 - </template>
55 -
56 - <template x-if="$store.diffViewer.payload?.is_git_repo && !$store.diffViewer.hasChanges() && !$store.diffViewer.loading && !$store.diffViewer.error">
57 - <div class="diff-viewer-empty">
58 - <span class="material-symbols-outlined">check_circle</span>
59 - <strong>No changes</strong>
60 - <span>The selected context workspace is clean.</span>
61 - </div>
62 - </template>
63 -
64 - <div class="diff-viewer-groups" x-show="$store.diffViewer.hasChanges()" style="display: none;">
65 - <template x-for="group in $store.diffViewer.visibleGroups()" :key="group.kind">
66 - <section class="diff-viewer-group">
67 - <header class="diff-viewer-group-header">
68 - <span x-text="$store.diffViewer.groupTitle(group.kind)"></span>
69 - <span class="diff-viewer-count" x-text="group.files.length"></span>
70 - </header>
71 - <template x-for="file in group.files" :key="$store.diffViewer.fileKey(group, file)">
72 - <article class="diff-file">
73 - <button type="button" class="diff-file-header" @click="$store.diffViewer.toggleFile(group, file)" :title="$store.diffViewer.fileTitle(file)">
74 - <span class="material-symbols-outlined diff-file-chevron" x-text="$store.diffViewer.isExpanded(group, file) ? 'expand_less' : 'expand_more'"></span>
75 - <span class="diff-file-name" x-text="$store.diffViewer.fileTitle(file)"></span>
76 - <span class="diff-file-status" x-text="$store.diffViewer.statusLabel(file)"></span>
77 - <span class="diff-file-counts">
78 - <span class="diff-add" x-text="$store.diffViewer.formatSigned(file.additions, '+')"></span>
79 - <span class="diff-del" x-text="$store.diffViewer.formatSigned(file.deletions, '-')"></span>
80 - </span>
81 - </button>
82 -
83 - <div class="diff-file-tools" x-show="$store.diffViewer.isExpanded(group, file)" style="display: none;">
84 - <button type="button" class="diff-viewer-tool-button" title="Open containing folder" @click="$store.diffViewer.openContainingFolder(file)">
85 - <span class="material-symbols-outlined">folder_open</span>
86 - <span>Folder</span>
87 - </button>
88 - <button type="button" class="diff-viewer-tool-button" title="Copy path" @click="$store.diffViewer.copyPath(file)">
89 - <span class="material-symbols-outlined">content_copy</span>
90 - <span>Path</span>
91 - </button>
92 - </div>
93 -
94 - <div class="diff-file-body" x-show="$store.diffViewer.isExpanded(group, file)" style="display: none;">
95 - <template x-if="file.binary">
96 - <div class="diff-file-note">
97 - <span class="material-symbols-outlined">data_object</span>
98 - <span>Binary file changed.</span>
99 - </div>
100 - </template>
101 - <template x-if="file.too_large && !file.binary">
102 - <div class="diff-file-note">
103 - <span class="material-symbols-outlined">text_snippet</span>
104 - <span>Diff is too large to render inline.</span>
105 - </div>
106 - </template>
107 - <template x-if="!file.binary && !file.too_large && file.patch">
108 - <div class="diff-code" role="table" aria-label="Unified diff">
109 - <template x-for="line in $store.diffViewer.patchLines(file)" :key="line.id">
110 - <div class="diff-line" :class="`is-${line.type}`" role="row">
111 - <span class="diff-line-marker" x-text="line.type === 'add' ? '+' : (line.type === 'del' ? '-' : '')"></span>
112 - <code x-text="line.text"></code>
113 - </div>
114 - </template>
115 - </div>
116 - </template>
117 - </div>
118 - </article>
119 - </template>
120 - </section>
121 - </template>
122 - </div>
123 - </div>
124 - </div>
125 - </template>
126 - </div>
127 -
128 - <style>
129 - .diff-viewer-panel,
130 - .diff-viewer-shell {
131 - display: flex;
132 - flex: 1 1 auto;
133 - flex-direction: column;
134 - width: 100%;
135 - height: 100%;
136 - min-width: 0;
137 - min-height: 0;
138 - background: color-mix(in srgb, var(--color-background) 96%, #000 4%);
139 - color: var(--color-text);
140 - }
141 -
142 - .diff-viewer-panel {
143 - container-type: inline-size;
144 - }
145 -
146 - .modal-inner.diff-viewer-modal {
147 - box-sizing: border-box;
148 - container-type: inline-size;
149 - width: min(82vw, 1180px);
150 - height: min(88vh, 900px);
151 - min-width: min(340px, calc(100vw - 16px));
152 - min-height: min(500px, calc(100vh - 16px));
153 - max-width: calc(100vw - 16px);
154 - max-height: calc(100vh - 16px);
155 - resize: both;
156 - border: 1px solid color-mix(in srgb, var(--color-border) 75%, transparent);
157 - border-radius: 7px;
158 - box-shadow: 0 18px 48px rgba(0, 0, 0, 0.32);
159 - background: color-mix(in srgb, var(--color-background) 94%, #000 6%);
160 - }
161 -
162 - .modal.modal-floating {
163 - pointer-events: none;
164 - }
165 -
166 - .modal.modal-floating .modal-inner {
167 - pointer-events: auto;
168 - }
169 -
170 - .modal-inner.diff-viewer-modal .modal-header {
171 - min-height: 34px;
172 - padding: 0.35rem 0.75rem 0.35rem 1rem;
173 - cursor: move;
174 - user-select: none;
175 - background: color-mix(in srgb, var(--color-background) 92%, #000 8%);
176 - border-bottom: 1px solid color-mix(in srgb, var(--color-border) 70%, transparent);
177 - }
178 -
179 - .modal-inner.diff-viewer-modal .modal-scroll {
180 - display: flex;
181 - flex-direction: column;
182 - flex: 1 1 auto;
183 - min-height: 0;
184 - overflow: hidden;
185 - padding: 0;
186 - }
187 -
188 - .modal-inner.diff-viewer-modal .modal-bd.diff-viewer-modal-body {
189 - box-sizing: border-box;
190 - display: flex;
191 - flex-direction: column;
192 - flex: 1 1 auto;
193 - width: 100%;
194 - height: 100%;
195 - min-height: 0;
196 - padding: 0;
197 - }
198 -
199 - .modal-inner.diff-viewer-modal .modal-bd.diff-viewer-modal-body > x-component,
200 - .modal-inner.diff-viewer-modal .modal-bd.diff-viewer-modal-body > div[x-data] {
201 - display: flex;
202 - flex: 1 1 auto;
203 - width: 100%;
204 - height: 100%;
205 - min-height: 0;
206 - }
207 -
208 - .diff-viewer-toolbar {
209 - display: flex;
210 - align-items: center;
211 - gap: 6px;
212 - min-height: 44px;
213 - padding: var(--spacing-xs) var(--spacing-md);
214 - border-bottom: 1px solid color-mix(in srgb, var(--color-border) 66%, transparent);
215 - background: color-mix(in srgb, var(--color-background) 92%, #000 8%);
216 - }
217 -
218 - .diff-viewer-title,
219 - .diff-viewer-summary-main,
220 - .diff-viewer-summary-meta,
221 - .diff-viewer-status,
222 - .diff-file-counts,
223 - .diff-file-tools,
224 - .diff-viewer-tool-button,
225 - .diff-file-note {
226 - display: flex;
227 - align-items: center;
228 - }
229 -
230 - .diff-viewer-title {
231 - gap: 7px;
232 - min-width: 0;
233 - font-weight: 700;
234 - font-size: 0.9rem;
235 - }
236 -
237 - .diff-viewer-title .material-symbols-outlined {
238 - font-size: 19px;
239 - }
240 -
241 - .diff-viewer-spacer {
242 - flex: 1 1 auto;
243 - min-width: 8px;
244 - }
245 -
246 - .diff-viewer-icon-button,
247 - .diff-viewer-tool-button {
248 - appearance: none;
249 - border: 1px solid color-mix(in srgb, var(--color-border) 64%, transparent);
250 - border-radius: 7px;
251 - background: color-mix(in srgb, var(--color-panel) 80%, transparent);
252 - color: var(--color-text);
253 - font: inherit;
254 - cursor: pointer;
255 - }
256 -
257 - .diff-viewer-icon-button {
258 - display: inline-flex;
259 - align-items: center;
260 - justify-content: center;
261 - width: 32px;
262 - height: 32px;
263 - min-width: 32px;
264 - padding: 0;
265 - }
266 -
267 - .diff-viewer-tool-button {
268 - gap: 5px;
269 - min-height: 28px;
270 - padding: 4px 8px;
271 - font-size: 0.76rem;
272 - }
273 -
274 - .diff-viewer-icon-button:hover:not(:disabled),
275 - .diff-viewer-tool-button:hover {
276 - background: color-mix(in srgb, var(--color-background-hover) 70%, transparent);
277 - border-color: color-mix(in srgb, var(--color-primary) 28%, var(--color-border));
278 - }
279 -
280 - .diff-viewer-icon-button:disabled {
281 - cursor: not-allowed;
282 - opacity: 0.45;
283 - }
284 -
285 - .diff-viewer-icon-button .material-symbols-outlined,
286 - .diff-viewer-tool-button .material-symbols-outlined {
287 - font-size: 17px;
288 - }
289 -
290 - .diff-viewer-summary {
291 - display: grid;
292 - grid-template-columns: minmax(0, 1fr);
293 - gap: 3px;
294 - padding: 9px 11px;
295 - border-bottom: 1px solid color-mix(in srgb, var(--color-border) 54%, transparent);
296 - background: color-mix(in srgb, var(--color-panel) 54%, transparent);
297 - }
298 -
299 - .diff-viewer-summary-main {
300 - gap: 5px;
301 - min-width: 0;
302 - font-size: 0.86rem;
303 - }
304 -
305 - .diff-viewer-summary-meta {
306 - gap: 7px;
307 - min-width: 0;
308 - color: var(--color-text-muted);
309 - font-size: 0.74rem;
310 - }
311 -
312 - .diff-viewer-summary-meta span:last-child {
313 - min-width: 0;
314 - overflow: hidden;
315 - text-overflow: ellipsis;
316 - white-space: nowrap;
317 - }
318 -
319 - .diff-viewer-dot {
320 - width: 4px;
321 - height: 4px;
322 - flex: 0 0 auto;
323 - border-radius: 999px;
324 - background: color-mix(in srgb, var(--color-text-muted) 50%, transparent);
325 - }
326 -
327 - .diff-add {
328 - color: #31c48d;
329 - }
330 -
331 - .diff-del {
332 - color: #f05252;
333 - }
334 -
335 - .diff-viewer-status {
336 - gap: 8px;
337 - min-height: 34px;
338 - padding: 6px 11px;
339 - border-bottom: 1px solid color-mix(in srgb, var(--color-border) 44%, transparent);
340 - color: var(--color-text-muted);
341 - font-size: 0.82rem;
342 - }
343 -
344 - .diff-viewer-body {
345 - display: flex;
346 - flex: 1 1 auto;
347 - min-width: 0;
348 - min-height: 0;
349 - overflow: auto;
350 - }
351 -
352 - .diff-viewer-groups {
353 - display: flex;
354 - flex: 1 1 auto;
355 - min-width: 0;
356 - flex-direction: column;
357 - gap: 12px;
358 - padding: 12px;
359 - }
360 -
361 - .diff-viewer-group {
362 - display: flex;
363 - min-width: 0;
364 - flex-direction: column;
365 - gap: 7px;
366 - }
367 -
368 - .diff-viewer-group-header {
369 - display: flex;
370 - align-items: center;
371 - gap: 7px;
372 - min-height: 26px;
373 - color: var(--color-text);
374 - font-size: 0.82rem;
375 - font-weight: 700;
376 - }
377 -
378 - .diff-viewer-count {
379 - min-width: 22px;
380 - padding: 2px 6px;
381 - border-radius: 999px;
382 - background: color-mix(in srgb, var(--color-panel) 78%, transparent);
383 - color: var(--color-text-muted);
384 - text-align: center;
385 - font-size: 0.72rem;
386 - }
387 -
388 - .diff-file {
389 - overflow: hidden;
390 - border: 1px solid color-mix(in srgb, var(--color-border) 58%, transparent);
391 - border-radius: 7px;
392 - background: color-mix(in srgb, var(--color-panel) 72%, transparent);
393 - }
394 -
395 - .diff-file-header {
396 - display: grid;
397 - grid-template-columns: auto minmax(0, 1fr) auto auto;
398 - align-items: center;
399 - gap: 7px;
400 - width: 100%;
401 - min-height: 38px;
402 - padding: 0 9px;
403 - border: 0;
404 - background: transparent;
405 - color: var(--color-text);
406 - font: inherit;
407 - text-align: left;
408 - cursor: pointer;
409 - }
410 -
411 - .diff-file-header:hover {
412 - background: color-mix(in srgb, var(--color-background-hover) 56%, transparent);
413 - }
414 -
415 - .diff-file-chevron {
416 - font-size: 18px;
417 - color: var(--color-text-muted);
418 - }
419 -
420 - .diff-file-name {
421 - min-width: 0;
422 - overflow: hidden;
423 - text-overflow: ellipsis;
424 - white-space: nowrap;
425 - font-family: var(--font-family-code);
426 - font-size: 0.78rem;
427 - font-weight: 650;
428 - }
429 -
430 - .diff-file-status {
431 - padding: 2px 6px;
432 - border-radius: 999px;
433 - background: color-mix(in srgb, var(--color-background) 70%, transparent);
434 - color: var(--color-text-muted);
435 - font-size: 0.68rem;
436 - text-transform: capitalize;
437 - white-space: nowrap;
438 - }
439 -
440 - .diff-file-counts {
441 - gap: 5px;
442 - justify-content: end;
443 - min-width: 72px;
444 - font-family: var(--font-family-code);
445 - font-size: 0.74rem;
446 - }
447 -
448 - .diff-file-tools {
449 - justify-content: flex-end;
450 - gap: 6px;
451 - min-height: 34px;
452 - padding: 4px 8px;
453 - border-top: 1px solid color-mix(in srgb, var(--color-border) 34%, transparent);
454 - background: color-mix(in srgb, var(--color-background) 55%, transparent);
455 - }
456 -
457 - .diff-file-body {
458 - border-top: 1px solid color-mix(in srgb, var(--color-border) 44%, transparent);
459 - }
460 -
461 - .diff-file-note {
462 - gap: 8px;
463 - padding: 12px;
464 - color: var(--color-text-muted);
465 - font-size: 0.82rem;
466 - }
467 -
468 - .diff-code {
469 - overflow: auto;
470 - padding: 4px 0;
471 - background: color-mix(in srgb, var(--color-background) 91%, #000 9%);
472 - font-family: var(--font-family-code);
473 - font-size: 0.74rem;
474 - line-height: 1.45;
475 - }
476 -
477 - .diff-line {
478 - display: grid;
479 - grid-template-columns: 24px minmax(0, 1fr);
480 - min-width: max-content;
481 - }
482 -
483 - .diff-line-marker {
484 - position: sticky;
485 - left: 0;
486 - z-index: 1;
487 - min-height: 1.45em;
488 - padding-right: 5px;
489 - background: inherit;
490 - color: var(--color-text-muted);
491 - text-align: right;
492 - user-select: none;
493 - }
494 -
495 - .diff-line code {
496 - display: block;
497 - min-height: 1.45em;
498 - padding: 0 10px 0 6px;
499 - color: inherit;
500 - font: inherit;
501 - white-space: pre;
502 - }
503 -
504 - .diff-line.is-add {
505 - background: rgba(39, 174, 96, 0.16);
506 - color: color-mix(in srgb, #8df0b0 78%, var(--color-text));
507 - }
508 -
509 - .diff-line.is-del {
510 - background: rgba(231, 76, 60, 0.17);
511 - color: color-mix(in srgb, #ffaaa2 78%, var(--color-text));
512 - }
513 -
514 - .diff-line.is-hunk {
515 - background: rgba(99, 102, 241, 0.16);
516 - color: color-mix(in srgb, #b9c3ff 80%, var(--color-text));
517 - }
518 -
519 - .diff-line.is-meta,
520 - .diff-line.is-note {
521 - color: var(--color-text-muted);
522 - }
523 -
524 - .diff-viewer-empty {
525 - display: grid;
526 - flex: 1 1 auto;
527 - place-items: center;
528 - align-content: center;
529 - gap: 7px;
530 - min-width: 0;
531 - padding: 24px;
532 - color: var(--color-text-muted);
533 - text-align: center;
534 - }
535 -
536 - .diff-viewer-empty .material-symbols-outlined {
537 - font-size: 28px;
538 - }
539 -
540 - .diff-viewer-empty span:last-child {
541 - max-width: 100%;
542 - overflow: hidden;
543 - text-overflow: ellipsis;
544 - white-space: nowrap;
545 - font-size: 0.78rem;
546 - }
547 -
548 - .diff-viewer-panel .spinning {
549 - display: inline-block;
550 - animation: diff-viewer-spin 0.8s linear infinite;
551 - }
552 -
553 - @keyframes diff-viewer-spin {
554 - to { transform: rotate(360deg); }
555 - }
556 -
557 - @container (max-width: 560px) {
558 - .diff-file-header {
559 - grid-template-columns: auto minmax(0, 1fr) auto;
560 - }
561 -
562 - .diff-file-status {
563 - display: none;
564 - }
565 -
566 - .diff-viewer-tool-button span:last-child {
567 - display: none;
568 - }
569 - }
570 - </style>
571 -</body>
572 -</html>
plugins/_diff_viewer/webui/diff-viewer-store.js deleted
-321
@@ -1,321 +0,0 @@
1 -import { createStore } from "/js/AlpineStore.js";
2 -import { callJsonApi } from "/js/api.js";
3 -import { getContext } from "/index.js";
4 -import { store as fileBrowserStore } from "/components/modals/file-browser/file-browser-store.js";
5 -
6 -const REFRESH_DEBOUNCE_MS = 180;
7 -
8 -function lineType(text) {
9 - if (text.startsWith("@@")) return "hunk";
10 - if (text.startsWith("+++") || text.startsWith("---") || text.startsWith("diff --git") || text.startsWith("index ")) {
11 - return "meta";
12 - }
13 - if (text.startsWith("+")) return "add";
14 - if (text.startsWith("-")) return "del";
15 - if (text.startsWith("\\ No newline")) return "note";
16 - return "context";
17 -}
18 -
19 -function dirname(path) {
20 - const clean = String(path || "").replace(/\/+$/, "");
21 - const index = clean.lastIndexOf("/");
22 - return index > 0 ? clean.slice(0, index) : "";
23 -}
24 -
25 -const model = {
26 - loading: false,
27 - error: "",
28 - payload: null,
29 - contextId: "",
30 - workspacePath: "",
31 - expanded: {},
32 - _root: null,
33 - _mode: "canvas",
34 - _refreshTimer: null,
35 - _floatingCleanup: null,
36 - _requestSeq: 0,
37 -
38 - async init(element = null) {
39 - await this.onMount(element, { mode: "canvas" });
40 - },
41 -
42 - async onMount(element = null, options = {}) {
43 - if (element) this._root = element;
44 - this._mode = options?.mode === "modal" ? "modal" : "canvas";
45 - if (this._mode === "modal") {
46 - this.setupFloatingModal(element);
47 - } else {
48 - this.setupCanvasSurface(element);
49 - }
50 - this.contextId = this.resolveContextId();
51 - if (!this.payload && !this.loading) {
52 - await this.refresh({ contextId: this.contextId });
53 - }
54 - },
55 -
56 - async onOpen(payload = {}) {
57 - const nextContextId = String(payload.contextId || payload.context_id || this.resolveContextId() || "");
58 - await this.refresh({ contextId: nextContextId });
59 - },
60 -
61 - cleanup() {
62 - if (this._refreshTimer) {
63 - clearTimeout(this._refreshTimer);
64 - this._refreshTimer = null;
65 - }
66 - this._floatingCleanup?.();
67 - this._floatingCleanup = null;
68 - },
69 -
70 - setupFloatingModal(element = null) {
71 - this._floatingCleanup?.();
72 - const root = element || globalThis.document?.querySelector(".diff-viewer-panel");
73 - const modal = root?.closest?.(".modal");
74 - const inner = modal?.querySelector?.(".modal-inner");
75 - const body = modal?.querySelector?.(".modal-bd");
76 - const header = modal?.querySelector?.(".modal-header");
77 - if (!modal || !inner || !header) return;
78 - modal.classList.add("modal-floating");
79 - inner.classList.add("diff-viewer-modal", "modal-no-backdrop");
80 - body?.classList?.add("diff-viewer-modal-body");
81 -
82 - const rect = inner.getBoundingClientRect();
83 - inner.style.left = `${Math.max(8, rect.left)}px`;
84 - inner.style.top = `${Math.max(8, rect.top)}px`;
85 - inner.style.transform = "none";
86 -
87 - let drag = null;
88 - let resizeObserver = null;
89 - const viewportGap = 8;
90 - const clampPosition = (left, top) => {
91 - const bounds = inner.getBoundingClientRect();
92 - const maxLeft = Math.max(viewportGap, globalThis.innerWidth - bounds.width - viewportGap);
93 - const maxTop = Math.max(viewportGap, globalThis.innerHeight - bounds.height - viewportGap);
94 - return {
95 - left: Math.min(Math.max(viewportGap, left), maxLeft),
96 - top: Math.min(Math.max(viewportGap, top), maxTop),
97 - };
98 - };
99 - const clampGeometry = () => {
100 - const bounds = inner.getBoundingClientRect();
101 - const left = Math.max(viewportGap, bounds.left);
102 - const top = Math.max(viewportGap, bounds.top);
103 - const maxWidth = Math.max(340, globalThis.innerWidth - viewportGap * 2);
104 - const maxHeight = Math.max(360, globalThis.innerHeight - viewportGap * 2);
105 - if (bounds.width > maxWidth) inner.style.width = `${maxWidth}px`;
106 - if (bounds.height > maxHeight) inner.style.height = `${maxHeight}px`;
107 - const next = clampPosition(left, top);
108 - inner.style.left = `${next.left}px`;
109 - inner.style.top = `${next.top}px`;
110 - inner.style.maxWidth = `${Math.max(340, globalThis.innerWidth - next.left - viewportGap)}px`;
111 - inner.style.maxHeight = `${Math.max(360, globalThis.innerHeight - next.top - viewportGap)}px`;
112 - };
113 - clampGeometry();
114 - globalThis.addEventListener("resize", clampGeometry);
115 - if (globalThis.ResizeObserver) {
116 - resizeObserver = new ResizeObserver(clampGeometry);
117 - resizeObserver.observe(inner);
118 - }
119 -
120 - const onPointerMove = (event) => {
121 - if (!drag) return;
122 - const next = clampPosition(
123 - drag.left + event.clientX - drag.x,
124 - drag.top + event.clientY - drag.y,
125 - );
126 - inner.style.left = `${next.left}px`;
127 - inner.style.top = `${next.top}px`;
128 - clampGeometry();
129 - };
130 - const onPointerUp = () => {
131 - drag = null;
132 - globalThis.removeEventListener("pointermove", onPointerMove);
133 - globalThis.removeEventListener("pointerup", onPointerUp);
134 - try {
135 - header.releasePointerCapture?.(header.__diffViewerPanelPointerId || 0);
136 - } catch {}
137 - };
138 - const onPointerDown = (event) => {
139 - if (event.button !== 0) return;
140 - if (event.target?.closest?.("button, input, select, textarea, a")) return;
141 - const current = inner.getBoundingClientRect();
142 - drag = {
143 - x: event.clientX,
144 - y: event.clientY,
145 - left: current.left,
146 - top: current.top,
147 - };
148 - header.__diffViewerPanelPointerId = event.pointerId;
149 - header.setPointerCapture?.(event.pointerId);
150 - globalThis.addEventListener("pointermove", onPointerMove);
151 - globalThis.addEventListener("pointerup", onPointerUp);
152 - event.preventDefault();
153 - };
154 - header.addEventListener("pointerdown", onPointerDown);
155 -
156 - this._floatingCleanup = () => {
157 - header.removeEventListener("pointerdown", onPointerDown);
158 - globalThis.removeEventListener("pointermove", onPointerMove);
159 - globalThis.removeEventListener("pointerup", onPointerUp);
160 - globalThis.removeEventListener("resize", clampGeometry);
161 - resizeObserver?.disconnect?.();
162 - };
163 - },
164 -
165 - setupCanvasSurface(element = null) {
166 - this._floatingCleanup?.();
167 - this._floatingCleanup = null;
168 - if (element) this._root = element;
169 - },
170 -
171 - resolveContextId() {
172 - const urlContext = new URLSearchParams(globalThis.location?.search || "").get("ctxid");
173 - return getContext?.() || urlContext || globalThis.Alpine?.store?.("chats")?.selected || "";
174 - },
175 -
176 - scheduleRefresh(options = {}) {
177 - if (this._refreshTimer) clearTimeout(this._refreshTimer);
178 - this._refreshTimer = setTimeout(() => {
179 - this._refreshTimer = null;
180 - this.refresh(options).catch((error) => {
181 - console.error("Diff refresh failed", error);
182 - });
183 - }, REFRESH_DEBOUNCE_MS);
184 - },
185 -
186 - async refresh(options = {}) {
187 - const contextId = String(options.contextId || options.context_id || this.resolveContextId() || "");
188 - const seq = ++this._requestSeq;
189 - this.loading = true;
190 - this.error = "";
191 - try {
192 - const response = await callJsonApi("/plugins/_diff_viewer/diff", { context_id: contextId });
193 - if (seq !== this._requestSeq) return;
194 - if (!response?.ok) {
195 - throw new Error(response?.error || "Could not load diff.");
196 - }
197 - this.payload = response;
198 - this.contextId = String(response.context_id || contextId || "");
199 - this.workspacePath = String(response.workspace_path || "");
200 - this.reconcileExpanded();
201 - } catch (error) {
202 - if (seq !== this._requestSeq) return;
203 - this.error = error instanceof Error ? error.message : String(error);
204 - } finally {
205 - if (seq === this._requestSeq) this.loading = false;
206 - }
207 - },
208 -
209 - reconcileExpanded() {
210 - const next = {};
211 - let index = 0;
212 - for (const group of this.visibleGroups()) {
213 - for (const file of group.files || []) {
214 - const key = this.fileKey(group, file);
215 - next[key] = this.expanded[key] ?? index < 4;
216 - index += 1;
217 - }
218 - }
219 - this.expanded = next;
220 - },
221 -
222 - visibleGroups() {
223 - return (this.payload?.groups || []).filter((group) => Array.isArray(group.files) && group.files.length > 0);
224 - },
225 -
226 - hasChanges() {
227 - return this.visibleGroups().length > 0;
228 - },
229 -
230 - groupTitle(kind) {
231 - const labels = {
232 - staged: "Staged",
233 - unstaged: "Unstaged",
234 - untracked: "Untracked",
235 - };
236 - return labels[kind] || kind;
237 - },
238 -
239 - statusLabel(file) {
240 - return String(file?.status || "changed").replaceAll("_", " ");
241 - },
242 -
243 - fileKey(group, file) {
244 - return `${group?.kind || "diff"}:${file?.old_path || ""}:${file?.path || ""}`;
245 - },
246 -
247 - isExpanded(group, file) {
248 - return this.expanded[this.fileKey(group, file)] !== false;
249 - },
250 -
251 - toggleFile(group, file) {
252 - const key = this.fileKey(group, file);
253 - this.expanded[key] = !this.isExpanded(group, file);
254 - },
255 -
256 - expandAll() {
257 - const next = {};
258 - for (const group of this.visibleGroups()) {
259 - for (const file of group.files || []) {
260 - next[this.fileKey(group, file)] = true;
261 - }
262 - }
263 - this.expanded = next;
264 - },
265 -
266 - collapseAll() {
267 - const next = {};
268 - for (const group of this.visibleGroups()) {
269 - for (const file of group.files || []) {
270 - next[this.fileKey(group, file)] = false;
271 - }
272 - }
273 - this.expanded = next;
274 - },
275 -
276 - patchLines(file) {
277 - const patch = String(file?.patch || "");
278 - if (!patch) return [];
279 - const textLines = patch.endsWith("\n") ? patch.slice(0, -1).split("\n") : patch.split("\n");
280 - return textLines.map((text, index) => ({
281 - id: `${index}-${text.slice(0, 20)}`,
282 - text,
283 - type: lineType(text),
284 - }));
285 - },
286 -
287 - fileTitle(file) {
288 - if (file?.old_path && file.old_path !== file.path) {
289 - return `${file.old_path} -> ${file.path}`;
290 - }
291 - return file?.path || file?.old_path || "";
292 - },
293 -
294 - formatSigned(value, sign) {
295 - const number = Number(value) || 0;
296 - return `${sign}${number.toLocaleString()}`;
297 - },
298 -
299 - fullPath(file) {
300 - const relativePath = String(file?.path || file?.old_path || "").replace(/^\/+/, "");
301 - const base = String(this.workspacePath || "").replace(/\/+$/, "");
302 - return relativePath ? `${base}/${relativePath}` : base;
303 - },
304 -
305 - async openContainingFolder(file) {
306 - const parent = dirname(this.fullPath(file));
307 - await fileBrowserStore.open(parent || this.workspacePath || "$WORK_DIR");
308 - },
309 -
310 - async copyPath(file) {
311 - const path = this.fullPath(file);
312 - try {
313 - await navigator.clipboard.writeText(path);
314 - globalThis.justToast?.("Path copied", "success", 1200, "diff-viewer-copy");
315 - } catch (_error) {
316 - globalThis.prompt?.("Copy path", path);
317 - }
318 - },
319 -};
320 -
321 -export const store = createStore("diffViewer", model);
plugins/_diff_viewer/webui/main.html deleted
-14
@@ -1,14 +0,0 @@
1 -<html
2 - class="diff-viewer-modal modal-no-backdrop"
3 - data-canvas-surface="diff"
4 - data-canvas-modal-path="/plugins/_diff_viewer/webui/main.html"
5 - data-canvas-dock-title="Open Diff in canvas"
6 - data-canvas-dock-icon="dock_to_right"
7 ->
8 -<head>
9 - <title>Diff</title>
10 -</head>
11 -<body class="diff-viewer-modal-body">
12 - <x-component path="/plugins/_diff_viewer/webui/diff-viewer-panel.html" mode="modal"></x-component>
13 -</body>
14 -</html>
plugins/_time_travel/api/history_diff.py new
+25
@@ -0,0 +1,25 @@
1 +from __future__ import annotations
2 +
3 +from helpers.api import ApiHandler, Request, Response
4 +from plugins._time_travel.helpers.time_travel import (
5 + TimeTravelError,
6 + TimeTravelService,
7 + WorkspaceRejectedError,
8 + resolve_workspace,
9 +)
10 +
11 +
12 +class HistoryDiff(ApiHandler):
13 + async def process(self, input: dict, request: Request) -> dict | Response:
14 + context_id = str(input.get("context_id") or "").strip()
15 + try:
16 + workspace = resolve_workspace(context_id, context_loader=self.use_context)
17 + return TimeTravelService(workspace).history_diff(
18 + commit_hash=str(input.get("commit_hash") or ""),
19 + path=str(input.get("path") or ""),
20 + mode=str(input.get("mode") or "commit"),
21 + )
22 + except WorkspaceRejectedError as exc:
23 + return {"ok": False, "locked": True, "error": str(exc)}
24 + except TimeTravelError as exc:
25 + return {"ok": False, "error": str(exc)}
plugins/_time_travel/api/history_list.py new
+26
@@ -0,0 +1,26 @@
1 +from __future__ import annotations
2 +
3 +from helpers.api import ApiHandler, Request, Response
4 +from plugins._time_travel.helpers.time_travel import (
5 + TimeTravelError,
6 + TimeTravelService,
7 + WorkspaceRejectedError,
8 + resolve_workspace,
9 + unavailable_payload,
10 +)
11 +
12 +
13 +class HistoryList(ApiHandler):
14 + async def process(self, input: dict, request: Request) -> dict | Response:
15 + context_id = str(input.get("context_id") or "").strip()
16 + try:
17 + workspace = resolve_workspace(context_id, context_loader=self.use_context)
18 + return TimeTravelService(workspace).history_list(
19 + limit=int(input.get("limit") or 100),
20 + offset=int(input.get("offset") or 0),
21 + file_filter=str(input.get("file_filter") or ""),
22 + )
23 + except WorkspaceRejectedError as exc:
24 + return unavailable_payload(context_id, str(exc))
25 + except TimeTravelError as exc:
26 + return {"ok": False, "error": str(exc)}
plugins/_time_travel/api/history_preview.py new
+24
@@ -0,0 +1,24 @@
1 +from __future__ import annotations
2 +
3 +from helpers.api import ApiHandler, Request, Response
4 +from plugins._time_travel.helpers.time_travel import (
5 + TimeTravelError,
6 + TimeTravelService,
7 + WorkspaceRejectedError,
8 + resolve_workspace,
9 +)
10 +
11 +
12 +class HistoryPreview(ApiHandler):
13 + async def process(self, input: dict, request: Request) -> dict | Response:
14 + context_id = str(input.get("context_id") or "").strip()
15 + try:
16 + workspace = resolve_workspace(context_id, context_loader=self.use_context)
17 + return TimeTravelService(workspace).preview(
18 + operation=str(input.get("operation") or ""),
19 + commit_hash=str(input.get("commit_hash") or ""),
20 + )
21 + except WorkspaceRejectedError as exc:
22 + return {"ok": False, "locked": True, "error": str(exc)}
23 + except TimeTravelError as exc:
24 + return {"ok": False, "error": str(exc), "technical_details": getattr(exc, "stderr", "")}
plugins/_time_travel/api/history_revert.py new
+35
@@ -0,0 +1,35 @@
1 +from __future__ import annotations
2 +
3 +from helpers.api import ApiHandler, Request, Response
4 +from plugins._time_travel.helpers.time_travel import (
5 + TimeTravelError,
6 + TimeTravelService,
7 + WorkspaceRejectedError,
8 + resolve_workspace,
9 +)
10 +
11 +
12 +class HistoryRevert(ApiHandler):
13 + async def process(self, input: dict, request: Request) -> dict | Response:
14 + context_id = str(input.get("context_id") or "").strip()
15 + try:
16 + workspace = resolve_workspace(context_id, context_loader=self.use_context)
17 + return TimeTravelService(workspace).revert(
18 + commit_hash=str(input.get("commit_hash") or ""),
19 + metadata=input.get("metadata") if isinstance(input.get("metadata"), dict) else {},
20 + )
21 + except WorkspaceRejectedError as exc:
22 + return {"ok": False, "locked": True, "error": str(exc)}
23 + except TimeTravelError as exc:
24 + details = getattr(exc, "stderr", "") or str(exc)
25 + return {"ok": False, "error": _human_conflict_summary(str(exc)), "technical_details": details}
26 +
27 +
28 +def _human_conflict_summary(message: str) -> str:
29 + text = str(message or "").strip()
30 + if not text:
31 + return "Revert could not be applied cleanly."
32 + first = text.splitlines()[0]
33 + if "does not match index" in text or "patch failed" in text.lower() or "error:" in text.lower():
34 + return "Revert could not be applied cleanly because the current workspace has conflicting changes."
35 + return first
plugins/_time_travel/api/history_snapshot.py new
+28
@@ -0,0 +1,28 @@
1 +from __future__ import annotations
2 +
3 +from helpers.api import ApiHandler, Request, Response
4 +from plugins._time_travel.helpers.time_travel import (
5 + TimeTravelError,
6 + TimeTravelService,
7 + WorkspaceRejectedError,
8 + _snapshot_public,
9 + resolve_workspace,
10 +)
11 +
12 +
13 +class HistorySnapshot(ApiHandler):
14 + async def process(self, input: dict, request: Request) -> dict | Response:
15 + context_id = str(input.get("context_id") or "").strip()
16 + try:
17 + workspace = resolve_workspace(context_id, context_loader=self.use_context)
18 + snapshot = TimeTravelService(workspace).snapshot(
19 + trigger=str(input.get("trigger") or "manual"),
20 + message=str(input.get("message") or ""),
21 + metadata=input.get("metadata") if isinstance(input.get("metadata"), dict) else {},
22 + changed_path_hints=input.get("changed_path_hints") if isinstance(input.get("changed_path_hints"), list) else None,
23 + )
24 + return {"ok": True, "snapshot": _snapshot_public(snapshot)}
25 + except WorkspaceRejectedError as exc:
26 + return {"ok": False, "locked": True, "error": str(exc)}
27 + except TimeTravelError as exc:
28 + return {"ok": False, "error": str(exc), "technical_details": getattr(exc, "stderr", "")}
plugins/_time_travel/api/history_travel.py new
+28
@@ -0,0 +1,28 @@
1 +from __future__ import annotations
2 +
3 +from helpers.api import ApiHandler, Request, Response
4 +from plugins._time_travel.helpers.time_travel import (
5 + TimeTravelError,
6 + TimeTravelService,
7 + WorkspaceRejectedError,
8 + resolve_workspace,
9 +)
10 +
11 +
12 +class HistoryTravel(ApiHandler):
13 + async def process(self, input: dict, request: Request) -> dict | Response:
14 + context_id = str(input.get("context_id") or "").strip()
15 + try:
16 + workspace = resolve_workspace(context_id, context_loader=self.use_context)
17 + return TimeTravelService(workspace).travel(
18 + commit_hash=str(input.get("commit_hash") or ""),
19 + metadata=input.get("metadata") if isinstance(input.get("metadata"), dict) else {},
20 + )
21 + except WorkspaceRejectedError as exc:
22 + return {"ok": False, "locked": True, "error": str(exc)}
23 + except TimeTravelError as exc:
24 + return {
25 + "ok": False,
26 + "error": str(exc),
27 + "technical_details": getattr(exc, "stderr", "") or str(exc),
28 + }
plugins/_time_travel/extensions/python/text_editor_patch_after/_50_snapshot.py new
+18
@@ -0,0 +1,18 @@
1 +from __future__ import annotations
2 +
3 +from typing import Any
4 +
5 +from helpers.extension import Extension
6 +from plugins._time_travel.helpers.time_travel import snapshot_for_agent
7 +
8 +
9 +class TimeTravelTextEditorPatchSnapshot(Extension):
10 + async def execute(self, data: dict[str, Any] | None = None, **kwargs: Any):
11 + snapshot_for_agent(
12 + self.agent,
13 + trigger="text_editor_patch",
14 + metadata={
15 + "patch_mode": str((data or {}).get("mode") or "edits"),
16 + "changed_path_hints": [str((data or {}).get("path") or "")],
17 + },
18 + )
plugins/_time_travel/extensions/python/text_editor_write_after/_50_snapshot.py new
+17
@@ -0,0 +1,17 @@
1 +from __future__ import annotations
2 +
3 +from typing import Any
4 +
5 +from helpers.extension import Extension
6 +from plugins._time_travel.helpers.time_travel import snapshot_for_agent
7 +
8 +
9 +class TimeTravelTextEditorWriteSnapshot(Extension):
10 + async def execute(self, data: dict[str, Any] | None = None, **kwargs: Any):
11 + snapshot_for_agent(
12 + self.agent,
13 + trigger="text_editor_write",
14 + metadata={
15 + "changed_path_hints": [str((data or {}).get("path") or "")],
16 + },
17 + )
plugins/_time_travel/extensions/python/tool_execute_after/_50_code_execution_snapshot.py new
+39
@@ -0,0 +1,39 @@
1 +from __future__ import annotations
2 +
3 +import time
4 +from typing import Any
5 +
6 +from helpers.extension import Extension
7 +from plugins._time_travel.helpers.time_travel import snapshot_for_agent
8 +
9 +
10 +DEBOUNCE_SECONDS = 2.0
11 +_LAST_SNAPSHOT_BY_CONTEXT: dict[str, float] = {}
12 +
13 +
14 +class TimeTravelCodeExecutionSnapshot(Extension):
15 + async def execute(self, tool_name: str = "", response: Any = None, **kwargs: Any):
16 + if tool_name != "code_execution_tool" or not self.agent:
17 + return
18 +
19 + context_id = str(getattr(getattr(self.agent, "context", None), "id", "") or "")
20 + now = time.monotonic()
21 + if context_id and now - _LAST_SNAPSHOT_BY_CONTEXT.get(context_id, 0.0) < DEBOUNCE_SECONDS:
22 + return
23 + if context_id:
24 + _LAST_SNAPSHOT_BY_CONTEXT[context_id] = now
25 +
26 + tool = getattr(getattr(self.agent, "loop_data", None), "current_tool", None)
27 + args = getattr(tool, "args", {}) if tool else {}
28 + runtime = str(args.get("runtime") or "") if isinstance(args, dict) else ""
29 + if runtime == "output":
30 + return
31 +
32 + snapshot_for_agent(
33 + self.agent,
34 + trigger="code_execution",
35 + metadata={
36 + "tool_name": tool_name,
37 + "runtime": runtime,
38 + },
39 + )
plugins/_time_travel/extensions/python/workdir_file_mutation_after/_50_snapshot.py new
+28
@@ -0,0 +1,28 @@
1 +from __future__ import annotations
2 +
3 +from typing import Any
4 +
5 +from helpers.extension import Extension
6 +from plugins._time_travel.helpers.time_travel import snapshot_for_path_hint
7 +
8 +
9 +class TimeTravelWorkdirFileMutationSnapshot(Extension):
10 + async def execute(self, data: dict[str, Any] | None = None, **kwargs: Any):
11 + payload = data or {}
12 + paths = payload.get("paths")
13 + if not isinstance(paths, list):
14 + paths = [payload.get("path") or payload.get("current_path") or payload.get("parent_path")]
15 +
16 + first_path = next((str(path) for path in paths if path), "")
17 + if not first_path:
18 + return
19 +
20 + snapshot_for_path_hint(
21 + first_path,
22 + trigger=f"file_browser_{payload.get('action') or 'mutation'}",
23 + metadata={
24 + "source": "file_browser",
25 + "action": payload.get("action") or "mutation",
26 + "changed_path_hints": [str(path) for path in paths if path],
27 + },
28 + )
plugins/_time_travel/extensions/webui/apply_snapshot_before/refresh-time-travel.js new
+9
@@ -0,0 +1,9 @@
1 +export default function refreshTimeTravelOnContextChange(ctx) {
2 + const store = globalThis.Alpine?.store?.("timeTravel");
3 + const canvas = globalThis.Alpine?.store?.("rightCanvas");
4 + if (!store || !canvas?.isOpen || canvas.activeSurfaceId !== "time-travel") return;
5 + const nextContextId = String(ctx?.snapshot?.context || "");
6 + if (nextContextId && nextContextId !== store.contextId) {
7 + store.scheduleRefresh({ contextId: nextContextId, reason: "context-change" });
8 + }
9 +}
plugins/_time_travel/extensions/webui/right-canvas-panels/time-travel-panel.html new
+8
@@ -0,0 +1,8 @@
1 +<div
2 + class="right-canvas-surface-panel time-travel-canvas-surface"
3 + data-surface-id="time-travel"
4 + x-show="$store.rightCanvas && $store.rightCanvas.isOpen && $store.rightCanvas.activeSurfaceId === 'time-travel'"
5 + style="display: none;"
6 +>
7 + <x-component path="/plugins/_time_travel/webui/time-travel-panel.html" mode="canvas"></x-component>
8 +</div>
plugins/_time_travel/extensions/webui/right_canvas_register_surfaces/register-time-travel.js renamed
+10 -10
@@ -17,21 +17,21 @@ function waitForElement(selector, timeoutMs = 3000) {
17 });
18 }
19
20 -export default async function registerDiffViewerSurface(canvas) {
20 +export default async function registerTimeTravelSurface(canvas) {
21 canvas.registerSurface({
22 - id: "diff",
23 - title: "Diff",
24 - icon: "difference",
22 + id: "time-travel",
23 + title: "Time Travel",
24 + icon: "history",
25 order: 30,
26 - modalPath: "/plugins/_diff_viewer/webui/main.html",
26 + modalPath: "/plugins/_time_travel/webui/main.html",
27 async open(payload = {}) {
28 - await waitForElement('[data-surface-id="diff"] .diff-viewer-panel');
29 - const diffViewer = globalThis.Alpine?.store?.("diffViewer");
30 - await diffViewer?.onOpen?.(payload);
28 + await waitForElement('[data-surface-id="time-travel"] .time-travel-panel');
29 + const store = globalThis.Alpine?.store?.("timeTravel");
30 + await store?.onOpen?.(payload);
31 },
32 async close() {
33 - const diffViewer = globalThis.Alpine?.store?.("diffViewer");
34 - diffViewer?.cleanup?.();
33 + const store = globalThis.Alpine?.store?.("timeTravel");
34 + store?.cleanup?.();
35 },
36 });
37 }
plugins/_time_travel/helpers/__init__.py new
+1
@@ -0,0 +1 @@
1 +
plugins/_time_travel/helpers/time_travel.py new
+1087
@@ -0,0 +1,1087 @@
1 +from __future__ import annotations
2 +
3 +import base64
4 +import fnmatch
5 +import hashlib
6 +import json
7 +import os
8 +import posixpath
9 +import shutil
10 +import subprocess
11 +import time
12 +from dataclasses import dataclass
13 +from datetime import datetime, timezone
14 +from pathlib import Path
15 +from typing import Any, Iterable
16 +
17 +from helpers import files
18 +from helpers.print_style import PrintStyle
19 +
20 +
21 +PLUGIN_NAME = "_time_travel"
22 +USR_DISPLAY_ROOT = "/a0/usr"
23 +SHADOW_DISPLAY_ROOT = "/a0/usr/.time_travel/workspaces"
24 +CURRENT_REF = "refs/heads/current"
25 +PRESERVED_REF_PREFIX = "refs/a0-time-travel/preserved"
26 +METADATA_PREFIX = "A0-Time-Travel-Metadata:"
27 +EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"
28 +MAX_RENDERED_PATCH_BYTES = 1_000_000
29 +GIT_TIMEOUT_SECONDS = 20
30 +
31 +STATUS_LABELS = {
32 + "A": "added",
33 + "C": "copied",
34 + "D": "deleted",
35 + "M": "modified",
36 + "R": "renamed",
37 + "T": "type_changed",
38 + "U": "unmerged",
39 + "X": "unknown",
40 +}
41 +
42 +EXCLUDED_DIR_NAMES = {
43 + ".git",
44 + ".time_travel",
45 + "__pycache__",
46 + ".pytest_cache",
47 + ".mypy_cache",
48 + ".ruff_cache",
49 + ".cache",
50 + ".tox",
51 + ".nox",
52 + ".venv",
53 + "venv",
54 + "env",
55 + "node_modules",
56 + "bower_components",
57 + "dist",
58 + "build",
59 + ".next",
60 + ".nuxt",
61 + ".svelte-kit",
62 + ".turbo",
63 + "coverage",
64 + "htmlcov",
65 + ".parcel-cache",
66 +}
67 +
68 +EXCLUDED_DIR_PATTERNS = {
69 + "*.egg-info",
70 +}
71 +
72 +EXCLUDED_FILE_PATTERNS = {
73 + "*.pyc",
74 + "*.pyo",
75 + "*.pyd",
76 + ".env",
77 + ".env.*",
78 + "*.class",
79 +}
80 +
81 +SAFE_A0PROJ_FILES = {
82 + ".a0proj/project.json",
83 + ".a0proj/agents.json",
84 +}
85 +
86 +SAFE_A0PROJ_DIRS = {
87 + ".a0proj/instructions/",
88 + ".a0proj/knowledge/",
89 + ".a0proj/skills/",
90 +}
91 +
92 +SAFE_PLUGIN_ASSET_NAMES = {
93 + "config.json",
94 + "presets.yaml",
95 + ".toggle-0",
96 + ".toggle-1",
97 +}
98 +
99 +
100 +class TimeTravelError(RuntimeError):
101 + """Base error for user-visible Time Travel failures."""
102 +
103 +
104 +class WorkspaceRejectedError(TimeTravelError):
105 + """Raised when a workspace is outside the /a0/usr kernel boundary."""
106 +
107 +
108 +class TimeTravelConflictError(TimeTravelError):
109 + """Raised when an operation cannot safely mutate the workspace."""
110 +
111 +
112 +class GitCommandError(TimeTravelError):
113 + def __init__(self, message: str, *, stdout: str = "", stderr: str = "") -> None:
114 + super().__init__(message)
115 + self.stdout = stdout
116 + self.stderr = stderr
117 +
118 +
119 +@dataclass(frozen=True)
120 +class WorkspaceInfo:
121 + id: str
122 + display_path: str
123 + real_path: Path
124 + shadow_path: Path
125 + repo_git_path: Path
126 + context_id: str = ""
127 + project_name: str = ""
128 +
129 + def public(self) -> dict[str, Any]:
130 + return {
131 + "id": self.id,
132 + "path": self.display_path,
133 + "display_path": self.display_path,
134 + "real_path": str(self.real_path),
135 + "shadow_path": normalize_display_path(str(self.shadow_path)),
136 + "repo_git_path": normalize_display_path(str(self.repo_git_path)),
137 + "context_id": self.context_id,
138 + "project_name": self.project_name,
139 + "available": True,
140 + "locked": False,
141 + }
142 +
143 +
144 +@dataclass(frozen=True)
145 +class SnapshotResult:
146 + created: bool
147 + hash: str
148 + short_hash: str
149 + tree_hash: str
150 + message: str
151 + files: list[dict[str, Any]]
152 + metadata: dict[str, Any]
153 +
154 +
155 +def now_iso() -> str:
156 + return datetime.now(timezone.utc).isoformat()
157 +
158 +
159 +def normalize_display_path(path: str) -> str:
160 + raw = str(path or "").strip()
161 + if not raw:
162 + return ""
163 + if raw.startswith("/a0"):
164 + normalized = posixpath.normpath(raw.replace("\\", "/"))
165 + return "/" if normalized == "." else normalized
166 +
167 + resolved = Path(raw).expanduser().resolve(strict=False)
168 + normalized = files.normalize_a0_path(str(resolved))
169 + if normalized.startswith("/a0"):
170 + return posixpath.normpath(normalized.replace("\\", "/"))
171 + return str(resolved)
172 +
173 +
174 +def is_inside_usr_display(display_path: str) -> bool:
175 + normalized = normalize_display_path(display_path)
176 + return normalized == USR_DISPLAY_ROOT or normalized.startswith(USR_DISPLAY_ROOT + "/")
177 +
178 +
179 +def workspace_id_for(display_path: str) -> str:
180 + normalized = normalize_display_path(display_path).rstrip("/")
181 + return hashlib.sha256(normalized.encode("utf-8")).hexdigest()[:32]
182 +
183 +
184 +def real_path_for_display(display_path: str) -> Path:
185 + normalized = normalize_display_path(display_path)
186 + if normalized == "/a0":
187 + return Path(files.get_base_dir()).resolve(strict=False)
188 + if normalized.startswith("/a0/"):
189 + return Path(files.get_base_dir(), normalized.removeprefix("/a0/")).resolve(strict=False)
190 + return Path(normalized).expanduser().resolve(strict=False)
191 +
192 +
193 +def resolve_workspace(context_id: str = "", *, context_loader=None) -> WorkspaceInfo:
194 + from helpers import projects, settings
195 +
196 + context_id = str(context_id or "").strip()
197 + project_name = ""
198 + display_path = ""
199 +
200 + if context_id:
201 + context = context_loader(context_id) if context_loader else None
202 + if context is not None:
203 + project_name = projects.get_context_project_name(context) or ""
204 + if project_name:
205 + display_path = files.normalize_a0_path(projects.get_project_folder(project_name))
206 +
207 + if not display_path:
208 + configured = str(settings.get_settings().get("workdir_path") or "")
209 + display_path = configured or files.normalize_a0_path(files.get_abs_path("usr/workdir"))
210 +
211 + normalized = normalize_display_path(display_path)
212 + if not is_inside_usr_display(normalized):
213 + raise WorkspaceRejectedError("Time Travel is only available for workspaces inside /a0/usr.")
214 +
215 + workspace_id = workspace_id_for(normalized)
216 + shadow_display = f"{SHADOW_DISPLAY_ROOT}/{workspace_id}"
217 + shadow_path = real_path_for_display(shadow_display)
218 + return WorkspaceInfo(
219 + id=workspace_id,
220 + display_path=normalized.rstrip("/") or normalized,
221 + real_path=real_path_for_display(normalized),
222 + shadow_path=shadow_path,
223 + repo_git_path=shadow_path / "repo.git",
224 + context_id=context_id,
225 + project_name=project_name,
226 + )
227 +
228 +
229 +def resolve_workspace_for_path_hint(path_hint: str) -> WorkspaceInfo | None:
230 + from helpers import settings
231 +
232 + normalized = normalize_display_path(path_hint)
233 + if not is_inside_usr_display(normalized):
234 + return None
235 +
236 + parts = [part for part in normalized.split("/") if part]
237 + if len(parts) >= 4 and parts[0] == "a0" and parts[1] == "usr" and parts[2] == "projects":
238 + project_display = f"/a0/usr/projects/{parts[3]}"
239 + return _workspace_from_display(project_display, project_name=parts[3])
240 +
241 + configured = str(settings.get_settings().get("workdir_path") or "")
242 + workdir_display = normalize_display_path(configured or files.normalize_a0_path(files.get_abs_path("usr/workdir")))
243 + if normalized == workdir_display or normalized.startswith(workdir_display.rstrip("/") + "/"):
244 + return _workspace_from_display(workdir_display)
245 +
246 + return None
247 +
248 +
249 +def _workspace_from_display(display_path: str, *, project_name: str = "", context_id: str = "") -> WorkspaceInfo:
250 + normalized = normalize_display_path(display_path)
251 + if not is_inside_usr_display(normalized):
252 + raise WorkspaceRejectedError("Time Travel is only available for workspaces inside /a0/usr.")
253 + workspace_id = workspace_id_for(normalized)
254 + shadow_path = real_path_for_display(f"{SHADOW_DISPLAY_ROOT}/{workspace_id}")
255 + return WorkspaceInfo(
256 + id=workspace_id,
257 + display_path=normalized.rstrip("/") or normalized,
258 + real_path=real_path_for_display(normalized),
259 + shadow_path=shadow_path,
260 + repo_git_path=shadow_path / "repo.git",
261 + context_id=context_id,
262 + project_name=project_name,
263 + )
264 +
265 +
266 +def unavailable_payload(context_id: str, error: str) -> dict[str, Any]:
267 + return {
268 + "ok": True,
269 + "context_id": context_id,
270 + "workspace": {
271 + "available": False,
272 + "locked": True,
273 + "path": "",
274 + "display_path": "",
275 + "error": error,
276 + },
277 + "current_hash": "",
278 + "present": clean_summary(),
279 + "commits": [],
280 + "has_more": False,
281 + }
282 +
283 +
284 +def clean_summary() -> dict[str, Any]:
285 + return {
286 + "dirty": False,
287 + "files_count": 0,
288 + "additions": 0,
289 + "deletions": 0,
290 + "files": [],
291 + }
292 +
293 +
294 +def snapshot_for_agent(agent: Any, *, trigger: str, metadata: dict[str, Any] | None = None) -> SnapshotResult | None:
295 + if not agent:
296 + return None
297 +
298 + context_id = str(getattr(getattr(agent, "context", None), "id", "") or "")
299 + try:
300 + workspace = resolve_workspace(context_id, context_loader=lambda _ctxid: agent.context)
301 + return TimeTravelService(workspace).snapshot(trigger=trigger, metadata=_agent_metadata(agent, metadata))
302 + except WorkspaceRejectedError:
303 + return None
304 + except Exception as exc:
305 + PrintStyle.error(f"Time Travel snapshot failed: {exc}")
306 + return None
307 +
308 +
309 +def snapshot_for_path_hint(path_hint: str, *, trigger: str, metadata: dict[str, Any] | None = None) -> SnapshotResult | None:
310 + try:
311 + workspace = resolve_workspace_for_path_hint(path_hint)
312 + if workspace is None:
313 + return None
314 + return TimeTravelService(workspace).snapshot(trigger=trigger, metadata=metadata or {})
315 + except Exception as exc:
316 + PrintStyle.error(f"Time Travel file-browser snapshot failed: {exc}")
317 + return None
318 +
319 +
320 +def _agent_metadata(agent: Any, metadata: dict[str, Any] | None = None) -> dict[str, Any]:
321 + from helpers import projects
322 +
323 + result = dict(metadata or {})
324 + context = getattr(agent, "context", None)
325 + if context is not None:
326 + result.setdefault("context_id", str(getattr(context, "id", "") or ""))
327 + project_name = projects.get_context_project_name(context) or ""
328 + if project_name:
329 + result.setdefault("project_name", project_name)
330 + tool = getattr(getattr(agent, "loop_data", None), "current_tool", None)
331 + if tool is not None:
332 + result.setdefault("tool_name", str(getattr(tool, "name", "") or ""))
333 + args = getattr(tool, "args", None)
334 + if isinstance(args, dict):
335 + result.setdefault("runtime", str(args.get("runtime") or ""))
336 + log = getattr(tool, "log", None)
337 + if log is not None:
338 + result.setdefault("log_item_id", str(getattr(log, "id", "") or ""))
339 + result.setdefault("log_item_no", getattr(log, "no", None))
340 + return {key: value for key, value in result.items() if value not in (None, "")}
341 +
342 +
343 +class TimeTravelService:
344 + def __init__(self, workspace: WorkspaceInfo):
345 + self.workspace = workspace
346 +
347 + def ensure_repo(self) -> None:
348 + self.workspace.shadow_path.mkdir(parents=True, exist_ok=True)
349 + if not self.workspace.repo_git_path.exists():
350 + completed = subprocess.run(
351 + ["git", "init", "--bare", str(self.workspace.repo_git_path)],
352 + capture_output=True,
353 + text=True,
354 + encoding="utf-8",
355 + errors="replace",
356 + timeout=GIT_TIMEOUT_SECONDS,
357 + )
358 + if completed.returncode != 0:
359 + raise GitCommandError(
360 + (completed.stderr or completed.stdout or "Could not initialize shadow Git repository.").strip(),
361 + stdout=completed.stdout,
362 + stderr=completed.stderr,
363 + )
364 + self._git("symbolic-ref", "HEAD", CURRENT_REF)
365 +
366 + self._git("config", "user.name", "Agent Zero Time Travel")
367 + self._git("config", "user.email", "time-travel@agent-zero.local")
368 + self._git("config", "core.autocrlf", "false")
369 + self._git("config", "core.filemode", "true")
370 +
371 + def current_hash(self) -> str:
372 + self.ensure_repo()
373 + completed = self._git("rev-parse", "--verify", "HEAD", check=False)
374 + return completed.stdout.strip() if completed.returncode == 0 else ""
375 +
376 + def current_short_hash(self) -> str:
377 + current = self.current_hash()
378 + return current[:12] if current else ""
379 +
380 + def snapshot(
381 + self,
382 + *,
383 + trigger: str = "manual",
384 + message: str = "",
385 + metadata: dict[str, Any] | None = None,
386 + changed_path_hints: list[str] | None = None,
387 + ) -> SnapshotResult:
388 + self._ensure_workspace_dir()
389 + self.ensure_repo()
390 + previous_hash = self.current_hash()
391 + tree_hash, included_paths = self._stage_current_tree()
392 +
393 + if previous_hash and self._commit_tree(previous_hash) == tree_hash:
394 + return SnapshotResult(
395 + created=False,
396 + hash=previous_hash,
397 + short_hash=previous_hash[:12],
398 + tree_hash=tree_hash,
399 + message=message or self._default_snapshot_message(trigger),
400 + files=[],
401 + metadata=self._metadata(trigger, metadata, changed_path_hints),
402 + )
403 +
404 + if not previous_hash and not included_paths:
405 + return SnapshotResult(
406 + created=False,
407 + hash="",
408 + short_hash="",
409 + tree_hash=tree_hash,
410 + message=message or self._default_snapshot_message(trigger),
411 + files=[],
412 + metadata=self._metadata(trigger, metadata, changed_path_hints),
413 + )
414 +
415 + full_metadata = self._metadata(trigger, metadata, changed_path_hints)
416 + commit_message = self._commit_message(message or self._default_snapshot_message(trigger), full_metadata)
417 + args = ["commit-tree", tree_hash]
418 + if previous_hash:
419 + args.extend(["-p", previous_hash])
420 + args.extend(["-F", "-"])
421 + env = self._git_env()
422 + if timestamp := str(full_metadata.get("timestamp") or ""):
423 + env["GIT_AUTHOR_DATE"] = timestamp
424 + env["GIT_COMMITTER_DATE"] = timestamp
425 + commit = self._git(*args, input=commit_message, env=env).stdout.strip()
426 + self._git("update-ref", "HEAD", commit)
427 + diff_base = previous_hash or EMPTY_TREE
428 + return SnapshotResult(
429 + created=True,
430 + hash=commit,
431 + short_hash=commit[:12],
432 + tree_hash=tree_hash,
433 + message=message or self._default_snapshot_message(trigger),
434 + files=self.diff_files(diff_base, commit),
435 + metadata=full_metadata,
436 + )
437 +
438 + def history_list(self, *, limit: int = 100, offset: int = 0, file_filter: str = "") -> dict[str, Any]:
439 + self._ensure_workspace_dir()
440 + self.ensure_repo()
441 + limit = min(max(int(limit or 100), 1), 200)
442 + offset = max(int(offset or 0), 0)
443 + file_filter = str(file_filter or "").strip().lower()
444 + current = self.current_hash()
445 + present = self.present_summary()
446 +
447 + all_hashes = self._rev_list_all()
448 + if file_filter:
449 + all_hashes = [
450 + commit_hash
451 + for commit_hash in all_hashes
452 + if any(
453 + file_filter in str(item.get("path") or "").lower()
454 + or file_filter in str(item.get("old_path") or "").lower()
455 + for item in self.commit_files(commit_hash)
456 + )
457 + ]
458 +
459 + window = all_hashes[offset : offset + limit + 1]
460 + visible = window[:limit]
461 + return {
462 + "ok": True,
463 + "context_id": self.workspace.context_id,
464 + "workspace": self.workspace.public(),
465 + "current_hash": current,
466 + "present": present,
467 + "commits": [self.commit_object(commit_hash, current_hash=current) for commit_hash in visible],
468 + "has_more": len(window) > limit,
469 + }
470 +
471 + def history_diff(self, *, commit_hash: str, path: str, mode: str = "commit") -> dict[str, Any]:
472 + self.ensure_repo()
473 + path = self._safe_rel_path(path)
474 + mode = str(mode or "commit").strip().lower()
475 +
476 + if mode in {"present", "current"}:
477 + base = self.current_hash() or EMPTY_TREE
478 + target, _paths = self._current_tree()
479 + else:
480 + commit_hash = self._validate_commit(commit_hash)
481 + base = self._first_parent(commit_hash) or EMPTY_TREE
482 + target = commit_hash
483 +
484 + return self._patch_payload(base, target, path)
485 +
486 + def preview(self, *, operation: str, commit_hash: str) -> dict[str, Any]:
487 + self.ensure_repo()
488 + operation = str(operation or "").strip().lower()
489 + commit_hash = self._validate_commit(commit_hash)
490 + current = self.current_hash() or EMPTY_TREE
491 +
492 + if operation == "travel":
493 + base = current
494 + target = commit_hash
495 + elif operation == "revert":
496 + base = commit_hash
497 + target = self._first_parent(commit_hash) or EMPTY_TREE
498 + else:
499 + raise TimeTravelError("Unsupported preview operation.")
500 +
501 + files_changed = self.diff_files(base, target)
502 + previews = []
503 + for item in files_changed[:12]:
504 + rel_path = str(item.get("path") or item.get("old_path") or "")
505 + if not rel_path:
506 + continue
507 + previews.append(self._patch_payload(base, target, rel_path))
508 +
509 + return {
510 + "ok": True,
511 + "operation": operation,
512 + "commit_hash": commit_hash,
513 + "short_hash": commit_hash[:12],
514 + "files": files_changed,
515 + "previews": previews,
516 + }
517 +
518 + def travel(self, *, commit_hash: str, metadata: dict[str, Any] | None = None) -> dict[str, Any]:
519 + self._ensure_workspace_dir()
520 + self.ensure_repo()
521 + target = self._validate_commit(commit_hash)
522 + before = self.snapshot(trigger="before_travel", metadata=metadata or {})
523 + previous = self.current_hash()
524 + if previous:
525 + self._preserve_ref(previous, reason="travel")
526 + affected = self.diff_files(previous or EMPTY_TREE, target)
527 + self._apply_commit_tree(previous or EMPTY_TREE, target, affected)
528 + self._git("update-ref", "HEAD", target)
529 + return {
530 + "ok": True,
531 + "operation": "travel",
532 + "current_hash": target,
533 + "previous_hash": previous,
534 + "preserved_hash": previous,
535 + "auto_snapshot": _snapshot_public(before),
536 + "affected_files": affected,
537 + }
538 +
539 + def revert(self, *, commit_hash: str, metadata: dict[str, Any] | None = None) -> dict[str, Any]:
540 + self._ensure_workspace_dir()
541 + self.ensure_repo()
542 + target = self._validate_commit(commit_hash)
543 + before = self.snapshot(trigger="before_revert", metadata=metadata or {})
544 + parent = self._first_parent(target) or EMPTY_TREE
545 + patch = self._git_bytes("diff", "--binary", parent, target).stdout
546 + if patch:
547 + checked = self._git_bytes("apply", "--reverse", "--check", "--binary", "--whitespace=nowarn", input=patch, check=False)
548 + if checked.returncode != 0:
549 + raise TimeTravelConflictError(_compact_git_error(checked.stderr.decode("utf-8", "replace")))
550 + applied = self._git_bytes("apply", "--reverse", "--binary", "--whitespace=nowarn", input=patch, check=False)
551 + if applied.returncode != 0:
552 + raise TimeTravelConflictError(_compact_git_error(applied.stderr.decode("utf-8", "replace")))
553 +
554 + after = self.snapshot(
555 + trigger="revert",
556 + message=f"Revert {target[:12]}",
557 + metadata={
558 + **(metadata or {}),
559 + "reverted_commit": target,
560 + },
561 + )
562 + return {
563 + "ok": True,
564 + "operation": "revert",
565 + "current_hash": after.hash,
566 + "auto_snapshot": _snapshot_public(before),
567 + "snapshot": _snapshot_public(after),
568 + "affected_files": after.files,
569 + }
570 +
571 + def present_summary(self) -> dict[str, Any]:
572 + self.ensure_repo()
573 + current_tree, _paths = self._current_tree()
574 + current = self.current_hash()
575 + base = current or EMPTY_TREE
576 + base_tree = self._commit_tree(current) if current else EMPTY_TREE
577 + if base_tree == current_tree:
578 + return clean_summary()
579 + changed = self.diff_files(base, current_tree)
580 + return {
581 + "dirty": bool(changed),
582 + "files_count": len(changed),
583 + "additions": sum(int(item.get("additions") or 0) for item in changed),
584 + "deletions": sum(int(item.get("deletions") or 0) for item in changed),
585 + "files": changed,
586 + }
587 +
588 + def commit_object(self, commit_hash: str, *, current_hash: str = "") -> dict[str, Any]:
589 + commit_hash = self._validate_commit(commit_hash)
590 + show = self._git("show", "-s", "--format=%H%x00%h%x00%cI%x00%s%x00%B", commit_hash).stdout
591 + parts = show.split("\0", 4)
592 + full_hash = parts[0].strip()
593 + short_hash = parts[1].strip() if len(parts) > 1 else full_hash[:12]
594 + timestamp = parts[2].strip() if len(parts) > 2 else ""
595 + subject = parts[3].strip() if len(parts) > 3 else ""
596 + body = parts[4] if len(parts) > 4 else ""
597 + metadata = self._parse_metadata(body)
598 + return {
599 + "hash": full_hash,
600 + "short_hash": short_hash,
601 + "timestamp": timestamp,
602 + "message": subject,
603 + "is_current": bool(current_hash and full_hash == current_hash),
604 + "metadata": metadata,
605 + "files": self.commit_files(full_hash),
606 + }
607 +
608 + def commit_files(self, commit_hash: str) -> list[dict[str, Any]]:
609 + commit_hash = self._validate_commit(commit_hash)
610 + parent = self._first_parent(commit_hash) or EMPTY_TREE
611 + return self.diff_files(parent, commit_hash)
612 +
613 + def diff_files(self, base: str, target: str, *, path_filter: str = "") -> list[dict[str, Any]]:
614 + args = ["diff", "--name-status", "-z", "--find-renames", base, target]
615 + path_filter = str(path_filter or "").strip()
616 + if path_filter:
617 + args.extend(["--", path_filter])
618 + output = self._git(*args).stdout
619 + entries = _parse_name_status(output)
620 + result: list[dict[str, Any]] = []
621 + for entry in entries:
622 + path = entry["path"]
623 + old_path = entry.get("old_path", "")
624 + additions, deletions, binary = self._numstat(base, target, [p for p in (old_path, path) if p])
625 + action = STATUS_LABELS.get(entry["status"], entry["status"].lower())
626 + result.append(
627 + {
628 + "path": path,
629 + "old_path": old_path,
630 + "status": action,
631 + "action": action,
632 + "additions": additions,
633 + "deletions": deletions,
634 + "binary": binary,
635 + }
636 + )
637 + return result
638 +
639 + def _current_tree(self) -> tuple[str, list[str]]:
640 + return self._stage_current_tree()
641 +
642 + def _stage_current_tree(self) -> tuple[str, list[str]]:
643 + self.ensure_repo()
644 + self._git("read-tree", "--empty")
645 + paths = list(iter_snapshot_paths(self.workspace.real_path))
646 + if paths:
647 + payload = "\0".join(paths).encode("utf-8") + b"\0"
648 + self._git_bytes(
649 + "add",
650 + "-A",
651 + "--pathspec-from-file=-",
652 + "--pathspec-file-nul",
653 + input=payload,
654 + )
655 + tree_hash = self._git("write-tree").stdout.strip()
656 + return tree_hash, paths
657 +
658 + def _apply_commit_tree(self, base: str, target: str, affected: list[dict[str, Any]]) -> None:
659 + delete_paths: list[str] = []
660 + write_paths: list[str] = []
661 + for item in affected:
662 + action = str(item.get("action") or item.get("status") or "")
663 + old_path = str(item.get("old_path") or "")
664 + path = str(item.get("path") or "")
665 + if old_path and old_path != path:
666 + delete_paths.append(old_path)
667 + if action == "deleted":
668 + delete_paths.append(path)
669 + else:
670 + write_paths.append(path)
671 +
672 + for rel_path in sorted(set(delete_paths), key=lambda value: value.count("/"), reverse=True):
673 + self._delete_workspace_entry(rel_path)
674 + for rel_path in sorted(set(write_paths)):
675 + self._materialize_tree_path(target, rel_path)
676 + self._prune_empty_dirs()
677 +
678 + def _materialize_tree_path(self, commit_hash: str, rel_path: str) -> None:
679 + rel_path = self._safe_rel_path(rel_path)
680 + entry = self._tree_entry(commit_hash, rel_path)
681 + if entry is None:
682 + self._delete_workspace_entry(rel_path)
683 + return
684 + mode, obj_type, obj_hash = entry
685 + if obj_type != "blob":
686 + return
687 + target_path = self._workspace_child(rel_path)
688 + data = self._git_bytes("cat-file", "-p", obj_hash).stdout
689 + self._prepare_parent(target_path)
690 + if target_path.exists() or target_path.is_symlink():
691 + self._remove_for_replacement(target_path)
692 + if mode == "120000":
693 + os.symlink(data.decode("utf-8", errors="replace"), target_path)
694 + else:
695 + target_path.write_bytes(data)
696 + if mode == "100755":
697 + target_path.chmod(0o755)
698 +
699 + def _delete_workspace_entry(self, rel_path: str) -> None:
700 + rel_path = self._safe_rel_path(rel_path)
701 + target_path = self._workspace_child(rel_path)
702 + if target_path.is_symlink() or target_path.is_file():
703 + target_path.unlink()
704 + elif target_path.exists():
705 + if target_path.is_dir() and not any(target_path.iterdir()):
706 + target_path.rmdir()
707 + else:
708 + raise TimeTravelConflictError(
709 + f"Cannot safely replace non-empty directory: {rel_path}"
710 + )
711 +
712 + def _prepare_parent(self, target_path: Path) -> None:
713 + current = self.workspace.real_path
714 + rel_parts = target_path.relative_to(self.workspace.real_path).parts[:-1]
715 + for part in rel_parts:
716 + current = current / part
717 + if current.is_symlink() or current.is_file():
718 + self._remove_for_replacement(current)
719 + current.mkdir(exist_ok=True)
720 +
721 + def _remove_for_replacement(self, target_path: Path) -> None:
722 + if target_path.is_symlink() or target_path.is_file():
723 + target_path.unlink()
724 + return
725 + if target_path.is_dir():
726 + if any(target_path.iterdir()):
727 + raise TimeTravelConflictError(
728 + f"Cannot safely replace non-empty directory: {self._rel_from_workspace(target_path)}"
729 + )
730 + target_path.rmdir()
731 +
732 + def _prune_empty_dirs(self) -> None:
733 + for root, dirs, _filenames in os.walk(self.workspace.real_path, topdown=False, followlinks=False):
734 + root_path = Path(root)
735 + if root_path == self.workspace.real_path:
736 + continue
737 + if not is_snapshot_candidate(root_path.relative_to(self.workspace.real_path).as_posix(), is_dir=True):
738 + continue
739 + try:
740 + root_path.rmdir()
741 + except OSError:
742 + pass
743 +
744 + def _tree_entry(self, commit_hash: str, rel_path: str) -> tuple[str, str, str] | None:
745 + completed = self._git("ls-tree", "-z", commit_hash, "--", rel_path, check=False)
746 + if completed.returncode != 0 or not completed.stdout:
747 + return None
748 + record = completed.stdout.split("\0", 1)[0]
749 + meta, _sep, _name = record.partition("\t")
750 + parts = meta.split()
751 + if len(parts) < 3:
752 + return None
753 + return parts[0], parts[1], parts[2]
754 +
755 + def _patch_payload(self, base: str, target: str, path: str) -> dict[str, Any]:
756 + path = self._safe_rel_path(path)
757 + additions, deletions, binary = self._numstat(base, target, [path])
758 + completed = self._git_bytes("diff", "--binary", "--patch", base, target, "--", path, check=False)
759 + data = completed.stdout or b""
760 + too_large = len(data) > MAX_RENDERED_PATCH_BYTES
761 + rendered = data[:MAX_RENDERED_PATCH_BYTES].decode("utf-8", errors="replace")
762 + return {
763 + "ok": completed.returncode == 0,
764 + "path": path,
765 + "patch": "" if binary else rendered,
766 + "binary": binary,
767 + "too_large": too_large,
768 + "additions": additions,
769 + "deletions": deletions,
770 + "error": "" if completed.returncode == 0 else completed.stderr.decode("utf-8", errors="replace"),
771 + }
772 +
773 + def _numstat(self, base: str, target: str, paths: list[str]) -> tuple[int, int, bool]:
774 + if not paths:
775 + return 0, 0, False
776 + output = self._git("diff", "--numstat", "--find-renames", base, target, "--", *paths, check=False).stdout
777 + additions = 0
778 + deletions = 0
779 + binary = False
780 + for line in output.splitlines():
781 + if not line.strip():
782 + continue
783 + parts = line.split("\t")
784 + if len(parts) < 2:
785 + continue
786 + if parts[0] == "-" or parts[1] == "-":
787 + binary = True
788 + continue
789 + additions += _safe_int(parts[0])
790 + deletions += _safe_int(parts[1])
791 + return additions, deletions, binary
792 +
793 + def _rev_list_all(self) -> list[str]:
794 + completed = self._git("rev-list", "--date-order", "--all", check=False)
795 + if completed.returncode != 0:
796 + return []
797 + seen: set[str] = set()
798 + result: list[str] = []
799 + for line in completed.stdout.splitlines():
800 + commit = line.strip()
801 + if commit and commit not in seen:
802 + result.append(commit)
803 + seen.add(commit)
804 + return result
805 +
806 + def _validate_commit(self, commit_hash: str) -> str:
807 + candidate = str(commit_hash or "").strip()
808 + if not candidate:
809 + raise TimeTravelError("Commit hash is required.")
810 + completed = self._git("rev-parse", "--verify", f"{candidate}^{{commit}}", check=False)
811 + if completed.returncode != 0:
812 + raise TimeTravelError("Unknown Time Travel commit.")
813 + return completed.stdout.strip()
814 +
815 + def _first_parent(self, commit_hash: str) -> str:
816 + completed = self._git("rev-list", "--parents", "-n", "1", commit_hash)
817 + parts = completed.stdout.strip().split()
818 + return parts[1] if len(parts) > 1 else ""
819 +
820 + def _commit_tree(self, commit_hash: str) -> str:
821 + return self._git("show", "-s", "--format=%T", commit_hash).stdout.strip()
822 +
823 + def _preserve_ref(self, commit_hash: str, *, reason: str) -> str:
824 + stamp = datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S")
825 + base_ref = f"{PRESERVED_REF_PREFIX}/{stamp}-{reason}-{commit_hash[:12]}"
826 + ref = base_ref
827 + counter = 2
828 + while self._git("show-ref", "--verify", "--quiet", ref, check=False).returncode == 0:
829 + ref = f"{base_ref}-{counter}"
830 + counter += 1
831 + self._git("update-ref", ref, commit_hash)
832 + return ref
833 +
834 + def _commit_message(self, message: str, metadata: dict[str, Any]) -> str:
835 + encoded = base64.b64encode(json.dumps(metadata, sort_keys=True).encode("utf-8")).decode("ascii")
836 + return f"{message.strip() or 'Snapshot'}\n\n{METADATA_PREFIX} {encoded}\n"
837 +
838 + def _parse_metadata(self, body: str) -> dict[str, Any]:
839 + for line in body.splitlines():
840 + if line.startswith(METADATA_PREFIX):
841 + encoded = line.removeprefix(METADATA_PREFIX).strip()
842 + try:
843 + return json.loads(base64.b64decode(encoded).decode("utf-8"))
844 + except Exception:
845 + return {}
846 + return {}
847 +
848 + def _metadata(
849 + self,
850 + trigger: str,
851 + metadata: dict[str, Any] | None,
852 + changed_path_hints: list[str] | None,
853 + ) -> dict[str, Any]:
854 + result = dict(metadata or {})
855 + result.setdefault("context_id", self.workspace.context_id)
856 + result.setdefault("project_name", self.workspace.project_name)
857 + result.setdefault("trigger", trigger)
858 + result.setdefault("timestamp", now_iso())
859 + hints = [normalize_display_path(path) for path in (changed_path_hints or []) if path]
860 + if hints:
861 + result.setdefault("changed_path_hints", hints)
862 + return {key: value for key, value in result.items() if value not in (None, "")}
863 +
864 + def _default_snapshot_message(self, trigger: str) -> str:
865 + label = str(trigger or "snapshot").replace("_", " ").strip().title()
866 + return f"Snapshot: {label}"
867 +
868 + def _ensure_workspace_dir(self) -> None:
869 + if not self.workspace.real_path.exists():
870 + raise TimeTravelError("Workspace path does not exist.")
871 + if not self.workspace.real_path.is_dir():
872 + raise TimeTravelError("Workspace path is not a directory.")
873 +
874 + def _safe_rel_path(self, path: str) -> str:
875 + rel = str(path or "").replace("\\", "/").lstrip("/")
876 + normalized = posixpath.normpath(rel)
877 + if not normalized or normalized == "." or normalized.startswith("../") or normalized == "..":
878 + raise TimeTravelError("Invalid path.")
879 + return normalized
880 +
881 + def _workspace_child(self, rel_path: str) -> Path:
882 + rel = self._safe_rel_path(rel_path)
883 + path = self.workspace.real_path.joinpath(*rel.split("/"))
884 + try:
885 + path.relative_to(self.workspace.real_path)
886 + except ValueError:
887 + raise TimeTravelError("Invalid path.")
888 + return path
889 +
890 + def _rel_from_workspace(self, path: Path) -> str:
891 + try:
892 + return path.relative_to(self.workspace.real_path).as_posix()
893 + except ValueError:
894 + return str(path)
895 +
896 + def _git_env(self) -> dict[str, str]:
897 + env = os.environ.copy()
898 + env["GIT_TERMINAL_PROMPT"] = "0"
899 + env["GIT_OPTIONAL_LOCKS"] = "0"
900 + return env
901 +
902 + def _git(self, *args: str, input: str | None = None, env: dict[str, str] | None = None, check: bool = True) -> subprocess.CompletedProcess[str]:
903 + self.workspace.shadow_path.mkdir(parents=True, exist_ok=True)
904 + completed = subprocess.run(
905 + [
906 + "git",
907 + f"--git-dir={self.workspace.repo_git_path}",
908 + f"--work-tree={self.workspace.real_path}",
909 + "-c",
910 + "core.bare=false",
911 + *args,
912 + ],
913 + input=input,
914 + capture_output=True,
915 + text=True,
916 + encoding="utf-8",
917 + errors="replace",
918 + env=env or self._git_env(),
919 + cwd=str(self.workspace.real_path) if self.workspace.real_path.exists() else None,
920 + timeout=GIT_TIMEOUT_SECONDS,
921 + )
922 + if check and completed.returncode != 0:
923 + raise GitCommandError(
924 + (completed.stderr or completed.stdout or "Git command failed.").strip(),
925 + stdout=completed.stdout,
926 + stderr=completed.stderr,
927 + )
928 + return completed
929 +
930 + def _git_bytes(self, *args: str, input: bytes | None = None, check: bool = True) -> subprocess.CompletedProcess[bytes]:
931 + self.workspace.shadow_path.mkdir(parents=True, exist_ok=True)
932 + completed = subprocess.run(
933 + [
934 + "git",
935 + f"--git-dir={self.workspace.repo_git_path}",
936 + f"--work-tree={self.workspace.real_path}",
937 + "-c",
938 + "core.bare=false",
939 + *args,
940 + ],
941 + input=input,
942 + capture_output=True,
943 + env=self._git_env(),
944 + cwd=str(self.workspace.real_path) if self.workspace.real_path.exists() else None,
945 + timeout=GIT_TIMEOUT_SECONDS,
946 + )
947 + if check and completed.returncode != 0:
948 + stderr = completed.stderr.decode("utf-8", errors="replace")
949 + stdout = completed.stdout.decode("utf-8", errors="replace")
950 + raise GitCommandError((stderr or stdout or "Git command failed.").strip(), stdout=stdout, stderr=stderr)
951 + return completed
952 +
953 +
954 +def iter_snapshot_paths(workspace: Path) -> Iterable[str]:
955 + workspace = workspace.resolve(strict=False)
956 +
957 + def walk(folder: Path, rel_prefix: str = "") -> Iterable[str]:
958 + try:
959 + with os.scandir(folder) as iterator:
960 + entries = sorted(iterator, key=lambda entry: entry.name)
961 + except OSError:
962 + return
963 + for entry in entries:
964 + rel = f"{rel_prefix}/{entry.name}" if rel_prefix else entry.name
965 + rel = rel.replace("\\", "/")
966 + try:
967 + is_dir = entry.is_dir(follow_symlinks=False)
968 + is_file = entry.is_file(follow_symlinks=False)
969 + is_link = entry.is_symlink()
970 + except OSError:
971 + continue
972 + if is_dir:
973 + if not is_snapshot_candidate(rel, is_dir=True):
974 + continue
975 + yield from walk(Path(entry.path), rel)
976 + elif (is_file or is_link) and is_snapshot_candidate(rel, is_dir=False):
977 + yield rel
978 +
979 + yield from walk(workspace)
980 +
981 +
982 +def is_snapshot_candidate(rel_path: str, *, is_dir: bool) -> bool:
983 + rel = rel_path.replace("\\", "/").strip("/")
984 + if not rel:
985 + return False
986 + parts = rel.split("/")
987 + name = parts[-1]
988 +
989 + if name == ".git" or ".git" in parts:
990 + return False
991 + if name == ".time_travel" or ".time_travel" in parts:
992 + return False
993 + if name in {"secrets.env", "variables.env"}:
994 + return False
995 +
996 + if rel.startswith(".a0proj/"):
997 + return _is_safe_a0proj_candidate(rel, is_dir=is_dir)
998 +
999 + if is_dir:
1000 + if name in EXCLUDED_DIR_NAMES:
1001 + return False
1002 + if any(fnmatch.fnmatch(name, pattern) for pattern in EXCLUDED_DIR_PATTERNS):
1003 + return False
1004 + return True
1005 +
1006 + if any(fnmatch.fnmatch(name, pattern) for pattern in EXCLUDED_FILE_PATTERNS):
1007 + return False
1008 + return True
1009 +
1010 +
1011 +def _is_safe_a0proj_candidate(rel: str, *, is_dir: bool) -> bool:
1012 + if rel in {".a0proj/secrets.env", ".a0proj/variables.env"}:
1013 + return False
1014 + if rel == ".a0proj/memory" or rel.startswith(".a0proj/memory/"):
1015 + return False
1016 + if is_dir:
1017 + return (
1018 + rel == ".a0proj"
1019 + or any(prefix.startswith(rel.rstrip("/") + "/") or rel.startswith(prefix) for prefix in SAFE_A0PROJ_DIRS)
1020 + or rel.startswith(".a0proj/plugins")
1021 + or rel.startswith(".a0proj/agents")
1022 + )
1023 + if rel in SAFE_A0PROJ_FILES:
1024 + return True
1025 + if any(rel.startswith(prefix) for prefix in SAFE_A0PROJ_DIRS):
1026 + return True
1027 + return _is_safe_plugin_asset(rel)
1028 +
1029 +
1030 +def _is_safe_plugin_asset(rel: str) -> bool:
1031 + parts = rel.split("/")
1032 + if len(parts) < 4:
1033 + return False
1034 + for index, part in enumerate(parts):
1035 + if part != "plugins":
1036 + continue
1037 + tail = parts[index + 1 :]
1038 + if len(tail) == 2 and tail[1] in SAFE_PLUGIN_ASSET_NAMES:
1039 + return True
1040 + return False
1041 +
1042 +
1043 +def _parse_name_status(output: str) -> list[dict[str, str]]:
1044 + parts = [part for part in output.split("\0") if part]
1045 + entries: list[dict[str, str]] = []
1046 + index = 0
1047 + while index < len(parts):
1048 + raw_status = parts[index]
1049 + index += 1
1050 + status = raw_status[:1]
1051 + if status in {"R", "C"} and index + 1 < len(parts):
1052 + old_path = parts[index].replace("\\", "/")
1053 + new_path = parts[index + 1].replace("\\", "/")
1054 + index += 2
1055 + entries.append({"status": status, "old_path": old_path, "path": new_path})
1056 + continue
1057 + if index < len(parts):
1058 + path = parts[index].replace("\\", "/")
1059 + index += 1
1060 + entries.append({"status": status, "old_path": "", "path": path})
1061 + entries.sort(key=lambda item: item.get("path") or item.get("old_path") or "")
1062 + return entries
1063 +
1064 +
1065 +def _safe_int(value: str) -> int:
1066 + try:
1067 + return max(0, int(value))
1068 + except (TypeError, ValueError):
1069 + return 0
1070 +
1071 +
1072 +def _snapshot_public(snapshot: SnapshotResult) -> dict[str, Any]:
1073 + return {
1074 + "created": snapshot.created,
1075 + "hash": snapshot.hash,
1076 + "short_hash": snapshot.short_hash,
1077 + "message": snapshot.message,
1078 + "files": snapshot.files,
1079 + "metadata": snapshot.metadata,
1080 + }
1081 +
1082 +
1083 +def _compact_git_error(text: str) -> str:
1084 + lines = [line.strip() for line in str(text or "").splitlines() if line.strip()]
1085 + if not lines:
1086 + return "The patch could not be applied cleanly."
1087 + return "\n".join(lines[:8])
plugins/_time_travel/plugin.yaml new
+8
@@ -0,0 +1,8 @@
1 +name: _time_travel
2 +title: Time Travel
3 +description: Agent Zero-owned workspace history, diff inspection, travel, and revert for active /a0/usr workspaces.
4 +version: 0.1.0
5 +always_enabled: false
6 +settings_sections: []
7 +per_project_config: false
8 +per_agent_config: false
plugins/_time_travel/webui/main.html new
+8
@@ -0,0 +1,8 @@
1 +<html>
2 +<head>
3 + <title>Time Travel</title>
4 +</head>
5 +<body>
6 + <x-component path="/plugins/_time_travel/webui/time-travel-panel.html" mode="modal"></x-component>
7 +</body>
8 +</html>
plugins/_time_travel/webui/time-travel-panel.html new
+907
@@ -0,0 +1,907 @@
1 +<html>
2 +<head>
3 + <script type="module">
4 + import { store } from "/plugins/_time_travel/webui/time-travel-store.js";
5 + </script>
6 +</head>
7 +<body>
8 + <div class="time-travel-panel" x-data x-create="$store.timeTravel.onMount($el, xAttrs($el) || {})" x-destroy="$store.timeTravel.cleanup()">
9 + <template x-if="$store.timeTravel">
10 + <div class="time-travel-shell">
11 + <div class="time-travel-toolbar">
12 + <div class="time-travel-title">
13 + <span class="material-symbols-outlined">history</span>
14 + <span>Time Travel</span>
15 + </div>
16 + <div class="time-travel-workspace" :title="$store.timeTravel.workspacePath" x-text="$store.timeTravel.workspacePath || 'workspace'"></div>
17 + <span class="time-travel-spacer"></span>
18 + <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()">
19 + <span class="material-symbols-outlined">add_a_photo</span>
20 + </button>
21 + <button type="button" class="time-travel-icon-button" title="Refresh" aria-label="Refresh" @click="$store.timeTravel.refresh({ keepSelection: true })" :disabled="$store.timeTravel.loading">
22 + <span class="material-symbols-outlined" :class="{ spinning: $store.timeTravel.loading }">refresh</span>
23 + </button>
24 + </div>
25 +
26 + <div class="time-travel-status" x-show="$store.timeTravel.loading || $store.timeTravel.error || $store.timeTravel.busy" style="display: none;">
27 + <span class="material-symbols-outlined" :class="{ spinning: $store.timeTravel.loading || $store.timeTravel.busy }" x-text="$store.timeTravel.error ? 'error' : 'progress_activity'"></span>
28 + <span x-text="$store.timeTravel.error || ($store.timeTravel.busy ? 'Working...' : 'Loading history...')"></span>
29 + </div>
30 +
31 + <template x-if="$store.timeTravel.isLocked() && !$store.timeTravel.loading">
32 + <div class="time-travel-locked">
33 + <span class="material-symbols-outlined">lock</span>
34 + <strong>Unavailable</strong>
35 + <span x-text="$store.timeTravel.payload?.workspace?.error || 'Time Travel is available only inside /a0/usr.'"></span>
36 + </div>
37 + </template>
38 +
39 + <template x-if="!$store.timeTravel.isLocked()">
40 + <div class="time-travel-body">
41 + <aside class="time-travel-timeline">
42 + <div class="time-travel-filter">
43 + <span class="material-symbols-outlined">filter_list</span>
44 + <input
45 + type="search"
46 + placeholder="Filter files"
47 + x-model="$store.timeTravel.fileFilter"
48 + @input="$store.timeTravel.scheduleFilterRefresh()"
49 + />
50 + </div>
51 +
52 + <div class="time-travel-rows">
53 + <template x-for="row in $store.timeTravel.timelineRows()" :key="row.key">
54 + <button
55 + type="button"
56 + class="time-travel-row"
57 + :class="{ 'is-active': $store.timeTravel.selectedHash === row.key, 'is-present': row.kind === 'present', 'is-current': row.is_current }"
58 + @click="$store.timeTravel.selectRow(row)"
59 + :title="row.message"
60 + >
61 + <span class="time-travel-row-mark">
62 + <span class="material-symbols-outlined" x-text="row.kind === 'present' ? (row.dirty ? 'edit_note' : 'check_circle') : (row.is_current ? 'radio_button_checked' : 'commit')"></span>
63 + </span>
64 + <span class="time-travel-row-main">
65 + <span class="time-travel-row-title" x-text="row.message"></span>
66 + <span class="time-travel-row-meta" x-text="$store.timeTravel.rowMeta(row)"></span>
67 + </span>
68 + <span class="time-travel-row-count" x-text="row.files?.length || 0"></span>
69 + </button>
70 + </template>
71 + </div>
72 +
73 + <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">
74 + <span class="material-symbols-outlined">expand_more</span>
75 + <span>More</span>
76 + </button>
77 + </aside>
78 +
79 + <main class="time-travel-detail">
80 + <div class="time-travel-detail-header">
81 + <div class="time-travel-detail-title">
82 + <strong x-text="$store.timeTravel.selectedRow()?.message || 'History'"></strong>
83 + <span x-text="$store.timeTravel.rowMeta($store.timeTravel.selectedRow())"></span>
84 + </div>
85 + <div class="time-travel-actions" x-show="$store.timeTravel.selectedCommit()" style="display: none;">
86 + <button type="button" class="time-travel-tool-button" title="Travel" @click="$store.timeTravel.openPreview('travel')" :disabled="$store.timeTravel.selectedCommit()?.is_current || $store.timeTravel.busy">
87 + <span class="material-symbols-outlined">move_down</span>
88 + <span>Travel</span>
89 + </button>
90 + <button type="button" class="time-travel-tool-button" title="Revert" @click="$store.timeTravel.openPreview('revert')" :disabled="$store.timeTravel.busy">
91 + <span class="material-symbols-outlined">undo</span>
92 + <span>Revert</span>
93 + </button>
94 + </div>
95 + </div>
96 +
97 + <div class="time-travel-detail-grid">
98 + <section class="time-travel-files">
99 + <template x-if="$store.timeTravel.selectedFiles().length === 0">
100 + <div class="time-travel-empty">
101 + <span class="material-symbols-outlined">hourglass_empty</span>
102 + <span>No file changes</span>
103 + </div>
104 + </template>
105 + <template x-for="file in $store.timeTravel.selectedFiles()" :key="$store.timeTravel.fileKey(file)">
106 + <button
107 + type="button"
108 + class="time-travel-file-row"
109 + :class="{ 'is-active': $store.timeTravel.fileKey(file) === $store.timeTravel.selectedPath }"
110 + @click="$store.timeTravel.selectFile(file)"
111 + :title="$store.timeTravel.fileTitle(file)"
112 + >
113 + <span class="time-travel-file-name" x-text="$store.timeTravel.fileTitle(file)"></span>
114 + <span class="time-travel-file-status" x-text="$store.timeTravel.statusLabel(file)"></span>
115 + <span class="time-travel-file-counts">
116 + <span class="diff-add" x-text="$store.timeTravel.formatSigned(file.additions, '+')"></span>
117 + <span class="diff-del" x-text="$store.timeTravel.formatSigned(file.deletions, '-')"></span>
118 + </span>
119 + </button>
120 + </template>
121 + </section>
122 +
123 + <section class="time-travel-diff">
124 + <div class="time-travel-diff-toolbar">
125 + <span class="time-travel-diff-path" :title="$store.timeTravel.fileTitle($store.timeTravel.selectedFile())" x-text="$store.timeTravel.fileTitle($store.timeTravel.selectedFile()) || 'Diff'"></span>
126 + <span class="time-travel-spacer"></span>
127 + <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()">
128 + <span class="material-symbols-outlined">folder_open</span>
129 + </button>
130 + <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()">
131 + <span class="material-symbols-outlined">content_copy</span>
132 + </button>
133 + </div>
134 +
135 + <div class="time-travel-diff-status" x-show="$store.timeTravel.diffLoading || $store.timeTravel.diffError" style="display: none;">
136 + <span class="material-symbols-outlined" :class="{ spinning: $store.timeTravel.diffLoading }" x-text="$store.timeTravel.diffError ? 'error' : 'progress_activity'"></span>
137 + <span x-text="$store.timeTravel.diffError || 'Loading diff...'"></span>
138 + </div>
139 +
140 + <template x-if="$store.timeTravel.selectedDiff?.binary">
141 + <div class="time-travel-empty">
142 + <span class="material-symbols-outlined">data_object</span>
143 + <span>Binary file changed</span>
144 + </div>
145 + </template>
146 +
147 + <template x-if="$store.timeTravel.selectedDiff?.too_large && !$store.timeTravel.selectedDiff?.binary">
148 + <div class="time-travel-empty">
149 + <span class="material-symbols-outlined">text_snippet</span>
150 + <span>Diff exceeds the 1 MB render limit</span>
151 + </div>
152 + </template>
153 +
154 + <template x-if="$store.timeTravel.selectedDiff?.patch && !$store.timeTravel.selectedDiff?.binary">
155 + <div class="time-travel-code" role="table" aria-label="Unified diff">
156 + <template x-for="line in $store.timeTravel.patchLines()" :key="line.id">
157 + <div class="time-travel-line" :class="`is-${line.type}`" role="row">
158 + <span class="time-travel-line-marker" x-text="line.type === 'add' ? '+' : (line.type === 'del' ? '-' : '')"></span>
159 + <code x-text="line.text"></code>
160 + </div>
161 + </template>
162 + </div>
163 + </template>
164 + </section>
165 + </div>
166 + </main>
167 + </div>
168 + </template>
169 +
170 + <div class="time-travel-preview-backdrop" x-show="$store.timeTravel.previewOpen" style="display: none;">
171 + <section class="time-travel-preview" role="dialog" aria-modal="true" aria-label="Time Travel preview">
172 + <header>
173 + <span class="material-symbols-outlined" x-text="$store.timeTravel.preview?.operation === 'travel' ? 'move_down' : 'undo'"></span>
174 + <strong x-text="$store.timeTravel.preview?.operation === 'travel' ? 'Travel Preview' : 'Revert Preview'"></strong>
175 + <button type="button" class="time-travel-icon-button is-small" title="Close" aria-label="Close" @click="$store.timeTravel.closePreview()" :disabled="$store.timeTravel.busy">
176 + <span class="material-symbols-outlined">close</span>
177 + </button>
178 + </header>
179 + <div class="time-travel-preview-body">
180 + <div class="time-travel-preview-status" x-show="$store.timeTravel.previewLoading || $store.timeTravel.previewError" style="display: none;">
181 + <span class="material-symbols-outlined" :class="{ spinning: $store.timeTravel.previewLoading }" x-text="$store.timeTravel.previewError ? 'error' : 'progress_activity'"></span>
182 + <span x-text="$store.timeTravel.previewError || 'Building preview...'"></span>
183 + </div>
184 + <div class="time-travel-preview-summary">
185 + <span x-text="$store.timeTravel.preview?.short_hash || ''"></span>
186 + <span class="time-travel-dot"></span>
187 + <span x-text="`${$store.timeTravel.preview?.files?.length || 0} affected files`"></span>
188 + </div>
189 + <div class="time-travel-preview-files">
190 + <template x-for="file in ($store.timeTravel.preview?.files || []).slice(0, 80)" :key="$store.timeTravel.fileKey(file)">
191 + <div class="time-travel-preview-file">
192 + <span x-text="$store.timeTravel.fileTitle(file)"></span>
193 + <span x-text="$store.timeTravel.statusLabel(file)"></span>
194 + </div>
195 + </template>
196 + </div>
197 + <details x-show="$store.timeTravel.previewTechnicalDetails" style="display: none;">
198 + <summary>Details</summary>
199 + <pre x-text="$store.timeTravel.previewTechnicalDetails"></pre>
200 + </details>
201 + </div>
202 + <footer>
203 + <button type="button" class="time-travel-tool-button" @click="$store.timeTravel.closePreview()" :disabled="$store.timeTravel.busy">
204 + <span class="material-symbols-outlined">close</span>
205 + <span>Cancel</span>
206 + </button>
207 + <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)">
208 + <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>
209 + <span x-text="$store.timeTravel.preview?.operation === 'travel' ? 'Travel' : 'Revert'"></span>
210 + </button>
211 + </footer>
212 + </section>
213 + </div>
214 + </div>
215 + </template>
216 + </div>
217 +
218 + <style>
219 + .time-travel-panel,
220 + .time-travel-shell {
221 + display: flex;
222 + flex: 1 1 auto;
223 + flex-direction: column;
224 + width: 100%;
225 + height: 100%;
226 + min-width: 0;
227 + min-height: 0;
228 + background: color-mix(in srgb, var(--color-background) 95%, #000 5%);
229 + color: var(--color-text);
230 + }
231 +
232 + .time-travel-panel {
233 + container-type: inline-size;
234 + }
235 +
236 + .modal-inner.time-travel-modal {
237 + box-sizing: border-box;
238 + container-type: inline-size;
239 + width: min(86vw, 1240px);
240 + height: min(88vh, 920px);
241 + min-width: min(360px, calc(100vw - 16px));
242 + min-height: min(520px, calc(100vh - 16px));
243 + max-width: calc(100vw - 16px);
244 + max-height: calc(100vh - 16px);
245 + resize: both;
246 + border: 1px solid color-mix(in srgb, var(--color-border) 75%, transparent);
247 + border-radius: 7px;
248 + box-shadow: 0 18px 48px rgba(0, 0, 0, 0.32);
249 + background: color-mix(in srgb, var(--color-background) 94%, #000 6%);
250 + }
251 +
252 + .modal.modal-floating {
253 + pointer-events: none;
254 + }
255 +
256 + .modal.modal-floating .modal-inner {
257 + pointer-events: auto;
258 + }
259 +
260 + .modal-inner.time-travel-modal .modal-header {
261 + min-height: 34px;
262 + padding: 0.35rem 0.75rem 0.35rem 1rem;
263 + cursor: move;
264 + user-select: none;
265 + background: color-mix(in srgb, var(--color-background) 92%, #000 8%);
266 + border-bottom: 1px solid color-mix(in srgb, var(--color-border) 70%, transparent);
267 + }
268 +
269 + .modal-inner.time-travel-modal .modal-scroll,
270 + .modal-inner.time-travel-modal .modal-bd.time-travel-modal-body,
271 + .modal-inner.time-travel-modal .modal-bd.time-travel-modal-body > x-component,
272 + .modal-inner.time-travel-modal .modal-bd.time-travel-modal-body > div[x-data] {
273 + display: flex;
274 + flex: 1 1 auto;
275 + width: 100%;
276 + height: 100%;
277 + min-height: 0;
278 + overflow: hidden;
279 + padding: 0;
280 + }
281 +
282 + .time-travel-toolbar,
283 + .time-travel-status,
284 + .time-travel-title,
285 + .time-travel-filter,
286 + .time-travel-actions,
287 + .time-travel-tool-button,
288 + .time-travel-diff-toolbar,
289 + .time-travel-diff-status,
290 + .time-travel-file-counts,
291 + .time-travel-preview header,
292 + .time-travel-preview footer,
293 + .time-travel-preview-status,
294 + .time-travel-preview-summary {
295 + display: flex;
296 + align-items: center;
297 + }
298 +
299 + .time-travel-toolbar {
300 + gap: 8px;
301 + min-height: 44px;
302 + padding: var(--spacing-xs) var(--spacing-md);
303 + border-bottom: 1px solid color-mix(in srgb, var(--color-border) 64%, transparent);
304 + background: color-mix(in srgb, var(--color-background) 91%, #000 9%);
305 + }
306 +
307 + .time-travel-title {
308 + gap: 7px;
309 + font-weight: 750;
310 + font-size: 0.9rem;
311 + }
312 +
313 + .time-travel-title .material-symbols-outlined {
314 + font-size: 19px;
315 + }
316 +
317 + .time-travel-workspace {
318 + min-width: 0;
319 + max-width: 45%;
320 + overflow: hidden;
321 + color: var(--color-text-muted);
322 + text-overflow: ellipsis;
323 + white-space: nowrap;
324 + font-family: var(--font-family-code);
325 + font-size: 0.72rem;
326 + }
327 +
328 + .time-travel-spacer {
329 + flex: 1 1 auto;
330 + min-width: 8px;
331 + }
332 +
333 + .time-travel-icon-button,
334 + .time-travel-tool-button,
335 + .time-travel-load-more {
336 + appearance: none;
337 + border: 1px solid color-mix(in srgb, var(--color-border) 64%, transparent);
338 + border-radius: 7px;
339 + background: color-mix(in srgb, var(--color-panel) 80%, transparent);
340 + color: var(--color-text);
341 + font: inherit;
342 + cursor: pointer;
343 + }
344 +
345 + .time-travel-icon-button {
346 + display: inline-flex;
347 + align-items: center;
348 + justify-content: center;
349 + width: 32px;
350 + height: 32px;
351 + min-width: 32px;
352 + padding: 0;
353 + }
354 +
355 + .time-travel-icon-button.is-small {
356 + width: 28px;
357 + height: 28px;
358 + min-width: 28px;
359 + }
360 +
361 + .time-travel-tool-button {
362 + gap: 5px;
363 + min-height: 30px;
364 + padding: 4px 9px;
365 + font-size: 0.76rem;
366 + }
367 +
368 + .time-travel-tool-button.is-primary {
369 + border-color: color-mix(in srgb, var(--color-primary) 42%, var(--color-border));
370 + background: color-mix(in srgb, var(--color-primary) 22%, var(--color-panel));
371 + }
372 +
373 + .time-travel-icon-button:hover:not(:disabled),
374 + .time-travel-tool-button:hover:not(:disabled),
375 + .time-travel-load-more:hover:not(:disabled) {
376 + background: color-mix(in srgb, var(--color-background-hover) 70%, transparent);
377 + border-color: color-mix(in srgb, var(--color-primary) 28%, var(--color-border));
378 + }
379 +
380 + .time-travel-icon-button:disabled,
381 + .time-travel-tool-button:disabled,
382 + .time-travel-load-more:disabled {
383 + cursor: not-allowed;
384 + opacity: 0.45;
385 + }
386 +
387 + .time-travel-icon-button .material-symbols-outlined,
388 + .time-travel-tool-button .material-symbols-outlined,
389 + .time-travel-load-more .material-symbols-outlined {
390 + font-size: 17px;
391 + }
392 +
393 + .time-travel-status,
394 + .time-travel-diff-status,
395 + .time-travel-preview-status {
396 + gap: 8px;
397 + min-height: 34px;
398 + padding: 6px 11px;
399 + border-bottom: 1px solid color-mix(in srgb, var(--color-border) 44%, transparent);
400 + color: var(--color-text-muted);
401 + font-size: 0.82rem;
402 + }
403 +
404 + .time-travel-body {
405 + display: grid;
406 + flex: 1 1 auto;
407 + grid-template-columns: minmax(210px, 0.42fr) minmax(0, 1fr);
408 + min-width: 0;
409 + min-height: 0;
410 + }
411 +
412 + .time-travel-timeline {
413 + display: flex;
414 + flex-direction: column;
415 + min-width: 0;
416 + min-height: 0;
417 + border-right: 1px solid color-mix(in srgb, var(--color-border) 58%, transparent);
418 + background: color-mix(in srgb, var(--color-panel) 48%, transparent);
419 + }
420 +
421 + .time-travel-filter {
422 + gap: 6px;
423 + min-height: 40px;
424 + padding: 7px 9px;
425 + border-bottom: 1px solid color-mix(in srgb, var(--color-border) 42%, transparent);
426 + }
427 +
428 + .time-travel-filter .material-symbols-outlined {
429 + font-size: 17px;
430 + color: var(--color-text-muted);
431 + }
432 +
433 + .time-travel-filter input {
434 + width: 100%;
435 + min-width: 0;
436 + height: 28px;
437 + border: 1px solid color-mix(in srgb, var(--color-border) 60%, transparent);
438 + border-radius: 7px;
439 + background: color-mix(in srgb, var(--color-background) 72%, transparent);
440 + color: var(--color-text);
441 + font-size: 0.78rem;
442 + padding: 0 8px;
443 + }
444 +
445 + .time-travel-rows {
446 + display: flex;
447 + flex: 1 1 auto;
448 + flex-direction: column;
449 + min-width: 0;
450 + min-height: 0;
451 + overflow: auto;
452 + padding: 7px;
453 + }
454 +
455 + .time-travel-row {
456 + display: grid;
457 + grid-template-columns: 24px minmax(0, 1fr) auto;
458 + align-items: center;
459 + gap: 7px;
460 + width: 100%;
461 + min-height: 50px;
462 + padding: 6px 7px;
463 + border: 1px solid transparent;
464 + border-radius: 7px;
465 + background: transparent;
466 + color: var(--color-text);
467 + font: inherit;
468 + text-align: left;
469 + cursor: pointer;
470 + }
471 +
472 + .time-travel-row:hover,
473 + .time-travel-row.is-active {
474 + border-color: color-mix(in srgb, var(--color-primary) 25%, var(--color-border));
475 + background: color-mix(in srgb, var(--color-background-hover) 56%, transparent);
476 + }
477 +
478 + .time-travel-row-mark .material-symbols-outlined {
479 + font-size: 18px;
480 + color: var(--color-text-muted);
481 + }
482 +
483 + .time-travel-row.is-current .time-travel-row-mark .material-symbols-outlined {
484 + color: var(--color-primary);
485 + }
486 +
487 + .time-travel-row-main {
488 + display: grid;
489 + min-width: 0;
490 + gap: 2px;
491 + }
492 +
493 + .time-travel-row-title,
494 + .time-travel-row-meta,
495 + .time-travel-file-name,
496 + .time-travel-diff-path {
497 + min-width: 0;
498 + overflow: hidden;
499 + text-overflow: ellipsis;
500 + white-space: nowrap;
501 + }
502 +
503 + .time-travel-row-title {
504 + font-size: 0.79rem;
505 + font-weight: 650;
506 + }
507 +
508 + .time-travel-row-meta {
509 + color: var(--color-text-muted);
510 + font-family: var(--font-family-code);
511 + font-size: 0.69rem;
512 + }
513 +
514 + .time-travel-row-count {
515 + min-width: 24px;
516 + padding: 2px 6px;
517 + border-radius: 999px;
518 + background: color-mix(in srgb, var(--color-background) 70%, transparent);
519 + color: var(--color-text-muted);
520 + text-align: center;
521 + font-size: 0.7rem;
522 + }
523 +
524 + .time-travel-load-more {
525 + display: flex;
526 + align-items: center;
527 + justify-content: center;
528 + gap: 5px;
529 + min-height: 34px;
530 + margin: 7px;
531 + font-size: 0.76rem;
532 + }
533 +
534 + .time-travel-detail {
535 + display: flex;
536 + flex-direction: column;
537 + min-width: 0;
538 + min-height: 0;
539 + }
540 +
541 + .time-travel-detail-header {
542 + display: grid;
543 + grid-template-columns: minmax(0, 1fr) auto;
544 + gap: 8px;
545 + align-items: center;
546 + min-height: 48px;
547 + padding: 8px 10px;
548 + border-bottom: 1px solid color-mix(in srgb, var(--color-border) 50%, transparent);
549 + }
550 +
551 + .time-travel-detail-title {
552 + display: grid;
553 + min-width: 0;
554 + gap: 2px;
555 + }
556 +
557 + .time-travel-detail-title strong,
558 + .time-travel-detail-title span {
559 + min-width: 0;
560 + overflow: hidden;
561 + text-overflow: ellipsis;
562 + white-space: nowrap;
563 + }
564 +
565 + .time-travel-detail-title strong {
566 + font-size: 0.88rem;
567 + }
568 +
569 + .time-travel-detail-title span {
570 + color: var(--color-text-muted);
571 + font-size: 0.72rem;
572 + }
573 +
574 + .time-travel-actions {
575 + gap: 6px;
576 + }
577 +
578 + .time-travel-detail-grid {
579 + display: grid;
580 + flex: 1 1 auto;
581 + grid-template-columns: minmax(190px, 0.36fr) minmax(0, 1fr);
582 + min-width: 0;
583 + min-height: 0;
584 + }
585 +
586 + .time-travel-files {
587 + min-width: 0;
588 + min-height: 0;
589 + overflow: auto;
590 + border-right: 1px solid color-mix(in srgb, var(--color-border) 45%, transparent);
591 + }
592 +
593 + .time-travel-file-row {
594 + display: grid;
595 + grid-template-columns: minmax(0, 1fr) auto;
596 + gap: 4px 7px;
597 + width: 100%;
598 + min-height: 46px;
599 + padding: 7px 9px;
600 + border: 0;
601 + border-bottom: 1px solid color-mix(in srgb, var(--color-border) 26%, transparent);
602 + background: transparent;
603 + color: var(--color-text);
604 + font: inherit;
605 + text-align: left;
606 + cursor: pointer;
607 + }
608 +
609 + .time-travel-file-row:hover,
610 + .time-travel-file-row.is-active {
611 + background: color-mix(in srgb, var(--color-background-hover) 54%, transparent);
612 + }
613 +
614 + .time-travel-file-name {
615 + font-family: var(--font-family-code);
616 + font-size: 0.74rem;
617 + grid-column: 1 / -1;
618 + }
619 +
620 + .time-travel-file-status {
621 + color: var(--color-text-muted);
622 + font-size: 0.68rem;
623 + text-transform: capitalize;
624 + }
625 +
626 + .time-travel-file-counts {
627 + gap: 5px;
628 + justify-content: end;
629 + font-family: var(--font-family-code);
630 + font-size: 0.7rem;
631 + }
632 +
633 + .time-travel-diff {
634 + display: flex;
635 + flex-direction: column;
636 + min-width: 0;
637 + min-height: 0;
638 + overflow: hidden;
639 + }
640 +
641 + .time-travel-diff-toolbar {
642 + gap: 6px;
643 + min-height: 38px;
644 + padding: 5px 8px;
645 + border-bottom: 1px solid color-mix(in srgb, var(--color-border) 42%, transparent);
646 + background: color-mix(in srgb, var(--color-panel) 44%, transparent);
647 + }
648 +
649 + .time-travel-diff-path {
650 + font-family: var(--font-family-code);
651 + font-size: 0.74rem;
652 + font-weight: 650;
653 + }
654 +
655 + .time-travel-code {
656 + flex: 1 1 auto;
657 + min-width: 0;
658 + min-height: 0;
659 + overflow: auto;
660 + padding: 4px 0;
661 + background: color-mix(in srgb, var(--color-background) 91%, #000 9%);
662 + font-family: var(--font-family-code);
663 + font-size: 0.73rem;
664 + line-height: 1.45;
665 + }
666 +
667 + .time-travel-line {
668 + display: grid;
669 + grid-template-columns: 24px minmax(0, 1fr);
670 + min-width: max-content;
671 + }
672 +
673 + .time-travel-line-marker {
674 + position: sticky;
675 + left: 0;
676 + z-index: 1;
677 + min-height: 1.45em;
678 + padding-right: 5px;
679 + background: inherit;
680 + color: var(--color-text-muted);
681 + text-align: right;
682 + user-select: none;
683 + }
684 +
685 + .time-travel-line code {
686 + display: block;
687 + min-height: 1.45em;
688 + padding: 0 10px 0 6px;
689 + color: inherit;
690 + font: inherit;
691 + white-space: pre;
692 + }
693 +
694 + .time-travel-line.is-add {
695 + background: rgba(39, 174, 96, 0.16);
696 + color: color-mix(in srgb, #8df0b0 78%, var(--color-text));
697 + }
698 +
699 + .time-travel-line.is-del {
700 + background: rgba(231, 76, 60, 0.17);
701 + color: color-mix(in srgb, #ffaaa2 78%, var(--color-text));
702 + }
703 +
704 + .time-travel-line.is-hunk {
705 + background: rgba(99, 102, 241, 0.16);
706 + color: color-mix(in srgb, #b9c3ff 80%, var(--color-text));
707 + }
708 +
709 + .time-travel-line.is-meta,
710 + .time-travel-line.is-note {
711 + color: var(--color-text-muted);
712 + }
713 +
714 + .time-travel-empty,
715 + .time-travel-locked {
716 + display: grid;
717 + flex: 1 1 auto;
718 + place-items: center;
719 + align-content: center;
720 + gap: 7px;
721 + min-width: 0;
722 + padding: 24px;
723 + color: var(--color-text-muted);
724 + text-align: center;
725 + }
726 +
727 + .time-travel-empty .material-symbols-outlined,
728 + .time-travel-locked .material-symbols-outlined {
729 + font-size: 28px;
730 + }
731 +
732 + .time-travel-locked span:last-child {
733 + max-width: 520px;
734 + overflow-wrap: anywhere;
735 + font-size: 0.8rem;
736 + }
737 +
738 + .diff-add {
739 + color: #31c48d;
740 + }
741 +
742 + .diff-del {
743 + color: #f05252;
744 + }
745 +
746 + .time-travel-dot {
747 + width: 4px;
748 + height: 4px;
749 + flex: 0 0 auto;
750 + border-radius: 999px;
751 + background: color-mix(in srgb, var(--color-text-muted) 50%, transparent);
752 + }
753 +
754 + .time-travel-preview-backdrop {
755 + position: absolute;
756 + inset: 0;
757 + z-index: 20;
758 + display: grid;
759 + place-items: center;
760 + padding: 16px;
761 + background: rgba(0, 0, 0, 0.34);
762 + }
763 +
764 + .time-travel-preview {
765 + display: flex;
766 + flex-direction: column;
767 + width: min(640px, 100%);
768 + max-height: min(680px, 100%);
769 + min-height: 260px;
770 + border: 1px solid color-mix(in srgb, var(--color-border) 72%, transparent);
771 + border-radius: 8px;
772 + background: color-mix(in srgb, var(--color-background) 95%, #000 5%);
773 + box-shadow: 0 18px 46px rgba(0, 0, 0, 0.36);
774 + overflow: hidden;
775 + }
776 +
777 + .time-travel-preview header,
778 + .time-travel-preview footer {
779 + gap: 8px;
780 + min-height: 44px;
781 + padding: 8px 10px;
782 + border-bottom: 1px solid color-mix(in srgb, var(--color-border) 54%, transparent);
783 + }
784 +
785 + .time-travel-preview footer {
786 + justify-content: flex-end;
787 + border-top: 1px solid color-mix(in srgb, var(--color-border) 54%, transparent);
788 + border-bottom: 0;
789 + }
790 +
791 + .time-travel-preview header strong {
792 + flex: 1 1 auto;
793 + min-width: 0;
794 + font-size: 0.9rem;
795 + }
796 +
797 + .time-travel-preview-body {
798 + display: flex;
799 + flex: 1 1 auto;
800 + flex-direction: column;
801 + min-width: 0;
802 + min-height: 0;
803 + overflow: auto;
804 + }
805 +
806 + .time-travel-preview-summary {
807 + gap: 8px;
808 + padding: 9px 11px;
809 + color: var(--color-text-muted);
810 + font-family: var(--font-family-code);
811 + font-size: 0.74rem;
812 + }
813 +
814 + .time-travel-preview-files {
815 + display: grid;
816 + gap: 4px;
817 + padding: 0 11px 11px;
818 + }
819 +
820 + .time-travel-preview-file {
821 + display: grid;
822 + grid-template-columns: minmax(0, 1fr) auto;
823 + gap: 8px;
824 + min-height: 30px;
825 + align-items: center;
826 + border: 1px solid color-mix(in srgb, var(--color-border) 34%, transparent);
827 + border-radius: 7px;
828 + padding: 4px 7px;
829 + background: color-mix(in srgb, var(--color-panel) 62%, transparent);
830 + font-size: 0.73rem;
831 + }
832 +
833 + .time-travel-preview-file span:first-child {
834 + min-width: 0;
835 + overflow: hidden;
836 + text-overflow: ellipsis;
837 + white-space: nowrap;
838 + font-family: var(--font-family-code);
839 + }
840 +
841 + .time-travel-preview-file span:last-child {
842 + color: var(--color-text-muted);
843 + text-transform: capitalize;
844 + }
845 +
846 + .time-travel-preview details {
847 + margin: 0 11px 11px;
848 + color: var(--color-text-muted);
849 + font-size: 0.76rem;
850 + }
851 +
852 + .time-travel-preview pre {
853 + max-height: 160px;
854 + overflow: auto;
855 + padding: 8px;
856 + border-radius: 7px;
857 + background: color-mix(in srgb, var(--color-background) 86%, #000 14%);
858 + white-space: pre-wrap;
859 + }
860 +
861 + .time-travel-panel .spinning {
862 + display: inline-block;
863 + animation: time-travel-spin 0.8s linear infinite;
864 + }
865 +
866 + @keyframes time-travel-spin {
867 + to { transform: rotate(360deg); }
868 + }
869 +
870 + @container (max-width: 720px) {
871 + .time-travel-body,
872 + .time-travel-detail-grid {
873 + grid-template-columns: minmax(0, 1fr);
874 + }
875 +
876 + .time-travel-timeline,
877 + .time-travel-files {
878 + max-height: 34vh;
879 + border-right: 0;
880 + border-bottom: 1px solid color-mix(in srgb, var(--color-border) 48%, transparent);
881 + }
882 +
883 + .time-travel-detail-header {
884 + grid-template-columns: minmax(0, 1fr);
885 + }
886 +
887 + .time-travel-actions {
888 + justify-content: flex-start;
889 + }
890 +
891 + .time-travel-workspace {
892 + display: none;
893 + }
894 + }
895 +
896 + @container (max-width: 520px) {
897 + .time-travel-tool-button span:last-child {
898 + display: none;
899 + }
900 +
901 + .time-travel-row {
902 + min-height: 46px;
903 + }
904 + }
905 + </style>
906 +</body>
907 +</html>
plugins/_time_travel/webui/time-travel-store.js new
+527
@@ -0,0 +1,527 @@
1 +import { createStore } from "/js/AlpineStore.js";
2 +import { callJsonApi } from "/js/api.js";
3 +import { getContext } from "/index.js";
4 +import { store as fileBrowserStore } from "/components/modals/file-browser/file-browser-store.js";
5 +
6 +const REFRESH_DEBOUNCE_MS = 180;
7 +
8 +function lineType(text) {
9 + if (text.startsWith("@@")) return "hunk";
10 + if (text.startsWith("+++") || text.startsWith("---") || text.startsWith("diff --git") || text.startsWith("index ")) {
11 + return "meta";
12 + }
13 + if (text.startsWith("+")) return "add";
14 + if (text.startsWith("-")) return "del";
15 + if (text.startsWith("\\ No newline")) return "note";
16 + return "context";
17 +}
18 +
19 +function dirname(path) {
20 + const clean = String(path || "").replace(/\/+$/, "");
21 + const index = clean.lastIndexOf("/");
22 + return index > 0 ? clean.slice(0, index) : "";
23 +}
24 +
25 +function apiPath(name) {
26 + return `/plugins/_time_travel/${name}`;
27 +}
28 +
29 +const model = {
30 + loading: false,
31 + busy: false,
32 + error: "",
33 + payload: null,
34 + contextId: "",
35 + workspacePath: "",
36 + fileFilter: "",
37 + selectedHash: "",
38 + selectedPath: "",
39 + selectedDiff: null,
40 + diffLoading: false,
41 + diffError: "",
42 + previewOpen: false,
43 + previewLoading: false,
44 + previewError: "",
45 + previewTechnicalDetails: "",
46 + previewDetailsOpen: false,
47 + preview: null,
48 + _root: null,
49 + _mode: "canvas",
50 + _refreshTimer: null,
51 + _filterTimer: null,
52 + _floatingCleanup: null,
53 + _requestSeq: 0,
54 + _diffSeq: 0,
55 +
56 + async init(element = null) {
57 + await this.onMount(element, { mode: "canvas" });
58 + },
59 +
60 + async onMount(element = null, options = {}) {
61 + if (element) this._root = element;
62 + this._mode = options?.mode === "modal" ? "modal" : "canvas";
63 + if (this._mode === "modal") {
64 + this.setupFloatingModal(element);
65 + } else {
66 + this.setupCanvasSurface(element);
67 + }
68 + this.contextId = this.resolveContextId();
69 + if (!this.payload && !this.loading) {
70 + await this.refresh({ contextId: this.contextId });
71 + }
72 + },
73 +
74 + async onOpen(payload = {}) {
75 + const nextContextId = String(payload.contextId || payload.context_id || this.resolveContextId() || "");
76 + await this.refresh({ contextId: nextContextId });
77 + },
78 +
79 + cleanup() {
80 + if (this._refreshTimer) {
81 + clearTimeout(this._refreshTimer);
82 + this._refreshTimer = null;
83 + }
84 + if (this._filterTimer) {
85 + clearTimeout(this._filterTimer);
86 + this._filterTimer = null;
87 + }
88 + this._floatingCleanup?.();
89 + this._floatingCleanup = null;
90 + },
91 +
92 + setupFloatingModal(element = null) {
93 + this._floatingCleanup?.();
94 + const root = element || globalThis.document?.querySelector(".time-travel-panel");
95 + const modal = root?.closest?.(".modal");
96 + const inner = modal?.querySelector?.(".modal-inner");
97 + const body = modal?.querySelector?.(".modal-bd");
98 + const header = modal?.querySelector?.(".modal-header");
99 + if (!modal || !inner || !header) return;
100 + modal.classList.add("modal-floating");
101 + inner.classList.add("time-travel-modal", "modal-no-backdrop");
102 + body?.classList?.add("time-travel-modal-body");
103 +
104 + const rect = inner.getBoundingClientRect();
105 + inner.style.left = `${Math.max(8, rect.left)}px`;
106 + inner.style.top = `${Math.max(8, rect.top)}px`;
107 + inner.style.transform = "none";
108 +
109 + let drag = null;
110 + let resizeObserver = null;
111 + const viewportGap = 8;
112 + const clampPosition = (left, top) => {
113 + const bounds = inner.getBoundingClientRect();
114 + const maxLeft = Math.max(viewportGap, globalThis.innerWidth - bounds.width - viewportGap);
115 + const maxTop = Math.max(viewportGap, globalThis.innerHeight - bounds.height - viewportGap);
116 + return {
117 + left: Math.min(Math.max(viewportGap, left), maxLeft),
118 + top: Math.min(Math.max(viewportGap, top), maxTop),
119 + };
120 + };
121 + const clampGeometry = () => {
122 + const bounds = inner.getBoundingClientRect();
123 + const maxWidth = Math.max(360, globalThis.innerWidth - viewportGap * 2);
124 + const maxHeight = Math.max(420, globalThis.innerHeight - viewportGap * 2);
125 + if (bounds.width > maxWidth) inner.style.width = `${maxWidth}px`;
126 + if (bounds.height > maxHeight) inner.style.height = `${maxHeight}px`;
127 + const next = clampPosition(bounds.left, bounds.top);
128 + inner.style.left = `${next.left}px`;
129 + inner.style.top = `${next.top}px`;
130 + inner.style.maxWidth = `${Math.max(360, globalThis.innerWidth - next.left - viewportGap)}px`;
131 + inner.style.maxHeight = `${Math.max(420, globalThis.innerHeight - next.top - viewportGap)}px`;
132 + };
133 + clampGeometry();
134 + globalThis.addEventListener("resize", clampGeometry);
135 + if (globalThis.ResizeObserver) {
136 + resizeObserver = new ResizeObserver(clampGeometry);
137 + resizeObserver.observe(inner);
138 + }
139 +
140 + const onPointerMove = (event) => {
141 + if (!drag) return;
142 + const next = clampPosition(drag.left + event.clientX - drag.x, drag.top + event.clientY - drag.y);
143 + inner.style.left = `${next.left}px`;
144 + inner.style.top = `${next.top}px`;
145 + clampGeometry();
146 + };
147 + const onPointerUp = () => {
148 + drag = null;
149 + globalThis.removeEventListener("pointermove", onPointerMove);
150 + globalThis.removeEventListener("pointerup", onPointerUp);
151 + try {
152 + header.releasePointerCapture?.(header.__timeTravelPanelPointerId || 0);
153 + } catch {}
154 + };
155 + const onPointerDown = (event) => {
156 + if (event.button !== 0) return;
157 + if (event.target?.closest?.("button, input, select, textarea, a")) return;
158 + const current = inner.getBoundingClientRect();
159 + drag = {
160 + x: event.clientX,
161 + y: event.clientY,
162 + left: current.left,
163 + top: current.top,
164 + };
165 + header.__timeTravelPanelPointerId = event.pointerId;
166 + header.setPointerCapture?.(event.pointerId);
167 + globalThis.addEventListener("pointermove", onPointerMove);
168 + globalThis.addEventListener("pointerup", onPointerUp);
169 + event.preventDefault();
170 + };
171 + header.addEventListener("pointerdown", onPointerDown);
172 +
173 + this._floatingCleanup = () => {
174 + header.removeEventListener("pointerdown", onPointerDown);
175 + globalThis.removeEventListener("pointermove", onPointerMove);
176 + globalThis.removeEventListener("pointerup", onPointerUp);
177 + globalThis.removeEventListener("resize", clampGeometry);
178 + resizeObserver?.disconnect?.();
179 + };
180 + },
181 +
182 + setupCanvasSurface(element = null) {
183 + this._floatingCleanup?.();
184 + this._floatingCleanup = null;
185 + if (element) this._root = element;
186 + },
187 +
188 + resolveContextId() {
189 + const urlContext = new URLSearchParams(globalThis.location?.search || "").get("ctxid");
190 + return getContext?.() || urlContext || globalThis.Alpine?.store?.("chats")?.selected || "";
191 + },
192 +
193 + scheduleRefresh(options = {}) {
194 + if (this._refreshTimer) clearTimeout(this._refreshTimer);
195 + this._refreshTimer = setTimeout(() => {
196 + this._refreshTimer = null;
197 + this.refresh(options).catch((error) => console.error("Time Travel refresh failed", error));
198 + }, REFRESH_DEBOUNCE_MS);
199 + },
200 +
201 + scheduleFilterRefresh() {
202 + if (this._filterTimer) clearTimeout(this._filterTimer);
203 + this._filterTimer = setTimeout(() => {
204 + this._filterTimer = null;
205 + this.refresh({ keepSelection: false });
206 + }, 240);
207 + },
208 +
209 + async refresh(options = {}) {
210 + const contextId = String(options.contextId || options.context_id || this.resolveContextId() || "");
211 + const seq = ++this._requestSeq;
212 + this.loading = true;
213 + this.error = "";
214 + try {
215 + const response = await callJsonApi(apiPath("history_list"), {
216 + context_id: contextId,
217 + limit: 100,
218 + offset: 0,
219 + file_filter: this.fileFilter,
220 + });
221 + if (seq !== this._requestSeq) return;
222 + if (!response?.ok) throw new Error(response?.error || "Could not load history.");
223 + this.payload = response;
224 + this.contextId = String(response.context_id || contextId || "");
225 + this.workspacePath = String(response.workspace?.display_path || response.workspace?.path || "");
226 + this.reconcileSelection(Boolean(options.keepSelection));
227 + } catch (error) {
228 + if (seq !== this._requestSeq) return;
229 + this.error = error instanceof Error ? error.message : String(error);
230 + } finally {
231 + if (seq === this._requestSeq) this.loading = false;
232 + }
233 + },
234 +
235 + async loadMore() {
236 + if (this.loading || !this.payload?.has_more || this.isLocked()) return;
237 + const seq = ++this._requestSeq;
238 + this.loading = true;
239 + try {
240 + const response = await callJsonApi(apiPath("history_list"), {
241 + context_id: this.contextId,
242 + limit: 100,
243 + offset: this.commits().length,
244 + file_filter: this.fileFilter,
245 + });
246 + if (seq !== this._requestSeq) return;
247 + if (!response?.ok) throw new Error(response?.error || "Could not load history.");
248 + this.payload.commits = [...this.commits(), ...(response.commits || [])];
249 + this.payload.has_more = Boolean(response.has_more);
250 + } catch (error) {
251 + this.error = error instanceof Error ? error.message : String(error);
252 + } finally {
253 + if (seq === this._requestSeq) this.loading = false;
254 + }
255 + },
256 +
257 + reconcileSelection(keepSelection = false) {
258 + if (this.isLocked()) {
259 + this.selectedHash = "";
260 + this.selectedPath = "";
261 + this.selectedDiff = null;
262 + return;
263 + }
264 +
265 + const rows = this.timelineRows();
266 + let selected = keepSelection ? rows.find((row) => row.key === this.selectedHash) : null;
267 + if (!selected) selected = rows[0] || null;
268 + this.selectedHash = selected?.key || "";
269 +
270 + const files = this.selectedFiles();
271 + if (!files.some((file) => this.fileKey(file) === this.selectedPath)) {
272 + this.selectedPath = files[0] ? this.fileKey(files[0]) : "";
273 + }
274 + void this.loadSelectedDiff();
275 + },
276 +
277 + commits() {
278 + return Array.isArray(this.payload?.commits) ? this.payload.commits : [];
279 + },
280 +
281 + present() {
282 + return this.payload?.present || {};
283 + },
284 +
285 + isLocked() {
286 + return Boolean(this.payload?.workspace?.locked || this.payload?.workspace?.available === false);
287 + },
288 +
289 + hasHistory() {
290 + return this.commits().length > 0;
291 + },
292 +
293 + hasPresentChanges() {
294 + return Boolean(this.present()?.dirty);
295 + },
296 +
297 + timelineRows() {
298 + const rows = [];
299 + rows.push({
300 + key: "present",
301 + kind: "present",
302 + hash: this.payload?.current_hash || "",
303 + short_hash: "present",
304 + message: this.hasPresentChanges() ? "Present changes" : "Present clean",
305 + timestamp: "",
306 + files: this.present()?.files || [],
307 + is_current: false,
308 + dirty: this.hasPresentChanges(),
309 + });
310 + for (const commit of this.commits()) {
311 + rows.push({ key: commit.hash, kind: "commit", ...commit });
312 + }
313 + return rows;
314 + },
315 +
316 + selectedRow() {
317 + return this.timelineRows().find((row) => row.key === this.selectedHash) || null;
318 + },
319 +
320 + selectedCommit() {
321 + const row = this.selectedRow();
322 + return row?.kind === "commit" ? row : null;
323 + },
324 +
325 + selectedFiles() {
326 + const row = this.selectedRow();
327 + return Array.isArray(row?.files) ? row.files : [];
328 + },
329 +
330 + selectedFile() {
331 + return this.selectedFiles().find((file) => this.fileKey(file) === this.selectedPath) || null;
332 + },
333 +
334 + selectRow(row) {
335 + this.selectedHash = row?.key || "";
336 + const files = this.selectedFiles();
337 + this.selectedPath = files[0] ? this.fileKey(files[0]) : "";
338 + void this.loadSelectedDiff();
339 + },
340 +
341 + selectFile(file) {
342 + this.selectedPath = this.fileKey(file);
343 + void this.loadSelectedDiff();
344 + },
345 +
346 + fileKey(file) {
347 + return `${file?.old_path || ""}:${file?.path || ""}`;
348 + },
349 +
350 + async loadSelectedDiff() {
351 + const file = this.selectedFile();
352 + const row = this.selectedRow();
353 + this.selectedDiff = null;
354 + this.diffError = "";
355 + if (!file || !row || this.isLocked()) return;
356 + const seq = ++this._diffSeq;
357 + this.diffLoading = true;
358 + try {
359 + const response = await callJsonApi(apiPath("history_diff"), {
360 + context_id: this.contextId,
361 + commit_hash: row.kind === "present" ? this.payload?.current_hash || "" : row.hash,
362 + path: file.path || file.old_path,
363 + mode: row.kind === "present" ? "present" : "commit",
364 + });
365 + if (seq !== this._diffSeq) return;
366 + if (!response?.ok) throw new Error(response?.error || "Could not load diff.");
367 + this.selectedDiff = response;
368 + } catch (error) {
369 + if (seq !== this._diffSeq) return;
370 + this.diffError = error instanceof Error ? error.message : String(error);
371 + } finally {
372 + if (seq === this._diffSeq) this.diffLoading = false;
373 + }
374 + },
375 +
376 + async manualSnapshot() {
377 + if (this.busy || this.isLocked()) return;
378 + this.busy = true;
379 + this.error = "";
380 + try {
381 + const response = await callJsonApi(apiPath("history_snapshot"), {
382 + context_id: this.contextId,
383 + trigger: "manual",
384 + });
385 + if (!response?.ok) throw new Error(response?.error || "Snapshot failed.");
386 + globalThis.justToast?.(response.snapshot?.created ? "Snapshot captured" : "No changes to snapshot", "success", 1400, "time-travel-snapshot");
387 + await this.refresh({ keepSelection: true });
388 + } catch (error) {
389 + this.error = error instanceof Error ? error.message : String(error);
390 + } finally {
391 + this.busy = false;
392 + }
393 + },
394 +
395 + async openPreview(operation, commit = null) {
396 + const target = commit || this.selectedCommit();
397 + if (!target || this.busy || this.isLocked()) return;
398 + if (operation === "travel" && target.is_current) return;
399 + this.previewOpen = true;
400 + this.previewLoading = true;
401 + this.previewError = "";
402 + this.previewTechnicalDetails = "";
403 + this.previewDetailsOpen = false;
404 + this.preview = { operation, commit_hash: target.hash, short_hash: target.short_hash, files: [], previews: [] };
405 + try {
406 + const response = await callJsonApi(apiPath("history_preview"), {
407 + context_id: this.contextId,
408 + operation,
409 + commit_hash: target.hash,
410 + });
411 + if (!response?.ok) throw new Error(response?.error || "Preview failed.");
412 + this.preview = response;
413 + } catch (error) {
414 + this.previewError = error instanceof Error ? error.message : String(error);
415 + } finally {
416 + this.previewLoading = false;
417 + }
418 + },
419 +
420 + closePreview() {
421 + if (this.busy) return;
422 + this.previewOpen = false;
423 + this.preview = null;
424 + this.previewError = "";
425 + this.previewTechnicalDetails = "";
426 + this.previewDetailsOpen = false;
427 + },
428 +
429 + async confirmPreview() {
430 + if (!this.preview || this.busy || this.previewLoading) return;
431 + const operation = this.preview.operation;
432 + const endpoint = operation === "travel" ? "history_travel" : "history_revert";
433 + this.busy = true;
434 + this.previewError = "";
435 + this.previewDetailsOpen = false;
436 + try {
437 + const response = await callJsonApi(apiPath(endpoint), {
438 + context_id: this.contextId,
439 + commit_hash: this.preview.commit_hash,
440 + metadata: { source: "time_travel_ui" },
441 + });
442 + if (!response?.ok) {
443 + const error = new Error(response?.error || `${operation} failed.`);
444 + error.technicalDetails = response?.technical_details || "";
445 + throw error;
446 + }
447 + globalThis.justToast?.(operation === "travel" ? "Workspace traveled" : "Revert applied", "success", 1500, "time-travel-apply");
448 + this.previewOpen = false;
449 + this.preview = null;
450 + await this.refresh({ keepSelection: false });
451 + } catch (error) {
452 + this.previewError = error instanceof Error ? error.message : String(error);
453 + this.previewTechnicalDetails = error?.technicalDetails || "";
454 + } finally {
455 + this.busy = false;
456 + }
457 + },
458 +
459 + patchLines(diff = null) {
460 + const patch = String((diff || this.selectedDiff)?.patch || "");
461 + if (!patch) return [];
462 + const textLines = patch.endsWith("\n") ? patch.slice(0, -1).split("\n") : patch.split("\n");
463 + return textLines.map((text, index) => ({
464 + id: `${index}-${text.slice(0, 20)}`,
465 + text,
466 + type: lineType(text),
467 + }));
468 + },
469 +
470 + fileTitle(file) {
471 + if (file?.old_path && file.old_path !== file.path) {
472 + return `${file.old_path} -> ${file.path}`;
473 + }
474 + return file?.path || file?.old_path || "";
475 + },
476 +
477 + statusLabel(file) {
478 + return String(file?.action || file?.status || "changed").replaceAll("_", " ");
479 + },
480 +
481 + rowMeta(row) {
482 + if (!row) return "";
483 + const files = Array.isArray(row.files) ? row.files.length : 0;
484 + if (row.kind === "present") return files ? `${files} file${files === 1 ? "" : "s"}` : "clean";
485 + return `${row.short_hash || ""} · ${this.formatTime(row.timestamp)}`;
486 + },
487 +
488 + formatTime(value) {
489 + if (!value) return "";
490 + const date = new Date(value);
491 + if (Number.isNaN(date.getTime())) return String(value);
492 + return date.toLocaleString(undefined, {
493 + month: "short",
494 + day: "numeric",
495 + hour: "2-digit",
496 + minute: "2-digit",
497 + });
498 + },
499 +
500 + formatSigned(value, sign) {
501 + const number = Number(value) || 0;
502 + return `${sign}${number.toLocaleString()}`;
503 + },
504 +
505 + fullPath(file) {
506 + const relativePath = String(file?.path || file?.old_path || "").replace(/^\/+/, "");
507 + const base = String(this.workspacePath || "").replace(/\/+$/, "");
508 + return relativePath ? `${base}/${relativePath}` : base;
509 + },
510 +
511 + async openContainingFolder(file) {
512 + const parent = dirname(this.fullPath(file));
513 + await fileBrowserStore.open(parent || this.workspacePath || "$WORK_DIR");
514 + },
515 +
516 + async copyPath(file) {
517 + const path = this.fullPath(file);
518 + try {
519 + await navigator.clipboard.writeText(path);
520 + globalThis.justToast?.("Path copied", "success", 1200, "time-travel-copy");
521 + } catch (_error) {
522 + globalThis.prompt?.("Copy path", path);
523 + }
524 + },
525 +};
526 +
527 +export const store = createStore("timeTravel", model);
tests/test_diff_viewer.py deleted
-192
@@ -1,192 +0,0 @@
1 -from __future__ import annotations
2 -
3 -import asyncio
4 -import subprocess
5 -import sys
6 -import threading
7 -from pathlib import Path
8 -from types import SimpleNamespace
9 -
10 -import pytest
11 -from flask import Flask
12 -
13 -PROJECT_ROOT = Path(__file__).resolve().parents[1]
14 -if str(PROJECT_ROOT) not in sys.path:
15 - sys.path.insert(0, str(PROJECT_ROOT))
16 -
17 -from plugins._diff_viewer.helpers import diff as diff_helper
18 -from plugins._diff_viewer.helpers.diff import collect_workspace_diff
19 -
20 -
21 -def run_git(repo_dir: Path, *args: str) -> str:
22 - completed = subprocess.run(
23 - ["git", "-C", str(repo_dir), *args],
24 - check=True,
25 - text=True,
26 - capture_output=True,
27 - )
28 - return completed.stdout.strip()
29 -
30 -
31 -def init_repo(repo_dir: Path) -> None:
32 - run_git(repo_dir, "init")
33 - run_git(repo_dir, "config", "user.name", "Test User")
34 - run_git(repo_dir, "config", "user.email", "test@example.com")
35 - (repo_dir / "tracked.txt").write_text("one\n", encoding="utf-8")
36 - run_git(repo_dir, "add", "tracked.txt")
37 - run_git(repo_dir, "commit", "-m", "initial")
38 -
39 -
40 -def files_for_group(payload: dict, kind: str) -> list[dict]:
41 - return next(group["files"] for group in payload["groups"] if group["kind"] == kind)
42 -
43 -
44 -def test_collect_workspace_diff_returns_non_git_state(tmp_path: Path) -> None:
45 - payload = collect_workspace_diff(str(tmp_path), context_id="ctx")
46 -
47 - assert payload["ok"] is True
48 - assert payload["context_id"] == "ctx"
49 - assert payload["is_git_repo"] is False
50 - assert payload["totals"] == {"files": 0, "additions": 0, "deletions": 0}
51 -
52 -
53 -def test_collect_workspace_diff_groups_staged_unstaged_and_untracked(tmp_path: Path) -> None:
54 - init_repo(tmp_path)
55 - (tmp_path / "tracked.txt").write_text("one\nstaged\n", encoding="utf-8")
56 - run_git(tmp_path, "add", "tracked.txt")
57 - (tmp_path / "tracked.txt").write_text("one\nstaged\nunstaged\n", encoding="utf-8")
58 - (tmp_path / "new.txt").write_text("hello\n", encoding="utf-8")
59 -
60 - payload = collect_workspace_diff(str(tmp_path))
61 -
62 - staged = files_for_group(payload, "staged")
63 - unstaged = files_for_group(payload, "unstaged")
64 - untracked = files_for_group(payload, "untracked")
65 - assert staged[0]["path"] == "tracked.txt"
66 - assert staged[0]["status"] == "modified"
67 - assert "+staged" in staged[0]["patch"]
68 - assert unstaged[0]["path"] == "tracked.txt"
69 - assert "+unstaged" in unstaged[0]["patch"]
70 - assert untracked[0]["path"] == "new.txt"
71 - assert untracked[0]["status"] == "untracked"
72 - assert "+hello" in untracked[0]["patch"]
73 - assert payload["totals"]["files"] == 2
74 - assert payload["totals"]["additions"] == 3
75 -
76 -
77 -def test_collect_workspace_diff_ignores_zero_line_gitkeep(tmp_path: Path) -> None:
78 - init_repo(tmp_path)
79 - (tmp_path / ".gitkeep").write_text("", encoding="utf-8")
80 - (tmp_path / "nested").mkdir()
81 - (tmp_path / "nested" / ".gitkeep").write_text("", encoding="utf-8")
82 - (tmp_path / "real.txt").write_text("real\n", encoding="utf-8")
83 - run_git(tmp_path, "add", ".gitkeep")
84 -
85 - payload = collect_workspace_diff(str(tmp_path))
86 - paths = [
87 - item["path"]
88 - for group in payload["groups"]
89 - for item in group["files"]
90 - ]
91 -
92 - assert ".gitkeep" not in paths
93 - assert "nested/.gitkeep" not in paths
94 - assert paths == ["real.txt"]
95 - assert payload["totals"] == {"files": 1, "additions": 1, "deletions": 0}
96 -
97 -
98 -def test_collect_workspace_diff_deleted_renamed_binary_large_and_a0_exclusion(
99 - tmp_path: Path,
100 - monkeypatch: pytest.MonkeyPatch,
101 -) -> None:
102 - init_repo(tmp_path)
103 - (tmp_path / "rename_me.txt").write_text("move\n", encoding="utf-8")
104 - (tmp_path / "delete_me.txt").write_text("delete\n", encoding="utf-8")
105 - run_git(tmp_path, "add", "rename_me.txt", "delete_me.txt")
106 - run_git(tmp_path, "commit", "-m", "fixtures")
107 -
108 - run_git(tmp_path, "mv", "rename_me.txt", "renamed.txt")
109 - (tmp_path / "delete_me.txt").unlink()
110 - (tmp_path / "binary.bin").write_bytes(b"\x00\x01data")
111 - (tmp_path / ".a0proj").mkdir()
112 - (tmp_path / ".a0proj" / "project.json").write_text("{}", encoding="utf-8")
113 - monkeypatch.setattr(diff_helper, "MAX_UNTRACKED_BYTES", 10)
114 - (tmp_path / "large.txt").write_text("0123456789\n" * 5, encoding="utf-8")
115 -
116 - payload = collect_workspace_diff(str(tmp_path))
117 - staged = files_for_group(payload, "staged")
118 - unstaged = files_for_group(payload, "unstaged")
119 - untracked = files_for_group(payload, "untracked")
120 -
121 - git_changes = staged + unstaged
122 - statuses = {(item["path"], item["status"]) for item in git_changes}
123 - assert ("delete_me.txt", "deleted") in statuses
124 - assert ("renamed.txt", "renamed") in statuses
125 - renamed = next(item for item in git_changes if item["path"] == "renamed.txt")
126 - assert renamed["old_path"] == "rename_me.txt"
127 - assert renamed["additions"] == 0
128 - assert renamed["deletions"] == 0
129 - binary = next(item for item in untracked if item["path"] == "binary.bin")
130 - assert binary["binary"] is True
131 - large = next(item for item in untracked if item["path"] == "large.txt")
132 - assert large["too_large"] is True
133 - assert all(not item["path"].startswith(".a0proj") for item in untracked)
134 -
135 -
136 -def test_collect_workspace_diff_limits_nested_workspace_to_pathspec(tmp_path: Path) -> None:
137 - init_repo(tmp_path)
138 - (tmp_path / "outside.txt").write_text("outside\n", encoding="utf-8")
139 - (tmp_path / "nested").mkdir()
140 - (tmp_path / "nested" / "inside.txt").write_text("inside\n", encoding="utf-8")
141 -
142 - payload = collect_workspace_diff(str(tmp_path / "nested"))
143 - untracked = files_for_group(payload, "untracked")
144 -
145 - assert [item["path"] for item in untracked] == ["inside.txt"]
146 -
147 -
148 -def test_diff_api_resolves_project_context_workspace(
149 - tmp_path: Path,
150 - monkeypatch: pytest.MonkeyPatch,
151 -) -> None:
152 - pytest.importorskip("whisper")
153 - pytest.importorskip("langchain_core")
154 - from plugins._diff_viewer.api import diff as diff_api
155 -
156 - init_repo(tmp_path)
157 - (tmp_path / "changed.txt").write_text("changed\n", encoding="utf-8")
158 - handler = diff_api.Diff(Flask("diff-test"), threading.RLock())
159 - monkeypatch.setattr(handler, "use_context", lambda context_id: SimpleNamespace(id=context_id))
160 - monkeypatch.setattr(diff_api.projects, "get_context_project_name", lambda _context: "demo")
161 - monkeypatch.setattr(diff_api.projects, "get_project_folder", lambda _name: str(tmp_path))
162 - monkeypatch.setattr(diff_api.files, "normalize_a0_path", lambda path: path)
163 - monkeypatch.setattr(diff_api.files, "fix_dev_path", lambda path: path)
164 -
165 - payload = asyncio.run(handler.process({"context_id": "ctx-project"}, None))
166 -
167 - assert isinstance(payload, dict)
168 - assert payload["ok"] is True
169 - assert payload["context_id"] == "ctx-project"
170 - assert payload["workspace_path"] == str(tmp_path)
171 - assert files_for_group(payload, "untracked")[0]["path"] == "changed.txt"
172 -
173 -
174 -def test_diff_api_falls_back_to_default_workdir(
175 - tmp_path: Path,
176 - monkeypatch: pytest.MonkeyPatch,
177 -) -> None:
178 - pytest.importorskip("whisper")
179 - pytest.importorskip("langchain_core")
180 - from plugins._diff_viewer.api import diff as diff_api
181 -
182 - init_repo(tmp_path)
183 - (tmp_path / "workdir.txt").write_text("workdir\n", encoding="utf-8")
184 - handler = diff_api.Diff(Flask("diff-test-default"), threading.RLock())
185 - monkeypatch.setattr(diff_api.settings, "get_settings", lambda: {"workdir_path": str(tmp_path)})
186 - monkeypatch.setattr(diff_api.files, "fix_dev_path", lambda path: path)
187 -
188 - payload = asyncio.run(handler.process({}, None))
189 -
190 - assert isinstance(payload, dict)
191 - assert payload["workspace_path"] == str(tmp_path)
192 - assert files_for_group(payload, "untracked")[0]["path"] == "workdir.txt"
tests/test_time_travel.py new
+262
@@ -0,0 +1,262 @@
1 +from __future__ import annotations
2 +
3 +import os
4 +import shutil
5 +import subprocess
6 +import sys
7 +import threading
8 +import uuid
9 +from pathlib import Path
10 +from types import ModuleType, SimpleNamespace
11 +
12 +import pytest
13 +
14 +PROJECT_ROOT = Path(__file__).resolve().parents[1]
15 +if str(PROJECT_ROOT) not in sys.path:
16 + sys.path.insert(0, str(PROJECT_ROOT))
17 +
18 +from plugins._time_travel.helpers import time_travel as tt
19 +from plugins._time_travel.helpers.time_travel import (
20 + TimeTravelConflictError,
21 + TimeTravelError,
22 + TimeTravelService,
23 + WorkspaceRejectedError,
24 + _workspace_from_display,
25 + resolve_workspace,
26 +)
27 +
28 +
29 +def run_git(repo_dir: Path, *args: str, check: bool = True) -> str:
30 + completed = subprocess.run(
31 + ["git", "-C", str(repo_dir), *args],
32 + check=check,
33 + text=True,
34 + capture_output=True,
35 + )
36 + return completed.stdout.strip()
37 +
38 +
39 +@pytest.fixture
40 +def workspace():
41 + name = f"tt-{uuid.uuid4().hex}"
42 + root = PROJECT_ROOT / "usr" / "time-travel-tests" / name
43 + root.mkdir(parents=True)
44 + service = TimeTravelService(_workspace_from_display(f"/a0/usr/time-travel-tests/{name}"))
45 + try:
46 + yield root, service
47 + finally:
48 + shutil.rmtree(root, ignore_errors=True)
49 + shutil.rmtree(service.workspace.shadow_path, ignore_errors=True)
50 +
51 +
52 +def tracked_paths(service: TimeTravelService, commit_hash: str = "HEAD") -> set[str]:
53 + output = service._git("ls-tree", "-r", "--name-only", commit_hash).stdout
54 + return {line.strip() for line in output.splitlines() if line.strip()}
55 +
56 +
57 +def test_shadow_history_snapshot_diff_travel_preserve_refs_and_root_revert(workspace):
58 + root, service = workspace
59 + (root / "a.txt").write_text("one\n", encoding="utf-8")
60 +
61 + initial = service.snapshot(trigger="manual", metadata={"context_id": "ctx"})
62 + duplicate = service.snapshot(trigger="manual")
63 +
64 + assert initial.created is True
65 + assert duplicate.created is False
66 + assert duplicate.hash == initial.hash
67 + assert (service.workspace.repo_git_path / "objects").is_dir()
68 +
69 + (root / "a.txt").write_text("one\ntwo\n", encoding="utf-8")
70 + present = service.present_summary()
71 + assert present["dirty"] is True
72 + assert present["files"][0]["path"] == "a.txt"
73 + assert "+two" in service.history_diff(commit_hash=initial.hash, path="a.txt", mode="present")["patch"]
74 +
75 + second = service.snapshot(trigger="tool", metadata={"tool_name": "code_execution_tool"})
76 + history = service.history_list(limit=10)
77 + assert [commit["hash"] for commit in history["commits"][:2]] == [second.hash, initial.hash]
78 + assert history["commits"][0]["metadata"]["tool_name"] == "code_execution_tool"
79 + assert "+two" in service.history_diff(commit_hash=second.hash, path="a.txt", mode="commit")["patch"]
80 +
81 + service.travel(commit_hash=initial.hash)
82 + assert (root / "a.txt").read_text(encoding="utf-8") == "one\n"
83 + preserved = service._git(
84 + "for-each-ref",
85 + "--format=%(objectname)",
86 + "refs/a0-time-travel/preserved",
87 + ).stdout
88 + assert second.hash in preserved
89 + assert second.hash in [commit["hash"] for commit in service.history_list(limit=10)["commits"]]
90 +
91 + reverted = service.revert(commit_hash=initial.hash)
92 + assert reverted["ok"] is True
93 + assert not (root / "a.txt").exists()
94 + assert reverted["snapshot"]["created"] is True
95 +
96 +
97 +def test_revert_conflict_auto_snapshots_present_without_losing_changes(workspace):
98 + root, service = workspace
99 + (root / "a.txt").write_text("one\n", encoding="utf-8")
100 + first = service.snapshot(trigger="manual")
101 + (root / "a.txt").write_text("one\ntwo\n", encoding="utf-8")
102 + second = service.snapshot(trigger="manual")
103 + (root / "a.txt").write_text("custom\n", encoding="utf-8")
104 +
105 + with pytest.raises(TimeTravelConflictError):
106 + service.revert(commit_hash=second.hash)
107 +
108 + assert (root / "a.txt").read_text(encoding="utf-8") == "custom\n"
109 + assert service.current_hash() not in {first.hash, second.hash}
110 + assert "custom" in service.history_diff(commit_hash=service.current_hash(), path="a.txt", mode="commit")["patch"]
111 +
112 +
113 +def test_kernel_boundary_real_git_repo_and_git_dir_exclusion(workspace):
114 + root, service = workspace
115 + with pytest.raises(WorkspaceRejectedError):
116 + _workspace_from_display("/tmp/outside")
117 +
118 + run_git(root, "init")
119 + run_git(root, "config", "user.name", "Test User")
120 + run_git(root, "config", "user.email", "test@example.com")
121 + (root / "tracked.txt").write_text("tracked\n", encoding="utf-8")
122 + run_git(root, "add", "tracked.txt")
123 + run_git(root, "commit", "-m", "real initial")
124 + real_head = run_git(root, "rev-parse", "HEAD")
125 + (root / "untracked.txt").write_text("shadow only\n", encoding="utf-8")
126 + real_status_before = run_git(root, "status", "--short")
127 +
128 + snapshot = service.snapshot(trigger="manual")
129 +
130 + assert snapshot.created is True
131 + assert run_git(root, "rev-parse", "HEAD") == real_head
132 + assert run_git(root, "status", "--short") == real_status_before
133 + assert all(not path.startswith(".git/") and path != ".git" for path in tracked_paths(service))
134 +
135 +
136 +def test_metadata_policy_tracks_safe_project_files_and_preserves_exclusions(workspace):
137 + root, service = workspace
138 + (root / "src").mkdir()
139 + (root / "src" / "app.py").write_text("print('one')\n", encoding="utf-8")
140 + (root / ".a0proj" / "instructions").mkdir(parents=True)
141 + (root / ".a0proj" / "knowledge").mkdir(parents=True)
142 + (root / ".a0proj" / "skills" / "demo").mkdir(parents=True)
143 + (root / ".a0proj" / "plugins" / "demo").mkdir(parents=True)
144 + (root / ".a0proj" / "memory").mkdir(parents=True)
145 + (root / "node_modules").mkdir()
146 + (root / "dist").mkdir()
147 + (root / "__pycache__").mkdir()
148 + (root / ".a0proj" / "project.json").write_text("{}", encoding="utf-8")
149 + (root / ".a0proj" / "agents.json").write_text("{}", encoding="utf-8")
150 + (root / ".a0proj" / "instructions" / "one.md").write_text("i\n", encoding="utf-8")
151 + (root / ".a0proj" / "knowledge" / "one.md").write_text("k\n", encoding="utf-8")
152 + (root / ".a0proj" / "skills" / "demo" / "SKILL.md").write_text("s\n", encoding="utf-8")
153 + (root / ".a0proj" / "plugins" / "demo" / "config.json").write_text("{}", encoding="utf-8")
154 + (root / ".a0proj" / "plugins" / "demo" / "presets.yaml").write_text("[]\n", encoding="utf-8")
155 + (root / ".a0proj" / "plugins" / "demo" / "state.json").write_text('{"state": true}\n', encoding="utf-8")
156 + (root / ".a0proj" / "secrets.env").write_text("SECRET=one\n", encoding="utf-8")
157 + (root / ".a0proj" / "variables.env").write_text("VAR=one\n", encoding="utf-8")
158 + (root / ".a0proj" / "memory" / "index.faiss").write_bytes(b"memory")
159 + (root / ".env").write_text("TOKEN=one\n", encoding="utf-8")
160 + (root / "node_modules" / "pkg.js").write_text("pkg\n", encoding="utf-8")
161 + (root / "dist" / "bundle.js").write_text("dist\n", encoding="utf-8")
162 + (root / "__pycache__" / "app.pyc").write_bytes(b"pyc")
163 +
164 + first = service.snapshot(trigger="manual")
165 + paths = tracked_paths(service, first.hash)
166 +
167 + assert "src/app.py" in paths
168 + assert ".a0proj/project.json" in paths
169 + assert ".a0proj/agents.json" in paths
170 + assert ".a0proj/instructions/one.md" in paths
171 + assert ".a0proj/knowledge/one.md" in paths
172 + assert ".a0proj/skills/demo/SKILL.md" in paths
173 + assert ".a0proj/plugins/demo/config.json" in paths
174 + assert ".a0proj/plugins/demo/presets.yaml" in paths
175 + assert ".a0proj/plugins/demo/state.json" not in paths
176 + assert ".a0proj/secrets.env" not in paths
177 + assert ".a0proj/variables.env" not in paths
178 + assert ".a0proj/memory/index.faiss" not in paths
179 + assert ".env" not in paths
180 + assert "node_modules/pkg.js" not in paths
181 + assert "dist/bundle.js" not in paths
182 + assert "__pycache__/app.pyc" not in paths
183 +
184 + (root / "src" / "app.py").write_text("print('two')\n", encoding="utf-8")
185 + (root / ".a0proj" / "secrets.env").write_text("SECRET=two\n", encoding="utf-8")
186 + service.snapshot(trigger="manual")
187 + service.travel(commit_hash=first.hash)
188 +
189 + assert (root / "src" / "app.py").read_text(encoding="utf-8") == "print('one')\n"
190 + assert (root / ".a0proj" / "secrets.env").read_text(encoding="utf-8") == "SECRET=two\n"
191 +
192 +
193 +def test_symlink_entries_are_snapshotted_and_deleted_without_following_targets(workspace, tmp_path: Path):
194 + root, service = workspace
195 + outside = tmp_path / "outside.txt"
196 + outside.write_text("outside\n", encoding="utf-8")
197 + os.symlink(outside, root / "outside-link")
198 +
199 + first = service.snapshot(trigger="manual")
200 + assert "outside-link" in tracked_paths(service, first.hash)
201 + assert service._git("ls-tree", "HEAD", "outside-link").stdout.startswith("120000")
202 +
203 + (root / "outside-link").unlink()
204 + second = service.snapshot(trigger="manual")
205 + assert outside.exists()
206 +
207 + service.travel(commit_hash=first.hash)
208 + assert (root / "outside-link").is_symlink()
209 + assert outside.exists()
210 +
211 + service.travel(commit_hash=second.hash)
212 + assert not (root / "outside-link").exists()
213 + assert outside.exists()
214 +
215 +
216 +def test_pagination_large_diff_and_invalid_inputs(workspace, monkeypatch: pytest.MonkeyPatch):
217 + root, service = workspace
218 + (root / "file.txt").write_text("0\n", encoding="utf-8")
219 + hashes = [service.snapshot(trigger="manual").hash]
220 + for index in range(1, 4):
221 + (root / "file.txt").write_text(("x\n" * index), encoding="utf-8")
222 + hashes.append(service.snapshot(trigger="manual").hash)
223 +
224 + page = service.history_list(limit=2)
225 + assert len(page["commits"]) == 2
226 + assert page["has_more"] is True
227 + page2 = service.history_list(limit=2, offset=2)
228 + assert page2["commits"][0]["hash"] == hashes[1]
229 +
230 + monkeypatch.setattr(tt, "MAX_RENDERED_PATCH_BYTES", 30)
231 + diff = service.history_diff(commit_hash=hashes[-1], path="file.txt", mode="commit")
232 + assert diff["too_large"] is True
233 + assert len(diff["patch"].encode("utf-8")) <= 30
234 +
235 + with pytest.raises(TimeTravelError):
236 + service.history_diff(commit_hash="not-a-commit", path="file.txt", mode="commit")
237 + with pytest.raises(TimeTravelError):
238 + service.history_diff(commit_hash=hashes[-1], path="../file.txt", mode="commit")
239 +
240 +
241 +def test_workspace_resolution_prefers_project_and_rejects_external_paths(monkeypatch: pytest.MonkeyPatch, workspace):
242 + root, _service = workspace
243 + projects_mod = ModuleType("helpers.projects")
244 + projects_mod.get_context_project_name = lambda _context: "demo"
245 + projects_mod.get_project_folder = lambda _name: str(root)
246 + settings_mod = ModuleType("helpers.settings")
247 + settings_mod.get_settings = lambda: {"workdir_path": "/tmp/not-a0"}
248 +
249 + import helpers
250 +
251 + monkeypatch.setitem(sys.modules, "helpers.projects", projects_mod)
252 + monkeypatch.setitem(sys.modules, "helpers.settings", settings_mod)
253 + monkeypatch.setattr(helpers, "projects", projects_mod, raising=False)
254 + monkeypatch.setattr(helpers, "settings", settings_mod, raising=False)
255 +
256 + resolved = resolve_workspace("ctx", context_loader=lambda _ctxid: SimpleNamespace(id="ctx"))
257 + assert resolved.project_name == "demo"
258 + assert resolved.display_path.startswith("/a0/usr/time-travel-tests/")
259 +
260 + projects_mod.get_context_project_name = lambda _context: ""
261 + with pytest.raises(WorkspaceRejectedError):
262 + resolve_workspace("ctx", context_loader=lambda _ctxid: SimpleNamespace(id="ctx"))
webui/js/modals.js
+2 -2
@@ -58,8 +58,8 @@ function modalSuppressesBackdrop(modal) {
58 || path === "plugins/_browser/webui/main.html"
59 || path === "/plugins/_office/webui/main.html"
60 || path === "plugins/_office/webui/main.html"
61 - || path === "/plugins/_diff_viewer/webui/main.html"
62 - || path === "plugins/_diff_viewer/webui/main.html"
61 + || path === "/plugins/_time_travel/webui/main.html"
62 + || path === "plugins/_time_travel/webui/main.html"
63 || modal?.element?.classList?.contains("modal-floating")
64 || modal?.element?.classList?.contains("modal-no-backdrop")
65 || modal?.inner?.classList?.contains("modal-no-backdrop");