portacode 0.3.19.dev4__py3-none-any.whl → 1.4.11.dev1__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 (92) hide show
  1. portacode/_version.py +16 -3
  2. portacode/cli.py +143 -17
  3. portacode/connection/client.py +149 -10
  4. portacode/connection/handlers/WEBSOCKET_PROTOCOL.md +824 -21
  5. portacode/connection/handlers/__init__.py +28 -1
  6. portacode/connection/handlers/base.py +78 -16
  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 -2185
  20. portacode/connection/handlers/proxmox_infra.py +361 -0
  21. portacode/connection/handlers/registry.py +15 -4
  22. portacode/connection/handlers/session.py +483 -32
  23. portacode/connection/handlers/system_handlers.py +147 -8
  24. portacode/connection/handlers/tab_factory.py +53 -46
  25. portacode/connection/handlers/terminal_handlers.py +21 -8
  26. portacode/connection/handlers/update_handler.py +61 -0
  27. portacode/connection/multiplex.py +60 -2
  28. portacode/connection/terminal.py +214 -24
  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/static/js/test-ntp-clock.html +63 -0
  53. portacode/static/js/utils/ntp-clock.js +232 -0
  54. portacode/utils/NTP_ARCHITECTURE.md +136 -0
  55. portacode/utils/__init__.py +1 -0
  56. portacode/utils/diff_apply.py +456 -0
  57. portacode/utils/diff_renderer.py +371 -0
  58. portacode/utils/ntp_clock.py +65 -0
  59. portacode-1.4.11.dev1.dist-info/METADATA +298 -0
  60. portacode-1.4.11.dev1.dist-info/RECORD +97 -0
  61. {portacode-0.3.19.dev4.dist-info → portacode-1.4.11.dev1.dist-info}/WHEEL +1 -1
  62. portacode-1.4.11.dev1.dist-info/top_level.txt +3 -0
  63. test_modules/README.md +296 -0
  64. test_modules/__init__.py +1 -0
  65. test_modules/test_device_online.py +44 -0
  66. test_modules/test_file_operations.py +743 -0
  67. test_modules/test_git_status_ui.py +370 -0
  68. test_modules/test_login_flow.py +50 -0
  69. test_modules/test_navigate_testing_folder.py +361 -0
  70. test_modules/test_play_store_screenshots.py +294 -0
  71. test_modules/test_terminal_buffer_performance.py +261 -0
  72. test_modules/test_terminal_interaction.py +80 -0
  73. test_modules/test_terminal_loading_race_condition.py +95 -0
  74. test_modules/test_terminal_start.py +56 -0
  75. testing_framework/.env.example +21 -0
  76. testing_framework/README.md +334 -0
  77. testing_framework/__init__.py +17 -0
  78. testing_framework/cli.py +326 -0
  79. testing_framework/core/__init__.py +1 -0
  80. testing_framework/core/base_test.py +336 -0
  81. testing_framework/core/cli_manager.py +177 -0
  82. testing_framework/core/hierarchical_runner.py +577 -0
  83. testing_framework/core/playwright_manager.py +520 -0
  84. testing_framework/core/runner.py +447 -0
  85. testing_framework/core/shared_cli_manager.py +234 -0
  86. testing_framework/core/test_discovery.py +112 -0
  87. testing_framework/requirements.txt +12 -0
  88. portacode-0.3.19.dev4.dist-info/METADATA +0 -241
  89. portacode-0.3.19.dev4.dist-info/RECORD +0 -30
  90. portacode-0.3.19.dev4.dist-info/top_level.txt +0 -1
  91. {portacode-0.3.19.dev4.dist-info → portacode-1.4.11.dev1.dist-info}/entry_points.txt +0 -0
  92. {portacode-0.3.19.dev4.dist-info → portacode-1.4.11.dev1.dist-info/licenses}/LICENSE +0 -0
