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");