portacode 1.3.32__py3-none-any.whl → 1.4.11.dev5__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.
- portacode/_version.py +2 -2
- portacode/cli.py +158 -14
- portacode/connection/client.py +127 -8
- portacode/connection/handlers/WEBSOCKET_PROTOCOL.md +370 -4
- portacode/connection/handlers/__init__.py +16 -1
- portacode/connection/handlers/diff_handlers.py +603 -0
- portacode/connection/handlers/file_handlers.py +674 -17
- portacode/connection/handlers/project_aware_file_handlers.py +11 -0
- portacode/connection/handlers/project_state/file_system_watcher.py +31 -61
- portacode/connection/handlers/project_state/git_manager.py +139 -572
- portacode/connection/handlers/project_state/handlers.py +28 -14
- portacode/connection/handlers/project_state/manager.py +226 -101
- portacode/connection/handlers/proxmox_infra.py +790 -0
- portacode/connection/handlers/session.py +465 -84
- portacode/connection/handlers/system_handlers.py +181 -8
- portacode/connection/handlers/tab_factory.py +1 -47
- portacode/connection/handlers/update_handler.py +61 -0
- portacode/connection/terminal.py +55 -10
- portacode/keypair.py +63 -1
- portacode/link_capture/__init__.py +38 -0
- portacode/link_capture/__pycache__/__init__.cpython-311.pyc +0 -0
- portacode/link_capture/bin/__pycache__/link_capture_wrapper.cpython-311.pyc +0 -0
- portacode/link_capture/bin/elinks +3 -0
- portacode/link_capture/bin/gio-open +3 -0
- portacode/link_capture/bin/gnome-open +3 -0
- portacode/link_capture/bin/gvfs-open +3 -0
- portacode/link_capture/bin/kde-open +3 -0
- portacode/link_capture/bin/kfmclient +3 -0
- portacode/link_capture/bin/link_capture_exec.sh +11 -0
- portacode/link_capture/bin/link_capture_wrapper.py +75 -0
- portacode/link_capture/bin/links +3 -0
- portacode/link_capture/bin/links2 +3 -0
- portacode/link_capture/bin/lynx +3 -0
- portacode/link_capture/bin/mate-open +3 -0
- portacode/link_capture/bin/netsurf +3 -0
- portacode/link_capture/bin/sensible-browser +3 -0
- portacode/link_capture/bin/w3m +3 -0
- portacode/link_capture/bin/x-www-browser +3 -0
- portacode/link_capture/bin/xdg-open +3 -0
- portacode/pairing.py +103 -0
- portacode/static/js/utils/ntp-clock.js +170 -79
- portacode/utils/diff_apply.py +456 -0
- portacode/utils/diff_renderer.py +371 -0
- portacode/utils/ntp_clock.py +45 -131
- {portacode-1.3.32.dist-info → portacode-1.4.11.dev5.dist-info}/METADATA +71 -3
- portacode-1.4.11.dev5.dist-info/RECORD +97 -0
- test_modules/test_device_online.py +1 -1
- test_modules/test_login_flow.py +8 -4
- test_modules/test_play_store_screenshots.py +294 -0
- testing_framework/.env.example +4 -1
- testing_framework/core/playwright_manager.py +63 -9
- portacode-1.3.32.dist-info/RECORD +0 -70
- {portacode-1.3.32.dist-info → portacode-1.4.11.dev5.dist-info}/WHEEL +0 -0
- {portacode-1.3.32.dist-info → portacode-1.4.11.dev5.dist-info}/entry_points.txt +0 -0
- {portacode-1.3.32.dist-info → portacode-1.4.11.dev5.dist-info}/licenses/LICENSE +0 -0
- {portacode-1.3.32.dist-info → portacode-1.4.11.dev5.dist-info}/top_level.txt +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
|