@@ -0,0 +1,371 @@
1
+ """Stateless utilities for rendering unified diffs as HTML."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import difflib
6
+ import logging
7
+ import os
8
+ import time
9
+ from typing import Dict, List, Optional
10
+
11
+ try:
12
+ from pygments import highlight
13
+ from pygments.formatters import HtmlFormatter
14
+ from pygments.lexers import get_lexer_for_filename
15
+ from pygments.util import ClassNotFound
16
+
17
+ PYGMENTS_AVAILABLE = True
18
+ except ImportError:
19
+ PYGMENTS_AVAILABLE = False
20
+ highlight = None # type: ignore
21
+ HtmlFormatter = None # type: ignore
22
+ get_lexer_for_filename = None # type: ignore
23
+ ClassNotFound = Exception # type: ignore
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+
28
+ def generate_html_diff(
29
+ original_content: str,
30
+ modified_content: str,
31
+ file_path: str,
32
+ ) -> Optional[Dict[str, str]]:
33
+ """Generate unified HTML diff (minimal + full context) for two strings."""
34
+ # If pygments isn't available we fall back to the simplified renderer
35
+ if not PYGMENTS_AVAILABLE:
36
+ return _generate_simple_diff_html(original_content, modified_content, file_path)
37
+
38
+ # Basic safety limits to keep rendering responsive
39
+ max_content_size = 500000 # 500KB
40
+ max_lines = 5000
41
+ original_line_count = original_content.count("\n")
42
+ modified_line_count = modified_content.count("\n")
43
+ if (
44
+ len(original_content) > max_content_size
45
+ or len(modified_content) > max_content_size
46
+ or max(original_line_count, modified_line_count) > max_lines
47
+ ):
48
+ logger.warning("Large file detected for diff generation: %s", file_path)
49
+ return _generate_simple_diff_html(original_content, modified_content, file_path)
50
+
51
+ try:
52
+ original_lines = original_content.splitlines(keepends=True)
53
+ modified_lines = modified_content.splitlines(keepends=True)
54
+
55
+ start_time = time.time()
56
+ timeout_seconds = 5
57
+
58
+ minimal_diff_lines = list(
59
+ difflib.unified_diff(
60
+ original_lines,
61
+ modified_lines,
62
+ fromfile=f"a/{os.path.basename(file_path)}",
63
+ tofile=f"b/{os.path.basename(file_path)}",
64
+ lineterm="",
65
+ n=3,
66
+ )
67
+ )
68
+ if time.time() - start_time > timeout_seconds:
69
+ logger.warning("Diff generation timeout for %s", file_path)
70
+ return None
71
+
72
+ if len(original_lines) + len(modified_lines) < 2000:
73
+ context_span = len(original_lines) + len(modified_lines)
74
+ full_diff_lines = list(
75
+ difflib.unified_diff(
76
+ original_lines,
77
+ modified_lines,
78
+ fromfile=f"a/{os.path.basename(file_path)}",
79
+ tofile=f"b/{os.path.basename(file_path)}",
80
+ lineterm="",
81
+ n=context_span,
82
+ )
83
+ )
84
+ else:
85
+ full_diff_lines = minimal_diff_lines
86
+
87
+ parsed_minimal = parse_unified_diff_simple(minimal_diff_lines)
88
+ parsed_full = parse_unified_diff_simple(full_diff_lines)
89
+
90
+ if time.time() - start_time > timeout_seconds:
91
+ logger.warning("Diff generation timeout for %s", file_path)
92
+ return None
93
+
94
+ minimal_html = render_diff_html(parsed_minimal, file_path, "minimal")
95
+ full_html = render_diff_html(parsed_full, file_path, "full")
96
+ return {"minimal": minimal_html, "full": full_html}
97
+ except Exception as exc: # pragma: no cover - defensive
98
+ logger.error("Error generating HTML diff for %s: %s", file_path, exc)
99
+ return None
100
+
101
+
102
+ def parse_unified_diff_simple(diff_lines: List[str]) -> List[Dict]:
103
+ """Parse unified diff lines into structured rows (no intraline highlighting)."""
104
+ parsed: List[Dict] = []
105
+ old_line_num = 0
106
+ new_line_num = 0
107
+
108
+ for line in diff_lines:
109
+ if line.startswith("@@"):
110
+ import re
111
+
112
+ match = re.match(r"@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@", line)
113
+ if match:
114
+ old_line_num = int(match.group(1)) - 1
115
+ new_line_num = int(match.group(2)) - 1
116
+ parsed.append(
117
+ {
118
+ "type": "header",
119
+ "content": line,
120
+ "old_line_num": "",
121
+ "new_line_num": "",
122
+ }
123
+ )
124
+ elif line.startswith("---") or line.startswith("+++"):
125
+ parsed.append(
126
+ {
127
+ "type": "header",
128
+ "content": line,
129
+ "old_line_num": "",
130
+ "new_line_num": "",
131
+ }
132
+ )
133
+ elif line.startswith("-"):
134
+ old_line_num += 1
135
+ parsed.append(
136
+ {
137
+ "type": "delete",
138
+ "old_line_num": old_line_num,
139
+ "new_line_num": "",
140
+ "content": line,
141
+ }
142
+ )
143
+ elif line.startswith("+"):
144
+ new_line_num += 1
145
+ parsed.append(
146
+ {
147
+ "type": "add",
148
+ "old_line_num": "",
149
+ "new_line_num": new_line_num,
150
+ "content": line,
151
+ }
152
+ )
153
+ elif line.startswith(" "):
154
+ old_line_num += 1
155
+ new_line_num += 1
156
+ parsed.append(
157
+ {
158
+ "type": "context",
159
+ "old_line_num": old_line_num,
160
+ "new_line_num": new_line_num,
161
+ "content": line,
162
+ }
163
+ )
164
+ return parsed
165
+
166
+
167
+ def render_diff_html(parsed_diff: List[Dict], file_path: str, view_mode: str) -> str:
168
+ """Convert parsed diff entries into styled HTML."""
169
+ if len(parsed_diff) > 1000:
170
+ logger.warning("Diff too large, truncating: %s (%s lines)", file_path, len(parsed_diff))
171
+ parsed_diff = parsed_diff[:1000]
172
+
173
+ lexer = _get_pygments_lexer(file_path)
174
+ highlighted_cache = {}
175
+ if lexer and PYGMENTS_AVAILABLE:
176
+ unique_lines = {
177
+ line_info["content"][1:].rstrip("\n")
178
+ for line_info in parsed_diff
179
+ if line_info.get("content") and line_info["content"][0] in "+- "
180
+ }
181
+ unique_lines = {line for line in unique_lines if line.strip()}
182
+ if unique_lines:
183
+ try:
184
+ combined = "\n".join(unique_lines)
185
+ highlighted = highlight(
186
+ combined, lexer, HtmlFormatter(nowrap=True, noclasses=False, style="monokai")
187
+ )
188
+ split_highlighted = highlighted.split("\n")
189
+ unique_list = list(unique_lines)
190
+ for idx, content in enumerate(unique_list):
191
+ if idx < len(split_highlighted):
192
+ highlighted_cache[content] = split_highlighted[idx]
193
+ except Exception as exc:
194
+ logger.debug("Error in batch syntax highlighting: %s", exc)
195
+ highlighted_cache = {}
196
+
197
+ html_parts: List[str] = []
198
+ html_parts.append(f'<div class="unified-diff-container" data-view-mode="{view_mode}">')
199
+
200
+ line_additions = sum(1 for line in parsed_diff if line["type"] == "add")
201
+ line_deletions = sum(1 for line in parsed_diff if line["type"] == "delete")
202
+
203
+ html_parts.append(
204
+ f"""
205
+ <div class="diff-stats">
206
+ <div class="diff-stats-left">
207
+ <span class="additions">+{line_additions}</span>
208
+ <span class="deletions">-{line_deletions}</span>
209
+ <span class="file-path">{os.path.basename(file_path)}</span>
210
+ </div>
211
+ <div class="diff-stats-right">
212
+ <button class="diff-toggle-btn" data-current-mode="{view_mode}">
213
+ <i class="fas fa-eye"></i>
214
+ <span class="toggle-text"></span>
215
+ </button>
216
+ </div>
217
+ </div>
218
+ """
219
+ )
220
+
221
+ html_parts.append('<div class="diff-content">')
222
+ html_parts.append('<table class="diff-table">')
223
+
224
+ for line_info in parsed_diff:
225
+ if line_info["type"] == "header":
226
+ continue
227
+
228
+ line_type = line_info["type"]
229
+ old_line_num = line_info.get("old_line_num", "")
230
+ new_line_num = line_info.get("new_line_num", "")
231
+ content = line_info.get("content", "")
232
+
233
+ final_content = _escape_html(content)
234
+ if content and content[0] in "+- ":
235
+ prefix = content[0] if content[0] in "+-" else " "
236
+ clean_content = content[1:].rstrip("\n")
237
+ if clean_content.strip():
238
+ cached = highlighted_cache.get(clean_content)
239
+ if cached:
240
+ final_content = prefix + cached
241
+ elif lexer and PYGMENTS_AVAILABLE:
242
+ try:
243
+ highlighted_line = highlight(
244
+ clean_content, lexer, HtmlFormatter(nowrap=True, noclasses=False, style="monokai")
245
+ )
246
+ final_content = prefix + highlighted_line
247
+ except Exception as exc:
248
+ logger.debug("Error applying syntax highlighting: %s", exc)
249
+ final_content = _escape_html(content)
250
+
251
+ row_class = f"diff-line diff-{line_type}"
252
+ html_parts.append(
253
+ f"""
254
+ <tr class="{row_class}">
255
+ <td class="line-num old-line-num">{old_line_num}</td>
256
+ <td class="line-num new-line-num">{new_line_num}</td>
257
+ <td class="line-content">{final_content}</td>
258
+ </tr>
259
+ """
260
+ )
261
+
262
+ html_parts.append("</table>")
263
+ html_parts.append("</div>")
264
+ html_parts.append("</div>")
265
+ return "".join(html_parts)
266
+
267
+
268
+ def render_simple_diff_html(parsed_diff: List[Dict], file_path: str) -> str:
269
+ """Generate simplified diff HTML tables (no syntax highlighting)."""
270
+ html_parts = []
271
+ html_parts.append('<div class="unified-diff-container" data-view-mode="minimal">')
272
+
273
+ line_additions = sum(1 for line in parsed_diff if line["type"] == "add")
274
+ line_deletions = sum(1 for line in parsed_diff if line["type"] == "delete")
275
+ html_parts.append(
276
+ f"""
277
+ <div class="diff-stats">
278
+ <div class="diff-stats-left">
279
+ <span class="additions">+{line_additions}</span>
280
+ <span class="deletions">-{line_deletions}</span>
281
+ <span class="file-path">{os.path.basename(file_path)} (Large file - simplified view)</span>
282
+ </div>
283
+ </div>
284
+ """
285
+ )
286
+
287
+ html_parts.append('<div class="diff-content">')
288
+ html_parts.append('<table class="diff-table">')
289
+
290
+ for line_info in parsed_diff:
291
+ if line_info["type"] == "header":
292
+ continue
293
+ row_class = f'diff-line diff-{line_info["type"]}'
294
+ html_parts.append(
295
+ f"""
296
+ <tr class="{row_class}">
297
+ <td class="line-num old-line-num">{line_info.get("old_line_num", "")}</td>
298
+ <td class="line-num new-line-num">{line_info.get("new_line_num", "")}</td>
299
+ <td class="line-content">{_escape_html(line_info.get("content", ""))}</td>
300
+ </tr>
301
+ """
302
+ )
303
+
304
+ html_parts.append("</table>")
305
+ html_parts.append("</div>")
306
+ html_parts.append("</div>")
307
+ return "".join(html_parts)
308
+
309
+
310
+ def generate_fallback_diff_html(file_path: str) -> str:
311
+ """Fallback view when diff can't be rendered."""
312
+ return f"""
313
+ <div class="unified-diff-container" data-view-mode="minimal">
314
+ <div class="diff-stats">
315
+ <div class="diff-stats-left">
316
+ <span class="file-path">{os.path.basename(file_path)} (Diff unavailable)</span>
317
+ </div>
318
+ </div>
319
+ <div class="diff-content">
320
+ <div style="padding: 2rem; text-align: center; color: var(--text-secondary);">
321
+ <i class="fas fa-exclamation-triangle" style="font-size: 2rem; margin-bottom: 1rem;"></i>
322
+ <p>Diff view unavailable for this file</p>
323
+ <p style="font-size: 0.9rem;">File may be too large or binary</p>
324
+ </div>
325
+ </div>
326
+ </div>
327
+ """
328
+
329
+
330
+ def _generate_simple_diff_html(original_content: str, modified_content: str, file_path: str) -> Dict[str, str]:
331
+ """Render simplified HTML diff for large files or when syntax highlighting is unavailable."""
332
+ diff_lines = list(
333
+ difflib.unified_diff(
334
+ original_content.splitlines(keepends=True),
335
+ modified_content.splitlines(keepends=True),
336
+ fromfile=f"a/{os.path.basename(file_path)}",
337
+ tofile=f"b/{os.path.basename(file_path)}",
338
+ lineterm="",
339
+ n=3,
340
+ )
341
+ )
342
+ parsed = parse_unified_diff_simple(diff_lines)
343
+ if len(parsed) > 500:
344
+ parsed = parsed[:500]
345
+ logger.info("Truncated large diff to 500 lines for %s", file_path)
346
+ html = render_simple_diff_html(parsed, file_path)
347
+ return {"minimal": html, "full": html}
348
+
349
+
350
+ def _get_pygments_lexer(file_path: str):
351
+ if not PYGMENTS_AVAILABLE or not get_lexer_for_filename: # type: ignore
352
+ return None
353
+ try:
354
+ return get_lexer_for_filename(file_path) # type: ignore
355
+ except ClassNotFound:
356
+ logger.debug("No Pygments lexer found for file: %s", file_path)
357
+ return None
358
+ except Exception as exc:
359
+ logger.debug("Error getting Pygments lexer for %s: %s", file_path, exc)
360
+ return None
361
+
362
+
363
+ def _escape_html(text: str) -> str:
364
+ return (
365
+ text.replace("&", "&amp;")
366
+ .replace("<", "&lt;")
367
+ .replace(">", "&gt;")
368
+ .replace('"', "&quot;")
369
+ .replace("'", "&#x27;")
370
+ )
371
+
@@ -0,0 +1,65 @@
1
+ """
2
+ Simple clock offset helper that tracks the difference between the local clock and
3
+ the server time. The device obtains the server time over the existing gateway
4
+ WebSocket and updates the offset based on measured round-trip time.
5
+ """
6
+ import time
7
+ from datetime import datetime, timezone
8
+ from typing import Optional
9
+
10
+
11
+ class NTPClock:
12
+ """Clock helper that stores a millisecond offset from the local system clock."""
13
+
14
+ def __init__(self):
15
+ self._offset_ms = 0.0
16
+ self._last_sync = None
17
+ self._last_latency_ms = None
18
+ self._smoothing_weight = 0.2
19
+
20
+ def now(self) -> float:
21
+ """Return the current timestamp (seconds since epoch) adjusted by the offset."""
22
+ return time.time() + (self._offset_ms / 1000.0)
23
+
24
+ def now_ms(self) -> int:
25
+ """Return the current timestamp in milliseconds adjusted by the offset."""
26
+ return int(time.time() * 1000 + self._offset_ms)
27
+
28
+ def now_iso(self) -> str:
29
+ """Return the current ISO timestamp adjusted by the offset."""
30
+ return datetime.fromtimestamp(self.now(), tz=timezone.utc).isoformat()
31
+
32
+ def get_status(self) -> dict:
33
+ """Return metadata about the last synchronization."""
34
+ return {
35
+ 'server': 'gateway',
36
+ 'offset_ms': self._offset_ms,
37
+ 'last_sync': datetime.fromtimestamp(self._last_sync, tz=timezone.utc).isoformat() if self._last_sync else None,
38
+ 'last_latency_ms': self._last_latency_ms,
39
+ 'is_synced': self._last_sync is not None,
40
+ }
41
+
42
+ def update_from_server(self, server_time_ms: float, latency_ms: float) -> None:
43
+ """Update the clock offset using the server timestamp and measured latency."""
44
+ client_receive_ms = time.time() * 1000
45
+ half_latency = latency_ms / 2
46
+ estimated_server_received = client_receive_ms - half_latency
47
+ new_offset = server_time_ms - estimated_server_received
48
+ if self._last_sync is not None:
49
+ self._offset_ms += self._smoothing_weight * (new_offset - self._offset_ms)
50
+ else:
51
+ self._offset_ms = new_offset
52
+ self._last_latency_ms = latency_ms
53
+ self._last_sync = time.time()
54
+
55
+ def sync(self) -> bool:
56
+ """Legacy sync stub kept for compatibility; no-op so callers continue to work."""
57
+ return False
58
+
59
+ def start_auto_sync(self):
60
+ """Legacy stub that does nothing now that sync happens via the gateway."""
61
+ pass
62
+
63
+
64
+ # Global instance
65
+ ntp_clock = NTPClock()