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.
- portacode/_version.py +16 -3
- portacode/connection/handlers/WEBSOCKET_PROTOCOL.md +188 -16
- portacode/connection/handlers/__init__.py +4 -0
- portacode/connection/handlers/base.py +9 -5
- portacode/connection/handlers/chunked_content.py +244 -0
- portacode/connection/handlers/file_handlers.py +68 -2
- portacode/connection/handlers/project_aware_file_handlers.py +143 -1
- portacode/connection/handlers/project_state/git_manager.py +326 -66
- portacode/connection/handlers/project_state/handlers.py +307 -31
- portacode/connection/handlers/project_state/manager.py +44 -1
- portacode/connection/handlers/project_state/models.py +7 -0
- portacode/connection/handlers/project_state/utils.py +17 -1
- portacode/connection/handlers/project_state_handlers.py +1 -0
- portacode/connection/handlers/tab_factory.py +60 -7
- portacode/connection/terminal.py +13 -7
- {portacode-0.3.22.dist-info → portacode-0.3.24.dist-info}/METADATA +14 -3
- {portacode-0.3.22.dist-info → portacode-0.3.24.dist-info}/RECORD +25 -24
- {portacode-0.3.22.dist-info → portacode-0.3.24.dist-info}/WHEEL +1 -1
- test_modules/test_git_status_ui.py +24 -66
- testing_framework/core/playwright_manager.py +23 -0
- testing_framework/core/runner.py +10 -2
- testing_framework/core/test_discovery.py +7 -3
- {portacode-0.3.22.dist-info → portacode-0.3.24.dist-info}/entry_points.txt +0 -0
- {portacode-0.3.22.dist-info → portacode-0.3.24.dist-info/licenses}/LICENSE +0 -0
- {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}")
|