portacode 0.3.4.dev0__py3-none-any.whl → 1.4.11.dev0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of portacode might be problematic. Click here for more details.

Files changed (93) hide show
  1. portacode/_version.py +16 -3
  2. portacode/cli.py +155 -19
  3. portacode/connection/client.py +152 -12
  4. portacode/connection/handlers/WEBSOCKET_PROTOCOL.md +1577 -0
  5. portacode/connection/handlers/__init__.py +43 -1
  6. portacode/connection/handlers/base.py +122 -18
  7. portacode/connection/handlers/chunked_content.py +244 -0
  8. portacode/connection/handlers/diff_handlers.py +603 -0
  9. portacode/connection/handlers/file_handlers.py +902 -17
  10. portacode/connection/handlers/project_aware_file_handlers.py +226 -0
  11. portacode/connection/handlers/project_state/README.md +312 -0
  12. portacode/connection/handlers/project_state/__init__.py +92 -0
  13. portacode/connection/handlers/project_state/file_system_watcher.py +179 -0
  14. portacode/connection/handlers/project_state/git_manager.py +1502 -0
  15. portacode/connection/handlers/project_state/handlers.py +875 -0
  16. portacode/connection/handlers/project_state/manager.py +1331 -0
  17. portacode/connection/handlers/project_state/models.py +108 -0
  18. portacode/connection/handlers/project_state/utils.py +50 -0
  19. portacode/connection/handlers/project_state_handlers.py +45 -0
  20. portacode/connection/handlers/proxmox_infra.py +307 -0
  21. portacode/connection/handlers/registry.py +53 -10
  22. portacode/connection/handlers/session.py +705 -53
  23. portacode/connection/handlers/system_handlers.py +142 -8
  24. portacode/connection/handlers/tab_factory.py +389 -0
  25. portacode/connection/handlers/terminal_handlers.py +150 -11
  26. portacode/connection/handlers/update_handler.py +61 -0
  27. portacode/connection/multiplex.py +60 -2
  28. portacode/connection/terminal.py +695 -28
  29. portacode/keypair.py +63 -1
  30. portacode/link_capture/__init__.py +38 -0
  31. portacode/link_capture/__pycache__/__init__.cpython-311.pyc +0 -0
  32. portacode/link_capture/bin/__pycache__/link_capture_wrapper.cpython-311.pyc +0 -0
  33. portacode/link_capture/bin/elinks +3 -0
  34. portacode/link_capture/bin/gio-open +3 -0
  35. portacode/link_capture/bin/gnome-open +3 -0
  36. portacode/link_capture/bin/gvfs-open +3 -0
  37. portacode/link_capture/bin/kde-open +3 -0
  38. portacode/link_capture/bin/kfmclient +3 -0
  39. portacode/link_capture/bin/link_capture_exec.sh +11 -0
  40. portacode/link_capture/bin/link_capture_wrapper.py +75 -0
  41. portacode/link_capture/bin/links +3 -0
  42. portacode/link_capture/bin/links2 +3 -0
  43. portacode/link_capture/bin/lynx +3 -0
  44. portacode/link_capture/bin/mate-open +3 -0
  45. portacode/link_capture/bin/netsurf +3 -0
  46. portacode/link_capture/bin/sensible-browser +3 -0
  47. portacode/link_capture/bin/w3m +3 -0
  48. portacode/link_capture/bin/x-www-browser +3 -0
  49. portacode/link_capture/bin/xdg-open +3 -0
  50. portacode/logging_categories.py +140 -0
  51. portacode/pairing.py +103 -0
  52. portacode/service.py +6 -0
  53. portacode/static/js/test-ntp-clock.html +63 -0
  54. portacode/static/js/utils/ntp-clock.js +232 -0
  55. portacode/utils/NTP_ARCHITECTURE.md +136 -0
  56. portacode/utils/__init__.py +1 -0
  57. portacode/utils/diff_apply.py +456 -0
  58. portacode/utils/diff_renderer.py +371 -0
  59. portacode/utils/ntp_clock.py +65 -0
  60. portacode-1.4.11.dev0.dist-info/METADATA +298 -0
  61. portacode-1.4.11.dev0.dist-info/RECORD +97 -0
  62. {portacode-0.3.4.dev0.dist-info → portacode-1.4.11.dev0.dist-info}/WHEEL +1 -1
  63. portacode-1.4.11.dev0.dist-info/top_level.txt +3 -0
  64. test_modules/README.md +296 -0
  65. test_modules/__init__.py +1 -0
  66. test_modules/test_device_online.py +44 -0
  67. test_modules/test_file_operations.py +743 -0
  68. test_modules/test_git_status_ui.py +370 -0
  69. test_modules/test_login_flow.py +50 -0
  70. test_modules/test_navigate_testing_folder.py +361 -0
  71. test_modules/test_play_store_screenshots.py +294 -0
  72. test_modules/test_terminal_buffer_performance.py +261 -0
  73. test_modules/test_terminal_interaction.py +80 -0
  74. test_modules/test_terminal_loading_race_condition.py +95 -0
  75. test_modules/test_terminal_start.py +56 -0
  76. testing_framework/.env.example +21 -0
  77. testing_framework/README.md +334 -0
  78. testing_framework/__init__.py +17 -0
  79. testing_framework/cli.py +326 -0
  80. testing_framework/core/__init__.py +1 -0
  81. testing_framework/core/base_test.py +336 -0
  82. testing_framework/core/cli_manager.py +177 -0
  83. testing_framework/core/hierarchical_runner.py +577 -0
  84. testing_framework/core/playwright_manager.py +520 -0
  85. testing_framework/core/runner.py +447 -0
  86. testing_framework/core/shared_cli_manager.py +234 -0
  87. testing_framework/core/test_discovery.py +112 -0
  88. testing_framework/requirements.txt +12 -0
  89. portacode-0.3.4.dev0.dist-info/METADATA +0 -236
  90. portacode-0.3.4.dev0.dist-info/RECORD +0 -27
  91. portacode-0.3.4.dev0.dist-info/top_level.txt +0 -1
  92. {portacode-0.3.4.dev0.dist-info → portacode-1.4.11.dev0.dist-info}/entry_points.txt +0 -0
  93. {portacode-0.3.4.dev0.dist-info → portacode-1.4.11.dev0.dist-info/licenses}/LICENSE +0 -0
@@ -0,0 +1,603 @@
1
+ """Handlers for applying unified diffs to project files."""
2
+
3
+ import asyncio
4
+ import logging
5
+ import os
6
+ import re
7
+ import time
8
+ from functools import partial
9
+ from typing import Any, Dict, List, Optional, Tuple
10
+
11
+ from .base import AsyncHandler
12
+ from .project_state.manager import get_or_create_project_state_manager
13
+ from ...utils import diff_renderer
14
+ from ...utils.diff_apply import (
15
+ DiffApplyError,
16
+ DiffParseError,
17
+ FilePatch,
18
+ Hunk,
19
+ PatchLine,
20
+ apply_file_patch,
21
+ preview_file_patch,
22
+ parse_unified_diff,
23
+ )
24
+
25
+ logger = logging.getLogger(__name__)
26
+ _DEBUG_LOG_PATH = os.path.expanduser("~/portacode_diff_debug.log")
27
+
28
+
29
+ def _debug_log(message: str) -> None:
30
+ """Append debug traces for troubleshooting without affecting runtime."""
31
+ try:
32
+ timestamp = time.strftime("%Y-%m-%d %H:%M:%S")
33
+ with open(_DEBUG_LOG_PATH, "a", encoding="utf-8") as fh:
34
+ fh.write(f"[{timestamp}] {message}\n")
35
+ except Exception:
36
+ # Ignore logging errors entirely.
37
+ pass
38
+
39
+
40
+ def _resolve_preview_path(base_path: str, relative_path: Optional[str]) -> str:
41
+ """Compute an absolute path hint for diff previews."""
42
+ if not relative_path:
43
+ return base_path
44
+ if os.path.isabs(relative_path):
45
+ return relative_path
46
+ return os.path.abspath(os.path.join(base_path, relative_path))
47
+
48
+
49
+ _DIRECTIVE_LINE_PATTERN = re.compile(r"^@@(?P<cmd>[a-z_]+):(?P<body>.+)@@$", re.IGNORECASE)
50
+
51
+
52
+ def _normalize_directive_path(raw: str) -> str:
53
+ """Normalize relative paths referenced by inline directives."""
54
+ if raw is None:
55
+ raise ValueError("Path is required")
56
+ candidate = os.path.normpath(raw.strip())
57
+ if candidate in ("", ".", ".."):
58
+ raise ValueError("Path must reference a file inside the project")
59
+ if os.path.isabs(candidate):
60
+ raise ValueError("Absolute paths are not allowed in inline directives")
61
+ if candidate.startswith(".."):
62
+ raise ValueError("Path cannot traverse outside the project")
63
+ return candidate
64
+
65
+
66
+ def _extract_inline_directives(diff_text: str) -> Tuple[str, List[Dict[str, str]]]:
67
+ """Strip inline @@command directives and return (clean_diff, directives)."""
68
+ if not diff_text:
69
+ return "", []
70
+
71
+ directives: List[Dict[str, str]] = []
72
+ remaining_lines: List[str] = []
73
+
74
+ for line in diff_text.splitlines(keepends=True):
75
+ stripped = line.strip()
76
+ match = _DIRECTIVE_LINE_PATTERN.match(stripped)
77
+ if not match:
78
+ remaining_lines.append(line)
79
+ continue
80
+
81
+ cmd = match.group("cmd").lower()
82
+ body = (match.group("body") or "").strip()
83
+ if not body:
84
+ raise ValueError(f"Inline directive '{cmd}' is missing required arguments")
85
+
86
+ if cmd == "delete":
87
+ directives.append(
88
+ {
89
+ "type": "delete",
90
+ "path": _normalize_directive_path(body),
91
+ }
92
+ )
93
+ elif cmd in {"move", "rename"}:
94
+ if "->" not in body:
95
+ raise ValueError("move directive must be formatted as 'source -> destination'")
96
+ source_raw, dest_raw = body.split("->", 1)
97
+ source = _normalize_directive_path(source_raw)
98
+ destination = _normalize_directive_path(dest_raw)
99
+ if source == destination:
100
+ raise ValueError("Source and destination paths must be different for move directives")
101
+ directives.append(
102
+ {
103
+ "type": "move",
104
+ "source": source,
105
+ "destination": destination,
106
+ }
107
+ )
108
+ else:
109
+ raise ValueError(f"Unknown inline directive '{cmd}'")
110
+
111
+ return "".join(remaining_lines), directives
112
+
113
+
114
+ def _resolve_directive_path(base_path: str, relative_path: str) -> str:
115
+ """Resolve a directive path and enforce that it stays inside the project root."""
116
+ root = os.path.abspath(base_path or os.getcwd())
117
+ target = os.path.abspath(os.path.join(root, relative_path))
118
+ if os.path.commonpath([root, target]) != root:
119
+ raise DiffApplyError(f"Path escapes project root: {relative_path}", file_path=target)
120
+ return target
121
+
122
+
123
+ def _read_all_lines(abs_path: str) -> List[str]:
124
+ """Load file content for directive handling."""
125
+ try:
126
+ with open(abs_path, "r", encoding="utf-8") as fh:
127
+ data = fh.read()
128
+ return data.splitlines(keepends=True)
129
+ except FileNotFoundError as exc:
130
+ raise DiffApplyError(f"File does not exist: {abs_path}", file_path=abs_path) from exc
131
+ except OSError as exc:
132
+ raise DiffApplyError(f"Unable to read file: {abs_path}", file_path=abs_path) from exc
133
+
134
+
135
+ def _build_delete_patch(relative_path: str, lines: List[str]) -> FilePatch:
136
+ """Construct a FilePatch that removes an entire file."""
137
+ hunk = Hunk(
138
+ old_start=1,
139
+ old_length=len(lines),
140
+ new_start=1,
141
+ new_length=0,
142
+ lines=[PatchLine("-", line) for line in lines],
143
+ )
144
+ return FilePatch(old_path=relative_path, new_path="/dev/null", hunks=[hunk])
145
+
146
+
147
+ def _build_add_patch(relative_path: str, lines: List[str]) -> FilePatch:
148
+ """Construct a FilePatch that creates a file with the provided lines."""
149
+ hunk = Hunk(
150
+ old_start=1,
151
+ old_length=0,
152
+ new_start=1,
153
+ new_length=len(lines),
154
+ lines=[PatchLine("+", line) for line in lines],
155
+ )
156
+ return FilePatch(old_path="/dev/null", new_path=relative_path, hunks=[hunk])
157
+
158
+
159
+ def _build_directive_patches(directives: List[Dict[str, str]], base_path: str) -> List[FilePatch]:
160
+ """Translate inline directives into concrete FilePatch objects."""
161
+ if not directives:
162
+ return []
163
+
164
+ patches: List[FilePatch] = []
165
+ base = base_path or os.getcwd()
166
+
167
+ for directive in directives:
168
+ if directive["type"] == "delete":
169
+ rel_path = directive["path"]
170
+ abs_path = _resolve_directive_path(base, rel_path)
171
+ lines = _read_all_lines(abs_path)
172
+ patches.append(_build_delete_patch(rel_path, lines))
173
+ elif directive["type"] == "move":
174
+ source_rel = directive["source"]
175
+ dest_rel = directive["destination"]
176
+ source_abs = _resolve_directive_path(base, source_rel)
177
+ dest_abs = _resolve_directive_path(base, dest_rel)
178
+ if os.path.exists(dest_abs):
179
+ raise DiffApplyError(f"Destination already exists: {dest_abs}", file_path=dest_abs)
180
+ lines = _read_all_lines(source_abs)
181
+ patches.append(_build_delete_patch(source_rel, lines))
182
+ patches.append(_build_add_patch(dest_rel, lines))
183
+ else:
184
+ raise DiffApplyError(f"Unsupported directive: {directive['type']}")
185
+
186
+ return patches
187
+
188
+
189
+ class FileApplyDiffHandler(AsyncHandler):
190
+ """Handler that applies unified diff patches to one or more files."""
191
+
192
+ @property
193
+ def command_name(self) -> str:
194
+ return "file_apply_diff"
195
+
196
+ async def handle(self, message: Dict[str, Any], reply_channel: Optional[str] = None) -> None:
197
+ """Handle the command by executing it and sending the response to the requesting client session."""
198
+ logger.info("handler: Processing command %s with reply_channel=%s",
199
+ self.command_name, reply_channel)
200
+ _debug_log(
201
+ f"handle start cmd={self.command_name} request_id={message.get('request_id')} "
202
+ f"project_id={message.get('project_id')} base_path={message.get('base_path')} "
203
+ f"diff_chars={len(message.get('diff') or '')}"
204
+ )
205
+
206
+ try:
207
+ response = await self.execute(message)
208
+ logger.info("handler: Command %s executed successfully", self.command_name)
209
+
210
+ # Automatically copy request_id if present in the incoming message
211
+ if "request_id" in message and "request_id" not in response:
212
+ response["request_id"] = message["request_id"]
213
+
214
+ # Get the source client session from the message
215
+ source_client_session = message.get("source_client_session")
216
+ project_id = response.get("project_id")
217
+
218
+ logger.info("handler: %s response project_id=%s, source_client_session=%s",
219
+ self.command_name, project_id, source_client_session)
220
+
221
+ # Send response only to the requesting client session
222
+ if source_client_session:
223
+ # Add client_sessions field to target only the requesting session
224
+ response["client_sessions"] = [source_client_session]
225
+
226
+ import json
227
+ logger.info("handler: 📤 SENDING EVENT '%s' (via direct control_channel.send)", response.get("event", "unknown"))
228
+ logger.info("handler: 📤 FULL EVENT PAYLOAD: %s", json.dumps(response, indent=2, default=str))
229
+
230
+ await self.control_channel.send(response)
231
+ else:
232
+ # Fallback to original behavior if no source_client_session
233
+ await self.send_response(response, reply_channel, project_id)
234
+ except Exception as exc:
235
+ logger.exception("handler: Error in command %s: %s", self.command_name, exc)
236
+ _debug_log(
237
+ f"handle error cmd={self.command_name} request_id={message.get('request_id')} error={exc}"
238
+ )
239
+ error_payload = {
240
+ "event": "file_apply_diff_response",
241
+ "project_id": message.get("project_id"),
242
+ "base_path": message.get("base_path") or os.getcwd(),
243
+ "results": [],
244
+ "files_changed": 0,
245
+ "status": "error",
246
+ "success": False,
247
+ "error": str(exc),
248
+ }
249
+ if "request_id" in message:
250
+ error_payload["request_id"] = message["request_id"]
251
+
252
+ source_client_session = message.get("source_client_session")
253
+ if source_client_session:
254
+ error_payload["client_sessions"] = [source_client_session]
255
+ await self.control_channel.send(error_payload)
256
+ else:
257
+ await self.send_response(error_payload, reply_channel, message.get("project_id"))
258
+ else:
259
+ _debug_log(
260
+ f"handle complete cmd={self.command_name} request_id={message.get('request_id')} "
261
+ f"status={(response or {}).get('status') if response else 'no-response'}"
262
+ )
263
+
264
+ async def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
265
+ diff_text = message.get("diff")
266
+ if not diff_text or not diff_text.strip():
267
+ raise ValueError("diff parameter is required")
268
+
269
+ project_id = message.get("project_id")
270
+ source_client_session = message.get("source_client_session")
271
+ requested_base_path = message.get("base_path")
272
+
273
+ manager = None
274
+ project_root: Optional[str] = None
275
+ if source_client_session:
276
+ try:
277
+ manager = get_or_create_project_state_manager(self.context, self.control_channel)
278
+ project_state = manager.projects.get(source_client_session)
279
+ if project_state:
280
+ project_root = project_state.project_folder_path
281
+ except Exception:
282
+ logger.exception("file_apply_diff: Unable to determine project root for session %s", source_client_session)
283
+
284
+ base_path = requested_base_path or project_root or os.getcwd()
285
+ logger.info("file_apply_diff: Using base path %s", base_path)
286
+
287
+ try:
288
+ cleaned_diff, directives = _extract_inline_directives(diff_text)
289
+ except ValueError as exc:
290
+ raise ValueError(f"Invalid inline directive: {exc}") from exc
291
+
292
+ file_patches: List[FilePatch] = []
293
+ if cleaned_diff.strip():
294
+ try:
295
+ file_patches = parse_unified_diff(cleaned_diff)
296
+ except DiffParseError as exc:
297
+ raise ValueError(f"Invalid diff content: {exc}") from exc
298
+
299
+ try:
300
+ directive_patches = _build_directive_patches(directives, base_path)
301
+ file_patches = directive_patches + file_patches
302
+ except DiffApplyError as exc:
303
+ raise ValueError(str(exc)) from exc
304
+
305
+ if not file_patches:
306
+ raise ValueError("No file changes were provided")
307
+
308
+ results: List[Dict[str, Any]] = []
309
+ applied_paths: List[str] = []
310
+ loop = asyncio.get_running_loop()
311
+ _debug_log(
312
+ f"execute parsed {len(file_patches)} patches base_path={base_path} "
313
+ f"source_session={source_client_session}"
314
+ )
315
+
316
+ for file_patch in file_patches:
317
+ heuristics: List[str] = []
318
+ apply_func = partial(
319
+ apply_file_patch, file_patch, base_path, heuristic_log=heuristics
320
+ )
321
+ try:
322
+ target_path, action, bytes_written = await loop.run_in_executor(None, apply_func)
323
+ applied_paths.append(target_path)
324
+ result_entry = {
325
+ "path": target_path,
326
+ "status": "applied",
327
+ "action": action,
328
+ "bytes_written": bytes_written,
329
+ }
330
+ if heuristics:
331
+ result_entry["heuristic_adjustments"] = heuristics
332
+ results.append(result_entry)
333
+ logger.info("file_apply_diff: %s %s (%s bytes)", action, target_path, bytes_written)
334
+ except DiffApplyError as exc:
335
+ logger.warning("file_apply_diff: Failed to apply diff for %s: %s", file_patch.target_path, exc)
336
+ results.append(
337
+ {
338
+ "path": file_patch.target_path,
339
+ "status": "error",
340
+ "error": str(exc),
341
+ "line": getattr(exc, "line_number", None),
342
+ }
343
+ )
344
+ except Exception as exc:
345
+ logger.exception("file_apply_diff: Unexpected error applying patch")
346
+ results.append(
347
+ {
348
+ "path": file_patch.target_path,
349
+ "status": "error",
350
+ "error": str(exc),
351
+ }
352
+ )
353
+
354
+ if manager and applied_paths:
355
+ for path in applied_paths:
356
+ try:
357
+ await manager.refresh_project_state_for_file_change(path)
358
+ except Exception:
359
+ logger.exception("file_apply_diff: Failed to refresh project state for %s", path)
360
+
361
+ success_count = sum(1 for result in results if result["status"] == "applied")
362
+ failure_count = len(results) - success_count
363
+ overall_status = "success"
364
+ if success_count and failure_count:
365
+ overall_status = "partial_failure"
366
+ elif failure_count and not success_count:
367
+ overall_status = "failed"
368
+
369
+ response = {
370
+ "event": "file_apply_diff_response",
371
+ "project_id": project_id,
372
+ "base_path": base_path,
373
+ "results": results,
374
+ "files_changed": success_count,
375
+ "status": overall_status,
376
+ "success": failure_count == 0,
377
+ }
378
+ _debug_log(
379
+ f"execute done request_id={message.get('request_id')} success={response['success']} "
380
+ f"files_changed={success_count} failures={failure_count}"
381
+ )
382
+ return response
383
+
384
+
385
+ class FilePreviewDiffHandler(AsyncHandler):
386
+ """Handler that validates diffs and returns HTML previews without applying changes."""
387
+
388
+ @property
389
+ def command_name(self) -> str:
390
+ return "file_preview_diff"
391
+
392
+ async def handle(self, message: Dict[str, Any], reply_channel: Optional[str] = None) -> None:
393
+ logger.info(
394
+ "handler: Processing command %s with reply_channel=%s",
395
+ self.command_name,
396
+ reply_channel,
397
+ )
398
+ _debug_log(
399
+ f"handle start cmd={self.command_name} request_id={message.get('request_id')} "
400
+ f"project_id={message.get('project_id')} base_path={message.get('base_path')} "
401
+ f"diff_chars={len(message.get('diff') or '')}"
402
+ )
403
+
404
+ try:
405
+ response = await self.execute(message)
406
+ logger.info("handler: Command %s executed successfully", self.command_name)
407
+
408
+ if "request_id" in message and "request_id" not in response:
409
+ response["request_id"] = message["request_id"]
410
+
411
+ source_client_session = message.get("source_client_session")
412
+ project_id = response.get("project_id")
413
+ logger.info(
414
+ "handler: %s response project_id=%s, source_client_session=%s",
415
+ self.command_name,
416
+ project_id,
417
+ source_client_session,
418
+ )
419
+
420
+ if source_client_session:
421
+ response["client_sessions"] = [source_client_session]
422
+ import json
423
+
424
+ logger.info(
425
+ "handler: 📤 SENDING EVENT '%s' (via direct control_channel.send)",
426
+ response.get("event", "unknown"),
427
+ )
428
+ logger.info(
429
+ "handler: 📤 FULL EVENT PAYLOAD: %s",
430
+ json.dumps(response, indent=2, default=str),
431
+ )
432
+ await self.control_channel.send(response)
433
+ else:
434
+ await self.send_response(response, reply_channel, project_id)
435
+ except Exception as exc:
436
+ logger.exception("handler: Error in command %s: %s", self.command_name, exc)
437
+ _debug_log(
438
+ f"handle error cmd={self.command_name} request_id={message.get('request_id')} error={exc}"
439
+ )
440
+ error_payload = {
441
+ "event": "file_preview_diff_response",
442
+ "project_id": message.get("project_id"),
443
+ "base_path": message.get("base_path") or os.getcwd(),
444
+ "previews": [],
445
+ "status": "error",
446
+ "success": False,
447
+ "error": str(exc),
448
+ }
449
+ if "request_id" in message:
450
+ error_payload["request_id"] = message["request_id"]
451
+
452
+ source_client_session = message.get("source_client_session")
453
+ if source_client_session:
454
+ error_payload["client_sessions"] = [source_client_session]
455
+ await self.control_channel.send(error_payload)
456
+ else:
457
+ await self.send_response(error_payload, reply_channel, message.get("project_id"))
458
+ else:
459
+ _debug_log(
460
+ f"handle complete cmd={self.command_name} request_id={message.get('request_id')} "
461
+ f"status={(response or {}).get('status') if response else 'no-response'}"
462
+ )
463
+
464
+ async def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
465
+ diff_text = message.get("diff")
466
+ if not diff_text or not diff_text.strip():
467
+ raise ValueError("diff parameter is required")
468
+
469
+ project_id = message.get("project_id")
470
+ source_client_session = message.get("source_client_session")
471
+ requested_base_path = message.get("base_path")
472
+
473
+ manager = None
474
+ project_root: Optional[str] = None
475
+ if source_client_session:
476
+ try:
477
+ manager = get_or_create_project_state_manager(self.context, self.control_channel)
478
+ project_state = manager.projects.get(source_client_session)
479
+ if project_state:
480
+ project_root = project_state.project_folder_path
481
+ except Exception:
482
+ logger.exception(
483
+ "file_preview_diff: Unable to determine project root for session %s",
484
+ source_client_session,
485
+ )
486
+
487
+ base_path = requested_base_path or project_root or os.getcwd()
488
+ logger.info("file_preview_diff: Using base path %s", base_path)
489
+
490
+ try:
491
+ cleaned_diff, directives = _extract_inline_directives(diff_text)
492
+ except ValueError as exc:
493
+ raise ValueError(f"Invalid inline directive: {exc}") from exc
494
+
495
+ file_patches: List[FilePatch] = []
496
+ if cleaned_diff.strip():
497
+ try:
498
+ file_patches = parse_unified_diff(cleaned_diff)
499
+ except DiffParseError as exc:
500
+ raise ValueError(f"Invalid diff content: {exc}") from exc
501
+
502
+ try:
503
+ directive_patches = _build_directive_patches(directives, base_path)
504
+ file_patches = directive_patches + file_patches
505
+ except DiffApplyError as exc:
506
+ raise ValueError(str(exc)) from exc
507
+
508
+ if not file_patches:
509
+ raise ValueError("No file changes were provided")
510
+
511
+ previews: List[Dict[str, Any]] = []
512
+
513
+ for file_patch in file_patches:
514
+ target_hint = file_patch.target_path or file_patch.new_path or file_patch.old_path
515
+ display_path = _resolve_preview_path(base_path, target_hint)
516
+
517
+ heuristics: List[str] = []
518
+ try:
519
+ (
520
+ preview_path,
521
+ file_action,
522
+ original_lines,
523
+ updated_lines,
524
+ ) = preview_file_patch(
525
+ file_patch, base_path, heuristic_log=heuristics
526
+ )
527
+ except DiffApplyError as exc:
528
+ logger.exception(
529
+ "file_preview_diff: Unable to compute preview for %s", display_path
530
+ )
531
+ previews.append(
532
+ {
533
+ "path": display_path,
534
+ "relative_path": target_hint,
535
+ "status": "error",
536
+ "error": str(exc),
537
+ }
538
+ )
539
+ continue
540
+
541
+ try:
542
+ if file_action == "deleted":
543
+ preview_action = "deleted"
544
+ elif file_action == "created":
545
+ preview_action = "added"
546
+ else:
547
+ preview_action = "modified"
548
+
549
+ original_text = "".join(original_lines)
550
+ updated_text = "".join(updated_lines)
551
+ html_versions = diff_renderer.generate_html_diff(
552
+ original_text, updated_text, display_path
553
+ )
554
+ if not html_versions:
555
+ fallback = diff_renderer.generate_fallback_diff_html(display_path)
556
+ html_versions = {"minimal": fallback, "full": fallback}
557
+
558
+ preview_entry = {
559
+ "path": display_path,
560
+ "relative_path": target_hint,
561
+ "status": "ready",
562
+ "html": html_versions["minimal"],
563
+ "html_versions": html_versions,
564
+ "has_full": html_versions["minimal"] != html_versions["full"],
565
+ "action": preview_action,
566
+ }
567
+ if heuristics:
568
+ preview_entry["heuristic_adjustments"] = heuristics
569
+ previews.append(preview_entry)
570
+ except Exception as exc:
571
+ logger.exception(
572
+ "file_preview_diff: Failed to render preview for %s", display_path
573
+ )
574
+ previews.append(
575
+ {
576
+ "path": display_path,
577
+ "relative_path": target_hint,
578
+ "status": "error",
579
+ "error": str(exc),
580
+ }
581
+ )
582
+
583
+ success_count = sum(1 for result in previews if result["status"] == "ready")
584
+ failure_count = len(previews) - success_count
585
+ overall_status = "success"
586
+ if success_count and failure_count:
587
+ overall_status = "partial_failure"
588
+ elif failure_count and not success_count:
589
+ overall_status = "failed"
590
+
591
+ response = {
592
+ "event": "file_preview_diff_response",
593
+ "project_id": project_id,
594
+ "base_path": base_path,
595
+ "previews": previews,
596
+ "status": overall_status,
597
+ "success": failure_count == 0,
598
+ }
599
+ _debug_log(
600
+ f"preview execute done request_id={message.get('request_id')} "
601
+ f"success={response['success']} previews={len(previews)}"
602
+ )
603
+ return response