portacode 0.3.22__py3-none-any.whl → 0.3.24__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.
Files changed (25) hide show
  1. portacode/_version.py +16 -3
  2. portacode/connection/handlers/WEBSOCKET_PROTOCOL.md +188 -16
  3. portacode/connection/handlers/__init__.py +4 -0
  4. portacode/connection/handlers/base.py +9 -5
  5. portacode/connection/handlers/chunked_content.py +244 -0
  6. portacode/connection/handlers/file_handlers.py +68 -2
  7. portacode/connection/handlers/project_aware_file_handlers.py +143 -1
  8. portacode/connection/handlers/project_state/git_manager.py +326 -66
  9. portacode/connection/handlers/project_state/handlers.py +307 -31
  10. portacode/connection/handlers/project_state/manager.py +44 -1
  11. portacode/connection/handlers/project_state/models.py +7 -0
  12. portacode/connection/handlers/project_state/utils.py +17 -1
  13. portacode/connection/handlers/project_state_handlers.py +1 -0
  14. portacode/connection/handlers/tab_factory.py +60 -7
  15. portacode/connection/terminal.py +13 -7
  16. {portacode-0.3.22.dist-info → portacode-0.3.24.dist-info}/METADATA +14 -3
  17. {portacode-0.3.22.dist-info → portacode-0.3.24.dist-info}/RECORD +25 -24
  18. {portacode-0.3.22.dist-info → portacode-0.3.24.dist-info}/WHEEL +1 -1
  19. test_modules/test_git_status_ui.py +24 -66
  20. testing_framework/core/playwright_manager.py +23 -0
  21. testing_framework/core/runner.py +10 -2
  22. testing_framework/core/test_discovery.py +7 -3
  23. {portacode-0.3.22.dist-info → portacode-0.3.24.dist-info}/entry_points.txt +0 -0
  24. {portacode-0.3.22.dist-info → portacode-0.3.24.dist-info/licenses}/LICENSE +0 -0
  25. {portacode-0.3.22.dist-info → portacode-0.3.24.dist-info}/top_level.txt +0 -0
@@ -2,13 +2,17 @@
2
2
 
3
3
  import os
4
4
  import logging
5
- from typing import Any, Dict
5
+ from typing import Any, Dict, List
6
6
  from pathlib import Path
7
7
 
8
8
  from .base import AsyncHandler, SyncHandler
9
+ from .chunked_content import create_chunked_response
9
10
 
10
11
  logger = logging.getLogger(__name__)
11
12
 
13
+ # Global content cache: hash -> content
14
+ _content_cache = {}
15
+
12
16
 
13
17
  class FileReadHandler(SyncHandler):
14
18
  """Handler for reading file contents."""
@@ -365,4 +369,66 @@ class FileRenameHandler(SyncHandler):
365
369
  except PermissionError:
366
370
  raise RuntimeError(f"Permission denied: {old_path}")
367
371
  except OSError as e:
368
- raise RuntimeError(f"Failed to rename: {e}")
372
+ raise RuntimeError(f"Failed to rename: {e}")
373
+
374
+
375
+ class ContentRequestHandler(AsyncHandler):
376
+ """Handler for requesting content by hash for caching optimization."""
377
+
378
+ @property
379
+ def command_name(self) -> str:
380
+ return "content_request"
381
+
382
+ async def execute(self, message: Dict[str, Any]) -> None:
383
+ """Return content by hash if available, chunked for large content."""
384
+ content_hash = message.get("content_hash")
385
+ request_id = message.get("request_id")
386
+ source_client_session = message.get("source_client_session")
387
+
388
+ if not content_hash:
389
+ raise ValueError("content_hash parameter is required")
390
+ if not request_id:
391
+ raise ValueError("request_id parameter is required")
392
+
393
+ # Check if content is in cache
394
+ content = _content_cache.get(content_hash)
395
+
396
+ if content is not None:
397
+ # Create base response
398
+ base_response = {
399
+ "event": "content_response",
400
+ "request_id": request_id,
401
+ "content_hash": content_hash,
402
+ "success": True,
403
+ }
404
+
405
+ # Create chunked responses
406
+ responses = create_chunked_response(base_response, "content", content)
407
+
408
+ # Send all responses
409
+ for response in responses:
410
+ await self.send_response(response, project_id=None)
411
+
412
+ logger.info(f"Sent content response in {len(responses)} chunk(s) for hash: {content_hash[:16]}...")
413
+ else:
414
+ # Content not found in cache
415
+ response = {
416
+ "event": "content_response",
417
+ "request_id": request_id,
418
+ "content_hash": content_hash,
419
+ "content": None,
420
+ "success": False,
421
+ "error": "Content not found in cache",
422
+ "chunked": False,
423
+ }
424
+ await self.send_response(response, project_id=None)
425
+
426
+
427
+ def cache_content(content_hash: str, content: str) -> None:
428
+ """Cache content by hash for future retrieval."""
429
+ _content_cache[content_hash] = content
430
+
431
+
432
+ def get_cached_content(content_hash: str) -> str:
433
+ """Get cached content by hash."""
434
+ return _content_cache.get(content_hash)
@@ -70,4 +70,146 @@ class ProjectAwareFileWriteHandler(SyncHandler):
70
70
  except PermissionError:
71
71
  raise RuntimeError(f"Permission denied: {file_path}")
72
72
  except OSError as e:
73
- raise RuntimeError(f"Failed to write file: {e}")
73
+ raise RuntimeError(f"Failed to write file: {e}")
74
+
75
+
76
+ class ProjectAwareFileCreateHandler(SyncHandler):
77
+ """Handler for creating new files that updates project state."""
78
+
79
+ @property
80
+ def command_name(self) -> str:
81
+ return "file_create"
82
+
83
+ def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
84
+ """Create a new file and refresh project state."""
85
+ parent_path = message.get("parent_path")
86
+ file_name = message.get("file_name")
87
+ content = message.get("content", "")
88
+
89
+ if not parent_path:
90
+ raise ValueError("parent_path parameter is required")
91
+ if not file_name:
92
+ raise ValueError("file_name parameter is required")
93
+
94
+ # Validate file name (no path separators or special chars)
95
+ if "/" in file_name or "\\" in file_name or file_name in [".", ".."]:
96
+ raise ValueError("Invalid file name")
97
+
98
+ try:
99
+ # Ensure parent directory exists
100
+ parent_dir = Path(parent_path)
101
+ if not parent_dir.exists():
102
+ raise ValueError(f"Parent directory does not exist: {parent_path}")
103
+ if not parent_dir.is_dir():
104
+ raise ValueError(f"Parent path is not a directory: {parent_path}")
105
+
106
+ # Create the full file path
107
+ file_path = parent_dir / file_name
108
+
109
+ # Check if file already exists
110
+ if file_path.exists():
111
+ raise ValueError(f"File already exists: {file_name}")
112
+
113
+ # Create the file
114
+ with open(file_path, 'w', encoding='utf-8') as f:
115
+ f.write(content)
116
+
117
+ # Trigger project state refresh
118
+ try:
119
+ manager = get_or_create_project_state_manager(self.context, self.control_channel)
120
+
121
+ # Schedule the refresh (don't await since this is sync handler)
122
+ import asyncio
123
+ try:
124
+ loop = asyncio.get_event_loop()
125
+ if loop.is_running():
126
+ loop.create_task(manager.refresh_project_state_for_file_change(str(file_path)))
127
+ logger.info(f"Scheduled project state refresh after file creation: {file_path}")
128
+ except Exception as e:
129
+ logger.warning(f"Could not schedule project state refresh: {e}")
130
+
131
+ except Exception as e:
132
+ logger.warning(f"Failed to refresh project state after file creation: {e}")
133
+ # Don't fail the file creation just because project state refresh failed
134
+
135
+ return {
136
+ "event": "file_create_response",
137
+ "parent_path": parent_path,
138
+ "file_name": file_name,
139
+ "file_path": str(file_path),
140
+ "success": True,
141
+ }
142
+ except PermissionError:
143
+ raise RuntimeError(f"Permission denied: {parent_path}")
144
+ except OSError as e:
145
+ raise RuntimeError(f"Failed to create file: {e}")
146
+
147
+
148
+ class ProjectAwareFolderCreateHandler(SyncHandler):
149
+ """Handler for creating new folders that updates project state."""
150
+
151
+ @property
152
+ def command_name(self) -> str:
153
+ return "folder_create"
154
+
155
+ def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
156
+ """Create a new folder and refresh project state."""
157
+ parent_path = message.get("parent_path")
158
+ folder_name = message.get("folder_name")
159
+
160
+ if not parent_path:
161
+ raise ValueError("parent_path parameter is required")
162
+ if not folder_name:
163
+ raise ValueError("folder_name parameter is required")
164
+
165
+ # Validate folder name (no path separators or special chars)
166
+ if "/" in folder_name or "\\" in folder_name or folder_name in [".", ".."]:
167
+ raise ValueError("Invalid folder name")
168
+
169
+ try:
170
+ # Ensure parent directory exists
171
+ parent_dir = Path(parent_path)
172
+ if not parent_dir.exists():
173
+ raise ValueError(f"Parent directory does not exist: {parent_path}")
174
+ if not parent_dir.is_dir():
175
+ raise ValueError(f"Parent path is not a directory: {parent_path}")
176
+
177
+ # Create the full folder path
178
+ folder_path = parent_dir / folder_name
179
+
180
+ # Check if folder already exists
181
+ if folder_path.exists():
182
+ raise ValueError(f"Folder already exists: {folder_name}")
183
+
184
+ # Create the folder
185
+ folder_path.mkdir(parents=False, exist_ok=False)
186
+
187
+ # Trigger project state refresh
188
+ try:
189
+ manager = get_or_create_project_state_manager(self.context, self.control_channel)
190
+
191
+ # Schedule the refresh (don't await since this is sync handler)
192
+ import asyncio
193
+ try:
194
+ loop = asyncio.get_event_loop()
195
+ if loop.is_running():
196
+ loop.create_task(manager.refresh_project_state_for_file_change(str(folder_path)))
197
+ logger.info(f"Scheduled project state refresh after folder creation: {folder_path}")
198
+ except Exception as e:
199
+ logger.warning(f"Could not schedule project state refresh: {e}")
200
+
201
+ except Exception as e:
202
+ logger.warning(f"Failed to refresh project state after folder creation: {e}")
203
+ # Don't fail the folder creation just because project state refresh failed
204
+
205
+ return {
206
+ "event": "folder_create_response",
207
+ "parent_path": parent_path,
208
+ "folder_name": folder_name,
209
+ "folder_path": str(folder_path),
210
+ "success": True,
211
+ }
212
+ except PermissionError:
213
+ raise RuntimeError(f"Permission denied: {parent_path}")
214
+ except OSError as e:
215
+ raise RuntimeError(f"Failed to create folder: {e}")