mirror of
https://github.com/agent0ai/agent-zero.git
synced 2026-05-19 07:59:34 +00:00
Add Time Travel workspace history
Add the _time_travel core plugin with Agent Zero-owned shadow Git snapshots, history/diff/preview/travel/revert APIs, capture hooks, and canvas plus floating window UI surfaces for /a0/usr workspaces. Wire generic file-browser mutation hooks for UI edits, update modal backdrop handling, remove the legacy _diff_viewer plugin, and replace Diff Viewer tests with focused Time Travel coverage. Inspired by Space Agent :-)
This commit is contained in:
parent
e92e518ab2
commit
c5ea678052
35 changed files with 3152 additions and 1518 deletions
|
|
@ -2,7 +2,7 @@ from helpers.api import ApiHandler, Input, Output, Request, Response
|
|||
|
||||
|
||||
from helpers.file_browser import FileBrowser
|
||||
from helpers import files, runtime
|
||||
from helpers import files, runtime, extension
|
||||
from api import get_work_dir_files
|
||||
|
||||
|
||||
|
|
@ -19,6 +19,16 @@ class DeleteWorkDirFile(ApiHandler):
|
|||
res = await runtime.call_development_function(delete_file, file_path)
|
||||
|
||||
if res:
|
||||
await extension.call_extensions_async(
|
||||
"workdir_file_mutation_after",
|
||||
agent=None,
|
||||
data={
|
||||
"action": "delete",
|
||||
"path": file_path,
|
||||
"paths": [file_path],
|
||||
"current_path": current_path,
|
||||
},
|
||||
)
|
||||
# Get updated file list
|
||||
# result = browser.get_files(current_path)
|
||||
result = await runtime.call_development_function(get_work_dir_files.get_files, current_path)
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import os
|
|||
|
||||
from helpers.api import ApiHandler, Input, Output, Request
|
||||
from helpers.file_browser import FileBrowser
|
||||
from helpers import runtime, files
|
||||
from helpers import runtime, files, extension
|
||||
|
||||
MAX_EDIT_FILE_SIZE = 1024 * 1024
|
||||
BINARY_SAMPLE_SIZE = 10 * 1024
|
||||
|
|
@ -51,6 +51,15 @@ class EditWorkDirFile(ApiHandler):
|
|||
if not res:
|
||||
return {"error": "Failed to save file"}
|
||||
|
||||
await extension.call_extensions_async(
|
||||
"workdir_file_mutation_after",
|
||||
agent=None,
|
||||
data={
|
||||
"action": "edit",
|
||||
"path": file_path,
|
||||
"paths": [file_path],
|
||||
},
|
||||
)
|
||||
return {"ok": True}
|
||||
except Exception as e:
|
||||
# Extract clean error message from exception
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
from helpers.api import ApiHandler, Input, Output, Request
|
||||
from helpers.file_browser import FileBrowser
|
||||
from helpers import runtime
|
||||
from helpers import runtime, extension
|
||||
from api import get_work_dir_files
|
||||
import posixpath
|
||||
|
||||
|
||||
class RenameWorkDirFile(ApiHandler):
|
||||
|
|
@ -21,6 +22,7 @@ class RenameWorkDirFile(ApiHandler):
|
|||
res = await runtime.call_development_function(
|
||||
create_folder, parent_path, new_name
|
||||
)
|
||||
changed_paths = [posixpath.join(str(parent_path).rstrip("/"), new_name)]
|
||||
else:
|
||||
file_path = input.get("path", "")
|
||||
if not file_path:
|
||||
|
|
@ -30,8 +32,22 @@ class RenameWorkDirFile(ApiHandler):
|
|||
res = await runtime.call_development_function(
|
||||
rename_item, file_path, new_name
|
||||
)
|
||||
changed_paths = [
|
||||
file_path,
|
||||
posixpath.join(posixpath.dirname(file_path), new_name),
|
||||
]
|
||||
|
||||
if res:
|
||||
await extension.call_extensions_async(
|
||||
"workdir_file_mutation_after",
|
||||
agent=None,
|
||||
data={
|
||||
"action": action,
|
||||
"path": changed_paths[-1],
|
||||
"paths": changed_paths,
|
||||
"current_path": current_path,
|
||||
},
|
||||
)
|
||||
result = await runtime.call_development_function(
|
||||
get_work_dir_files.get_files, current_path
|
||||
)
|
||||
|
|
|
|||
|
|
@ -2,9 +2,10 @@ import base64
|
|||
from werkzeug.datastructures import FileStorage
|
||||
from helpers.api import ApiHandler, Request, Response
|
||||
from helpers.file_browser import FileBrowser
|
||||
from helpers import files, runtime
|
||||
from helpers import files, runtime, extension
|
||||
from api import get_work_dir_files
|
||||
import os
|
||||
import posixpath
|
||||
|
||||
|
||||
class UploadWorkDirFiles(ApiHandler):
|
||||
|
|
@ -23,6 +24,21 @@ class UploadWorkDirFiles(ApiHandler):
|
|||
if not successful and failed:
|
||||
raise Exception("All uploads failed")
|
||||
|
||||
if successful:
|
||||
await extension.call_extensions_async(
|
||||
"workdir_file_mutation_after",
|
||||
agent=None,
|
||||
data={
|
||||
"action": "upload",
|
||||
"path": current_path,
|
||||
"paths": [
|
||||
posixpath.join(str(current_path).rstrip("/"), name)
|
||||
for name in successful
|
||||
],
|
||||
"current_path": current_path,
|
||||
},
|
||||
)
|
||||
|
||||
# result = browser.get_files(current_path)
|
||||
result = await runtime.call_development_function(get_work_dir_files.get_files, current_path)
|
||||
|
||||
|
|
@ -61,4 +77,3 @@ async def upload_files(uploaded_files: list[FileStorage], current_path: str):
|
|||
async def upload_file(current_path: str, filename: str, base64_content: str):
|
||||
browser = FileBrowser()
|
||||
return browser.save_file_b64(current_path, filename, base64_content)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,29 +0,0 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from helpers import files, projects, settings
|
||||
from helpers.api import ApiHandler, Request, Response
|
||||
from plugins._diff_viewer.helpers.diff import collect_workspace_diff
|
||||
|
||||
|
||||
class Diff(ApiHandler):
|
||||
async def process(self, input: dict, request: Request) -> dict | Response:
|
||||
context_id = str(input.get("context_id") or "").strip()
|
||||
workspace_path, display_path = self._resolve_workspace(context_id)
|
||||
return collect_workspace_diff(
|
||||
workspace_path,
|
||||
context_id=context_id,
|
||||
display_path=display_path,
|
||||
)
|
||||
|
||||
def _resolve_workspace(self, context_id: str) -> tuple[str, str]:
|
||||
if context_id:
|
||||
context = self.use_context(context_id)
|
||||
project_name = projects.get_context_project_name(context)
|
||||
if project_name:
|
||||
project_path = projects.get_project_folder(project_name)
|
||||
display_path = files.normalize_a0_path(project_path)
|
||||
return files.fix_dev_path(display_path), display_path
|
||||
|
||||
configured = str(settings.get_settings().get("workdir_path") or "")
|
||||
display_path = configured or files.normalize_a0_path(files.get_abs_path("usr/workdir"))
|
||||
return files.fix_dev_path(display_path), display_path
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
export default function refreshDiffViewerOnContextChange(ctx) {
|
||||
const diffViewer = globalThis.Alpine?.store?.("diffViewer");
|
||||
const canvas = globalThis.Alpine?.store?.("rightCanvas");
|
||||
if (!diffViewer || !canvas?.isOpen || canvas.activeSurfaceId !== "diff") return;
|
||||
const nextContextId = String(ctx?.snapshot?.context || "");
|
||||
if (nextContextId && nextContextId !== diffViewer.contextId) {
|
||||
diffViewer.scheduleRefresh({ contextId: nextContextId, reason: "context-change" });
|
||||
}
|
||||
}
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
<div
|
||||
class="right-canvas-surface-panel diff-viewer-canvas-surface"
|
||||
data-surface-id="diff"
|
||||
x-show="$store.rightCanvas && $store.rightCanvas.isOpen && $store.rightCanvas.activeSurfaceId === 'diff'"
|
||||
style="display: none;"
|
||||
>
|
||||
<x-component path="/plugins/_diff_viewer/webui/diff-viewer-panel.html" mode="canvas"></x-component>
|
||||
</div>
|
||||
|
|
@ -1 +0,0 @@
|
|||
"""Helpers for the built-in diff viewer plugin."""
|
||||
|
|
@ -1,347 +0,0 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from helpers import files
|
||||
|
||||
|
||||
MAX_PATCH_LINES = 2500
|
||||
MAX_PATCH_BYTES = 240_000
|
||||
MAX_UNTRACKED_BYTES = 180_000
|
||||
GIT_TIMEOUT_SECONDS = 8
|
||||
|
||||
GROUP_ORDER = ("staged", "unstaged", "untracked")
|
||||
STATUS_LABELS = {
|
||||
"A": "added",
|
||||
"C": "copied",
|
||||
"D": "deleted",
|
||||
"M": "modified",
|
||||
"R": "renamed",
|
||||
"T": "type_changed",
|
||||
"U": "unmerged",
|
||||
"?": "untracked",
|
||||
}
|
||||
|
||||
|
||||
class GitDiffError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def collect_workspace_diff(
|
||||
workspace_path: str,
|
||||
*,
|
||||
context_id: str = "",
|
||||
display_path: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
workspace = Path(workspace_path).expanduser().resolve()
|
||||
display = display_path or str(workspace)
|
||||
|
||||
if not workspace.exists() or not workspace.is_dir():
|
||||
return {
|
||||
"ok": False,
|
||||
"context_id": context_id,
|
||||
"workspace_path": display,
|
||||
"is_git_repo": False,
|
||||
"error": "Workspace path does not exist or is not a directory.",
|
||||
"branch": "",
|
||||
"totals": {"files": 0, "additions": 0, "deletions": 0},
|
||||
"groups": _empty_groups(),
|
||||
}
|
||||
|
||||
if not _is_git_repo(workspace):
|
||||
return {
|
||||
"ok": True,
|
||||
"context_id": context_id,
|
||||
"workspace_path": display,
|
||||
"is_git_repo": False,
|
||||
"branch": "",
|
||||
"totals": {"files": 0, "additions": 0, "deletions": 0},
|
||||
"groups": _empty_groups(),
|
||||
}
|
||||
|
||||
groups = [
|
||||
{"kind": "staged", "files": _collect_diff_group(workspace, "staged")},
|
||||
{"kind": "unstaged", "files": _collect_diff_group(workspace, "unstaged")},
|
||||
{"kind": "untracked", "files": _collect_untracked_group(workspace)},
|
||||
]
|
||||
|
||||
seen_paths: set[str] = set()
|
||||
additions = 0
|
||||
deletions = 0
|
||||
for group in groups:
|
||||
for item in group["files"]:
|
||||
seen_paths.add(str(item.get("path") or item.get("old_path") or ""))
|
||||
additions += int(item.get("additions") or 0)
|
||||
deletions += int(item.get("deletions") or 0)
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"context_id": context_id,
|
||||
"workspace_path": display,
|
||||
"is_git_repo": True,
|
||||
"branch": _branch_name(workspace),
|
||||
"totals": {
|
||||
"files": len([path for path in seen_paths if path]),
|
||||
"additions": additions,
|
||||
"deletions": deletions,
|
||||
},
|
||||
"groups": groups,
|
||||
}
|
||||
|
||||
|
||||
def _empty_groups() -> list[dict[str, Any]]:
|
||||
return [{"kind": kind, "files": []} for kind in GROUP_ORDER]
|
||||
|
||||
|
||||
def _git(workspace: Path, *args: str, check: bool = True) -> subprocess.CompletedProcess[str]:
|
||||
env = os.environ.copy()
|
||||
env["GIT_TERMINAL_PROMPT"] = "0"
|
||||
env["GIT_OPTIONAL_LOCKS"] = "0"
|
||||
completed = subprocess.run(
|
||||
["git", "-C", str(workspace), *args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
env=env,
|
||||
timeout=GIT_TIMEOUT_SECONDS,
|
||||
)
|
||||
if check and completed.returncode != 0:
|
||||
message = (completed.stderr or completed.stdout or "Git command failed.").strip()
|
||||
raise GitDiffError(message)
|
||||
return completed
|
||||
|
||||
|
||||
def _is_git_repo(workspace: Path) -> bool:
|
||||
return _git(workspace, "rev-parse", "--is-inside-work-tree", check=False).returncode == 0
|
||||
|
||||
|
||||
def _branch_name(workspace: Path) -> str:
|
||||
branch = _git(workspace, "branch", "--show-current", check=False).stdout.strip()
|
||||
if branch:
|
||||
return branch
|
||||
return _git(workspace, "rev-parse", "--short", "HEAD", check=False).stdout.strip()
|
||||
|
||||
|
||||
def _collect_diff_group(workspace: Path, kind: str) -> list[dict[str, Any]]:
|
||||
cached_args = ["--cached"] if kind == "staged" else []
|
||||
status_output = _git(
|
||||
workspace,
|
||||
"diff",
|
||||
*cached_args,
|
||||
"--name-status",
|
||||
"-z",
|
||||
"--find-renames",
|
||||
"--relative",
|
||||
"--",
|
||||
".",
|
||||
).stdout
|
||||
entries = _parse_name_status(status_output)
|
||||
result: list[dict[str, Any]] = []
|
||||
|
||||
for entry in entries:
|
||||
path = entry["path"]
|
||||
old_path = entry.get("old_path", "")
|
||||
if _is_a0_metadata_path(path) and (not old_path or _is_a0_metadata_path(old_path)):
|
||||
continue
|
||||
|
||||
stat_paths = [candidate for candidate in (old_path, path) if candidate]
|
||||
additions, deletions, binary = _diff_numstat(workspace, kind, stat_paths)
|
||||
if _is_zero_line_gitkeep_change(path, old_path, additions, deletions):
|
||||
continue
|
||||
|
||||
too_large = additions + deletions > MAX_PATCH_LINES
|
||||
patch = ""
|
||||
if not binary and not too_large:
|
||||
patch = _diff_patch(workspace, kind, stat_paths)
|
||||
if len(patch.encode("utf-8", errors="replace")) > MAX_PATCH_BYTES:
|
||||
patch = ""
|
||||
too_large = True
|
||||
|
||||
result.append(
|
||||
{
|
||||
"path": path,
|
||||
"old_path": old_path,
|
||||
"status": STATUS_LABELS.get(entry["status"], entry["status"].lower()),
|
||||
"additions": additions,
|
||||
"deletions": deletions,
|
||||
"binary": binary,
|
||||
"too_large": too_large,
|
||||
"patch": patch,
|
||||
}
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _collect_untracked_group(workspace: Path) -> list[dict[str, Any]]:
|
||||
output = _git(
|
||||
workspace,
|
||||
"ls-files",
|
||||
"--others",
|
||||
"--exclude-standard",
|
||||
"-z",
|
||||
"--",
|
||||
".",
|
||||
).stdout
|
||||
result: list[dict[str, Any]] = []
|
||||
for path in [part for part in output.split("\0") if part]:
|
||||
path = path.replace("\\", "/")
|
||||
if _is_a0_metadata_path(path):
|
||||
continue
|
||||
item = _untracked_file_diff(workspace, path)
|
||||
if _is_zero_line_gitkeep_change(item["path"], item.get("old_path", ""), item["additions"], item["deletions"]):
|
||||
continue
|
||||
result.append(item)
|
||||
result.sort(key=lambda item: item["path"])
|
||||
return result
|
||||
|
||||
|
||||
def _parse_name_status(output: str) -> list[dict[str, str]]:
|
||||
parts = [part for part in output.split("\0") if part]
|
||||
entries: list[dict[str, str]] = []
|
||||
index = 0
|
||||
while index < len(parts):
|
||||
raw_status = parts[index]
|
||||
index += 1
|
||||
status = raw_status[:1]
|
||||
if status in {"R", "C"} and index + 1 < len(parts):
|
||||
old_path = parts[index].replace("\\", "/")
|
||||
new_path = parts[index + 1].replace("\\", "/")
|
||||
index += 2
|
||||
entries.append({"status": status, "old_path": old_path, "path": new_path})
|
||||
continue
|
||||
if index < len(parts):
|
||||
path = parts[index].replace("\\", "/")
|
||||
index += 1
|
||||
entries.append({"status": status, "path": path, "old_path": ""})
|
||||
entries.sort(key=lambda item: item.get("path") or item.get("old_path") or "")
|
||||
return entries
|
||||
|
||||
|
||||
def _diff_numstat(workspace: Path, kind: str, paths: list[str]) -> tuple[int, int, bool]:
|
||||
args = ["diff"]
|
||||
if kind == "staged":
|
||||
args.append("--cached")
|
||||
output = _git(
|
||||
workspace,
|
||||
*args,
|
||||
"--numstat",
|
||||
"--find-renames",
|
||||
"--relative",
|
||||
"--",
|
||||
*paths,
|
||||
).stdout
|
||||
first = next((line for line in output.splitlines() if line.strip()), "")
|
||||
if not first:
|
||||
return 0, 0, False
|
||||
parts = first.split("\t")
|
||||
if len(parts) < 2:
|
||||
return 0, 0, False
|
||||
if parts[0] == "-" or parts[1] == "-":
|
||||
return 0, 0, True
|
||||
return _safe_int(parts[0]), _safe_int(parts[1]), False
|
||||
|
||||
|
||||
def _diff_patch(workspace: Path, kind: str, paths: list[str]) -> str:
|
||||
args = ["diff"]
|
||||
if kind == "staged":
|
||||
args.append("--cached")
|
||||
return _git(
|
||||
workspace,
|
||||
*args,
|
||||
"--patch",
|
||||
"--find-renames",
|
||||
"--relative",
|
||||
"--",
|
||||
*paths,
|
||||
).stdout
|
||||
|
||||
|
||||
def _untracked_file_diff(workspace: Path, path: str) -> dict[str, Any]:
|
||||
file_path = (workspace / path).resolve()
|
||||
additions = 0
|
||||
binary = False
|
||||
too_large = False
|
||||
patch = ""
|
||||
|
||||
try:
|
||||
with open(file_path, "rb") as handle:
|
||||
data = handle.read(MAX_UNTRACKED_BYTES + 1)
|
||||
too_large = len(data) > MAX_UNTRACKED_BYTES
|
||||
sample = data[: min(len(data), 10 * 1024)]
|
||||
binary = files.is_probably_binary_bytes(sample)
|
||||
if not binary and not too_large:
|
||||
text = data.decode("utf-8", errors="replace")
|
||||
lines = text.splitlines()
|
||||
additions = len(lines)
|
||||
if len(lines) > MAX_PATCH_LINES:
|
||||
too_large = True
|
||||
else:
|
||||
patch = _synthetic_untracked_patch(path, lines, text.endswith("\n"))
|
||||
elif not binary:
|
||||
additions = _count_newlines(data[:MAX_UNTRACKED_BYTES])
|
||||
except OSError:
|
||||
too_large = True
|
||||
|
||||
return {
|
||||
"path": path,
|
||||
"old_path": "",
|
||||
"status": "untracked",
|
||||
"additions": additions,
|
||||
"deletions": 0,
|
||||
"binary": binary,
|
||||
"too_large": too_large,
|
||||
"patch": patch,
|
||||
}
|
||||
|
||||
|
||||
def _synthetic_untracked_patch(path: str, lines: list[str], has_trailing_newline: bool) -> str:
|
||||
escaped = path.replace("\t", "\\t")
|
||||
header = [
|
||||
f"diff --git a/{escaped} b/{escaped}",
|
||||
"new file mode 100644",
|
||||
"index 0000000..0000000",
|
||||
"--- /dev/null",
|
||||
f"+++ b/{escaped}",
|
||||
]
|
||||
if not lines:
|
||||
return "\n".join(header) + "\n"
|
||||
body = [f"@@ -0,0 +1,{len(lines)} @@"]
|
||||
body.extend(f"+{line}" for line in lines)
|
||||
if not has_trailing_newline:
|
||||
body.append("\\ No newline at end of file")
|
||||
return "\n".join(header + body) + "\n"
|
||||
|
||||
|
||||
def _count_newlines(data: bytes) -> int:
|
||||
if not data:
|
||||
return 0
|
||||
count = data.count(b"\n")
|
||||
return count if data.endswith(b"\n") else count + 1
|
||||
|
||||
|
||||
def _safe_int(value: str) -> int:
|
||||
try:
|
||||
return max(0, int(value))
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
|
||||
def _is_a0_metadata_path(path: str) -> bool:
|
||||
normalized = path.replace("\\", "/").lstrip("/")
|
||||
return normalized == ".a0proj" or normalized.startswith(".a0proj/")
|
||||
|
||||
|
||||
def _is_zero_line_gitkeep_change(path: str, old_path: str, additions: int, deletions: int) -> bool:
|
||||
if additions != 0 or deletions != 0:
|
||||
return False
|
||||
candidates = [path, old_path]
|
||||
return any(
|
||||
candidate.replace("\\", "/").rstrip("/").split("/")[-1] == ".gitkeep"
|
||||
for candidate in candidates
|
||||
if candidate
|
||||
)
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
name: _diff_viewer
|
||||
title: Diff Viewer
|
||||
description: Right canvas Git working tree diff viewer for the active chat or task workspace.
|
||||
version: 0.1.0
|
||||
always_enabled: false
|
||||
settings_sections: []
|
||||
per_project_config: false
|
||||
per_agent_config: false
|
||||
|
|
@ -1,572 +0,0 @@
|
|||
<html>
|
||||
<head>
|
||||
<script type="module">
|
||||
import { store } from "/plugins/_diff_viewer/webui/diff-viewer-store.js";
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="diff-viewer-panel" x-data x-create="$store.diffViewer.onMount($el, xAttrs($el) || {})" x-destroy="$store.diffViewer.cleanup()">
|
||||
<template x-if="$store.diffViewer">
|
||||
<div class="diff-viewer-shell">
|
||||
<div class="diff-viewer-toolbar">
|
||||
<div class="diff-viewer-title">
|
||||
<span class="material-symbols-outlined">difference</span>
|
||||
<span>Review</span>
|
||||
</div>
|
||||
<span class="diff-viewer-spacer"></span>
|
||||
<button type="button" class="diff-viewer-icon-button" title="Expand all" aria-label="Expand all" @click="$store.diffViewer.expandAll()" :disabled="!$store.diffViewer.hasChanges()">
|
||||
<span class="material-symbols-outlined">unfold_more</span>
|
||||
</button>
|
||||
<button type="button" class="diff-viewer-icon-button" title="Collapse all" aria-label="Collapse all" @click="$store.diffViewer.collapseAll()" :disabled="!$store.diffViewer.hasChanges()">
|
||||
<span class="material-symbols-outlined">unfold_less</span>
|
||||
</button>
|
||||
<button type="button" class="diff-viewer-icon-button" title="Refresh" aria-label="Refresh" @click="$store.diffViewer.refresh()" :disabled="$store.diffViewer.loading">
|
||||
<span class="material-symbols-outlined" :class="{ spinning: $store.diffViewer.loading }">refresh</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="diff-viewer-summary">
|
||||
<div class="diff-viewer-summary-main">
|
||||
<strong x-text="$store.diffViewer.payload?.totals?.files || 0"></strong>
|
||||
<span>files changed</span>
|
||||
<span class="diff-add" x-text="$store.diffViewer.formatSigned($store.diffViewer.payload?.totals?.additions, '+')"></span>
|
||||
<span class="diff-del" x-text="$store.diffViewer.formatSigned($store.diffViewer.payload?.totals?.deletions, '-')"></span>
|
||||
</div>
|
||||
<div class="diff-viewer-summary-meta">
|
||||
<span x-text="$store.diffViewer.payload?.branch || 'no branch'"></span>
|
||||
<span class="diff-viewer-dot"></span>
|
||||
<span :title="$store.diffViewer.workspacePath" x-text="$store.diffViewer.workspacePath || 'workspace'"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="diff-viewer-status" x-show="$store.diffViewer.loading || $store.diffViewer.error" style="display: none;">
|
||||
<span class="material-symbols-outlined" :class="{ spinning: $store.diffViewer.loading }" x-text="$store.diffViewer.loading ? 'progress_activity' : 'error'"></span>
|
||||
<span x-text="$store.diffViewer.loading ? 'Loading changes...' : $store.diffViewer.error"></span>
|
||||
</div>
|
||||
|
||||
<div class="diff-viewer-body">
|
||||
<template x-if="$store.diffViewer.payload && !$store.diffViewer.payload.is_git_repo && !$store.diffViewer.loading && !$store.diffViewer.error">
|
||||
<div class="diff-viewer-empty">
|
||||
<span class="material-symbols-outlined">folder_off</span>
|
||||
<strong>Not a Git workspace</strong>
|
||||
<span x-text="$store.diffViewer.workspacePath"></span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="$store.diffViewer.payload?.is_git_repo && !$store.diffViewer.hasChanges() && !$store.diffViewer.loading && !$store.diffViewer.error">
|
||||
<div class="diff-viewer-empty">
|
||||
<span class="material-symbols-outlined">check_circle</span>
|
||||
<strong>No changes</strong>
|
||||
<span>The selected context workspace is clean.</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="diff-viewer-groups" x-show="$store.diffViewer.hasChanges()" style="display: none;">
|
||||
<template x-for="group in $store.diffViewer.visibleGroups()" :key="group.kind">
|
||||
<section class="diff-viewer-group">
|
||||
<header class="diff-viewer-group-header">
|
||||
<span x-text="$store.diffViewer.groupTitle(group.kind)"></span>
|
||||
<span class="diff-viewer-count" x-text="group.files.length"></span>
|
||||
</header>
|
||||
<template x-for="file in group.files" :key="$store.diffViewer.fileKey(group, file)">
|
||||
<article class="diff-file">
|
||||
<button type="button" class="diff-file-header" @click="$store.diffViewer.toggleFile(group, file)" :title="$store.diffViewer.fileTitle(file)">
|
||||
<span class="material-symbols-outlined diff-file-chevron" x-text="$store.diffViewer.isExpanded(group, file) ? 'expand_less' : 'expand_more'"></span>
|
||||
<span class="diff-file-name" x-text="$store.diffViewer.fileTitle(file)"></span>
|
||||
<span class="diff-file-status" x-text="$store.diffViewer.statusLabel(file)"></span>
|
||||
<span class="diff-file-counts">
|
||||
<span class="diff-add" x-text="$store.diffViewer.formatSigned(file.additions, '+')"></span>
|
||||
<span class="diff-del" x-text="$store.diffViewer.formatSigned(file.deletions, '-')"></span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div class="diff-file-tools" x-show="$store.diffViewer.isExpanded(group, file)" style="display: none;">
|
||||
<button type="button" class="diff-viewer-tool-button" title="Open containing folder" @click="$store.diffViewer.openContainingFolder(file)">
|
||||
<span class="material-symbols-outlined">folder_open</span>
|
||||
<span>Folder</span>
|
||||
</button>
|
||||
<button type="button" class="diff-viewer-tool-button" title="Copy path" @click="$store.diffViewer.copyPath(file)">
|
||||
<span class="material-symbols-outlined">content_copy</span>
|
||||
<span>Path</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="diff-file-body" x-show="$store.diffViewer.isExpanded(group, file)" style="display: none;">
|
||||
<template x-if="file.binary">
|
||||
<div class="diff-file-note">
|
||||
<span class="material-symbols-outlined">data_object</span>
|
||||
<span>Binary file changed.</span>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="file.too_large && !file.binary">
|
||||
<div class="diff-file-note">
|
||||
<span class="material-symbols-outlined">text_snippet</span>
|
||||
<span>Diff is too large to render inline.</span>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="!file.binary && !file.too_large && file.patch">
|
||||
<div class="diff-code" role="table" aria-label="Unified diff">
|
||||
<template x-for="line in $store.diffViewer.patchLines(file)" :key="line.id">
|
||||
<div class="diff-line" :class="`is-${line.type}`" role="row">
|
||||
<span class="diff-line-marker" x-text="line.type === 'add' ? '+' : (line.type === 'del' ? '-' : '')"></span>
|
||||
<code x-text="line.text"></code>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.diff-viewer-panel,
|
||||
.diff-viewer-shell {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
background: color-mix(in srgb, var(--color-background) 96%, #000 4%);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.diff-viewer-panel {
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
.modal-inner.diff-viewer-modal {
|
||||
box-sizing: border-box;
|
||||
container-type: inline-size;
|
||||
width: min(82vw, 1180px);
|
||||
height: min(88vh, 900px);
|
||||
min-width: min(340px, calc(100vw - 16px));
|
||||
min-height: min(500px, calc(100vh - 16px));
|
||||
max-width: calc(100vw - 16px);
|
||||
max-height: calc(100vh - 16px);
|
||||
resize: both;
|
||||
border: 1px solid color-mix(in srgb, var(--color-border) 75%, transparent);
|
||||
border-radius: 7px;
|
||||
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.32);
|
||||
background: color-mix(in srgb, var(--color-background) 94%, #000 6%);
|
||||
}
|
||||
|
||||
.modal.modal-floating {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.modal.modal-floating .modal-inner {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.modal-inner.diff-viewer-modal .modal-header {
|
||||
min-height: 34px;
|
||||
padding: 0.35rem 0.75rem 0.35rem 1rem;
|
||||
cursor: move;
|
||||
user-select: none;
|
||||
background: color-mix(in srgb, var(--color-background) 92%, #000 8%);
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--color-border) 70%, transparent);
|
||||
}
|
||||
|
||||
.modal-inner.diff-viewer-modal .modal-scroll {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.modal-inner.diff-viewer-modal .modal-bd.diff-viewer-modal-body {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.modal-inner.diff-viewer-modal .modal-bd.diff-viewer-modal-body > x-component,
|
||||
.modal-inner.diff-viewer-modal .modal-bd.diff-viewer-modal-body > div[x-data] {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.diff-viewer-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: 44px;
|
||||
padding: var(--spacing-xs) var(--spacing-md);
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--color-border) 66%, transparent);
|
||||
background: color-mix(in srgb, var(--color-background) 92%, #000 8%);
|
||||
}
|
||||
|
||||
.diff-viewer-title,
|
||||
.diff-viewer-summary-main,
|
||||
.diff-viewer-summary-meta,
|
||||
.diff-viewer-status,
|
||||
.diff-file-counts,
|
||||
.diff-file-tools,
|
||||
.diff-viewer-tool-button,
|
||||
.diff-file-note {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.diff-viewer-title {
|
||||
gap: 7px;
|
||||
min-width: 0;
|
||||
font-weight: 700;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.diff-viewer-title .material-symbols-outlined {
|
||||
font-size: 19px;
|
||||
}
|
||||
|
||||
.diff-viewer-spacer {
|
||||
flex: 1 1 auto;
|
||||
min-width: 8px;
|
||||
}
|
||||
|
||||
.diff-viewer-icon-button,
|
||||
.diff-viewer-tool-button {
|
||||
appearance: none;
|
||||
border: 1px solid color-mix(in srgb, var(--color-border) 64%, transparent);
|
||||
border-radius: 7px;
|
||||
background: color-mix(in srgb, var(--color-panel) 80%, transparent);
|
||||
color: var(--color-text);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.diff-viewer-icon-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
min-width: 32px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.diff-viewer-tool-button {
|
||||
gap: 5px;
|
||||
min-height: 28px;
|
||||
padding: 4px 8px;
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
|
||||
.diff-viewer-icon-button:hover:not(:disabled),
|
||||
.diff-viewer-tool-button:hover {
|
||||
background: color-mix(in srgb, var(--color-background-hover) 70%, transparent);
|
||||
border-color: color-mix(in srgb, var(--color-primary) 28%, var(--color-border));
|
||||
}
|
||||
|
||||
.diff-viewer-icon-button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.diff-viewer-icon-button .material-symbols-outlined,
|
||||
.diff-viewer-tool-button .material-symbols-outlined {
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.diff-viewer-summary {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 3px;
|
||||
padding: 9px 11px;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--color-border) 54%, transparent);
|
||||
background: color-mix(in srgb, var(--color-panel) 54%, transparent);
|
||||
}
|
||||
|
||||
.diff-viewer-summary-main {
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
|
||||
.diff-viewer-summary-meta {
|
||||
gap: 7px;
|
||||
min-width: 0;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.74rem;
|
||||
}
|
||||
|
||||
.diff-viewer-summary-meta span:last-child {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.diff-viewer-dot {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
flex: 0 0 auto;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--color-text-muted) 50%, transparent);
|
||||
}
|
||||
|
||||
.diff-add {
|
||||
color: #31c48d;
|
||||
}
|
||||
|
||||
.diff-del {
|
||||
color: #f05252;
|
||||
}
|
||||
|
||||
.diff-viewer-status {
|
||||
gap: 8px;
|
||||
min-height: 34px;
|
||||
padding: 6px 11px;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--color-border) 44%, transparent);
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.diff-viewer-body {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.diff-viewer-groups {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.diff-viewer-group {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.diff-viewer-group-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
min-height: 26px;
|
||||
color: var(--color-text);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.diff-viewer-count {
|
||||
min-width: 22px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--color-panel) 78%, transparent);
|
||||
color: var(--color-text-muted);
|
||||
text-align: center;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.diff-file {
|
||||
overflow: hidden;
|
||||
border: 1px solid color-mix(in srgb, var(--color-border) 58%, transparent);
|
||||
border-radius: 7px;
|
||||
background: color-mix(in srgb, var(--color-panel) 72%, transparent);
|
||||
}
|
||||
|
||||
.diff-file-header {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto auto;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
width: 100%;
|
||||
min-height: 38px;
|
||||
padding: 0 9px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.diff-file-header:hover {
|
||||
background: color-mix(in srgb, var(--color-background-hover) 56%, transparent);
|
||||
}
|
||||
|
||||
.diff-file-chevron {
|
||||
font-size: 18px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.diff-file-name {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-family: var(--font-family-code);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.diff-file-status {
|
||||
padding: 2px 6px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--color-background) 70%, transparent);
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.68rem;
|
||||
text-transform: capitalize;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.diff-file-counts {
|
||||
gap: 5px;
|
||||
justify-content: end;
|
||||
min-width: 72px;
|
||||
font-family: var(--font-family-code);
|
||||
font-size: 0.74rem;
|
||||
}
|
||||
|
||||
.diff-file-tools {
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
min-height: 34px;
|
||||
padding: 4px 8px;
|
||||
border-top: 1px solid color-mix(in srgb, var(--color-border) 34%, transparent);
|
||||
background: color-mix(in srgb, var(--color-background) 55%, transparent);
|
||||
}
|
||||
|
||||
.diff-file-body {
|
||||
border-top: 1px solid color-mix(in srgb, var(--color-border) 44%, transparent);
|
||||
}
|
||||
|
||||
.diff-file-note {
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.diff-code {
|
||||
overflow: auto;
|
||||
padding: 4px 0;
|
||||
background: color-mix(in srgb, var(--color-background) 91%, #000 9%);
|
||||
font-family: var(--font-family-code);
|
||||
font-size: 0.74rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.diff-line {
|
||||
display: grid;
|
||||
grid-template-columns: 24px minmax(0, 1fr);
|
||||
min-width: max-content;
|
||||
}
|
||||
|
||||
.diff-line-marker {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 1;
|
||||
min-height: 1.45em;
|
||||
padding-right: 5px;
|
||||
background: inherit;
|
||||
color: var(--color-text-muted);
|
||||
text-align: right;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.diff-line code {
|
||||
display: block;
|
||||
min-height: 1.45em;
|
||||
padding: 0 10px 0 6px;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.diff-line.is-add {
|
||||
background: rgba(39, 174, 96, 0.16);
|
||||
color: color-mix(in srgb, #8df0b0 78%, var(--color-text));
|
||||
}
|
||||
|
||||
.diff-line.is-del {
|
||||
background: rgba(231, 76, 60, 0.17);
|
||||
color: color-mix(in srgb, #ffaaa2 78%, var(--color-text));
|
||||
}
|
||||
|
||||
.diff-line.is-hunk {
|
||||
background: rgba(99, 102, 241, 0.16);
|
||||
color: color-mix(in srgb, #b9c3ff 80%, var(--color-text));
|
||||
}
|
||||
|
||||
.diff-line.is-meta,
|
||||
.diff-line.is-note {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.diff-viewer-empty {
|
||||
display: grid;
|
||||
flex: 1 1 auto;
|
||||
place-items: center;
|
||||
align-content: center;
|
||||
gap: 7px;
|
||||
min-width: 0;
|
||||
padding: 24px;
|
||||
color: var(--color-text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.diff-viewer-empty .material-symbols-outlined {
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.diff-viewer-empty span:last-child {
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.diff-viewer-panel .spinning {
|
||||
display: inline-block;
|
||||
animation: diff-viewer-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes diff-viewer-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@container (max-width: 560px) {
|
||||
.diff-file-header {
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.diff-file-status {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.diff-viewer-tool-button span:last-child {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,321 +0,0 @@
|
|||
import { createStore } from "/js/AlpineStore.js";
|
||||
import { callJsonApi } from "/js/api.js";
|
||||
import { getContext } from "/index.js";
|
||||
import { store as fileBrowserStore } from "/components/modals/file-browser/file-browser-store.js";
|
||||
|
||||
const REFRESH_DEBOUNCE_MS = 180;
|
||||
|
||||
function lineType(text) {
|
||||
if (text.startsWith("@@")) return "hunk";
|
||||
if (text.startsWith("+++") || text.startsWith("---") || text.startsWith("diff --git") || text.startsWith("index ")) {
|
||||
return "meta";
|
||||
}
|
||||
if (text.startsWith("+")) return "add";
|
||||
if (text.startsWith("-")) return "del";
|
||||
if (text.startsWith("\\ No newline")) return "note";
|
||||
return "context";
|
||||
}
|
||||
|
||||
function dirname(path) {
|
||||
const clean = String(path || "").replace(/\/+$/, "");
|
||||
const index = clean.lastIndexOf("/");
|
||||
return index > 0 ? clean.slice(0, index) : "";
|
||||
}
|
||||
|
||||
const model = {
|
||||
loading: false,
|
||||
error: "",
|
||||
payload: null,
|
||||
contextId: "",
|
||||
workspacePath: "",
|
||||
expanded: {},
|
||||
_root: null,
|
||||
_mode: "canvas",
|
||||
_refreshTimer: null,
|
||||
_floatingCleanup: null,
|
||||
_requestSeq: 0,
|
||||
|
||||
async init(element = null) {
|
||||
await this.onMount(element, { mode: "canvas" });
|
||||
},
|
||||
|
||||
async onMount(element = null, options = {}) {
|
||||
if (element) this._root = element;
|
||||
this._mode = options?.mode === "modal" ? "modal" : "canvas";
|
||||
if (this._mode === "modal") {
|
||||
this.setupFloatingModal(element);
|
||||
} else {
|
||||
this.setupCanvasSurface(element);
|
||||
}
|
||||
this.contextId = this.resolveContextId();
|
||||
if (!this.payload && !this.loading) {
|
||||
await this.refresh({ contextId: this.contextId });
|
||||
}
|
||||
},
|
||||
|
||||
async onOpen(payload = {}) {
|
||||
const nextContextId = String(payload.contextId || payload.context_id || this.resolveContextId() || "");
|
||||
await this.refresh({ contextId: nextContextId });
|
||||
},
|
||||
|
||||
cleanup() {
|
||||
if (this._refreshTimer) {
|
||||
clearTimeout(this._refreshTimer);
|
||||
this._refreshTimer = null;
|
||||
}
|
||||
this._floatingCleanup?.();
|
||||
this._floatingCleanup = null;
|
||||
},
|
||||
|
||||
setupFloatingModal(element = null) {
|
||||
this._floatingCleanup?.();
|
||||
const root = element || globalThis.document?.querySelector(".diff-viewer-panel");
|
||||
const modal = root?.closest?.(".modal");
|
||||
const inner = modal?.querySelector?.(".modal-inner");
|
||||
const body = modal?.querySelector?.(".modal-bd");
|
||||
const header = modal?.querySelector?.(".modal-header");
|
||||
if (!modal || !inner || !header) return;
|
||||
modal.classList.add("modal-floating");
|
||||
inner.classList.add("diff-viewer-modal", "modal-no-backdrop");
|
||||
body?.classList?.add("diff-viewer-modal-body");
|
||||
|
||||
const rect = inner.getBoundingClientRect();
|
||||
inner.style.left = `${Math.max(8, rect.left)}px`;
|
||||
inner.style.top = `${Math.max(8, rect.top)}px`;
|
||||
inner.style.transform = "none";
|
||||
|
||||
let drag = null;
|
||||
let resizeObserver = null;
|
||||
const viewportGap = 8;
|
||||
const clampPosition = (left, top) => {
|
||||
const bounds = inner.getBoundingClientRect();
|
||||
const maxLeft = Math.max(viewportGap, globalThis.innerWidth - bounds.width - viewportGap);
|
||||
const maxTop = Math.max(viewportGap, globalThis.innerHeight - bounds.height - viewportGap);
|
||||
return {
|
||||
left: Math.min(Math.max(viewportGap, left), maxLeft),
|
||||
top: Math.min(Math.max(viewportGap, top), maxTop),
|
||||
};
|
||||
};
|
||||
const clampGeometry = () => {
|
||||
const bounds = inner.getBoundingClientRect();
|
||||
const left = Math.max(viewportGap, bounds.left);
|
||||
const top = Math.max(viewportGap, bounds.top);
|
||||
const maxWidth = Math.max(340, globalThis.innerWidth - viewportGap * 2);
|
||||
const maxHeight = Math.max(360, globalThis.innerHeight - viewportGap * 2);
|
||||
if (bounds.width > maxWidth) inner.style.width = `${maxWidth}px`;
|
||||
if (bounds.height > maxHeight) inner.style.height = `${maxHeight}px`;
|
||||
const next = clampPosition(left, top);
|
||||
inner.style.left = `${next.left}px`;
|
||||
inner.style.top = `${next.top}px`;
|
||||
inner.style.maxWidth = `${Math.max(340, globalThis.innerWidth - next.left - viewportGap)}px`;
|
||||
inner.style.maxHeight = `${Math.max(360, globalThis.innerHeight - next.top - viewportGap)}px`;
|
||||
};
|
||||
clampGeometry();
|
||||
globalThis.addEventListener("resize", clampGeometry);
|
||||
if (globalThis.ResizeObserver) {
|
||||
resizeObserver = new ResizeObserver(clampGeometry);
|
||||
resizeObserver.observe(inner);
|
||||
}
|
||||
|
||||
const onPointerMove = (event) => {
|
||||
if (!drag) return;
|
||||
const next = clampPosition(
|
||||
drag.left + event.clientX - drag.x,
|
||||
drag.top + event.clientY - drag.y,
|
||||
);
|
||||
inner.style.left = `${next.left}px`;
|
||||
inner.style.top = `${next.top}px`;
|
||||
clampGeometry();
|
||||
};
|
||||
const onPointerUp = () => {
|
||||
drag = null;
|
||||
globalThis.removeEventListener("pointermove", onPointerMove);
|
||||
globalThis.removeEventListener("pointerup", onPointerUp);
|
||||
try {
|
||||
header.releasePointerCapture?.(header.__diffViewerPanelPointerId || 0);
|
||||
} catch {}
|
||||
};
|
||||
const onPointerDown = (event) => {
|
||||
if (event.button !== 0) return;
|
||||
if (event.target?.closest?.("button, input, select, textarea, a")) return;
|
||||
const current = inner.getBoundingClientRect();
|
||||
drag = {
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
left: current.left,
|
||||
top: current.top,
|
||||
};
|
||||
header.__diffViewerPanelPointerId = event.pointerId;
|
||||
header.setPointerCapture?.(event.pointerId);
|
||||
globalThis.addEventListener("pointermove", onPointerMove);
|
||||
globalThis.addEventListener("pointerup", onPointerUp);
|
||||
event.preventDefault();
|
||||
};
|
||||
header.addEventListener("pointerdown", onPointerDown);
|
||||
|
||||
this._floatingCleanup = () => {
|
||||
header.removeEventListener("pointerdown", onPointerDown);
|
||||
globalThis.removeEventListener("pointermove", onPointerMove);
|
||||
globalThis.removeEventListener("pointerup", onPointerUp);
|
||||
globalThis.removeEventListener("resize", clampGeometry);
|
||||
resizeObserver?.disconnect?.();
|
||||
};
|
||||
},
|
||||
|
||||
setupCanvasSurface(element = null) {
|
||||
this._floatingCleanup?.();
|
||||
this._floatingCleanup = null;
|
||||
if (element) this._root = element;
|
||||
},
|
||||
|
||||
resolveContextId() {
|
||||
const urlContext = new URLSearchParams(globalThis.location?.search || "").get("ctxid");
|
||||
return getContext?.() || urlContext || globalThis.Alpine?.store?.("chats")?.selected || "";
|
||||
},
|
||||
|
||||
scheduleRefresh(options = {}) {
|
||||
if (this._refreshTimer) clearTimeout(this._refreshTimer);
|
||||
this._refreshTimer = setTimeout(() => {
|
||||
this._refreshTimer = null;
|
||||
this.refresh(options).catch((error) => {
|
||||
console.error("Diff refresh failed", error);
|
||||
});
|
||||
}, REFRESH_DEBOUNCE_MS);
|
||||
},
|
||||
|
||||
async refresh(options = {}) {
|
||||
const contextId = String(options.contextId || options.context_id || this.resolveContextId() || "");
|
||||
const seq = ++this._requestSeq;
|
||||
this.loading = true;
|
||||
this.error = "";
|
||||
try {
|
||||
const response = await callJsonApi("/plugins/_diff_viewer/diff", { context_id: contextId });
|
||||
if (seq !== this._requestSeq) return;
|
||||
if (!response?.ok) {
|
||||
throw new Error(response?.error || "Could not load diff.");
|
||||
}
|
||||
this.payload = response;
|
||||
this.contextId = String(response.context_id || contextId || "");
|
||||
this.workspacePath = String(response.workspace_path || "");
|
||||
this.reconcileExpanded();
|
||||
} catch (error) {
|
||||
if (seq !== this._requestSeq) return;
|
||||
this.error = error instanceof Error ? error.message : String(error);
|
||||
} finally {
|
||||
if (seq === this._requestSeq) this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
reconcileExpanded() {
|
||||
const next = {};
|
||||
let index = 0;
|
||||
for (const group of this.visibleGroups()) {
|
||||
for (const file of group.files || []) {
|
||||
const key = this.fileKey(group, file);
|
||||
next[key] = this.expanded[key] ?? index < 4;
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
this.expanded = next;
|
||||
},
|
||||
|
||||
visibleGroups() {
|
||||
return (this.payload?.groups || []).filter((group) => Array.isArray(group.files) && group.files.length > 0);
|
||||
},
|
||||
|
||||
hasChanges() {
|
||||
return this.visibleGroups().length > 0;
|
||||
},
|
||||
|
||||
groupTitle(kind) {
|
||||
const labels = {
|
||||
staged: "Staged",
|
||||
unstaged: "Unstaged",
|
||||
untracked: "Untracked",
|
||||
};
|
||||
return labels[kind] || kind;
|
||||
},
|
||||
|
||||
statusLabel(file) {
|
||||
return String(file?.status || "changed").replaceAll("_", " ");
|
||||
},
|
||||
|
||||
fileKey(group, file) {
|
||||
return `${group?.kind || "diff"}:${file?.old_path || ""}:${file?.path || ""}`;
|
||||
},
|
||||
|
||||
isExpanded(group, file) {
|
||||
return this.expanded[this.fileKey(group, file)] !== false;
|
||||
},
|
||||
|
||||
toggleFile(group, file) {
|
||||
const key = this.fileKey(group, file);
|
||||
this.expanded[key] = !this.isExpanded(group, file);
|
||||
},
|
||||
|
||||
expandAll() {
|
||||
const next = {};
|
||||
for (const group of this.visibleGroups()) {
|
||||
for (const file of group.files || []) {
|
||||
next[this.fileKey(group, file)] = true;
|
||||
}
|
||||
}
|
||||
this.expanded = next;
|
||||
},
|
||||
|
||||
collapseAll() {
|
||||
const next = {};
|
||||
for (const group of this.visibleGroups()) {
|
||||
for (const file of group.files || []) {
|
||||
next[this.fileKey(group, file)] = false;
|
||||
}
|
||||
}
|
||||
this.expanded = next;
|
||||
},
|
||||
|
||||
patchLines(file) {
|
||||
const patch = String(file?.patch || "");
|
||||
if (!patch) return [];
|
||||
const textLines = patch.endsWith("\n") ? patch.slice(0, -1).split("\n") : patch.split("\n");
|
||||
return textLines.map((text, index) => ({
|
||||
id: `${index}-${text.slice(0, 20)}`,
|
||||
text,
|
||||
type: lineType(text),
|
||||
}));
|
||||
},
|
||||
|
||||
fileTitle(file) {
|
||||
if (file?.old_path && file.old_path !== file.path) {
|
||||
return `${file.old_path} -> ${file.path}`;
|
||||
}
|
||||
return file?.path || file?.old_path || "";
|
||||
},
|
||||
|
||||
formatSigned(value, sign) {
|
||||
const number = Number(value) || 0;
|
||||
return `${sign}${number.toLocaleString()}`;
|
||||
},
|
||||
|
||||
fullPath(file) {
|
||||
const relativePath = String(file?.path || file?.old_path || "").replace(/^\/+/, "");
|
||||
const base = String(this.workspacePath || "").replace(/\/+$/, "");
|
||||
return relativePath ? `${base}/${relativePath}` : base;
|
||||
},
|
||||
|
||||
async openContainingFolder(file) {
|
||||
const parent = dirname(this.fullPath(file));
|
||||
await fileBrowserStore.open(parent || this.workspacePath || "$WORK_DIR");
|
||||
},
|
||||
|
||||
async copyPath(file) {
|
||||
const path = this.fullPath(file);
|
||||
try {
|
||||
await navigator.clipboard.writeText(path);
|
||||
globalThis.justToast?.("Path copied", "success", 1200, "diff-viewer-copy");
|
||||
} catch (_error) {
|
||||
globalThis.prompt?.("Copy path", path);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export const store = createStore("diffViewer", model);
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
<html
|
||||
class="diff-viewer-modal modal-no-backdrop"
|
||||
data-canvas-surface="diff"
|
||||
data-canvas-modal-path="/plugins/_diff_viewer/webui/main.html"
|
||||
data-canvas-dock-title="Open Diff in canvas"
|
||||
data-canvas-dock-icon="dock_to_right"
|
||||
>
|
||||
<head>
|
||||
<title>Diff</title>
|
||||
</head>
|
||||
<body class="diff-viewer-modal-body">
|
||||
<x-component path="/plugins/_diff_viewer/webui/diff-viewer-panel.html" mode="modal"></x-component>
|
||||
</body>
|
||||
</html>
|
||||
25
plugins/_time_travel/api/history_diff.py
Normal file
25
plugins/_time_travel/api/history_diff.py
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from helpers.api import ApiHandler, Request, Response
|
||||
from plugins._time_travel.helpers.time_travel import (
|
||||
TimeTravelError,
|
||||
TimeTravelService,
|
||||
WorkspaceRejectedError,
|
||||
resolve_workspace,
|
||||
)
|
||||
|
||||
|
||||
class HistoryDiff(ApiHandler):
|
||||
async def process(self, input: dict, request: Request) -> dict | Response:
|
||||
context_id = str(input.get("context_id") or "").strip()
|
||||
try:
|
||||
workspace = resolve_workspace(context_id, context_loader=self.use_context)
|
||||
return TimeTravelService(workspace).history_diff(
|
||||
commit_hash=str(input.get("commit_hash") or ""),
|
||||
path=str(input.get("path") or ""),
|
||||
mode=str(input.get("mode") or "commit"),
|
||||
)
|
||||
except WorkspaceRejectedError as exc:
|
||||
return {"ok": False, "locked": True, "error": str(exc)}
|
||||
except TimeTravelError as exc:
|
||||
return {"ok": False, "error": str(exc)}
|
||||
26
plugins/_time_travel/api/history_list.py
Normal file
26
plugins/_time_travel/api/history_list.py
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from helpers.api import ApiHandler, Request, Response
|
||||
from plugins._time_travel.helpers.time_travel import (
|
||||
TimeTravelError,
|
||||
TimeTravelService,
|
||||
WorkspaceRejectedError,
|
||||
resolve_workspace,
|
||||
unavailable_payload,
|
||||
)
|
||||
|
||||
|
||||
class HistoryList(ApiHandler):
|
||||
async def process(self, input: dict, request: Request) -> dict | Response:
|
||||
context_id = str(input.get("context_id") or "").strip()
|
||||
try:
|
||||
workspace = resolve_workspace(context_id, context_loader=self.use_context)
|
||||
return TimeTravelService(workspace).history_list(
|
||||
limit=int(input.get("limit") or 100),
|
||||
offset=int(input.get("offset") or 0),
|
||||
file_filter=str(input.get("file_filter") or ""),
|
||||
)
|
||||
except WorkspaceRejectedError as exc:
|
||||
return unavailable_payload(context_id, str(exc))
|
||||
except TimeTravelError as exc:
|
||||
return {"ok": False, "error": str(exc)}
|
||||
24
plugins/_time_travel/api/history_preview.py
Normal file
24
plugins/_time_travel/api/history_preview.py
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from helpers.api import ApiHandler, Request, Response
|
||||
from plugins._time_travel.helpers.time_travel import (
|
||||
TimeTravelError,
|
||||
TimeTravelService,
|
||||
WorkspaceRejectedError,
|
||||
resolve_workspace,
|
||||
)
|
||||
|
||||
|
||||
class HistoryPreview(ApiHandler):
|
||||
async def process(self, input: dict, request: Request) -> dict | Response:
|
||||
context_id = str(input.get("context_id") or "").strip()
|
||||
try:
|
||||
workspace = resolve_workspace(context_id, context_loader=self.use_context)
|
||||
return TimeTravelService(workspace).preview(
|
||||
operation=str(input.get("operation") or ""),
|
||||
commit_hash=str(input.get("commit_hash") or ""),
|
||||
)
|
||||
except WorkspaceRejectedError as exc:
|
||||
return {"ok": False, "locked": True, "error": str(exc)}
|
||||
except TimeTravelError as exc:
|
||||
return {"ok": False, "error": str(exc), "technical_details": getattr(exc, "stderr", "")}
|
||||
35
plugins/_time_travel/api/history_revert.py
Normal file
35
plugins/_time_travel/api/history_revert.py
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from helpers.api import ApiHandler, Request, Response
|
||||
from plugins._time_travel.helpers.time_travel import (
|
||||
TimeTravelError,
|
||||
TimeTravelService,
|
||||
WorkspaceRejectedError,
|
||||
resolve_workspace,
|
||||
)
|
||||
|
||||
|
||||
class HistoryRevert(ApiHandler):
|
||||
async def process(self, input: dict, request: Request) -> dict | Response:
|
||||
context_id = str(input.get("context_id") or "").strip()
|
||||
try:
|
||||
workspace = resolve_workspace(context_id, context_loader=self.use_context)
|
||||
return TimeTravelService(workspace).revert(
|
||||
commit_hash=str(input.get("commit_hash") or ""),
|
||||
metadata=input.get("metadata") if isinstance(input.get("metadata"), dict) else {},
|
||||
)
|
||||
except WorkspaceRejectedError as exc:
|
||||
return {"ok": False, "locked": True, "error": str(exc)}
|
||||
except TimeTravelError as exc:
|
||||
details = getattr(exc, "stderr", "") or str(exc)
|
||||
return {"ok": False, "error": _human_conflict_summary(str(exc)), "technical_details": details}
|
||||
|
||||
|
||||
def _human_conflict_summary(message: str) -> str:
|
||||
text = str(message or "").strip()
|
||||
if not text:
|
||||
return "Revert could not be applied cleanly."
|
||||
first = text.splitlines()[0]
|
||||
if "does not match index" in text or "patch failed" in text.lower() or "error:" in text.lower():
|
||||
return "Revert could not be applied cleanly because the current workspace has conflicting changes."
|
||||
return first
|
||||
28
plugins/_time_travel/api/history_snapshot.py
Normal file
28
plugins/_time_travel/api/history_snapshot.py
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from helpers.api import ApiHandler, Request, Response
|
||||
from plugins._time_travel.helpers.time_travel import (
|
||||
TimeTravelError,
|
||||
TimeTravelService,
|
||||
WorkspaceRejectedError,
|
||||
_snapshot_public,
|
||||
resolve_workspace,
|
||||
)
|
||||
|
||||
|
||||
class HistorySnapshot(ApiHandler):
|
||||
async def process(self, input: dict, request: Request) -> dict | Response:
|
||||
context_id = str(input.get("context_id") or "").strip()
|
||||
try:
|
||||
workspace = resolve_workspace(context_id, context_loader=self.use_context)
|
||||
snapshot = TimeTravelService(workspace).snapshot(
|
||||
trigger=str(input.get("trigger") or "manual"),
|
||||
message=str(input.get("message") or ""),
|
||||
metadata=input.get("metadata") if isinstance(input.get("metadata"), dict) else {},
|
||||
changed_path_hints=input.get("changed_path_hints") if isinstance(input.get("changed_path_hints"), list) else None,
|
||||
)
|
||||
return {"ok": True, "snapshot": _snapshot_public(snapshot)}
|
||||
except WorkspaceRejectedError as exc:
|
||||
return {"ok": False, "locked": True, "error": str(exc)}
|
||||
except TimeTravelError as exc:
|
||||
return {"ok": False, "error": str(exc), "technical_details": getattr(exc, "stderr", "")}
|
||||
28
plugins/_time_travel/api/history_travel.py
Normal file
28
plugins/_time_travel/api/history_travel.py
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from helpers.api import ApiHandler, Request, Response
|
||||
from plugins._time_travel.helpers.time_travel import (
|
||||
TimeTravelError,
|
||||
TimeTravelService,
|
||||
WorkspaceRejectedError,
|
||||
resolve_workspace,
|
||||
)
|
||||
|
||||
|
||||
class HistoryTravel(ApiHandler):
|
||||
async def process(self, input: dict, request: Request) -> dict | Response:
|
||||
context_id = str(input.get("context_id") or "").strip()
|
||||
try:
|
||||
workspace = resolve_workspace(context_id, context_loader=self.use_context)
|
||||
return TimeTravelService(workspace).travel(
|
||||
commit_hash=str(input.get("commit_hash") or ""),
|
||||
metadata=input.get("metadata") if isinstance(input.get("metadata"), dict) else {},
|
||||
)
|
||||
except WorkspaceRejectedError as exc:
|
||||
return {"ok": False, "locked": True, "error": str(exc)}
|
||||
except TimeTravelError as exc:
|
||||
return {
|
||||
"ok": False,
|
||||
"error": str(exc),
|
||||
"technical_details": getattr(exc, "stderr", "") or str(exc),
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from helpers.extension import Extension
|
||||
from plugins._time_travel.helpers.time_travel import snapshot_for_agent
|
||||
|
||||
|
||||
class TimeTravelTextEditorPatchSnapshot(Extension):
|
||||
async def execute(self, data: dict[str, Any] | None = None, **kwargs: Any):
|
||||
snapshot_for_agent(
|
||||
self.agent,
|
||||
trigger="text_editor_patch",
|
||||
metadata={
|
||||
"patch_mode": str((data or {}).get("mode") or "edits"),
|
||||
"changed_path_hints": [str((data or {}).get("path") or "")],
|
||||
},
|
||||
)
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from helpers.extension import Extension
|
||||
from plugins._time_travel.helpers.time_travel import snapshot_for_agent
|
||||
|
||||
|
||||
class TimeTravelTextEditorWriteSnapshot(Extension):
|
||||
async def execute(self, data: dict[str, Any] | None = None, **kwargs: Any):
|
||||
snapshot_for_agent(
|
||||
self.agent,
|
||||
trigger="text_editor_write",
|
||||
metadata={
|
||||
"changed_path_hints": [str((data or {}).get("path") or "")],
|
||||
},
|
||||
)
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from helpers.extension import Extension
|
||||
from plugins._time_travel.helpers.time_travel import snapshot_for_agent
|
||||
|
||||
|
||||
DEBOUNCE_SECONDS = 2.0
|
||||
_LAST_SNAPSHOT_BY_CONTEXT: dict[str, float] = {}
|
||||
|
||||
|
||||
class TimeTravelCodeExecutionSnapshot(Extension):
|
||||
async def execute(self, tool_name: str = "", response: Any = None, **kwargs: Any):
|
||||
if tool_name != "code_execution_tool" or not self.agent:
|
||||
return
|
||||
|
||||
context_id = str(getattr(getattr(self.agent, "context", None), "id", "") or "")
|
||||
now = time.monotonic()
|
||||
if context_id and now - _LAST_SNAPSHOT_BY_CONTEXT.get(context_id, 0.0) < DEBOUNCE_SECONDS:
|
||||
return
|
||||
if context_id:
|
||||
_LAST_SNAPSHOT_BY_CONTEXT[context_id] = now
|
||||
|
||||
tool = getattr(getattr(self.agent, "loop_data", None), "current_tool", None)
|
||||
args = getattr(tool, "args", {}) if tool else {}
|
||||
runtime = str(args.get("runtime") or "") if isinstance(args, dict) else ""
|
||||
if runtime == "output":
|
||||
return
|
||||
|
||||
snapshot_for_agent(
|
||||
self.agent,
|
||||
trigger="code_execution",
|
||||
metadata={
|
||||
"tool_name": tool_name,
|
||||
"runtime": runtime,
|
||||
},
|
||||
)
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from helpers.extension import Extension
|
||||
from plugins._time_travel.helpers.time_travel import snapshot_for_path_hint
|
||||
|
||||
|
||||
class TimeTravelWorkdirFileMutationSnapshot(Extension):
|
||||
async def execute(self, data: dict[str, Any] | None = None, **kwargs: Any):
|
||||
payload = data or {}
|
||||
paths = payload.get("paths")
|
||||
if not isinstance(paths, list):
|
||||
paths = [payload.get("path") or payload.get("current_path") or payload.get("parent_path")]
|
||||
|
||||
first_path = next((str(path) for path in paths if path), "")
|
||||
if not first_path:
|
||||
return
|
||||
|
||||
snapshot_for_path_hint(
|
||||
first_path,
|
||||
trigger=f"file_browser_{payload.get('action') or 'mutation'}",
|
||||
metadata={
|
||||
"source": "file_browser",
|
||||
"action": payload.get("action") or "mutation",
|
||||
"changed_path_hints": [str(path) for path in paths if path],
|
||||
},
|
||||
)
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
export default function refreshTimeTravelOnContextChange(ctx) {
|
||||
const store = globalThis.Alpine?.store?.("timeTravel");
|
||||
const canvas = globalThis.Alpine?.store?.("rightCanvas");
|
||||
if (!store || !canvas?.isOpen || canvas.activeSurfaceId !== "time-travel") return;
|
||||
const nextContextId = String(ctx?.snapshot?.context || "");
|
||||
if (nextContextId && nextContextId !== store.contextId) {
|
||||
store.scheduleRefresh({ contextId: nextContextId, reason: "context-change" });
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
<div
|
||||
class="right-canvas-surface-panel time-travel-canvas-surface"
|
||||
data-surface-id="time-travel"
|
||||
x-show="$store.rightCanvas && $store.rightCanvas.isOpen && $store.rightCanvas.activeSurfaceId === 'time-travel'"
|
||||
style="display: none;"
|
||||
>
|
||||
<x-component path="/plugins/_time_travel/webui/time-travel-panel.html" mode="canvas"></x-component>
|
||||
</div>
|
||||
|
|
@ -17,21 +17,21 @@ function waitForElement(selector, timeoutMs = 3000) {
|
|||
});
|
||||
}
|
||||
|
||||
export default async function registerDiffViewerSurface(canvas) {
|
||||
export default async function registerTimeTravelSurface(canvas) {
|
||||
canvas.registerSurface({
|
||||
id: "diff",
|
||||
title: "Diff",
|
||||
icon: "difference",
|
||||
id: "time-travel",
|
||||
title: "Time Travel",
|
||||
icon: "history",
|
||||
order: 30,
|
||||
modalPath: "/plugins/_diff_viewer/webui/main.html",
|
||||
modalPath: "/plugins/_time_travel/webui/main.html",
|
||||
async open(payload = {}) {
|
||||
await waitForElement('[data-surface-id="diff"] .diff-viewer-panel');
|
||||
const diffViewer = globalThis.Alpine?.store?.("diffViewer");
|
||||
await diffViewer?.onOpen?.(payload);
|
||||
await waitForElement('[data-surface-id="time-travel"] .time-travel-panel');
|
||||
const store = globalThis.Alpine?.store?.("timeTravel");
|
||||
await store?.onOpen?.(payload);
|
||||
},
|
||||
async close() {
|
||||
const diffViewer = globalThis.Alpine?.store?.("diffViewer");
|
||||
diffViewer?.cleanup?.();
|
||||
const store = globalThis.Alpine?.store?.("timeTravel");
|
||||
store?.cleanup?.();
|
||||
},
|
||||
});
|
||||
}
|
||||
1
plugins/_time_travel/helpers/__init__.py
Normal file
1
plugins/_time_travel/helpers/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
|
||||
1087
plugins/_time_travel/helpers/time_travel.py
Normal file
1087
plugins/_time_travel/helpers/time_travel.py
Normal file
File diff suppressed because it is too large
Load diff
8
plugins/_time_travel/plugin.yaml
Normal file
8
plugins/_time_travel/plugin.yaml
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
name: _time_travel
|
||||
title: Time Travel
|
||||
description: Agent Zero-owned workspace history, diff inspection, travel, and revert for active /a0/usr workspaces.
|
||||
version: 0.1.0
|
||||
always_enabled: false
|
||||
settings_sections: []
|
||||
per_project_config: false
|
||||
per_agent_config: false
|
||||
8
plugins/_time_travel/webui/main.html
Normal file
8
plugins/_time_travel/webui/main.html
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
<html>
|
||||
<head>
|
||||
<title>Time Travel</title>
|
||||
</head>
|
||||
<body>
|
||||
<x-component path="/plugins/_time_travel/webui/time-travel-panel.html" mode="modal"></x-component>
|
||||
</body>
|
||||
</html>
|
||||
907
plugins/_time_travel/webui/time-travel-panel.html
Normal file
907
plugins/_time_travel/webui/time-travel-panel.html
Normal file
|
|
@ -0,0 +1,907 @@
|
|||
<html>
|
||||
<head>
|
||||
<script type="module">
|
||||
import { store } from "/plugins/_time_travel/webui/time-travel-store.js";
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="time-travel-panel" x-data x-create="$store.timeTravel.onMount($el, xAttrs($el) || {})" x-destroy="$store.timeTravel.cleanup()">
|
||||
<template x-if="$store.timeTravel">
|
||||
<div class="time-travel-shell">
|
||||
<div class="time-travel-toolbar">
|
||||
<div class="time-travel-title">
|
||||
<span class="material-symbols-outlined">history</span>
|
||||
<span>Time Travel</span>
|
||||
</div>
|
||||
<div class="time-travel-workspace" :title="$store.timeTravel.workspacePath" x-text="$store.timeTravel.workspacePath || 'workspace'"></div>
|
||||
<span class="time-travel-spacer"></span>
|
||||
<button type="button" class="time-travel-icon-button" title="Snapshot" aria-label="Snapshot" @click="$store.timeTravel.manualSnapshot()" :disabled="$store.timeTravel.busy || $store.timeTravel.loading || $store.timeTravel.isLocked()">
|
||||
<span class="material-symbols-outlined">add_a_photo</span>
|
||||
</button>
|
||||
<button type="button" class="time-travel-icon-button" title="Refresh" aria-label="Refresh" @click="$store.timeTravel.refresh({ keepSelection: true })" :disabled="$store.timeTravel.loading">
|
||||
<span class="material-symbols-outlined" :class="{ spinning: $store.timeTravel.loading }">refresh</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="time-travel-status" x-show="$store.timeTravel.loading || $store.timeTravel.error || $store.timeTravel.busy" style="display: none;">
|
||||
<span class="material-symbols-outlined" :class="{ spinning: $store.timeTravel.loading || $store.timeTravel.busy }" x-text="$store.timeTravel.error ? 'error' : 'progress_activity'"></span>
|
||||
<span x-text="$store.timeTravel.error || ($store.timeTravel.busy ? 'Working...' : 'Loading history...')"></span>
|
||||
</div>
|
||||
|
||||
<template x-if="$store.timeTravel.isLocked() && !$store.timeTravel.loading">
|
||||
<div class="time-travel-locked">
|
||||
<span class="material-symbols-outlined">lock</span>
|
||||
<strong>Unavailable</strong>
|
||||
<span x-text="$store.timeTravel.payload?.workspace?.error || 'Time Travel is available only inside /a0/usr.'"></span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="!$store.timeTravel.isLocked()">
|
||||
<div class="time-travel-body">
|
||||
<aside class="time-travel-timeline">
|
||||
<div class="time-travel-filter">
|
||||
<span class="material-symbols-outlined">filter_list</span>
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Filter files"
|
||||
x-model="$store.timeTravel.fileFilter"
|
||||
@input="$store.timeTravel.scheduleFilterRefresh()"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="time-travel-rows">
|
||||
<template x-for="row in $store.timeTravel.timelineRows()" :key="row.key">
|
||||
<button
|
||||
type="button"
|
||||
class="time-travel-row"
|
||||
:class="{ 'is-active': $store.timeTravel.selectedHash === row.key, 'is-present': row.kind === 'present', 'is-current': row.is_current }"
|
||||
@click="$store.timeTravel.selectRow(row)"
|
||||
:title="row.message"
|
||||
>
|
||||
<span class="time-travel-row-mark">
|
||||
<span class="material-symbols-outlined" x-text="row.kind === 'present' ? (row.dirty ? 'edit_note' : 'check_circle') : (row.is_current ? 'radio_button_checked' : 'commit')"></span>
|
||||
</span>
|
||||
<span class="time-travel-row-main">
|
||||
<span class="time-travel-row-title" x-text="row.message"></span>
|
||||
<span class="time-travel-row-meta" x-text="$store.timeTravel.rowMeta(row)"></span>
|
||||
</span>
|
||||
<span class="time-travel-row-count" x-text="row.files?.length || 0"></span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<button type="button" class="time-travel-load-more" x-show="$store.timeTravel.payload?.has_more" style="display: none;" @click="$store.timeTravel.loadMore()" :disabled="$store.timeTravel.loading">
|
||||
<span class="material-symbols-outlined">expand_more</span>
|
||||
<span>More</span>
|
||||
</button>
|
||||
</aside>
|
||||
|
||||
<main class="time-travel-detail">
|
||||
<div class="time-travel-detail-header">
|
||||
<div class="time-travel-detail-title">
|
||||
<strong x-text="$store.timeTravel.selectedRow()?.message || 'History'"></strong>
|
||||
<span x-text="$store.timeTravel.rowMeta($store.timeTravel.selectedRow())"></span>
|
||||
</div>
|
||||
<div class="time-travel-actions" x-show="$store.timeTravel.selectedCommit()" style="display: none;">
|
||||
<button type="button" class="time-travel-tool-button" title="Travel" @click="$store.timeTravel.openPreview('travel')" :disabled="$store.timeTravel.selectedCommit()?.is_current || $store.timeTravel.busy">
|
||||
<span class="material-symbols-outlined">move_down</span>
|
||||
<span>Travel</span>
|
||||
</button>
|
||||
<button type="button" class="time-travel-tool-button" title="Revert" @click="$store.timeTravel.openPreview('revert')" :disabled="$store.timeTravel.busy">
|
||||
<span class="material-symbols-outlined">undo</span>
|
||||
<span>Revert</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="time-travel-detail-grid">
|
||||
<section class="time-travel-files">
|
||||
<template x-if="$store.timeTravel.selectedFiles().length === 0">
|
||||
<div class="time-travel-empty">
|
||||
<span class="material-symbols-outlined">hourglass_empty</span>
|
||||
<span>No file changes</span>
|
||||
</div>
|
||||
</template>
|
||||
<template x-for="file in $store.timeTravel.selectedFiles()" :key="$store.timeTravel.fileKey(file)">
|
||||
<button
|
||||
type="button"
|
||||
class="time-travel-file-row"
|
||||
:class="{ 'is-active': $store.timeTravel.fileKey(file) === $store.timeTravel.selectedPath }"
|
||||
@click="$store.timeTravel.selectFile(file)"
|
||||
:title="$store.timeTravel.fileTitle(file)"
|
||||
>
|
||||
<span class="time-travel-file-name" x-text="$store.timeTravel.fileTitle(file)"></span>
|
||||
<span class="time-travel-file-status" x-text="$store.timeTravel.statusLabel(file)"></span>
|
||||
<span class="time-travel-file-counts">
|
||||
<span class="diff-add" x-text="$store.timeTravel.formatSigned(file.additions, '+')"></span>
|
||||
<span class="diff-del" x-text="$store.timeTravel.formatSigned(file.deletions, '-')"></span>
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
<section class="time-travel-diff">
|
||||
<div class="time-travel-diff-toolbar">
|
||||
<span class="time-travel-diff-path" :title="$store.timeTravel.fileTitle($store.timeTravel.selectedFile())" x-text="$store.timeTravel.fileTitle($store.timeTravel.selectedFile()) || 'Diff'"></span>
|
||||
<span class="time-travel-spacer"></span>
|
||||
<button type="button" class="time-travel-icon-button is-small" title="Open containing folder" aria-label="Open containing folder" @click="$store.timeTravel.openContainingFolder($store.timeTravel.selectedFile())" :disabled="!$store.timeTravel.selectedFile()">
|
||||
<span class="material-symbols-outlined">folder_open</span>
|
||||
</button>
|
||||
<button type="button" class="time-travel-icon-button is-small" title="Copy path" aria-label="Copy path" @click="$store.timeTravel.copyPath($store.timeTravel.selectedFile())" :disabled="!$store.timeTravel.selectedFile()">
|
||||
<span class="material-symbols-outlined">content_copy</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="time-travel-diff-status" x-show="$store.timeTravel.diffLoading || $store.timeTravel.diffError" style="display: none;">
|
||||
<span class="material-symbols-outlined" :class="{ spinning: $store.timeTravel.diffLoading }" x-text="$store.timeTravel.diffError ? 'error' : 'progress_activity'"></span>
|
||||
<span x-text="$store.timeTravel.diffError || 'Loading diff...'"></span>
|
||||
</div>
|
||||
|
||||
<template x-if="$store.timeTravel.selectedDiff?.binary">
|
||||
<div class="time-travel-empty">
|
||||
<span class="material-symbols-outlined">data_object</span>
|
||||
<span>Binary file changed</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="$store.timeTravel.selectedDiff?.too_large && !$store.timeTravel.selectedDiff?.binary">
|
||||
<div class="time-travel-empty">
|
||||
<span class="material-symbols-outlined">text_snippet</span>
|
||||
<span>Diff exceeds the 1 MB render limit</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="$store.timeTravel.selectedDiff?.patch && !$store.timeTravel.selectedDiff?.binary">
|
||||
<div class="time-travel-code" role="table" aria-label="Unified diff">
|
||||
<template x-for="line in $store.timeTravel.patchLines()" :key="line.id">
|
||||
<div class="time-travel-line" :class="`is-${line.type}`" role="row">
|
||||
<span class="time-travel-line-marker" x-text="line.type === 'add' ? '+' : (line.type === 'del' ? '-' : '')"></span>
|
||||
<code x-text="line.text"></code>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="time-travel-preview-backdrop" x-show="$store.timeTravel.previewOpen" style="display: none;">
|
||||
<section class="time-travel-preview" role="dialog" aria-modal="true" aria-label="Time Travel preview">
|
||||
<header>
|
||||
<span class="material-symbols-outlined" x-text="$store.timeTravel.preview?.operation === 'travel' ? 'move_down' : 'undo'"></span>
|
||||
<strong x-text="$store.timeTravel.preview?.operation === 'travel' ? 'Travel Preview' : 'Revert Preview'"></strong>
|
||||
<button type="button" class="time-travel-icon-button is-small" title="Close" aria-label="Close" @click="$store.timeTravel.closePreview()" :disabled="$store.timeTravel.busy">
|
||||
<span class="material-symbols-outlined">close</span>
|
||||
</button>
|
||||
</header>
|
||||
<div class="time-travel-preview-body">
|
||||
<div class="time-travel-preview-status" x-show="$store.timeTravel.previewLoading || $store.timeTravel.previewError" style="display: none;">
|
||||
<span class="material-symbols-outlined" :class="{ spinning: $store.timeTravel.previewLoading }" x-text="$store.timeTravel.previewError ? 'error' : 'progress_activity'"></span>
|
||||
<span x-text="$store.timeTravel.previewError || 'Building preview...'"></span>
|
||||
</div>
|
||||
<div class="time-travel-preview-summary">
|
||||
<span x-text="$store.timeTravel.preview?.short_hash || ''"></span>
|
||||
<span class="time-travel-dot"></span>
|
||||
<span x-text="`${$store.timeTravel.preview?.files?.length || 0} affected files`"></span>
|
||||
</div>
|
||||
<div class="time-travel-preview-files">
|
||||
<template x-for="file in ($store.timeTravel.preview?.files || []).slice(0, 80)" :key="$store.timeTravel.fileKey(file)">
|
||||
<div class="time-travel-preview-file">
|
||||
<span x-text="$store.timeTravel.fileTitle(file)"></span>
|
||||
<span x-text="$store.timeTravel.statusLabel(file)"></span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<details x-show="$store.timeTravel.previewTechnicalDetails" style="display: none;">
|
||||
<summary>Details</summary>
|
||||
<pre x-text="$store.timeTravel.previewTechnicalDetails"></pre>
|
||||
</details>
|
||||
</div>
|
||||
<footer>
|
||||
<button type="button" class="time-travel-tool-button" @click="$store.timeTravel.closePreview()" :disabled="$store.timeTravel.busy">
|
||||
<span class="material-symbols-outlined">close</span>
|
||||
<span>Cancel</span>
|
||||
</button>
|
||||
<button type="button" class="time-travel-tool-button is-primary" @click="$store.timeTravel.confirmPreview()" :disabled="Boolean($store.timeTravel.busy || $store.timeTravel.previewLoading || $store.timeTravel.previewError)">
|
||||
<span class="material-symbols-outlined" :class="{ spinning: $store.timeTravel.busy }" x-text="$store.timeTravel.busy ? 'progress_activity' : ($store.timeTravel.preview?.operation === 'travel' ? 'move_down' : 'undo')"></span>
|
||||
<span x-text="$store.timeTravel.preview?.operation === 'travel' ? 'Travel' : 'Revert'"></span>
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.time-travel-panel,
|
||||
.time-travel-shell {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
background: color-mix(in srgb, var(--color-background) 95%, #000 5%);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.time-travel-panel {
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
.modal-inner.time-travel-modal {
|
||||
box-sizing: border-box;
|
||||
container-type: inline-size;
|
||||
width: min(86vw, 1240px);
|
||||
height: min(88vh, 920px);
|
||||
min-width: min(360px, calc(100vw - 16px));
|
||||
min-height: min(520px, calc(100vh - 16px));
|
||||
max-width: calc(100vw - 16px);
|
||||
max-height: calc(100vh - 16px);
|
||||
resize: both;
|
||||
border: 1px solid color-mix(in srgb, var(--color-border) 75%, transparent);
|
||||
border-radius: 7px;
|
||||
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.32);
|
||||
background: color-mix(in srgb, var(--color-background) 94%, #000 6%);
|
||||
}
|
||||
|
||||
.modal.modal-floating {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.modal.modal-floating .modal-inner {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.modal-inner.time-travel-modal .modal-header {
|
||||
min-height: 34px;
|
||||
padding: 0.35rem 0.75rem 0.35rem 1rem;
|
||||
cursor: move;
|
||||
user-select: none;
|
||||
background: color-mix(in srgb, var(--color-background) 92%, #000 8%);
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--color-border) 70%, transparent);
|
||||
}
|
||||
|
||||
.modal-inner.time-travel-modal .modal-scroll,
|
||||
.modal-inner.time-travel-modal .modal-bd.time-travel-modal-body,
|
||||
.modal-inner.time-travel-modal .modal-bd.time-travel-modal-body > x-component,
|
||||
.modal-inner.time-travel-modal .modal-bd.time-travel-modal-body > div[x-data] {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.time-travel-toolbar,
|
||||
.time-travel-status,
|
||||
.time-travel-title,
|
||||
.time-travel-filter,
|
||||
.time-travel-actions,
|
||||
.time-travel-tool-button,
|
||||
.time-travel-diff-toolbar,
|
||||
.time-travel-diff-status,
|
||||
.time-travel-file-counts,
|
||||
.time-travel-preview header,
|
||||
.time-travel-preview footer,
|
||||
.time-travel-preview-status,
|
||||
.time-travel-preview-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.time-travel-toolbar {
|
||||
gap: 8px;
|
||||
min-height: 44px;
|
||||
padding: var(--spacing-xs) var(--spacing-md);
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--color-border) 64%, transparent);
|
||||
background: color-mix(in srgb, var(--color-background) 91%, #000 9%);
|
||||
}
|
||||
|
||||
.time-travel-title {
|
||||
gap: 7px;
|
||||
font-weight: 750;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.time-travel-title .material-symbols-outlined {
|
||||
font-size: 19px;
|
||||
}
|
||||
|
||||
.time-travel-workspace {
|
||||
min-width: 0;
|
||||
max-width: 45%;
|
||||
overflow: hidden;
|
||||
color: var(--color-text-muted);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-family: var(--font-family-code);
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.time-travel-spacer {
|
||||
flex: 1 1 auto;
|
||||
min-width: 8px;
|
||||
}
|
||||
|
||||
.time-travel-icon-button,
|
||||
.time-travel-tool-button,
|
||||
.time-travel-load-more {
|
||||
appearance: none;
|
||||
border: 1px solid color-mix(in srgb, var(--color-border) 64%, transparent);
|
||||
border-radius: 7px;
|
||||
background: color-mix(in srgb, var(--color-panel) 80%, transparent);
|
||||
color: var(--color-text);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.time-travel-icon-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
min-width: 32px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.time-travel-icon-button.is-small {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
min-width: 28px;
|
||||
}
|
||||
|
||||
.time-travel-tool-button {
|
||||
gap: 5px;
|
||||
min-height: 30px;
|
||||
padding: 4px 9px;
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
|
||||
.time-travel-tool-button.is-primary {
|
||||
border-color: color-mix(in srgb, var(--color-primary) 42%, var(--color-border));
|
||||
background: color-mix(in srgb, var(--color-primary) 22%, var(--color-panel));
|
||||
}
|
||||
|
||||
.time-travel-icon-button:hover:not(:disabled),
|
||||
.time-travel-tool-button:hover:not(:disabled),
|
||||
.time-travel-load-more:hover:not(:disabled) {
|
||||
background: color-mix(in srgb, var(--color-background-hover) 70%, transparent);
|
||||
border-color: color-mix(in srgb, var(--color-primary) 28%, var(--color-border));
|
||||
}
|
||||
|
||||
.time-travel-icon-button:disabled,
|
||||
.time-travel-tool-button:disabled,
|
||||
.time-travel-load-more:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.time-travel-icon-button .material-symbols-outlined,
|
||||
.time-travel-tool-button .material-symbols-outlined,
|
||||
.time-travel-load-more .material-symbols-outlined {
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.time-travel-status,
|
||||
.time-travel-diff-status,
|
||||
.time-travel-preview-status {
|
||||
gap: 8px;
|
||||
min-height: 34px;
|
||||
padding: 6px 11px;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--color-border) 44%, transparent);
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.time-travel-body {
|
||||
display: grid;
|
||||
flex: 1 1 auto;
|
||||
grid-template-columns: minmax(210px, 0.42fr) minmax(0, 1fr);
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.time-travel-timeline {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
border-right: 1px solid color-mix(in srgb, var(--color-border) 58%, transparent);
|
||||
background: color-mix(in srgb, var(--color-panel) 48%, transparent);
|
||||
}
|
||||
|
||||
.time-travel-filter {
|
||||
gap: 6px;
|
||||
min-height: 40px;
|
||||
padding: 7px 9px;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--color-border) 42%, transparent);
|
||||
}
|
||||
|
||||
.time-travel-filter .material-symbols-outlined {
|
||||
font-size: 17px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.time-travel-filter input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
height: 28px;
|
||||
border: 1px solid color-mix(in srgb, var(--color-border) 60%, transparent);
|
||||
border-radius: 7px;
|
||||
background: color-mix(in srgb, var(--color-background) 72%, transparent);
|
||||
color: var(--color-text);
|
||||
font-size: 0.78rem;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.time-travel-rows {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 7px;
|
||||
}
|
||||
|
||||
.time-travel-row {
|
||||
display: grid;
|
||||
grid-template-columns: 24px minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
width: 100%;
|
||||
min-height: 50px;
|
||||
padding: 6px 7px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 7px;
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.time-travel-row:hover,
|
||||
.time-travel-row.is-active {
|
||||
border-color: color-mix(in srgb, var(--color-primary) 25%, var(--color-border));
|
||||
background: color-mix(in srgb, var(--color-background-hover) 56%, transparent);
|
||||
}
|
||||
|
||||
.time-travel-row-mark .material-symbols-outlined {
|
||||
font-size: 18px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.time-travel-row.is-current .time-travel-row-mark .material-symbols-outlined {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.time-travel-row-main {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.time-travel-row-title,
|
||||
.time-travel-row-meta,
|
||||
.time-travel-file-name,
|
||||
.time-travel-diff-path {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.time-travel-row-title {
|
||||
font-size: 0.79rem;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.time-travel-row-meta {
|
||||
color: var(--color-text-muted);
|
||||
font-family: var(--font-family-code);
|
||||
font-size: 0.69rem;
|
||||
}
|
||||
|
||||
.time-travel-row-count {
|
||||
min-width: 24px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--color-background) 70%, transparent);
|
||||
color: var(--color-text-muted);
|
||||
text-align: center;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.time-travel-load-more {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
min-height: 34px;
|
||||
margin: 7px;
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
|
||||
.time-travel-detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.time-travel-detail-header {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
min-height: 48px;
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--color-border) 50%, transparent);
|
||||
}
|
||||
|
||||
.time-travel-detail-title {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.time-travel-detail-title strong,
|
||||
.time-travel-detail-title span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.time-travel-detail-title strong {
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.time-travel-detail-title span {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.time-travel-actions {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.time-travel-detail-grid {
|
||||
display: grid;
|
||||
flex: 1 1 auto;
|
||||
grid-template-columns: minmax(190px, 0.36fr) minmax(0, 1fr);
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.time-travel-files {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
border-right: 1px solid color-mix(in srgb, var(--color-border) 45%, transparent);
|
||||
}
|
||||
|
||||
.time-travel-file-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 4px 7px;
|
||||
width: 100%;
|
||||
min-height: 46px;
|
||||
padding: 7px 9px;
|
||||
border: 0;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--color-border) 26%, transparent);
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.time-travel-file-row:hover,
|
||||
.time-travel-file-row.is-active {
|
||||
background: color-mix(in srgb, var(--color-background-hover) 54%, transparent);
|
||||
}
|
||||
|
||||
.time-travel-file-name {
|
||||
font-family: var(--font-family-code);
|
||||
font-size: 0.74rem;
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.time-travel-file-status {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.68rem;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.time-travel-file-counts {
|
||||
gap: 5px;
|
||||
justify-content: end;
|
||||
font-family: var(--font-family-code);
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.time-travel-diff {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.time-travel-diff-toolbar {
|
||||
gap: 6px;
|
||||
min-height: 38px;
|
||||
padding: 5px 8px;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--color-border) 42%, transparent);
|
||||
background: color-mix(in srgb, var(--color-panel) 44%, transparent);
|
||||
}
|
||||
|
||||
.time-travel-diff-path {
|
||||
font-family: var(--font-family-code);
|
||||
font-size: 0.74rem;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.time-travel-code {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 4px 0;
|
||||
background: color-mix(in srgb, var(--color-background) 91%, #000 9%);
|
||||
font-family: var(--font-family-code);
|
||||
font-size: 0.73rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.time-travel-line {
|
||||
display: grid;
|
||||
grid-template-columns: 24px minmax(0, 1fr);
|
||||
min-width: max-content;
|
||||
}
|
||||
|
||||
.time-travel-line-marker {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 1;
|
||||
min-height: 1.45em;
|
||||
padding-right: 5px;
|
||||
background: inherit;
|
||||
color: var(--color-text-muted);
|
||||
text-align: right;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.time-travel-line code {
|
||||
display: block;
|
||||
min-height: 1.45em;
|
||||
padding: 0 10px 0 6px;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.time-travel-line.is-add {
|
||||
background: rgba(39, 174, 96, 0.16);
|
||||
color: color-mix(in srgb, #8df0b0 78%, var(--color-text));
|
||||
}
|
||||
|
||||
.time-travel-line.is-del {
|
||||
background: rgba(231, 76, 60, 0.17);
|
||||
color: color-mix(in srgb, #ffaaa2 78%, var(--color-text));
|
||||
}
|
||||
|
||||
.time-travel-line.is-hunk {
|
||||
background: rgba(99, 102, 241, 0.16);
|
||||
color: color-mix(in srgb, #b9c3ff 80%, var(--color-text));
|
||||
}
|
||||
|
||||
.time-travel-line.is-meta,
|
||||
.time-travel-line.is-note {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.time-travel-empty,
|
||||
.time-travel-locked {
|
||||
display: grid;
|
||||
flex: 1 1 auto;
|
||||
place-items: center;
|
||||
align-content: center;
|
||||
gap: 7px;
|
||||
min-width: 0;
|
||||
padding: 24px;
|
||||
color: var(--color-text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.time-travel-empty .material-symbols-outlined,
|
||||
.time-travel-locked .material-symbols-outlined {
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.time-travel-locked span:last-child {
|
||||
max-width: 520px;
|
||||
overflow-wrap: anywhere;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.diff-add {
|
||||
color: #31c48d;
|
||||
}
|
||||
|
||||
.diff-del {
|
||||
color: #f05252;
|
||||
}
|
||||
|
||||
.time-travel-dot {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
flex: 0 0 auto;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--color-text-muted) 50%, transparent);
|
||||
}
|
||||
|
||||
.time-travel-preview-backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 20;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 16px;
|
||||
background: rgba(0, 0, 0, 0.34);
|
||||
}
|
||||
|
||||
.time-travel-preview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: min(640px, 100%);
|
||||
max-height: min(680px, 100%);
|
||||
min-height: 260px;
|
||||
border: 1px solid color-mix(in srgb, var(--color-border) 72%, transparent);
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--color-background) 95%, #000 5%);
|
||||
box-shadow: 0 18px 46px rgba(0, 0, 0, 0.36);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.time-travel-preview header,
|
||||
.time-travel-preview footer {
|
||||
gap: 8px;
|
||||
min-height: 44px;
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--color-border) 54%, transparent);
|
||||
}
|
||||
|
||||
.time-travel-preview footer {
|
||||
justify-content: flex-end;
|
||||
border-top: 1px solid color-mix(in srgb, var(--color-border) 54%, transparent);
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.time-travel-preview header strong {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.time-travel-preview-body {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.time-travel-preview-summary {
|
||||
gap: 8px;
|
||||
padding: 9px 11px;
|
||||
color: var(--color-text-muted);
|
||||
font-family: var(--font-family-code);
|
||||
font-size: 0.74rem;
|
||||
}
|
||||
|
||||
.time-travel-preview-files {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
padding: 0 11px 11px;
|
||||
}
|
||||
|
||||
.time-travel-preview-file {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 8px;
|
||||
min-height: 30px;
|
||||
align-items: center;
|
||||
border: 1px solid color-mix(in srgb, var(--color-border) 34%, transparent);
|
||||
border-radius: 7px;
|
||||
padding: 4px 7px;
|
||||
background: color-mix(in srgb, var(--color-panel) 62%, transparent);
|
||||
font-size: 0.73rem;
|
||||
}
|
||||
|
||||
.time-travel-preview-file span:first-child {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-family: var(--font-family-code);
|
||||
}
|
||||
|
||||
.time-travel-preview-file span:last-child {
|
||||
color: var(--color-text-muted);
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.time-travel-preview details {
|
||||
margin: 0 11px 11px;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
|
||||
.time-travel-preview pre {
|
||||
max-height: 160px;
|
||||
overflow: auto;
|
||||
padding: 8px;
|
||||
border-radius: 7px;
|
||||
background: color-mix(in srgb, var(--color-background) 86%, #000 14%);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.time-travel-panel .spinning {
|
||||
display: inline-block;
|
||||
animation: time-travel-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes time-travel-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@container (max-width: 720px) {
|
||||
.time-travel-body,
|
||||
.time-travel-detail-grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.time-travel-timeline,
|
||||
.time-travel-files {
|
||||
max-height: 34vh;
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--color-border) 48%, transparent);
|
||||
}
|
||||
|
||||
.time-travel-detail-header {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.time-travel-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.time-travel-workspace {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@container (max-width: 520px) {
|
||||
.time-travel-tool-button span:last-child {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.time-travel-row {
|
||||
min-height: 46px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</body>
|
||||
</html>
|
||||
527
plugins/_time_travel/webui/time-travel-store.js
Normal file
527
plugins/_time_travel/webui/time-travel-store.js
Normal file
|
|
@ -0,0 +1,527 @@
|
|||
import { createStore } from "/js/AlpineStore.js";
|
||||
import { callJsonApi } from "/js/api.js";
|
||||
import { getContext } from "/index.js";
|
||||
import { store as fileBrowserStore } from "/components/modals/file-browser/file-browser-store.js";
|
||||
|
||||
const REFRESH_DEBOUNCE_MS = 180;
|
||||
|
||||
function lineType(text) {
|
||||
if (text.startsWith("@@")) return "hunk";
|
||||
if (text.startsWith("+++") || text.startsWith("---") || text.startsWith("diff --git") || text.startsWith("index ")) {
|
||||
return "meta";
|
||||
}
|
||||
if (text.startsWith("+")) return "add";
|
||||
if (text.startsWith("-")) return "del";
|
||||
if (text.startsWith("\\ No newline")) return "note";
|
||||
return "context";
|
||||
}
|
||||
|
||||
function dirname(path) {
|
||||
const clean = String(path || "").replace(/\/+$/, "");
|
||||
const index = clean.lastIndexOf("/");
|
||||
return index > 0 ? clean.slice(0, index) : "";
|
||||
}
|
||||
|
||||
function apiPath(name) {
|
||||
return `/plugins/_time_travel/${name}`;
|
||||
}
|
||||
|
||||
const model = {
|
||||
loading: false,
|
||||
busy: false,
|
||||
error: "",
|
||||
payload: null,
|
||||
contextId: "",
|
||||
workspacePath: "",
|
||||
fileFilter: "",
|
||||
selectedHash: "",
|
||||
selectedPath: "",
|
||||
selectedDiff: null,
|
||||
diffLoading: false,
|
||||
diffError: "",
|
||||
previewOpen: false,
|
||||
previewLoading: false,
|
||||
previewError: "",
|
||||
previewTechnicalDetails: "",
|
||||
previewDetailsOpen: false,
|
||||
preview: null,
|
||||
_root: null,
|
||||
_mode: "canvas",
|
||||
_refreshTimer: null,
|
||||
_filterTimer: null,
|
||||
_floatingCleanup: null,
|
||||
_requestSeq: 0,
|
||||
_diffSeq: 0,
|
||||
|
||||
async init(element = null) {
|
||||
await this.onMount(element, { mode: "canvas" });
|
||||
},
|
||||
|
||||
async onMount(element = null, options = {}) {
|
||||
if (element) this._root = element;
|
||||
this._mode = options?.mode === "modal" ? "modal" : "canvas";
|
||||
if (this._mode === "modal") {
|
||||
this.setupFloatingModal(element);
|
||||
} else {
|
||||
this.setupCanvasSurface(element);
|
||||
}
|
||||
this.contextId = this.resolveContextId();
|
||||
if (!this.payload && !this.loading) {
|
||||
await this.refresh({ contextId: this.contextId });
|
||||
}
|
||||
},
|
||||
|
||||
async onOpen(payload = {}) {
|
||||
const nextContextId = String(payload.contextId || payload.context_id || this.resolveContextId() || "");
|
||||
await this.refresh({ contextId: nextContextId });
|
||||
},
|
||||
|
||||
cleanup() {
|
||||
if (this._refreshTimer) {
|
||||
clearTimeout(this._refreshTimer);
|
||||
this._refreshTimer = null;
|
||||
}
|
||||
if (this._filterTimer) {
|
||||
clearTimeout(this._filterTimer);
|
||||
this._filterTimer = null;
|
||||
}
|
||||
this._floatingCleanup?.();
|
||||
this._floatingCleanup = null;
|
||||
},
|
||||
|
||||
setupFloatingModal(element = null) {
|
||||
this._floatingCleanup?.();
|
||||
const root = element || globalThis.document?.querySelector(".time-travel-panel");
|
||||
const modal = root?.closest?.(".modal");
|
||||
const inner = modal?.querySelector?.(".modal-inner");
|
||||
const body = modal?.querySelector?.(".modal-bd");
|
||||
const header = modal?.querySelector?.(".modal-header");
|
||||
if (!modal || !inner || !header) return;
|
||||
modal.classList.add("modal-floating");
|
||||
inner.classList.add("time-travel-modal", "modal-no-backdrop");
|
||||
body?.classList?.add("time-travel-modal-body");
|
||||
|
||||
const rect = inner.getBoundingClientRect();
|
||||
inner.style.left = `${Math.max(8, rect.left)}px`;
|
||||
inner.style.top = `${Math.max(8, rect.top)}px`;
|
||||
inner.style.transform = "none";
|
||||
|
||||
let drag = null;
|
||||
let resizeObserver = null;
|
||||
const viewportGap = 8;
|
||||
const clampPosition = (left, top) => {
|
||||
const bounds = inner.getBoundingClientRect();
|
||||
const maxLeft = Math.max(viewportGap, globalThis.innerWidth - bounds.width - viewportGap);
|
||||
const maxTop = Math.max(viewportGap, globalThis.innerHeight - bounds.height - viewportGap);
|
||||
return {
|
||||
left: Math.min(Math.max(viewportGap, left), maxLeft),
|
||||
top: Math.min(Math.max(viewportGap, top), maxTop),
|
||||
};
|
||||
};
|
||||
const clampGeometry = () => {
|
||||
const bounds = inner.getBoundingClientRect();
|
||||
const maxWidth = Math.max(360, globalThis.innerWidth - viewportGap * 2);
|
||||
const maxHeight = Math.max(420, globalThis.innerHeight - viewportGap * 2);
|
||||
if (bounds.width > maxWidth) inner.style.width = `${maxWidth}px`;
|
||||
if (bounds.height > maxHeight) inner.style.height = `${maxHeight}px`;
|
||||
const next = clampPosition(bounds.left, bounds.top);
|
||||
inner.style.left = `${next.left}px`;
|
||||
inner.style.top = `${next.top}px`;
|
||||
inner.style.maxWidth = `${Math.max(360, globalThis.innerWidth - next.left - viewportGap)}px`;
|
||||
inner.style.maxHeight = `${Math.max(420, globalThis.innerHeight - next.top - viewportGap)}px`;
|
||||
};
|
||||
clampGeometry();
|
||||
globalThis.addEventListener("resize", clampGeometry);
|
||||
if (globalThis.ResizeObserver) {
|
||||
resizeObserver = new ResizeObserver(clampGeometry);
|
||||
resizeObserver.observe(inner);
|
||||
}
|
||||
|
||||
const onPointerMove = (event) => {
|
||||
if (!drag) return;
|
||||
const next = clampPosition(drag.left + event.clientX - drag.x, drag.top + event.clientY - drag.y);
|
||||
inner.style.left = `${next.left}px`;
|
||||
inner.style.top = `${next.top}px`;
|
||||
clampGeometry();
|
||||
};
|
||||
const onPointerUp = () => {
|
||||
drag = null;
|
||||
globalThis.removeEventListener("pointermove", onPointerMove);
|
||||
globalThis.removeEventListener("pointerup", onPointerUp);
|
||||
try {
|
||||
header.releasePointerCapture?.(header.__timeTravelPanelPointerId || 0);
|
||||
} catch {}
|
||||
};
|
||||
const onPointerDown = (event) => {
|
||||
if (event.button !== 0) return;
|
||||
if (event.target?.closest?.("button, input, select, textarea, a")) return;
|
||||
const current = inner.getBoundingClientRect();
|
||||
drag = {
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
left: current.left,
|
||||
top: current.top,
|
||||
};
|
||||
header.__timeTravelPanelPointerId = event.pointerId;
|
||||
header.setPointerCapture?.(event.pointerId);
|
||||
globalThis.addEventListener("pointermove", onPointerMove);
|
||||
globalThis.addEventListener("pointerup", onPointerUp);
|
||||
event.preventDefault();
|
||||
};
|
||||
header.addEventListener("pointerdown", onPointerDown);
|
||||
|
||||
this._floatingCleanup = () => {
|
||||
header.removeEventListener("pointerdown", onPointerDown);
|
||||
globalThis.removeEventListener("pointermove", onPointerMove);
|
||||
globalThis.removeEventListener("pointerup", onPointerUp);
|
||||
globalThis.removeEventListener("resize", clampGeometry);
|
||||
resizeObserver?.disconnect?.();
|
||||
};
|
||||
},
|
||||
|
||||
setupCanvasSurface(element = null) {
|
||||
this._floatingCleanup?.();
|
||||
this._floatingCleanup = null;
|
||||
if (element) this._root = element;
|
||||
},
|
||||
|
||||
resolveContextId() {
|
||||
const urlContext = new URLSearchParams(globalThis.location?.search || "").get("ctxid");
|
||||
return getContext?.() || urlContext || globalThis.Alpine?.store?.("chats")?.selected || "";
|
||||
},
|
||||
|
||||
scheduleRefresh(options = {}) {
|
||||
if (this._refreshTimer) clearTimeout(this._refreshTimer);
|
||||
this._refreshTimer = setTimeout(() => {
|
||||
this._refreshTimer = null;
|
||||
this.refresh(options).catch((error) => console.error("Time Travel refresh failed", error));
|
||||
}, REFRESH_DEBOUNCE_MS);
|
||||
},
|
||||
|
||||
scheduleFilterRefresh() {
|
||||
if (this._filterTimer) clearTimeout(this._filterTimer);
|
||||
this._filterTimer = setTimeout(() => {
|
||||
this._filterTimer = null;
|
||||
this.refresh({ keepSelection: false });
|
||||
}, 240);
|
||||
},
|
||||
|
||||
async refresh(options = {}) {
|
||||
const contextId = String(options.contextId || options.context_id || this.resolveContextId() || "");
|
||||
const seq = ++this._requestSeq;
|
||||
this.loading = true;
|
||||
this.error = "";
|
||||
try {
|
||||
const response = await callJsonApi(apiPath("history_list"), {
|
||||
context_id: contextId,
|
||||
limit: 100,
|
||||
offset: 0,
|
||||
file_filter: this.fileFilter,
|
||||
});
|
||||
if (seq !== this._requestSeq) return;
|
||||
if (!response?.ok) throw new Error(response?.error || "Could not load history.");
|
||||
this.payload = response;
|
||||
this.contextId = String(response.context_id || contextId || "");
|
||||
this.workspacePath = String(response.workspace?.display_path || response.workspace?.path || "");
|
||||
this.reconcileSelection(Boolean(options.keepSelection));
|
||||
} catch (error) {
|
||||
if (seq !== this._requestSeq) return;
|
||||
this.error = error instanceof Error ? error.message : String(error);
|
||||
} finally {
|
||||
if (seq === this._requestSeq) this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async loadMore() {
|
||||
if (this.loading || !this.payload?.has_more || this.isLocked()) return;
|
||||
const seq = ++this._requestSeq;
|
||||
this.loading = true;
|
||||
try {
|
||||
const response = await callJsonApi(apiPath("history_list"), {
|
||||
context_id: this.contextId,
|
||||
limit: 100,
|
||||
offset: this.commits().length,
|
||||
file_filter: this.fileFilter,
|
||||
});
|
||||
if (seq !== this._requestSeq) return;
|
||||
if (!response?.ok) throw new Error(response?.error || "Could not load history.");
|
||||
this.payload.commits = [...this.commits(), ...(response.commits || [])];
|
||||
this.payload.has_more = Boolean(response.has_more);
|
||||
} catch (error) {
|
||||
this.error = error instanceof Error ? error.message : String(error);
|
||||
} finally {
|
||||
if (seq === this._requestSeq) this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
reconcileSelection(keepSelection = false) {
|
||||
if (this.isLocked()) {
|
||||
this.selectedHash = "";
|
||||
this.selectedPath = "";
|
||||
this.selectedDiff = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = this.timelineRows();
|
||||
let selected = keepSelection ? rows.find((row) => row.key === this.selectedHash) : null;
|
||||
if (!selected) selected = rows[0] || null;
|
||||
this.selectedHash = selected?.key || "";
|
||||
|
||||
const files = this.selectedFiles();
|
||||
if (!files.some((file) => this.fileKey(file) === this.selectedPath)) {
|
||||
this.selectedPath = files[0] ? this.fileKey(files[0]) : "";
|
||||
}
|
||||
void this.loadSelectedDiff();
|
||||
},
|
||||
|
||||
commits() {
|
||||
return Array.isArray(this.payload?.commits) ? this.payload.commits : [];
|
||||
},
|
||||
|
||||
present() {
|
||||
return this.payload?.present || {};
|
||||
},
|
||||
|
||||
isLocked() {
|
||||
return Boolean(this.payload?.workspace?.locked || this.payload?.workspace?.available === false);
|
||||
},
|
||||
|
||||
hasHistory() {
|
||||
return this.commits().length > 0;
|
||||
},
|
||||
|
||||
hasPresentChanges() {
|
||||
return Boolean(this.present()?.dirty);
|
||||
},
|
||||
|
||||
timelineRows() {
|
||||
const rows = [];
|
||||
rows.push({
|
||||
key: "present",
|
||||
kind: "present",
|
||||
hash: this.payload?.current_hash || "",
|
||||
short_hash: "present",
|
||||
message: this.hasPresentChanges() ? "Present changes" : "Present clean",
|
||||
timestamp: "",
|
||||
files: this.present()?.files || [],
|
||||
is_current: false,
|
||||
dirty: this.hasPresentChanges(),
|
||||
});
|
||||
for (const commit of this.commits()) {
|
||||
rows.push({ key: commit.hash, kind: "commit", ...commit });
|
||||
}
|
||||
return rows;
|
||||
},
|
||||
|
||||
selectedRow() {
|
||||
return this.timelineRows().find((row) => row.key === this.selectedHash) || null;
|
||||
},
|
||||
|
||||
selectedCommit() {
|
||||
const row = this.selectedRow();
|
||||
return row?.kind === "commit" ? row : null;
|
||||
},
|
||||
|
||||
selectedFiles() {
|
||||
const row = this.selectedRow();
|
||||
return Array.isArray(row?.files) ? row.files : [];
|
||||
},
|
||||
|
||||
selectedFile() {
|
||||
return this.selectedFiles().find((file) => this.fileKey(file) === this.selectedPath) || null;
|
||||
},
|
||||
|
||||
selectRow(row) {
|
||||
this.selectedHash = row?.key || "";
|
||||
const files = this.selectedFiles();
|
||||
this.selectedPath = files[0] ? this.fileKey(files[0]) : "";
|
||||
void this.loadSelectedDiff();
|
||||
},
|
||||
|
||||
selectFile(file) {
|
||||
this.selectedPath = this.fileKey(file);
|
||||
void this.loadSelectedDiff();
|
||||
},
|
||||
|
||||
fileKey(file) {
|
||||
return `${file?.old_path || ""}:${file?.path || ""}`;
|
||||
},
|
||||
|
||||
async loadSelectedDiff() {
|
||||
const file = this.selectedFile();
|
||||
const row = this.selectedRow();
|
||||
this.selectedDiff = null;
|
||||
this.diffError = "";
|
||||
if (!file || !row || this.isLocked()) return;
|
||||
const seq = ++this._diffSeq;
|
||||
this.diffLoading = true;
|
||||
try {
|
||||
const response = await callJsonApi(apiPath("history_diff"), {
|
||||
context_id: this.contextId,
|
||||
commit_hash: row.kind === "present" ? this.payload?.current_hash || "" : row.hash,
|
||||
path: file.path || file.old_path,
|
||||
mode: row.kind === "present" ? "present" : "commit",
|
||||
});
|
||||
if (seq !== this._diffSeq) return;
|
||||
if (!response?.ok) throw new Error(response?.error || "Could not load diff.");
|
||||
this.selectedDiff = response;
|
||||
} catch (error) {
|
||||
if (seq !== this._diffSeq) return;
|
||||
this.diffError = error instanceof Error ? error.message : String(error);
|
||||
} finally {
|
||||
if (seq === this._diffSeq) this.diffLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async manualSnapshot() {
|
||||
if (this.busy || this.isLocked()) return;
|
||||
this.busy = true;
|
||||
this.error = "";
|
||||
try {
|
||||
const response = await callJsonApi(apiPath("history_snapshot"), {
|
||||
context_id: this.contextId,
|
||||
trigger: "manual",
|
||||
});
|
||||
if (!response?.ok) throw new Error(response?.error || "Snapshot failed.");
|
||||
globalThis.justToast?.(response.snapshot?.created ? "Snapshot captured" : "No changes to snapshot", "success", 1400, "time-travel-snapshot");
|
||||
await this.refresh({ keepSelection: true });
|
||||
} catch (error) {
|
||||
this.error = error instanceof Error ? error.message : String(error);
|
||||
} finally {
|
||||
this.busy = false;
|
||||
}
|
||||
},
|
||||
|
||||
async openPreview(operation, commit = null) {
|
||||
const target = commit || this.selectedCommit();
|
||||
if (!target || this.busy || this.isLocked()) return;
|
||||
if (operation === "travel" && target.is_current) return;
|
||||
this.previewOpen = true;
|
||||
this.previewLoading = true;
|
||||
this.previewError = "";
|
||||
this.previewTechnicalDetails = "";
|
||||
this.previewDetailsOpen = false;
|
||||
this.preview = { operation, commit_hash: target.hash, short_hash: target.short_hash, files: [], previews: [] };
|
||||
try {
|
||||
const response = await callJsonApi(apiPath("history_preview"), {
|
||||
context_id: this.contextId,
|
||||
operation,
|
||||
commit_hash: target.hash,
|
||||
});
|
||||
if (!response?.ok) throw new Error(response?.error || "Preview failed.");
|
||||
this.preview = response;
|
||||
} catch (error) {
|
||||
this.previewError = error instanceof Error ? error.message : String(error);
|
||||
} finally {
|
||||
this.previewLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
closePreview() {
|
||||
if (this.busy) return;
|
||||
this.previewOpen = false;
|
||||
this.preview = null;
|
||||
this.previewError = "";
|
||||
this.previewTechnicalDetails = "";
|
||||
this.previewDetailsOpen = false;
|
||||
},
|
||||
|
||||
async confirmPreview() {
|
||||
if (!this.preview || this.busy || this.previewLoading) return;
|
||||
const operation = this.preview.operation;
|
||||
const endpoint = operation === "travel" ? "history_travel" : "history_revert";
|
||||
this.busy = true;
|
||||
this.previewError = "";
|
||||
this.previewDetailsOpen = false;
|
||||
try {
|
||||
const response = await callJsonApi(apiPath(endpoint), {
|
||||
context_id: this.contextId,
|
||||
commit_hash: this.preview.commit_hash,
|
||||
metadata: { source: "time_travel_ui" },
|
||||
});
|
||||
if (!response?.ok) {
|
||||
const error = new Error(response?.error || `${operation} failed.`);
|
||||
error.technicalDetails = response?.technical_details || "";
|
||||
throw error;
|
||||
}
|
||||
globalThis.justToast?.(operation === "travel" ? "Workspace traveled" : "Revert applied", "success", 1500, "time-travel-apply");
|
||||
this.previewOpen = false;
|
||||
this.preview = null;
|
||||
await this.refresh({ keepSelection: false });
|
||||
} catch (error) {
|
||||
this.previewError = error instanceof Error ? error.message : String(error);
|
||||
this.previewTechnicalDetails = error?.technicalDetails || "";
|
||||
} finally {
|
||||
this.busy = false;
|
||||
}
|
||||
},
|
||||
|
||||
patchLines(diff = null) {
|
||||
const patch = String((diff || this.selectedDiff)?.patch || "");
|
||||
if (!patch) return [];
|
||||
const textLines = patch.endsWith("\n") ? patch.slice(0, -1).split("\n") : patch.split("\n");
|
||||
return textLines.map((text, index) => ({
|
||||
id: `${index}-${text.slice(0, 20)}`,
|
||||
text,
|
||||
type: lineType(text),
|
||||
}));
|
||||
},
|
||||
|
||||
fileTitle(file) {
|
||||
if (file?.old_path && file.old_path !== file.path) {
|
||||
return `${file.old_path} -> ${file.path}`;
|
||||
}
|
||||
return file?.path || file?.old_path || "";
|
||||
},
|
||||
|
||||
statusLabel(file) {
|
||||
return String(file?.action || file?.status || "changed").replaceAll("_", " ");
|
||||
},
|
||||
|
||||
rowMeta(row) {
|
||||
if (!row) return "";
|
||||
const files = Array.isArray(row.files) ? row.files.length : 0;
|
||||
if (row.kind === "present") return files ? `${files} file${files === 1 ? "" : "s"}` : "clean";
|
||||
return `${row.short_hash || ""} · ${this.formatTime(row.timestamp)}`;
|
||||
},
|
||||
|
||||
formatTime(value) {
|
||||
if (!value) return "";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return String(value);
|
||||
return date.toLocaleString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
},
|
||||
|
||||
formatSigned(value, sign) {
|
||||
const number = Number(value) || 0;
|
||||
return `${sign}${number.toLocaleString()}`;
|
||||
},
|
||||
|
||||
fullPath(file) {
|
||||
const relativePath = String(file?.path || file?.old_path || "").replace(/^\/+/, "");
|
||||
const base = String(this.workspacePath || "").replace(/\/+$/, "");
|
||||
return relativePath ? `${base}/${relativePath}` : base;
|
||||
},
|
||||
|
||||
async openContainingFolder(file) {
|
||||
const parent = dirname(this.fullPath(file));
|
||||
await fileBrowserStore.open(parent || this.workspacePath || "$WORK_DIR");
|
||||
},
|
||||
|
||||
async copyPath(file) {
|
||||
const path = this.fullPath(file);
|
||||
try {
|
||||
await navigator.clipboard.writeText(path);
|
||||
globalThis.justToast?.("Path copied", "success", 1200, "time-travel-copy");
|
||||
} catch (_error) {
|
||||
globalThis.prompt?.("Copy path", path);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export const store = createStore("timeTravel", model);
|
||||
|
|
@ -1,192 +0,0 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from plugins._diff_viewer.helpers import diff as diff_helper
|
||||
from plugins._diff_viewer.helpers.diff import collect_workspace_diff
|
||||
|
||||
|
||||
def run_git(repo_dir: Path, *args: str) -> str:
|
||||
completed = subprocess.run(
|
||||
["git", "-C", str(repo_dir), *args],
|
||||
check=True,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
)
|
||||
return completed.stdout.strip()
|
||||
|
||||
|
||||
def init_repo(repo_dir: Path) -> None:
|
||||
run_git(repo_dir, "init")
|
||||
run_git(repo_dir, "config", "user.name", "Test User")
|
||||
run_git(repo_dir, "config", "user.email", "test@example.com")
|
||||
(repo_dir / "tracked.txt").write_text("one\n", encoding="utf-8")
|
||||
run_git(repo_dir, "add", "tracked.txt")
|
||||
run_git(repo_dir, "commit", "-m", "initial")
|
||||
|
||||
|
||||
def files_for_group(payload: dict, kind: str) -> list[dict]:
|
||||
return next(group["files"] for group in payload["groups"] if group["kind"] == kind)
|
||||
|
||||
|
||||
def test_collect_workspace_diff_returns_non_git_state(tmp_path: Path) -> None:
|
||||
payload = collect_workspace_diff(str(tmp_path), context_id="ctx")
|
||||
|
||||
assert payload["ok"] is True
|
||||
assert payload["context_id"] == "ctx"
|
||||
assert payload["is_git_repo"] is False
|
||||
assert payload["totals"] == {"files": 0, "additions": 0, "deletions": 0}
|
||||
|
||||
|
||||
def test_collect_workspace_diff_groups_staged_unstaged_and_untracked(tmp_path: Path) -> None:
|
||||
init_repo(tmp_path)
|
||||
(tmp_path / "tracked.txt").write_text("one\nstaged\n", encoding="utf-8")
|
||||
run_git(tmp_path, "add", "tracked.txt")
|
||||
(tmp_path / "tracked.txt").write_text("one\nstaged\nunstaged\n", encoding="utf-8")
|
||||
(tmp_path / "new.txt").write_text("hello\n", encoding="utf-8")
|
||||
|
||||
payload = collect_workspace_diff(str(tmp_path))
|
||||
|
||||
staged = files_for_group(payload, "staged")
|
||||
unstaged = files_for_group(payload, "unstaged")
|
||||
untracked = files_for_group(payload, "untracked")
|
||||
assert staged[0]["path"] == "tracked.txt"
|
||||
assert staged[0]["status"] == "modified"
|
||||
assert "+staged" in staged[0]["patch"]
|
||||
assert unstaged[0]["path"] == "tracked.txt"
|
||||
assert "+unstaged" in unstaged[0]["patch"]
|
||||
assert untracked[0]["path"] == "new.txt"
|
||||
assert untracked[0]["status"] == "untracked"
|
||||
assert "+hello" in untracked[0]["patch"]
|
||||
assert payload["totals"]["files"] == 2
|
||||
assert payload["totals"]["additions"] == 3
|
||||
|
||||
|
||||
def test_collect_workspace_diff_ignores_zero_line_gitkeep(tmp_path: Path) -> None:
|
||||
init_repo(tmp_path)
|
||||
(tmp_path / ".gitkeep").write_text("", encoding="utf-8")
|
||||
(tmp_path / "nested").mkdir()
|
||||
(tmp_path / "nested" / ".gitkeep").write_text("", encoding="utf-8")
|
||||
(tmp_path / "real.txt").write_text("real\n", encoding="utf-8")
|
||||
run_git(tmp_path, "add", ".gitkeep")
|
||||
|
||||
payload = collect_workspace_diff(str(tmp_path))
|
||||
paths = [
|
||||
item["path"]
|
||||
for group in payload["groups"]
|
||||
for item in group["files"]
|
||||
]
|
||||
|
||||
assert ".gitkeep" not in paths
|
||||
assert "nested/.gitkeep" not in paths
|
||||
assert paths == ["real.txt"]
|
||||
assert payload["totals"] == {"files": 1, "additions": 1, "deletions": 0}
|
||||
|
||||
|
||||
def test_collect_workspace_diff_deleted_renamed_binary_large_and_a0_exclusion(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
init_repo(tmp_path)
|
||||
(tmp_path / "rename_me.txt").write_text("move\n", encoding="utf-8")
|
||||
(tmp_path / "delete_me.txt").write_text("delete\n", encoding="utf-8")
|
||||
run_git(tmp_path, "add", "rename_me.txt", "delete_me.txt")
|
||||
run_git(tmp_path, "commit", "-m", "fixtures")
|
||||
|
||||
run_git(tmp_path, "mv", "rename_me.txt", "renamed.txt")
|
||||
(tmp_path / "delete_me.txt").unlink()
|
||||
(tmp_path / "binary.bin").write_bytes(b"\x00\x01data")
|
||||
(tmp_path / ".a0proj").mkdir()
|
||||
(tmp_path / ".a0proj" / "project.json").write_text("{}", encoding="utf-8")
|
||||
monkeypatch.setattr(diff_helper, "MAX_UNTRACKED_BYTES", 10)
|
||||
(tmp_path / "large.txt").write_text("0123456789\n" * 5, encoding="utf-8")
|
||||
|
||||
payload = collect_workspace_diff(str(tmp_path))
|
||||
staged = files_for_group(payload, "staged")
|
||||
unstaged = files_for_group(payload, "unstaged")
|
||||
untracked = files_for_group(payload, "untracked")
|
||||
|
||||
git_changes = staged + unstaged
|
||||
statuses = {(item["path"], item["status"]) for item in git_changes}
|
||||
assert ("delete_me.txt", "deleted") in statuses
|
||||
assert ("renamed.txt", "renamed") in statuses
|
||||
renamed = next(item for item in git_changes if item["path"] == "renamed.txt")
|
||||
assert renamed["old_path"] == "rename_me.txt"
|
||||
assert renamed["additions"] == 0
|
||||
assert renamed["deletions"] == 0
|
||||
binary = next(item for item in untracked if item["path"] == "binary.bin")
|
||||
assert binary["binary"] is True
|
||||
large = next(item for item in untracked if item["path"] == "large.txt")
|
||||
assert large["too_large"] is True
|
||||
assert all(not item["path"].startswith(".a0proj") for item in untracked)
|
||||
|
||||
|
||||
def test_collect_workspace_diff_limits_nested_workspace_to_pathspec(tmp_path: Path) -> None:
|
||||
init_repo(tmp_path)
|
||||
(tmp_path / "outside.txt").write_text("outside\n", encoding="utf-8")
|
||||
(tmp_path / "nested").mkdir()
|
||||
(tmp_path / "nested" / "inside.txt").write_text("inside\n", encoding="utf-8")
|
||||
|
||||
payload = collect_workspace_diff(str(tmp_path / "nested"))
|
||||
untracked = files_for_group(payload, "untracked")
|
||||
|
||||
assert [item["path"] for item in untracked] == ["inside.txt"]
|
||||
|
||||
|
||||
def test_diff_api_resolves_project_context_workspace(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
pytest.importorskip("whisper")
|
||||
pytest.importorskip("langchain_core")
|
||||
from plugins._diff_viewer.api import diff as diff_api
|
||||
|
||||
init_repo(tmp_path)
|
||||
(tmp_path / "changed.txt").write_text("changed\n", encoding="utf-8")
|
||||
handler = diff_api.Diff(Flask("diff-test"), threading.RLock())
|
||||
monkeypatch.setattr(handler, "use_context", lambda context_id: SimpleNamespace(id=context_id))
|
||||
monkeypatch.setattr(diff_api.projects, "get_context_project_name", lambda _context: "demo")
|
||||
monkeypatch.setattr(diff_api.projects, "get_project_folder", lambda _name: str(tmp_path))
|
||||
monkeypatch.setattr(diff_api.files, "normalize_a0_path", lambda path: path)
|
||||
monkeypatch.setattr(diff_api.files, "fix_dev_path", lambda path: path)
|
||||
|
||||
payload = asyncio.run(handler.process({"context_id": "ctx-project"}, None))
|
||||
|
||||
assert isinstance(payload, dict)
|
||||
assert payload["ok"] is True
|
||||
assert payload["context_id"] == "ctx-project"
|
||||
assert payload["workspace_path"] == str(tmp_path)
|
||||
assert files_for_group(payload, "untracked")[0]["path"] == "changed.txt"
|
||||
|
||||
|
||||
def test_diff_api_falls_back_to_default_workdir(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
pytest.importorskip("whisper")
|
||||
pytest.importorskip("langchain_core")
|
||||
from plugins._diff_viewer.api import diff as diff_api
|
||||
|
||||
init_repo(tmp_path)
|
||||
(tmp_path / "workdir.txt").write_text("workdir\n", encoding="utf-8")
|
||||
handler = diff_api.Diff(Flask("diff-test-default"), threading.RLock())
|
||||
monkeypatch.setattr(diff_api.settings, "get_settings", lambda: {"workdir_path": str(tmp_path)})
|
||||
monkeypatch.setattr(diff_api.files, "fix_dev_path", lambda path: path)
|
||||
|
||||
payload = asyncio.run(handler.process({}, None))
|
||||
|
||||
assert isinstance(payload, dict)
|
||||
assert payload["workspace_path"] == str(tmp_path)
|
||||
assert files_for_group(payload, "untracked")[0]["path"] == "workdir.txt"
|
||||
262
tests/test_time_travel.py
Normal file
262
tests/test_time_travel.py
Normal file
|
|
@ -0,0 +1,262 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from types import ModuleType, SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from plugins._time_travel.helpers import time_travel as tt
|
||||
from plugins._time_travel.helpers.time_travel import (
|
||||
TimeTravelConflictError,
|
||||
TimeTravelError,
|
||||
TimeTravelService,
|
||||
WorkspaceRejectedError,
|
||||
_workspace_from_display,
|
||||
resolve_workspace,
|
||||
)
|
||||
|
||||
|
||||
def run_git(repo_dir: Path, *args: str, check: bool = True) -> str:
|
||||
completed = subprocess.run(
|
||||
["git", "-C", str(repo_dir), *args],
|
||||
check=check,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
)
|
||||
return completed.stdout.strip()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def workspace():
|
||||
name = f"tt-{uuid.uuid4().hex}"
|
||||
root = PROJECT_ROOT / "usr" / "time-travel-tests" / name
|
||||
root.mkdir(parents=True)
|
||||
service = TimeTravelService(_workspace_from_display(f"/a0/usr/time-travel-tests/{name}"))
|
||||
try:
|
||||
yield root, service
|
||||
finally:
|
||||
shutil.rmtree(root, ignore_errors=True)
|
||||
shutil.rmtree(service.workspace.shadow_path, ignore_errors=True)
|
||||
|
||||
|
||||
def tracked_paths(service: TimeTravelService, commit_hash: str = "HEAD") -> set[str]:
|
||||
output = service._git("ls-tree", "-r", "--name-only", commit_hash).stdout
|
||||
return {line.strip() for line in output.splitlines() if line.strip()}
|
||||
|
||||
|
||||
def test_shadow_history_snapshot_diff_travel_preserve_refs_and_root_revert(workspace):
|
||||
root, service = workspace
|
||||
(root / "a.txt").write_text("one\n", encoding="utf-8")
|
||||
|
||||
initial = service.snapshot(trigger="manual", metadata={"context_id": "ctx"})
|
||||
duplicate = service.snapshot(trigger="manual")
|
||||
|
||||
assert initial.created is True
|
||||
assert duplicate.created is False
|
||||
assert duplicate.hash == initial.hash
|
||||
assert (service.workspace.repo_git_path / "objects").is_dir()
|
||||
|
||||
(root / "a.txt").write_text("one\ntwo\n", encoding="utf-8")
|
||||
present = service.present_summary()
|
||||
assert present["dirty"] is True
|
||||
assert present["files"][0]["path"] == "a.txt"
|
||||
assert "+two" in service.history_diff(commit_hash=initial.hash, path="a.txt", mode="present")["patch"]
|
||||
|
||||
second = service.snapshot(trigger="tool", metadata={"tool_name": "code_execution_tool"})
|
||||
history = service.history_list(limit=10)
|
||||
assert [commit["hash"] for commit in history["commits"][:2]] == [second.hash, initial.hash]
|
||||
assert history["commits"][0]["metadata"]["tool_name"] == "code_execution_tool"
|
||||
assert "+two" in service.history_diff(commit_hash=second.hash, path="a.txt", mode="commit")["patch"]
|
||||
|
||||
service.travel(commit_hash=initial.hash)
|
||||
assert (root / "a.txt").read_text(encoding="utf-8") == "one\n"
|
||||
preserved = service._git(
|
||||
"for-each-ref",
|
||||
"--format=%(objectname)",
|
||||
"refs/a0-time-travel/preserved",
|
||||
).stdout
|
||||
assert second.hash in preserved
|
||||
assert second.hash in [commit["hash"] for commit in service.history_list(limit=10)["commits"]]
|
||||
|
||||
reverted = service.revert(commit_hash=initial.hash)
|
||||
assert reverted["ok"] is True
|
||||
assert not (root / "a.txt").exists()
|
||||
assert reverted["snapshot"]["created"] is True
|
||||
|
||||
|
||||
def test_revert_conflict_auto_snapshots_present_without_losing_changes(workspace):
|
||||
root, service = workspace
|
||||
(root / "a.txt").write_text("one\n", encoding="utf-8")
|
||||
first = service.snapshot(trigger="manual")
|
||||
(root / "a.txt").write_text("one\ntwo\n", encoding="utf-8")
|
||||
second = service.snapshot(trigger="manual")
|
||||
(root / "a.txt").write_text("custom\n", encoding="utf-8")
|
||||
|
||||
with pytest.raises(TimeTravelConflictError):
|
||||
service.revert(commit_hash=second.hash)
|
||||
|
||||
assert (root / "a.txt").read_text(encoding="utf-8") == "custom\n"
|
||||
assert service.current_hash() not in {first.hash, second.hash}
|
||||
assert "custom" in service.history_diff(commit_hash=service.current_hash(), path="a.txt", mode="commit")["patch"]
|
||||
|
||||
|
||||
def test_kernel_boundary_real_git_repo_and_git_dir_exclusion(workspace):
|
||||
root, service = workspace
|
||||
with pytest.raises(WorkspaceRejectedError):
|
||||
_workspace_from_display("/tmp/outside")
|
||||
|
||||
run_git(root, "init")
|
||||
run_git(root, "config", "user.name", "Test User")
|
||||
run_git(root, "config", "user.email", "test@example.com")
|
||||
(root / "tracked.txt").write_text("tracked\n", encoding="utf-8")
|
||||
run_git(root, "add", "tracked.txt")
|
||||
run_git(root, "commit", "-m", "real initial")
|
||||
real_head = run_git(root, "rev-parse", "HEAD")
|
||||
(root / "untracked.txt").write_text("shadow only\n", encoding="utf-8")
|
||||
real_status_before = run_git(root, "status", "--short")
|
||||
|
||||
snapshot = service.snapshot(trigger="manual")
|
||||
|
||||
assert snapshot.created is True
|
||||
assert run_git(root, "rev-parse", "HEAD") == real_head
|
||||
assert run_git(root, "status", "--short") == real_status_before
|
||||
assert all(not path.startswith(".git/") and path != ".git" for path in tracked_paths(service))
|
||||
|
||||
|
||||
def test_metadata_policy_tracks_safe_project_files_and_preserves_exclusions(workspace):
|
||||
root, service = workspace
|
||||
(root / "src").mkdir()
|
||||
(root / "src" / "app.py").write_text("print('one')\n", encoding="utf-8")
|
||||
(root / ".a0proj" / "instructions").mkdir(parents=True)
|
||||
(root / ".a0proj" / "knowledge").mkdir(parents=True)
|
||||
(root / ".a0proj" / "skills" / "demo").mkdir(parents=True)
|
||||
(root / ".a0proj" / "plugins" / "demo").mkdir(parents=True)
|
||||
(root / ".a0proj" / "memory").mkdir(parents=True)
|
||||
(root / "node_modules").mkdir()
|
||||
(root / "dist").mkdir()
|
||||
(root / "__pycache__").mkdir()
|
||||
(root / ".a0proj" / "project.json").write_text("{}", encoding="utf-8")
|
||||
(root / ".a0proj" / "agents.json").write_text("{}", encoding="utf-8")
|
||||
(root / ".a0proj" / "instructions" / "one.md").write_text("i\n", encoding="utf-8")
|
||||
(root / ".a0proj" / "knowledge" / "one.md").write_text("k\n", encoding="utf-8")
|
||||
(root / ".a0proj" / "skills" / "demo" / "SKILL.md").write_text("s\n", encoding="utf-8")
|
||||
(root / ".a0proj" / "plugins" / "demo" / "config.json").write_text("{}", encoding="utf-8")
|
||||
(root / ".a0proj" / "plugins" / "demo" / "presets.yaml").write_text("[]\n", encoding="utf-8")
|
||||
(root / ".a0proj" / "plugins" / "demo" / "state.json").write_text('{"state": true}\n', encoding="utf-8")
|
||||
(root / ".a0proj" / "secrets.env").write_text("SECRET=one\n", encoding="utf-8")
|
||||
(root / ".a0proj" / "variables.env").write_text("VAR=one\n", encoding="utf-8")
|
||||
(root / ".a0proj" / "memory" / "index.faiss").write_bytes(b"memory")
|
||||
(root / ".env").write_text("TOKEN=one\n", encoding="utf-8")
|
||||
(root / "node_modules" / "pkg.js").write_text("pkg\n", encoding="utf-8")
|
||||
(root / "dist" / "bundle.js").write_text("dist\n", encoding="utf-8")
|
||||
(root / "__pycache__" / "app.pyc").write_bytes(b"pyc")
|
||||
|
||||
first = service.snapshot(trigger="manual")
|
||||
paths = tracked_paths(service, first.hash)
|
||||
|
||||
assert "src/app.py" in paths
|
||||
assert ".a0proj/project.json" in paths
|
||||
assert ".a0proj/agents.json" in paths
|
||||
assert ".a0proj/instructions/one.md" in paths
|
||||
assert ".a0proj/knowledge/one.md" in paths
|
||||
assert ".a0proj/skills/demo/SKILL.md" in paths
|
||||
assert ".a0proj/plugins/demo/config.json" in paths
|
||||
assert ".a0proj/plugins/demo/presets.yaml" in paths
|
||||
assert ".a0proj/plugins/demo/state.json" not in paths
|
||||
assert ".a0proj/secrets.env" not in paths
|
||||
assert ".a0proj/variables.env" not in paths
|
||||
assert ".a0proj/memory/index.faiss" not in paths
|
||||
assert ".env" not in paths
|
||||
assert "node_modules/pkg.js" not in paths
|
||||
assert "dist/bundle.js" not in paths
|
||||
assert "__pycache__/app.pyc" not in paths
|
||||
|
||||
(root / "src" / "app.py").write_text("print('two')\n", encoding="utf-8")
|
||||
(root / ".a0proj" / "secrets.env").write_text("SECRET=two\n", encoding="utf-8")
|
||||
service.snapshot(trigger="manual")
|
||||
service.travel(commit_hash=first.hash)
|
||||
|
||||
assert (root / "src" / "app.py").read_text(encoding="utf-8") == "print('one')\n"
|
||||
assert (root / ".a0proj" / "secrets.env").read_text(encoding="utf-8") == "SECRET=two\n"
|
||||
|
||||
|
||||
def test_symlink_entries_are_snapshotted_and_deleted_without_following_targets(workspace, tmp_path: Path):
|
||||
root, service = workspace
|
||||
outside = tmp_path / "outside.txt"
|
||||
outside.write_text("outside\n", encoding="utf-8")
|
||||
os.symlink(outside, root / "outside-link")
|
||||
|
||||
first = service.snapshot(trigger="manual")
|
||||
assert "outside-link" in tracked_paths(service, first.hash)
|
||||
assert service._git("ls-tree", "HEAD", "outside-link").stdout.startswith("120000")
|
||||
|
||||
(root / "outside-link").unlink()
|
||||
second = service.snapshot(trigger="manual")
|
||||
assert outside.exists()
|
||||
|
||||
service.travel(commit_hash=first.hash)
|
||||
assert (root / "outside-link").is_symlink()
|
||||
assert outside.exists()
|
||||
|
||||
service.travel(commit_hash=second.hash)
|
||||
assert not (root / "outside-link").exists()
|
||||
assert outside.exists()
|
||||
|
||||
|
||||
def test_pagination_large_diff_and_invalid_inputs(workspace, monkeypatch: pytest.MonkeyPatch):
|
||||
root, service = workspace
|
||||
(root / "file.txt").write_text("0\n", encoding="utf-8")
|
||||
hashes = [service.snapshot(trigger="manual").hash]
|
||||
for index in range(1, 4):
|
||||
(root / "file.txt").write_text(("x\n" * index), encoding="utf-8")
|
||||
hashes.append(service.snapshot(trigger="manual").hash)
|
||||
|
||||
page = service.history_list(limit=2)
|
||||
assert len(page["commits"]) == 2
|
||||
assert page["has_more"] is True
|
||||
page2 = service.history_list(limit=2, offset=2)
|
||||
assert page2["commits"][0]["hash"] == hashes[1]
|
||||
|
||||
monkeypatch.setattr(tt, "MAX_RENDERED_PATCH_BYTES", 30)
|
||||
diff = service.history_diff(commit_hash=hashes[-1], path="file.txt", mode="commit")
|
||||
assert diff["too_large"] is True
|
||||
assert len(diff["patch"].encode("utf-8")) <= 30
|
||||
|
||||
with pytest.raises(TimeTravelError):
|
||||
service.history_diff(commit_hash="not-a-commit", path="file.txt", mode="commit")
|
||||
with pytest.raises(TimeTravelError):
|
||||
service.history_diff(commit_hash=hashes[-1], path="../file.txt", mode="commit")
|
||||
|
||||
|
||||
def test_workspace_resolution_prefers_project_and_rejects_external_paths(monkeypatch: pytest.MonkeyPatch, workspace):
|
||||
root, _service = workspace
|
||||
projects_mod = ModuleType("helpers.projects")
|
||||
projects_mod.get_context_project_name = lambda _context: "demo"
|
||||
projects_mod.get_project_folder = lambda _name: str(root)
|
||||
settings_mod = ModuleType("helpers.settings")
|
||||
settings_mod.get_settings = lambda: {"workdir_path": "/tmp/not-a0"}
|
||||
|
||||
import helpers
|
||||
|
||||
monkeypatch.setitem(sys.modules, "helpers.projects", projects_mod)
|
||||
monkeypatch.setitem(sys.modules, "helpers.settings", settings_mod)
|
||||
monkeypatch.setattr(helpers, "projects", projects_mod, raising=False)
|
||||
monkeypatch.setattr(helpers, "settings", settings_mod, raising=False)
|
||||
|
||||
resolved = resolve_workspace("ctx", context_loader=lambda _ctxid: SimpleNamespace(id="ctx"))
|
||||
assert resolved.project_name == "demo"
|
||||
assert resolved.display_path.startswith("/a0/usr/time-travel-tests/")
|
||||
|
||||
projects_mod.get_context_project_name = lambda _context: ""
|
||||
with pytest.raises(WorkspaceRejectedError):
|
||||
resolve_workspace("ctx", context_loader=lambda _ctxid: SimpleNamespace(id="ctx"))
|
||||
|
|
@ -58,8 +58,8 @@ function modalSuppressesBackdrop(modal) {
|
|||
|| path === "plugins/_browser/webui/main.html"
|
||||
|| path === "/plugins/_office/webui/main.html"
|
||||
|| path === "plugins/_office/webui/main.html"
|
||||
|| path === "/plugins/_diff_viewer/webui/main.html"
|
||||
|| path === "plugins/_diff_viewer/webui/main.html"
|
||||
|| path === "/plugins/_time_travel/webui/main.html"
|
||||
|| path === "plugins/_time_travel/webui/main.html"
|
||||
|| modal?.element?.classList?.contains("modal-floating")
|
||||
|| modal?.element?.classList?.contains("modal-no-backdrop")
|
||||
|| modal?.inner?.classList?.contains("modal-no-backdrop");
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue