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