Add right canvas diff viewer

Adds the core _diff_viewer plugin for viewing staged, unstaged, and untracked working-tree changes in the right canvas and window modal. Includes context-aware workspace resolution, safe read-only Git collection, zero-line .gitkeep filtering, unified diff rendering, and focused diff collection tests.

Alessandro committed Apr 26, 2026 at 23:52 UTC 58a5f8276b8ee42d999311b839b36d6194882cda
12 files changed +1540
plugins/_diff_viewer/api/diff.py new
+29
@@ -0,0 +1,29 @@
1 +from __future__ import annotations
2 +
3 +from helpers import files, projects, settings
4 +from helpers.api import ApiHandler, Request, Response
5 +from plugins._diff_viewer.helpers.diff import collect_workspace_diff
6 +
7 +
8 +class Diff(ApiHandler):
9 + async def process(self, input: dict, request: Request) -> dict | Response:
10 + context_id = str(input.get("context_id") or "").strip()
11 + workspace_path, display_path = self._resolve_workspace(context_id)
12 + return collect_workspace_diff(
13 + workspace_path,
14 + context_id=context_id,
15 + display_path=display_path,
16 + )
17 +
18 + def _resolve_workspace(self, context_id: str) -> tuple[str, str]:
19 + if context_id:
20 + context = self.use_context(context_id)
21 + project_name = projects.get_context_project_name(context)
22 + if project_name:
23 + project_path = projects.get_project_folder(project_name)
24 + display_path = files.normalize_a0_path(project_path)
25 + return files.fix_dev_path(display_path), display_path
26 +
27 + configured = str(settings.get_settings().get("workdir_path") or "")
28 + display_path = configured or files.normalize_a0_path(files.get_abs_path("usr/workdir"))
29 + return files.fix_dev_path(display_path), display_path
plugins/_diff_viewer/extensions/webui/apply_snapshot_before/refresh-diff-viewer.js new
+9
@@ -0,0 +1,9 @@
1 +export default function refreshDiffViewerOnContextChange(ctx) {
2 + const diffViewer = globalThis.Alpine?.store?.("diffViewer");
3 + const canvas = globalThis.Alpine?.store?.("rightCanvas");
4 + if (!diffViewer || !canvas?.isOpen || canvas.activeSurfaceId !== "diff") return;
5 + const nextContextId = String(ctx?.snapshot?.context || "");
6 + if (nextContextId && nextContextId !== diffViewer.contextId) {
7 + diffViewer.scheduleRefresh({ contextId: nextContextId, reason: "context-change" });
8 + }
9 +}
plugins/_diff_viewer/extensions/webui/right-canvas-panels/diff-viewer-panel.html new
+8
@@ -0,0 +1,8 @@
1 +<div
2 + class="right-canvas-surface-panel diff-viewer-canvas-surface"
3 + data-surface-id="diff"
4 + x-show="$store.rightCanvas && $store.rightCanvas.isOpen && $store.rightCanvas.activeSurfaceId === 'diff'"
5 + style="display: none;"
6 +>
7 + <x-component path="/plugins/_diff_viewer/webui/diff-viewer-panel.html" mode="canvas"></x-component>
8 +</div>
plugins/_diff_viewer/extensions/webui/right_canvas_register_surfaces/register-diff-viewer.js new
+37
@@ -0,0 +1,37 @@
1 +function waitForElement(selector, timeoutMs = 3000) {
2 + const found = document.querySelector(selector);
3 + if (found) return Promise.resolve(found);
4 + return new Promise((resolve) => {
5 + const timeout = globalThis.setTimeout(() => {
6 + observer.disconnect();
7 + resolve(document.querySelector(selector));
8 + }, timeoutMs);
9 + const observer = new MutationObserver(() => {
10 + const element = document.querySelector(selector);
11 + if (!element) return;
12 + globalThis.clearTimeout(timeout);
13 + observer.disconnect();
14 + resolve(element);
15 + });
16 + observer.observe(document.body, { childList: true, subtree: true });
17 + });
18 +}
19 +
20 +export default async function registerDiffViewerSurface(canvas) {
21 + canvas.registerSurface({
22 + id: "diff",
23 + title: "Diff",
24 + icon: "difference",
25 + order: 30,
26 + modalPath: "/plugins/_diff_viewer/webui/main.html",
27 + async open(payload = {}) {
28 + await waitForElement('[data-surface-id="diff"] .diff-viewer-panel');
29 + const diffViewer = globalThis.Alpine?.store?.("diffViewer");
30 + await diffViewer?.onOpen?.(payload);
31 + },
32 + async close() {
33 + const diffViewer = globalThis.Alpine?.store?.("diffViewer");
34 + diffViewer?.cleanup?.();
35 + },
36 + });
37 +}
plugins/_diff_viewer/helpers/__init__.py new
+1
@@ -0,0 +1 @@
1 +"""Helpers for the built-in diff viewer plugin."""
plugins/_diff_viewer/helpers/diff.py new
+347
@@ -0,0 +1,347 @@
1 +from __future__ import annotations
2 +
3 +import os
4 +import subprocess
5 +from pathlib import Path
6 +from typing import Any
7 +
8 +from helpers import files
9 +
10 +
11 +MAX_PATCH_LINES = 2500
12 +MAX_PATCH_BYTES = 240_000
13 +MAX_UNTRACKED_BYTES = 180_000
14 +GIT_TIMEOUT_SECONDS = 8
15 +
16 +GROUP_ORDER = ("staged", "unstaged", "untracked")
17 +STATUS_LABELS = {
18 + "A": "added",
19 + "C": "copied",
20 + "D": "deleted",
21 + "M": "modified",
22 + "R": "renamed",
23 + "T": "type_changed",
24 + "U": "unmerged",
25 + "?": "untracked",
26 +}
27 +
28 +
29 +class GitDiffError(RuntimeError):
30 + pass
31 +
32 +
33 +def collect_workspace_diff(
34 + workspace_path: str,
35 + *,
36 + context_id: str = "",
37 + display_path: str | None = None,
38 +) -> dict[str, Any]:
39 + workspace = Path(workspace_path).expanduser().resolve()
40 + display = display_path or str(workspace)
41 +
42 + if not workspace.exists() or not workspace.is_dir():
43 + return {
44 + "ok": False,
45 + "context_id": context_id,
46 + "workspace_path": display,
47 + "is_git_repo": False,
48 + "error": "Workspace path does not exist or is not a directory.",
49 + "branch": "",
50 + "totals": {"files": 0, "additions": 0, "deletions": 0},
51 + "groups": _empty_groups(),
52 + }
53 +
54 + if not _is_git_repo(workspace):
55 + return {
56 + "ok": True,
57 + "context_id": context_id,
58 + "workspace_path": display,
59 + "is_git_repo": False,
60 + "branch": "",
61 + "totals": {"files": 0, "additions": 0, "deletions": 0},
62 + "groups": _empty_groups(),
63 + }
64 +
65 + groups = [
66 + {"kind": "staged", "files": _collect_diff_group(workspace, "staged")},
67 + {"kind": "unstaged", "files": _collect_diff_group(workspace, "unstaged")},
68 + {"kind": "untracked", "files": _collect_untracked_group(workspace)},
69 + ]
70 +
71 + seen_paths: set[str] = set()
72 + additions = 0
73 + deletions = 0
74 + for group in groups:
75 + for item in group["files"]:
76 + seen_paths.add(str(item.get("path") or item.get("old_path") or ""))
77 + additions += int(item.get("additions") or 0)
78 + deletions += int(item.get("deletions") or 0)
79 +
80 + return {
81 + "ok": True,
82 + "context_id": context_id,
83 + "workspace_path": display,
84 + "is_git_repo": True,
85 + "branch": _branch_name(workspace),
86 + "totals": {
87 + "files": len([path for path in seen_paths if path]),
88 + "additions": additions,
89 + "deletions": deletions,
90 + },
91 + "groups": groups,
92 + }
93 +
94 +
95 +def _empty_groups() -> list[dict[str, Any]]:
96 + return [{"kind": kind, "files": []} for kind in GROUP_ORDER]
97 +
98 +
99 +def _git(workspace: Path, *args: str, check: bool = True) -> subprocess.CompletedProcess[str]:
100 + env = os.environ.copy()
101 + env["GIT_TERMINAL_PROMPT"] = "0"
102 + env["GIT_OPTIONAL_LOCKS"] = "0"
103 + completed = subprocess.run(
104 + ["git", "-C", str(workspace), *args],
105 + capture_output=True,
106 + text=True,
107 + encoding="utf-8",
108 + errors="replace",
109 + env=env,
110 + timeout=GIT_TIMEOUT_SECONDS,
111 + )
112 + if check and completed.returncode != 0:
113 + message = (completed.stderr or completed.stdout or "Git command failed.").strip()
114 + raise GitDiffError(message)
115 + return completed
116 +
117 +
118 +def _is_git_repo(workspace: Path) -> bool:
119 + return _git(workspace, "rev-parse", "--is-inside-work-tree", check=False).returncode == 0
120 +
121 +
122 +def _branch_name(workspace: Path) -> str:
123 + branch = _git(workspace, "branch", "--show-current", check=False).stdout.strip()
124 + if branch:
125 + return branch
126 + return _git(workspace, "rev-parse", "--short", "HEAD", check=False).stdout.strip()
127 +
128 +
129 +def _collect_diff_group(workspace: Path, kind: str) -> list[dict[str, Any]]:
130 + cached_args = ["--cached"] if kind == "staged" else []
131 + status_output = _git(
132 + workspace,
133 + "diff",
134 + *cached_args,
135 + "--name-status",
136 + "-z",
137 + "--find-renames",
138 + "--relative",
139 + "--",
140 + ".",
141 + ).stdout
142 + entries = _parse_name_status(status_output)
143 + result: list[dict[str, Any]] = []
144 +
145 + for entry in entries:
146 + path = entry["path"]
147 + old_path = entry.get("old_path", "")
148 + if _is_a0_metadata_path(path) and (not old_path or _is_a0_metadata_path(old_path)):
149 + continue
150 +
151 + stat_paths = [candidate for candidate in (old_path, path) if candidate]
152 + additions, deletions, binary = _diff_numstat(workspace, kind, stat_paths)
153 + if _is_zero_line_gitkeep_change(path, old_path, additions, deletions):
154 + continue
155 +
156 + too_large = additions + deletions > MAX_PATCH_LINES
157 + patch = ""
158 + if not binary and not too_large:
159 + patch = _diff_patch(workspace, kind, stat_paths)
160 + if len(patch.encode("utf-8", errors="replace")) > MAX_PATCH_BYTES:
161 + patch = ""
162 + too_large = True
163 +
164 + result.append(
165 + {
166 + "path": path,
167 + "old_path": old_path,
168 + "status": STATUS_LABELS.get(entry["status"], entry["status"].lower()),
169 + "additions": additions,
170 + "deletions": deletions,
171 + "binary": binary,
172 + "too_large": too_large,
173 + "patch": patch,
174 + }
175 + )
176 +
177 + return result
178 +
179 +
180 +def _collect_untracked_group(workspace: Path) -> list[dict[str, Any]]:
181 + output = _git(
182 + workspace,
183 + "ls-files",
184 + "--others",
185 + "--exclude-standard",
186 + "-z",
187 + "--",
188 + ".",
189 + ).stdout
190 + result: list[dict[str, Any]] = []
191 + for path in [part for part in output.split("\0") if part]:
192 + path = path.replace("\\", "/")
193 + if _is_a0_metadata_path(path):
194 + continue
195 + item = _untracked_file_diff(workspace, path)
196 + if _is_zero_line_gitkeep_change(item["path"], item.get("old_path", ""), item["additions"], item["deletions"]):
197 + continue
198 + result.append(item)
199 + result.sort(key=lambda item: item["path"])
200 + return result
201 +
202 +
203 +def _parse_name_status(output: str) -> list[dict[str, str]]:
204 + parts = [part for part in output.split("\0") if part]
205 + entries: list[dict[str, str]] = []
206 + index = 0
207 + while index < len(parts):
208 + raw_status = parts[index]
209 + index += 1
210 + status = raw_status[:1]
211 + if status in {"R", "C"} and index + 1 < len(parts):
212 + old_path = parts[index].replace("\\", "/")
213 + new_path = parts[index + 1].replace("\\", "/")
214 + index += 2
215 + entries.append({"status": status, "old_path": old_path, "path": new_path})
216 + continue
217 + if index < len(parts):
218 + path = parts[index].replace("\\", "/")
219 + index += 1
220 + entries.append({"status": status, "path": path, "old_path": ""})
221 + entries.sort(key=lambda item: item.get("path") or item.get("old_path") or "")
222 + return entries
223 +
224 +
225 +def _diff_numstat(workspace: Path, kind: str, paths: list[str]) -> tuple[int, int, bool]:
226 + args = ["diff"]
227 + if kind == "staged":
228 + args.append("--cached")
229 + output = _git(
230 + workspace,
231 + *args,
232 + "--numstat",
233 + "--find-renames",
234 + "--relative",
235 + "--",
236 + *paths,
237 + ).stdout
238 + first = next((line for line in output.splitlines() if line.strip()), "")
239 + if not first:
240 + return 0, 0, False
241 + parts = first.split("\t")
242 + if len(parts) < 2:
243 + return 0, 0, False
244 + if parts[0] == "-" or parts[1] == "-":
245 + return 0, 0, True
246 + return _safe_int(parts[0]), _safe_int(parts[1]), False
247 +
248 +
249 +def _diff_patch(workspace: Path, kind: str, paths: list[str]) -> str:
250 + args = ["diff"]
251 + if kind == "staged":
252 + args.append("--cached")
253 + return _git(
254 + workspace,
255 + *args,
256 + "--patch",
257 + "--find-renames",
258 + "--relative",
259 + "--",
260 + *paths,
261 + ).stdout
262 +
263 +
264 +def _untracked_file_diff(workspace: Path, path: str) -> dict[str, Any]:
265 + file_path = (workspace / path).resolve()
266 + additions = 0
267 + binary = False
268 + too_large = False
269 + patch = ""
270 +
271 + try:
272 + with open(file_path, "rb") as handle:
273 + data = handle.read(MAX_UNTRACKED_BYTES + 1)
274 + too_large = len(data) > MAX_UNTRACKED_BYTES
275 + sample = data[: min(len(data), 10 * 1024)]
276 + binary = files.is_probably_binary_bytes(sample)
277 + if not binary and not too_large:
278 + text = data.decode("utf-8", errors="replace")
279 + lines = text.splitlines()
280 + additions = len(lines)
281 + if len(lines) > MAX_PATCH_LINES:
282 + too_large = True
283 + else:
284 + patch = _synthetic_untracked_patch(path, lines, text.endswith("\n"))
285 + elif not binary:
286 + additions = _count_newlines(data[:MAX_UNTRACKED_BYTES])
287 + except OSError:
288 + too_large = True
289 +
290 + return {
291 + "path": path,
292 + "old_path": "",
293 + "status": "untracked",
294 + "additions": additions,
295 + "deletions": 0,
296 + "binary": binary,
297 + "too_large": too_large,
298 + "patch": patch,
299 + }
300 +
301 +
302 +def _synthetic_untracked_patch(path: str, lines: list[str], has_trailing_newline: bool) -> str:
303 + escaped = path.replace("\t", "\\t")
304 + header = [
305 + f"diff --git a/{escaped} b/{escaped}",
306 + "new file mode 100644",
307 + "index 0000000..0000000",
308 + "--- /dev/null",
309 + f"+++ b/{escaped}",
310 + ]
311 + if not lines:
312 + return "\n".join(header) + "\n"
313 + body = [f"@@ -0,0 +1,{len(lines)} @@"]
314 + body.extend(f"+{line}" for line in lines)
315 + if not has_trailing_newline:
316 + body.append("\\ No newline at end of file")
317 + return "\n".join(header + body) + "\n"
318 +
319 +
320 +def _count_newlines(data: bytes) -> int:
321 + if not data:
322 + return 0
323 + count = data.count(b"\n")
324 + return count if data.endswith(b"\n") else count + 1
325 +
326 +
327 +def _safe_int(value: str) -> int:
328 + try:
329 + return max(0, int(value))
330 + except (TypeError, ValueError):
331 + return 0
332 +
333 +
334 +def _is_a0_metadata_path(path: str) -> bool:
335 + normalized = path.replace("\\", "/").lstrip("/")
336 + return normalized == ".a0proj" or normalized.startswith(".a0proj/")
337 +
338 +
339 +def _is_zero_line_gitkeep_change(path: str, old_path: str, additions: int, deletions: int) -> bool:
340 + if additions != 0 or deletions != 0:
341 + return False
342 + candidates = [path, old_path]
343 + return any(
344 + candidate.replace("\\", "/").rstrip("/").split("/")[-1] == ".gitkeep"
345 + for candidate in candidates
346 + if candidate
347 + )
plugins/_diff_viewer/plugin.yaml new
+8
@@ -0,0 +1,8 @@
1 +name: _diff_viewer
2 +title: Diff Viewer
3 +description: Right canvas Git working tree diff viewer for the active chat or task workspace.
4 +version: 0.1.0
5 +always_enabled: false
6 +settings_sections: []
7 +per_project_config: false
8 +per_agent_config: false
plugins/_diff_viewer/webui/diff-viewer-panel.html new
+572
@@ -0,0 +1,572 @@
1 +<html>
2 +<head>
3 + <script type="module">
4 + import { store } from "/plugins/_diff_viewer/webui/diff-viewer-store.js";
5 + </script>
6 +</head>
7 +<body>
8 + <div class="diff-viewer-panel" x-data x-create="$store.diffViewer.onMount($el, xAttrs($el) || {})" x-destroy="$store.diffViewer.cleanup()">
9 + <template x-if="$store.diffViewer">
10 + <div class="diff-viewer-shell">
11 + <div class="diff-viewer-toolbar">
12 + <div class="diff-viewer-title">
13 + <span class="material-symbols-outlined">difference</span>
14 + <span>Review</span>
15 + </div>
16 + <span class="diff-viewer-spacer"></span>
17 + <button type="button" class="diff-viewer-icon-button" title="Expand all" aria-label="Expand all" @click="$store.diffViewer.expandAll()" :disabled="!$store.diffViewer.hasChanges()">
18 + <span class="material-symbols-outlined">unfold_more</span>
19 + </button>
20 + <button type="button" class="diff-viewer-icon-button" title="Collapse all" aria-label="Collapse all" @click="$store.diffViewer.collapseAll()" :disabled="!$store.diffViewer.hasChanges()">
21 + <span class="material-symbols-outlined">unfold_less</span>
22 + </button>
23 + <button type="button" class="diff-viewer-icon-button" title="Refresh" aria-label="Refresh" @click="$store.diffViewer.refresh()" :disabled="$store.diffViewer.loading">
24 + <span class="material-symbols-outlined" :class="{ spinning: $store.diffViewer.loading }">refresh</span>
25 + </button>
26 + </div>
27 +
28 + <div class="diff-viewer-summary">
29 + <div class="diff-viewer-summary-main">
30 + <strong x-text="$store.diffViewer.payload?.totals?.files || 0"></strong>
31 + <span>files changed</span>
32 + <span class="diff-add" x-text="$store.diffViewer.formatSigned($store.diffViewer.payload?.totals?.additions, '+')"></span>
33 + <span class="diff-del" x-text="$store.diffViewer.formatSigned($store.diffViewer.payload?.totals?.deletions, '-')"></span>
34 + </div>
35 + <div class="diff-viewer-summary-meta">
36 + <span x-text="$store.diffViewer.payload?.branch || 'no branch'"></span>
37 + <span class="diff-viewer-dot"></span>
38 + <span :title="$store.diffViewer.workspacePath" x-text="$store.diffViewer.workspacePath || 'workspace'"></span>
39 + </div>
40 + </div>
41 +
42 + <div class="diff-viewer-status" x-show="$store.diffViewer.loading || $store.diffViewer.error" style="display: none;">
43 + <span class="material-symbols-outlined" :class="{ spinning: $store.diffViewer.loading }" x-text="$store.diffViewer.loading ? 'progress_activity' : 'error'"></span>
44 + <span x-text="$store.diffViewer.loading ? 'Loading changes...' : $store.diffViewer.error"></span>
45 + </div>
46 +
47 + <div class="diff-viewer-body">
48 + <template x-if="$store.diffViewer.payload && !$store.diffViewer.payload.is_git_repo && !$store.diffViewer.loading && !$store.diffViewer.error">
49 + <div class="diff-viewer-empty">
50 + <span class="material-symbols-outlined">folder_off</span>
51 + <strong>Not a Git workspace</strong>
52 + <span x-text="$store.diffViewer.workspacePath"></span>
53 + </div>
54 + </template>
55 +
56 + <template x-if="$store.diffViewer.payload?.is_git_repo && !$store.diffViewer.hasChanges() && !$store.diffViewer.loading && !$store.diffViewer.error">
57 + <div class="diff-viewer-empty">
58 + <span class="material-symbols-outlined">check_circle</span>
59 + <strong>No changes</strong>
60 + <span>The selected context workspace is clean.</span>
61 + </div>
62 + </template>
63 +
64 + <div class="diff-viewer-groups" x-show="$store.diffViewer.hasChanges()" style="display: none;">
65 + <template x-for="group in $store.diffViewer.visibleGroups()" :key="group.kind">
66 + <section class="diff-viewer-group">
67 + <header class="diff-viewer-group-header">
68 + <span x-text="$store.diffViewer.groupTitle(group.kind)"></span>
69 + <span class="diff-viewer-count" x-text="group.files.length"></span>
70 + </header>
71 + <template x-for="file in group.files" :key="$store.diffViewer.fileKey(group, file)">
72 + <article class="diff-file">
73 + <button type="button" class="diff-file-header" @click="$store.diffViewer.toggleFile(group, file)" :title="$store.diffViewer.fileTitle(file)">
74 + <span class="material-symbols-outlined diff-file-chevron" x-text="$store.diffViewer.isExpanded(group, file) ? 'expand_less' : 'expand_more'"></span>
75 + <span class="diff-file-name" x-text="$store.diffViewer.fileTitle(file)"></span>
76 + <span class="diff-file-status" x-text="$store.diffViewer.statusLabel(file)"></span>
77 + <span class="diff-file-counts">
78 + <span class="diff-add" x-text="$store.diffViewer.formatSigned(file.additions, '+')"></span>
79 + <span class="diff-del" x-text="$store.diffViewer.formatSigned(file.deletions, '-')"></span>
80 + </span>
81 + </button>
82 +
83 + <div class="diff-file-tools" x-show="$store.diffViewer.isExpanded(group, file)" style="display: none;">
84 + <button type="button" class="diff-viewer-tool-button" title="Open containing folder" @click="$store.diffViewer.openContainingFolder(file)">
85 + <span class="material-symbols-outlined">folder_open</span>
86 + <span>Folder</span>
87 + </button>
88 + <button type="button" class="diff-viewer-tool-button" title="Copy path" @click="$store.diffViewer.copyPath(file)">
89 + <span class="material-symbols-outlined">content_copy</span>
90 + <span>Path</span>
91 + </button>
92 + </div>
93 +
94 + <div class="diff-file-body" x-show="$store.diffViewer.isExpanded(group, file)" style="display: none;">
95 + <template x-if="file.binary">
96 + <div class="diff-file-note">
97 + <span class="material-symbols-outlined">data_object</span>
98 + <span>Binary file changed.</span>
99 + </div>
100 + </template>
101 + <template x-if="file.too_large && !file.binary">
102 + <div class="diff-file-note">
103 + <span class="material-symbols-outlined">text_snippet</span>
104 + <span>Diff is too large to render inline.</span>
105 + </div>
106 + </template>
107 + <template x-if="!file.binary && !file.too_large && file.patch">
108 + <div class="diff-code" role="table" aria-label="Unified diff">
109 + <template x-for="line in $store.diffViewer.patchLines(file)" :key="line.id">
110 + <div class="diff-line" :class="`is-${line.type}`" role="row">
111 + <span class="diff-line-marker" x-text="line.type === 'add' ? '+' : (line.type === 'del' ? '-' : '')"></span>
112 + <code x-text="line.text"></code>
113 + </div>
114 + </template>
115 + </div>
116 + </template>
117 + </div>
118 + </article>
119 + </template>
120 + </section>
121 + </template>
122 + </div>
123 + </div>
124 + </div>
125 + </template>
126 + </div>
127 +
128 + <style>
129 + .diff-viewer-panel,
130 + .diff-viewer-shell {
131 + display: flex;
132 + flex: 1 1 auto;
133 + flex-direction: column;
134 + width: 100%;
135 + height: 100%;
136 + min-width: 0;
137 + min-height: 0;
138 + background: color-mix(in srgb, var(--color-background) 96%, #000 4%);
139 + color: var(--color-text);
140 + }
141 +
142 + .diff-viewer-panel {
143 + container-type: inline-size;
144 + }
145 +
146 + .modal-inner.diff-viewer-modal {
147 + box-sizing: border-box;
148 + container-type: inline-size;
149 + width: min(82vw, 1180px);
150 + height: min(88vh, 900px);
151 + min-width: min(340px, calc(100vw - 16px));
152 + min-height: min(500px, calc(100vh - 16px));
153 + max-width: calc(100vw - 16px);
154 + max-height: calc(100vh - 16px);
155 + resize: both;
156 + border: 1px solid color-mix(in srgb, var(--color-border) 75%, transparent);
157 + border-radius: 7px;
158 + box-shadow: 0 18px 48px rgba(0, 0, 0, 0.32);
159 + background: color-mix(in srgb, var(--color-background) 94%, #000 6%);
160 + }
161 +
162 + .modal.modal-floating {
163 + pointer-events: none;
164 + }
165 +
166 + .modal.modal-floating .modal-inner {
167 + pointer-events: auto;
168 + }
169 +
170 + .modal-inner.diff-viewer-modal .modal-header {
171 + min-height: 34px;
172 + padding: 0.35rem 0.75rem 0.35rem 1rem;
173 + cursor: move;
174 + user-select: none;
175 + background: color-mix(in srgb, var(--color-background) 92%, #000 8%);
176 + border-bottom: 1px solid color-mix(in srgb, var(--color-border) 70%, transparent);
177 + }
178 +
179 + .modal-inner.diff-viewer-modal .modal-scroll {
180 + display: flex;
181 + flex-direction: column;
182 + flex: 1 1 auto;
183 + min-height: 0;
184 + overflow: hidden;
185 + padding: 0;
186 + }
187 +
188 + .modal-inner.diff-viewer-modal .modal-bd.diff-viewer-modal-body {
189 + box-sizing: border-box;
190 + display: flex;
191 + flex-direction: column;
192 + flex: 1 1 auto;
193 + width: 100%;
194 + height: 100%;
195 + min-height: 0;
196 + padding: 0;
197 + }
198 +
199 + .modal-inner.diff-viewer-modal .modal-bd.diff-viewer-modal-body > x-component,
200 + .modal-inner.diff-viewer-modal .modal-bd.diff-viewer-modal-body > div[x-data] {
201 + display: flex;
202 + flex: 1 1 auto;
203 + width: 100%;
204 + height: 100%;
205 + min-height: 0;
206 + }
207 +
208 + .diff-viewer-toolbar {
209 + display: flex;
210 + align-items: center;
211 + gap: 6px;
212 + min-height: 44px;
213 + padding: 7px 9px;
214 + border-bottom: 1px solid color-mix(in srgb, var(--color-border) 66%, transparent);
215 + background: color-mix(in srgb, var(--color-background) 92%, #000 8%);
216 + }
217 +
218 + .diff-viewer-title,
219 + .diff-viewer-summary-main,
220 + .diff-viewer-summary-meta,
221 + .diff-viewer-status,
222 + .diff-file-counts,
223 + .diff-file-tools,
224 + .diff-viewer-tool-button,
225 + .diff-file-note {
226 + display: flex;
227 + align-items: center;
228 + }
229 +
230 + .diff-viewer-title {
231 + gap: 7px;
232 + min-width: 0;
233 + font-weight: 700;
234 + font-size: 0.9rem;
235 + }
236 +
237 + .diff-viewer-title .material-symbols-outlined {
238 + font-size: 19px;
239 + }
240 +
241 + .diff-viewer-spacer {
242 + flex: 1 1 auto;
243 + min-width: 8px;
244 + }
245 +
246 + .diff-viewer-icon-button,
247 + .diff-viewer-tool-button {
248 + appearance: none;
249 + border: 1px solid color-mix(in srgb, var(--color-border) 64%, transparent);
250 + border-radius: 7px;
251 + background: color-mix(in srgb, var(--color-panel) 80%, transparent);
252 + color: var(--color-text);
253 + font: inherit;
254 + cursor: pointer;
255 + }
256 +
257 + .diff-viewer-icon-button {
258 + display: inline-flex;
259 + align-items: center;
260 + justify-content: center;
261 + width: 32px;
262 + height: 32px;
263 + min-width: 32px;
264 + padding: 0;
265 + }
266 +
267 + .diff-viewer-tool-button {
268 + gap: 5px;
269 + min-height: 28px;
270 + padding: 4px 8px;
271 + font-size: 0.76rem;
272 + }
273 +
274 + .diff-viewer-icon-button:hover:not(:disabled),
275 + .diff-viewer-tool-button:hover {
276 + background: color-mix(in srgb, var(--color-background-hover) 70%, transparent);
277 + border-color: color-mix(in srgb, var(--color-primary) 28%, var(--color-border));
278 + }
279 +
280 + .diff-viewer-icon-button:disabled {
281 + cursor: not-allowed;
282 + opacity: 0.45;
283 + }
284 +
285 + .diff-viewer-icon-button .material-symbols-outlined,
286 + .diff-viewer-tool-button .material-symbols-outlined {
287 + font-size: 17px;
288 + }
289 +
290 + .diff-viewer-summary {
291 + display: grid;
292 + grid-template-columns: minmax(0, 1fr);
293 + gap: 3px;
294 + padding: 9px 11px;
295 + border-bottom: 1px solid color-mix(in srgb, var(--color-border) 54%, transparent);
296 + background: color-mix(in srgb, var(--color-panel) 54%, transparent);
297 + }
298 +
299 + .diff-viewer-summary-main {
300 + gap: 5px;
301 + min-width: 0;
302 + font-size: 0.86rem;
303 + }
304 +
305 + .diff-viewer-summary-meta {
306 + gap: 7px;
307 + min-width: 0;
308 + color: var(--color-text-muted);
309 + font-size: 0.74rem;
310 + }
311 +
312 + .diff-viewer-summary-meta span:last-child {
313 + min-width: 0;
314 + overflow: hidden;
315 + text-overflow: ellipsis;
316 + white-space: nowrap;
317 + }
318 +
319 + .diff-viewer-dot {
320 + width: 4px;
321 + height: 4px;
322 + flex: 0 0 auto;
323 + border-radius: 999px;
324 + background: color-mix(in srgb, var(--color-text-muted) 50%, transparent);
325 + }
326 +
327 + .diff-add {
328 + color: #31c48d;
329 + }
330 +
331 + .diff-del {
332 + color: #f05252;
333 + }
334 +
335 + .diff-viewer-status {
336 + gap: 8px;
337 + min-height: 34px;
338 + padding: 6px 11px;
339 + border-bottom: 1px solid color-mix(in srgb, var(--color-border) 44%, transparent);
340 + color: var(--color-text-muted);
341 + font-size: 0.82rem;
342 + }
343 +
344 + .diff-viewer-body {
345 + display: flex;
346 + flex: 1 1 auto;
347 + min-width: 0;
348 + min-height: 0;
349 + overflow: auto;
350 + }
351 +
352 + .diff-viewer-groups {
353 + display: flex;
354 + flex: 1 1 auto;
355 + min-width: 0;
356 + flex-direction: column;
357 + gap: 12px;
358 + padding: 12px;
359 + }
360 +
361 + .diff-viewer-group {
362 + display: flex;
363 + min-width: 0;
364 + flex-direction: column;
365 + gap: 7px;
366 + }
367 +
368 + .diff-viewer-group-header {
369 + display: flex;
370 + align-items: center;
371 + gap: 7px;
372 + min-height: 26px;
373 + color: var(--color-text);
374 + font-size: 0.82rem;
375 + font-weight: 700;
376 + }
377 +
378 + .diff-viewer-count {
379 + min-width: 22px;
380 + padding: 2px 6px;
381 + border-radius: 999px;
382 + background: color-mix(in srgb, var(--color-panel) 78%, transparent);
383 + color: var(--color-text-muted);
384 + text-align: center;
385 + font-size: 0.72rem;
386 + }
387 +
388 + .diff-file {
389 + overflow: hidden;
390 + border: 1px solid color-mix(in srgb, var(--color-border) 58%, transparent);
391 + border-radius: 7px;
392 + background: color-mix(in srgb, var(--color-panel) 72%, transparent);
393 + }
394 +
395 + .diff-file-header {
396 + display: grid;
397 + grid-template-columns: auto minmax(0, 1fr) auto auto;
398 + align-items: center;
399 + gap: 7px;
400 + width: 100%;
401 + min-height: 38px;
402 + padding: 0 9px;
403 + border: 0;
404 + background: transparent;
405 + color: var(--color-text);
406 + font: inherit;
407 + text-align: left;
408 + cursor: pointer;
409 + }
410 +
411 + .diff-file-header:hover {
412 + background: color-mix(in srgb, var(--color-background-hover) 56%, transparent);
413 + }
414 +
415 + .diff-file-chevron {
416 + font-size: 18px;
417 + color: var(--color-text-muted);
418 + }
419 +
420 + .diff-file-name {
421 + min-width: 0;
422 + overflow: hidden;
423 + text-overflow: ellipsis;
424 + white-space: nowrap;
425 + font-family: var(--font-family-code);
426 + font-size: 0.78rem;
427 + font-weight: 650;
428 + }
429 +
430 + .diff-file-status {
431 + padding: 2px 6px;
432 + border-radius: 999px;
433 + background: color-mix(in srgb, var(--color-background) 70%, transparent);
434 + color: var(--color-text-muted);
435 + font-size: 0.68rem;
436 + text-transform: capitalize;
437 + white-space: nowrap;
438 + }
439 +
440 + .diff-file-counts {
441 + gap: 5px;
442 + justify-content: end;
443 + min-width: 72px;
444 + font-family: var(--font-family-code);
445 + font-size: 0.74rem;
446 + }
447 +
448 + .diff-file-tools {
449 + justify-content: flex-end;
450 + gap: 6px;
451 + min-height: 34px;
452 + padding: 4px 8px;
453 + border-top: 1px solid color-mix(in srgb, var(--color-border) 34%, transparent);
454 + background: color-mix(in srgb, var(--color-background) 55%, transparent);
455 + }
456 +
457 + .diff-file-body {
458 + border-top: 1px solid color-mix(in srgb, var(--color-border) 44%, transparent);
459 + }
460 +
461 + .diff-file-note {
462 + gap: 8px;
463 + padding: 12px;
464 + color: var(--color-text-muted);
465 + font-size: 0.82rem;
466 + }
467 +
468 + .diff-code {
469 + overflow: auto;
470 + padding: 4px 0;
471 + background: color-mix(in srgb, var(--color-background) 91%, #000 9%);
472 + font-family: var(--font-family-code);
473 + font-size: 0.74rem;
474 + line-height: 1.45;
475 + }
476 +
477 + .diff-line {
478 + display: grid;
479 + grid-template-columns: 24px minmax(0, 1fr);
480 + min-width: max-content;
481 + }
482 +
483 + .diff-line-marker {
484 + position: sticky;
485 + left: 0;
486 + z-index: 1;
487 + min-height: 1.45em;
488 + padding-right: 5px;
489 + background: inherit;
490 + color: var(--color-text-muted);
491 + text-align: right;
492 + user-select: none;
493 + }
494 +
495 + .diff-line code {
496 + display: block;
497 + min-height: 1.45em;
498 + padding: 0 10px 0 6px;
499 + color: inherit;
500 + font: inherit;
501 + white-space: pre;
502 + }
503 +
504 + .diff-line.is-add {
505 + background: rgba(39, 174, 96, 0.16);
506 + color: color-mix(in srgb, #8df0b0 78%, var(--color-text));
507 + }
508 +
509 + .diff-line.is-del {
510 + background: rgba(231, 76, 60, 0.17);
511 + color: color-mix(in srgb, #ffaaa2 78%, var(--color-text));
512 + }
513 +
514 + .diff-line.is-hunk {
515 + background: rgba(99, 102, 241, 0.16);
516 + color: color-mix(in srgb, #b9c3ff 80%, var(--color-text));
517 + }
518 +
519 + .diff-line.is-meta,
520 + .diff-line.is-note {
521 + color: var(--color-text-muted);
522 + }
523 +
524 + .diff-viewer-empty {
525 + display: grid;
526 + flex: 1 1 auto;
527 + place-items: center;
528 + align-content: center;
529 + gap: 7px;
530 + min-width: 0;
531 + padding: 24px;
532 + color: var(--color-text-muted);
533 + text-align: center;
534 + }
535 +
536 + .diff-viewer-empty .material-symbols-outlined {
537 + font-size: 28px;
538 + }
539 +
540 + .diff-viewer-empty span:last-child {
541 + max-width: 100%;
542 + overflow: hidden;
543 + text-overflow: ellipsis;
544 + white-space: nowrap;
545 + font-size: 0.78rem;
546 + }
547 +
548 + .diff-viewer-panel .spinning {
549 + display: inline-block;
550 + animation: diff-viewer-spin 0.8s linear infinite;
551 + }
552 +
553 + @keyframes diff-viewer-spin {
554 + to { transform: rotate(360deg); }
555 + }
556 +
557 + @container (max-width: 560px) {
558 + .diff-file-header {
559 + grid-template-columns: auto minmax(0, 1fr) auto;
560 + }
561 +
562 + .diff-file-status {
563 + display: none;
564 + }
565 +
566 + .diff-viewer-tool-button span:last-child {
567 + display: none;
568 + }
569 + }
570 + </style>
571 +</body>
572 +</html>
plugins/_diff_viewer/webui/diff-viewer-store.js new
+321
@@ -0,0 +1,321 @@
1 +import { createStore } from "/js/AlpineStore.js";
2 +import { callJsonApi } from "/js/api.js";
3 +import { getContext } from "/index.js";
4 +import { store as fileBrowserStore } from "/components/modals/file-browser/file-browser-store.js";
5 +
6 +const REFRESH_DEBOUNCE_MS = 180;
7 +
8 +function lineType(text) {
9 + if (text.startsWith("@@")) return "hunk";
10 + if (text.startsWith("+++") || text.startsWith("---") || text.startsWith("diff --git") || text.startsWith("index ")) {
11 + return "meta";
12 + }
13 + if (text.startsWith("+")) return "add";
14 + if (text.startsWith("-")) return "del";
15 + if (text.startsWith("\\ No newline")) return "note";
16 + return "context";
17 +}
18 +
19 +function dirname(path) {
20 + const clean = String(path || "").replace(/\/+$/, "");
21 + const index = clean.lastIndexOf("/");
22 + return index > 0 ? clean.slice(0, index) : "";
23 +}
24 +
25 +const model = {
26 + loading: false,
27 + error: "",
28 + payload: null,
29 + contextId: "",
30 + workspacePath: "",
31 + expanded: {},
32 + _root: null,
33 + _mode: "canvas",
34 + _refreshTimer: null,
35 + _floatingCleanup: null,
36 + _requestSeq: 0,
37 +
38 + async init(element = null) {
39 + await this.onMount(element, { mode: "canvas" });
40 + },
41 +
42 + async onMount(element = null, options = {}) {
43 + if (element) this._root = element;
44 + this._mode = options?.mode === "modal" ? "modal" : "canvas";
45 + if (this._mode === "modal") {
46 + this.setupFloatingModal(element);
47 + } else {
48 + this.setupCanvasSurface(element);
49 + }
50 + this.contextId = this.resolveContextId();
51 + if (!this.payload && !this.loading) {
52 + await this.refresh({ contextId: this.contextId });
53 + }
54 + },
55 +
56 + async onOpen(payload = {}) {
57 + const nextContextId = String(payload.contextId || payload.context_id || this.resolveContextId() || "");
58 + await this.refresh({ contextId: nextContextId });
59 + },
60 +
61 + cleanup() {
62 + if (this._refreshTimer) {
63 + clearTimeout(this._refreshTimer);
64 + this._refreshTimer = null;
65 + }
66 + this._floatingCleanup?.();
67 + this._floatingCleanup = null;
68 + },
69 +
70 + setupFloatingModal(element = null) {
71 + this._floatingCleanup?.();
72 + const root = element || globalThis.document?.querySelector(".diff-viewer-panel");
73 + const modal = root?.closest?.(".modal");
74 + const inner = modal?.querySelector?.(".modal-inner");
75 + const body = modal?.querySelector?.(".modal-bd");
76 + const header = modal?.querySelector?.(".modal-header");
77 + if (!modal || !inner || !header) return;
78 + modal.classList.add("modal-floating");
79 + inner.classList.add("diff-viewer-modal", "modal-no-backdrop");
80 + body?.classList?.add("diff-viewer-modal-body");
81 +
82 + const rect = inner.getBoundingClientRect();
83 + inner.style.left = `${Math.max(8, rect.left)}px`;
84 + inner.style.top = `${Math.max(8, rect.top)}px`;
85 + inner.style.transform = "none";
86 +
87 + let drag = null;
88 + let resizeObserver = null;
89 + const viewportGap = 8;
90 + const clampPosition = (left, top) => {
91 + const bounds = inner.getBoundingClientRect();
92 + const maxLeft = Math.max(viewportGap, globalThis.innerWidth - bounds.width - viewportGap);
93 + const maxTop = Math.max(viewportGap, globalThis.innerHeight - bounds.height - viewportGap);
94 + return {
95 + left: Math.min(Math.max(viewportGap, left), maxLeft),
96 + top: Math.min(Math.max(viewportGap, top), maxTop),
97 + };
98 + };
99 + const clampGeometry = () => {
100 + const bounds = inner.getBoundingClientRect();
101 + const left = Math.max(viewportGap, bounds.left);
102 + const top = Math.max(viewportGap, bounds.top);
103 + const maxWidth = Math.max(340, globalThis.innerWidth - viewportGap * 2);
104 + const maxHeight = Math.max(360, globalThis.innerHeight - viewportGap * 2);
105 + if (bounds.width > maxWidth) inner.style.width = `${maxWidth}px`;
106 + if (bounds.height > maxHeight) inner.style.height = `${maxHeight}px`;
107 + const next = clampPosition(left, top);
108 + inner.style.left = `${next.left}px`;
109 + inner.style.top = `${next.top}px`;
110 + inner.style.maxWidth = `${Math.max(340, globalThis.innerWidth - next.left - viewportGap)}px`;
111 + inner.style.maxHeight = `${Math.max(360, globalThis.innerHeight - next.top - viewportGap)}px`;
112 + };
113 + clampGeometry();
114 + globalThis.addEventListener("resize", clampGeometry);
115 + if (globalThis.ResizeObserver) {
116 + resizeObserver = new ResizeObserver(clampGeometry);
117 + resizeObserver.observe(inner);
118 + }
119 +
120 + const onPointerMove = (event) => {
121 + if (!drag) return;
122 + const next = clampPosition(
123 + drag.left + event.clientX - drag.x,
124 + drag.top + event.clientY - drag.y,
125 + );
126 + inner.style.left = `${next.left}px`;
127 + inner.style.top = `${next.top}px`;
128 + clampGeometry();
129 + };
130 + const onPointerUp = () => {
131 + drag = null;
132 + globalThis.removeEventListener("pointermove", onPointerMove);
133 + globalThis.removeEventListener("pointerup", onPointerUp);
134 + try {
135 + header.releasePointerCapture?.(header.__diffViewerPanelPointerId || 0);
136 + } catch {}
137 + };
138 + const onPointerDown = (event) => {
139 + if (event.button !== 0) return;
140 + if (event.target?.closest?.("button, input, select, textarea, a")) return;
141 + const current = inner.getBoundingClientRect();
142 + drag = {
143 + x: event.clientX,
144 + y: event.clientY,
145 + left: current.left,
146 + top: current.top,
147 + };
148 + header.__diffViewerPanelPointerId = event.pointerId;
149 + header.setPointerCapture?.(event.pointerId);
150 + globalThis.addEventListener("pointermove", onPointerMove);
151 + globalThis.addEventListener("pointerup", onPointerUp);
152 + event.preventDefault();
153 + };
154 + header.addEventListener("pointerdown", onPointerDown);
155 +
156 + this._floatingCleanup = () => {
157 + header.removeEventListener("pointerdown", onPointerDown);
158 + globalThis.removeEventListener("pointermove", onPointerMove);
159 + globalThis.removeEventListener("pointerup", onPointerUp);
160 + globalThis.removeEventListener("resize", clampGeometry);
161 + resizeObserver?.disconnect?.();
162 + };
163 + },
164 +
165 + setupCanvasSurface(element = null) {
166 + this._floatingCleanup?.();
167 + this._floatingCleanup = null;
168 + if (element) this._root = element;
169 + },
170 +
171 + resolveContextId() {
172 + const urlContext = new URLSearchParams(globalThis.location?.search || "").get("ctxid");
173 + return getContext?.() || urlContext || globalThis.Alpine?.store?.("chats")?.selected || "";
174 + },
175 +
176 + scheduleRefresh(options = {}) {
177 + if (this._refreshTimer) clearTimeout(this._refreshTimer);
178 + this._refreshTimer = setTimeout(() => {
179 + this._refreshTimer = null;
180 + this.refresh(options).catch((error) => {
181 + console.error("Diff refresh failed", error);
182 + });
183 + }, REFRESH_DEBOUNCE_MS);
184 + },
185 +
186 + async refresh(options = {}) {
187 + const contextId = String(options.contextId || options.context_id || this.resolveContextId() || "");
188 + const seq = ++this._requestSeq;
189 + this.loading = true;
190 + this.error = "";
191 + try {
192 + const response = await callJsonApi("/plugins/_diff_viewer/diff", { context_id: contextId });
193 + if (seq !== this._requestSeq) return;
194 + if (!response?.ok) {
195 + throw new Error(response?.error || "Could not load diff.");
196 + }
197 + this.payload = response;
198 + this.contextId = String(response.context_id || contextId || "");
199 + this.workspacePath = String(response.workspace_path || "");
200 + this.reconcileExpanded();
201 + } catch (error) {
202 + if (seq !== this._requestSeq) return;
203 + this.error = error instanceof Error ? error.message : String(error);
204 + } finally {
205 + if (seq === this._requestSeq) this.loading = false;
206 + }
207 + },
208 +
209 + reconcileExpanded() {
210 + const next = {};
211 + let index = 0;
212 + for (const group of this.visibleGroups()) {
213 + for (const file of group.files || []) {
214 + const key = this.fileKey(group, file);
215 + next[key] = this.expanded[key] ?? index < 4;
216 + index += 1;
217 + }
218 + }
219 + this.expanded = next;
220 + },
221 +
222 + visibleGroups() {
223 + return (this.payload?.groups || []).filter((group) => Array.isArray(group.files) && group.files.length > 0);
224 + },
225 +
226 + hasChanges() {
227 + return this.visibleGroups().length > 0;
228 + },
229 +
230 + groupTitle(kind) {
231 + const labels = {
232 + staged: "Staged",
233 + unstaged: "Unstaged",
234 + untracked: "Untracked",
235 + };
236 + return labels[kind] || kind;
237 + },
238 +
239 + statusLabel(file) {
240 + return String(file?.status || "changed").replaceAll("_", " ");
241 + },
242 +
243 + fileKey(group, file) {
244 + return `${group?.kind || "diff"}:${file?.old_path || ""}:${file?.path || ""}`;
245 + },
246 +
247 + isExpanded(group, file) {
248 + return this.expanded[this.fileKey(group, file)] !== false;
249 + },
250 +
251 + toggleFile(group, file) {
252 + const key = this.fileKey(group, file);
253 + this.expanded[key] = !this.isExpanded(group, file);
254 + },
255 +
256 + expandAll() {
257 + const next = {};
258 + for (const group of this.visibleGroups()) {
259 + for (const file of group.files || []) {
260 + next[this.fileKey(group, file)] = true;
261 + }
262 + }
263 + this.expanded = next;
264 + },
265 +
266 + collapseAll() {
267 + const next = {};
268 + for (const group of this.visibleGroups()) {
269 + for (const file of group.files || []) {
270 + next[this.fileKey(group, file)] = false;
271 + }
272 + }
273 + this.expanded = next;
274 + },
275 +
276 + patchLines(file) {
277 + const patch = String(file?.patch || "");
278 + if (!patch) return [];
279 + const textLines = patch.endsWith("\n") ? patch.slice(0, -1).split("\n") : patch.split("\n");
280 + return textLines.map((text, index) => ({
281 + id: `${index}-${text.slice(0, 20)}`,
282 + text,
283 + type: lineType(text),
284 + }));
285 + },
286 +
287 + fileTitle(file) {
288 + if (file?.old_path && file.old_path !== file.path) {
289 + return `${file.old_path} -> ${file.path}`;
290 + }
291 + return file?.path || file?.old_path || "";
292 + },
293 +
294 + formatSigned(value, sign) {
295 + const number = Number(value) || 0;
296 + return `${sign}${number.toLocaleString()}`;
297 + },
298 +
299 + fullPath(file) {
300 + const relativePath = String(file?.path || file?.old_path || "").replace(/^\/+/, "");
301 + const base = String(this.workspacePath || "").replace(/\/+$/, "");
302 + return relativePath ? `${base}/${relativePath}` : base;
303 + },
304 +
305 + async openContainingFolder(file) {
306 + const parent = dirname(this.fullPath(file));
307 + await fileBrowserStore.open(parent || this.workspacePath || "$WORK_DIR");
308 + },
309 +
310 + async copyPath(file) {
311 + const path = this.fullPath(file);
312 + try {
313 + await navigator.clipboard.writeText(path);
314 + globalThis.justToast?.("Path copied", "success", 1200, "diff-viewer-copy");
315 + } catch (_error) {
316 + globalThis.prompt?.("Copy path", path);
317 + }
318 + },
319 +};
320 +
321 +export const store = createStore("diffViewer", model);
plugins/_diff_viewer/webui/main.html new
+14
@@ -0,0 +1,14 @@
1 +<html
2 + class="diff-viewer-modal modal-no-backdrop"
3 + data-canvas-surface="diff"
4 + data-canvas-modal-path="/plugins/_diff_viewer/webui/main.html"
5 + data-canvas-dock-title="Open Diff in canvas"
6 + data-canvas-dock-icon="dock_to_right"
7 +>
8 +<head>
9 + <title>Diff</title>
10 +</head>
11 +<body class="diff-viewer-modal-body">
12 + <x-component path="/plugins/_diff_viewer/webui/diff-viewer-panel.html" mode="modal"></x-component>
13 +</body>
14 +</html>
tests/test_diff_viewer.py new
+192
@@ -0,0 +1,192 @@
1 +from __future__ import annotations
2 +
3 +import asyncio
4 +import subprocess
5 +import sys
6 +import threading
7 +from pathlib import Path
8 +from types import SimpleNamespace
9 +
10 +import pytest
11 +from flask import Flask
12 +
13 +PROJECT_ROOT = Path(__file__).resolve().parents[1]
14 +if str(PROJECT_ROOT) not in sys.path:
15 + sys.path.insert(0, str(PROJECT_ROOT))
16 +
17 +from plugins._diff_viewer.helpers import diff as diff_helper
18 +from plugins._diff_viewer.helpers.diff import collect_workspace_diff
19 +
20 +
21 +def run_git(repo_dir: Path, *args: str) -> str:
22 + completed = subprocess.run(
23 + ["git", "-C", str(repo_dir), *args],
24 + check=True,
25 + text=True,
26 + capture_output=True,
27 + )
28 + return completed.stdout.strip()
29 +
30 +
31 +def init_repo(repo_dir: Path) -> None:
32 + run_git(repo_dir, "init")
33 + run_git(repo_dir, "config", "user.name", "Test User")
34 + run_git(repo_dir, "config", "user.email", "test@example.com")
35 + (repo_dir / "tracked.txt").write_text("one\n", encoding="utf-8")
36 + run_git(repo_dir, "add", "tracked.txt")
37 + run_git(repo_dir, "commit", "-m", "initial")
38 +
39 +
40 +def files_for_group(payload: dict, kind: str) -> list[dict]:
41 + return next(group["files"] for group in payload["groups"] if group["kind"] == kind)
42 +
43 +
44 +def test_collect_workspace_diff_returns_non_git_state(tmp_path: Path) -> None:
45 + payload = collect_workspace_diff(str(tmp_path), context_id="ctx")
46 +
47 + assert payload["ok"] is True
48 + assert payload["context_id"] == "ctx"
49 + assert payload["is_git_repo"] is False
50 + assert payload["totals"] == {"files": 0, "additions": 0, "deletions": 0}
51 +
52 +
53 +def test_collect_workspace_diff_groups_staged_unstaged_and_untracked(tmp_path: Path) -> None:
54 + init_repo(tmp_path)
55 + (tmp_path / "tracked.txt").write_text("one\nstaged\n", encoding="utf-8")
56 + run_git(tmp_path, "add", "tracked.txt")
57 + (tmp_path / "tracked.txt").write_text("one\nstaged\nunstaged\n", encoding="utf-8")
58 + (tmp_path / "new.txt").write_text("hello\n", encoding="utf-8")
59 +
60 + payload = collect_workspace_diff(str(tmp_path))
61 +
62 + staged = files_for_group(payload, "staged")
63 + unstaged = files_for_group(payload, "unstaged")
64 + untracked = files_for_group(payload, "untracked")
65 + assert staged[0]["path"] == "tracked.txt"
66 + assert staged[0]["status"] == "modified"
67 + assert "+staged" in staged[0]["patch"]
68 + assert unstaged[0]["path"] == "tracked.txt"
69 + assert "+unstaged" in unstaged[0]["patch"]
70 + assert untracked[0]["path"] == "new.txt"
71 + assert untracked[0]["status"] == "untracked"
72 + assert "+hello" in untracked[0]["patch"]
73 + assert payload["totals"]["files"] == 2
74 + assert payload["totals"]["additions"] == 3
75 +
76 +
77 +def test_collect_workspace_diff_ignores_zero_line_gitkeep(tmp_path: Path) -> None:
78 + init_repo(tmp_path)
79 + (tmp_path / ".gitkeep").write_text("", encoding="utf-8")
80 + (tmp_path / "nested").mkdir()
81 + (tmp_path / "nested" / ".gitkeep").write_text("", encoding="utf-8")
82 + (tmp_path / "real.txt").write_text("real\n", encoding="utf-8")
83 + run_git(tmp_path, "add", ".gitkeep")
84 +
85 + payload = collect_workspace_diff(str(tmp_path))
86 + paths = [
87 + item["path"]
88 + for group in payload["groups"]
89 + for item in group["files"]
90 + ]
91 +
92 + assert ".gitkeep" not in paths
93 + assert "nested/.gitkeep" not in paths
94 + assert paths == ["real.txt"]
95 + assert payload["totals"] == {"files": 1, "additions": 1, "deletions": 0}
96 +
97 +
98 +def test_collect_workspace_diff_deleted_renamed_binary_large_and_a0_exclusion(
99 + tmp_path: Path,
100 + monkeypatch: pytest.MonkeyPatch,
101 +) -> None:
102 + init_repo(tmp_path)
103 + (tmp_path / "rename_me.txt").write_text("move\n", encoding="utf-8")
104 + (tmp_path / "delete_me.txt").write_text("delete\n", encoding="utf-8")
105 + run_git(tmp_path, "add", "rename_me.txt", "delete_me.txt")
106 + run_git(tmp_path, "commit", "-m", "fixtures")
107 +
108 + run_git(tmp_path, "mv", "rename_me.txt", "renamed.txt")
109 + (tmp_path / "delete_me.txt").unlink()
110 + (tmp_path / "binary.bin").write_bytes(b"\x00\x01data")
111 + (tmp_path / ".a0proj").mkdir()
112 + (tmp_path / ".a0proj" / "project.json").write_text("{}", encoding="utf-8")
113 + monkeypatch.setattr(diff_helper, "MAX_UNTRACKED_BYTES", 10)
114 + (tmp_path / "large.txt").write_text("0123456789\n" * 5, encoding="utf-8")
115 +
116 + payload = collect_workspace_diff(str(tmp_path))
117 + staged = files_for_group(payload, "staged")
118 + unstaged = files_for_group(payload, "unstaged")
119 + untracked = files_for_group(payload, "untracked")
120 +
121 + git_changes = staged + unstaged
122 + statuses = {(item["path"], item["status"]) for item in git_changes}
123 + assert ("delete_me.txt", "deleted") in statuses
124 + assert ("renamed.txt", "renamed") in statuses
125 + renamed = next(item for item in git_changes if item["path"] == "renamed.txt")
126 + assert renamed["old_path"] == "rename_me.txt"
127 + assert renamed["additions"] == 0
128 + assert renamed["deletions"] == 0
129 + binary = next(item for item in untracked if item["path"] == "binary.bin")
130 + assert binary["binary"] is True
131 + large = next(item for item in untracked if item["path"] == "large.txt")
132 + assert large["too_large"] is True
133 + assert all(not item["path"].startswith(".a0proj") for item in untracked)
134 +
135 +
136 +def test_collect_workspace_diff_limits_nested_workspace_to_pathspec(tmp_path: Path) -> None:
137 + init_repo(tmp_path)
138 + (tmp_path / "outside.txt").write_text("outside\n", encoding="utf-8")
139 + (tmp_path / "nested").mkdir()
140 + (tmp_path / "nested" / "inside.txt").write_text("inside\n", encoding="utf-8")
141 +
142 + payload = collect_workspace_diff(str(tmp_path / "nested"))
143 + untracked = files_for_group(payload, "untracked")
144 +
145 + assert [item["path"] for item in untracked] == ["inside.txt"]
146 +
147 +
148 +def test_diff_api_resolves_project_context_workspace(
149 + tmp_path: Path,
150 + monkeypatch: pytest.MonkeyPatch,
151 +) -> None:
152 + pytest.importorskip("whisper")
153 + pytest.importorskip("langchain_core")
154 + from plugins._diff_viewer.api import diff as diff_api
155 +
156 + init_repo(tmp_path)
157 + (tmp_path / "changed.txt").write_text("changed\n", encoding="utf-8")
158 + handler = diff_api.Diff(Flask("diff-test"), threading.RLock())
159 + monkeypatch.setattr(handler, "use_context", lambda context_id: SimpleNamespace(id=context_id))
160 + monkeypatch.setattr(diff_api.projects, "get_context_project_name", lambda _context: "demo")
161 + monkeypatch.setattr(diff_api.projects, "get_project_folder", lambda _name: str(tmp_path))
162 + monkeypatch.setattr(diff_api.files, "normalize_a0_path", lambda path: path)
163 + monkeypatch.setattr(diff_api.files, "fix_dev_path", lambda path: path)
164 +
165 + payload = asyncio.run(handler.process({"context_id": "ctx-project"}, None))
166 +
167 + assert isinstance(payload, dict)
168 + assert payload["ok"] is True
169 + assert payload["context_id"] == "ctx-project"
170 + assert payload["workspace_path"] == str(tmp_path)
171 + assert files_for_group(payload, "untracked")[0]["path"] == "changed.txt"
172 +
173 +
174 +def test_diff_api_falls_back_to_default_workdir(
175 + tmp_path: Path,
176 + monkeypatch: pytest.MonkeyPatch,
177 +) -> None:
178 + pytest.importorskip("whisper")
179 + pytest.importorskip("langchain_core")
180 + from plugins._diff_viewer.api import diff as diff_api
181 +
182 + init_repo(tmp_path)
183 + (tmp_path / "workdir.txt").write_text("workdir\n", encoding="utf-8")
184 + handler = diff_api.Diff(Flask("diff-test-default"), threading.RLock())
185 + monkeypatch.setattr(diff_api.settings, "get_settings", lambda: {"workdir_path": str(tmp_path)})
186 + monkeypatch.setattr(diff_api.files, "fix_dev_path", lambda path: path)
187 +
188 + payload = asyncio.run(handler.process({}, None))
189 +
190 + assert isinstance(payload, dict)
191 + assert payload["workspace_path"] == str(tmp_path)
192 + assert files_for_group(payload, "untracked")[0]["path"] == "workdir.txt"
webui/js/modals.js
+2
@@ -58,6 +58,8 @@ function modalSuppressesBackdrop(modal) {
58 || path === "plugins/_browser/webui/main.html"
59 || path === "/plugins/_office/webui/main.html"
60 || path === "plugins/_office/webui/main.html"
61 + || path === "/plugins/_diff_viewer/webui/main.html"
62 + || path === "plugins/_diff_viewer/webui/main.html"
63 || modal?.element?.classList?.contains("modal-floating")
64 || modal?.element?.classList?.contains("modal-no-backdrop")
65 || modal?.inner?.classList?.contains("modal-no-backdrop");