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
|
@@ -411,7 +411,11 @@ class ProjectStateGitStageHandler(AsyncHandler):
|
|
|
411
411
|
|
|
412
412
|
if success:
|
|
413
413
|
# Refresh git status only (no filesystem changes from staging)
|
|
414
|
-
await manager._refresh_project_state(
|
|
414
|
+
await manager._refresh_project_state(
|
|
415
|
+
source_client_session,
|
|
416
|
+
git_only=True,
|
|
417
|
+
reason="git_stage",
|
|
418
|
+
)
|
|
415
419
|
|
|
416
420
|
# Build response
|
|
417
421
|
response = {
|
|
@@ -485,7 +489,11 @@ class ProjectStateGitUnstageHandler(AsyncHandler):
|
|
|
485
489
|
|
|
486
490
|
if success:
|
|
487
491
|
# Refresh git status only (no filesystem changes from unstaging)
|
|
488
|
-
await manager._refresh_project_state(
|
|
492
|
+
await manager._refresh_project_state(
|
|
493
|
+
source_client_session,
|
|
494
|
+
git_only=True,
|
|
495
|
+
reason="git_unstage",
|
|
496
|
+
)
|
|
489
497
|
|
|
490
498
|
# Build response
|
|
491
499
|
response = {
|
|
@@ -559,7 +567,10 @@ class ProjectStateGitRevertHandler(AsyncHandler):
|
|
|
559
567
|
|
|
560
568
|
if success:
|
|
561
569
|
# Refresh entire project state to ensure consistency
|
|
562
|
-
await manager._refresh_project_state(
|
|
570
|
+
await manager._refresh_project_state(
|
|
571
|
+
source_client_session,
|
|
572
|
+
reason="git_revert",
|
|
573
|
+
)
|
|
563
574
|
|
|
564
575
|
# Build response
|
|
565
576
|
response = {
|
|
@@ -622,7 +633,11 @@ class ProjectStateGitCommitHandler(AsyncHandler):
|
|
|
622
633
|
commit_hash = git_manager.get_head_commit_hash()
|
|
623
634
|
|
|
624
635
|
# Refresh git status only (no filesystem changes from commit)
|
|
625
|
-
await manager._refresh_project_state(
|
|
636
|
+
await manager._refresh_project_state(
|
|
637
|
+
source_client_session,
|
|
638
|
+
git_only=True,
|
|
639
|
+
reason="git_commit",
|
|
640
|
+
)
|
|
626
641
|
except Exception as e:
|
|
627
642
|
error_message = str(e)
|
|
628
643
|
logger.error("Error during commit: %s", error_message)
|
|
@@ -656,7 +671,7 @@ async def handle_client_session_cleanup(handler, payload: Dict[str, Any], source
|
|
|
656
671
|
manager = get_or_create_project_state_manager(handler.context, handler.control_channel)
|
|
657
672
|
|
|
658
673
|
# Clean up the client session's project state
|
|
659
|
-
manager.cleanup_projects_by_client_session(client_session_id)
|
|
674
|
+
await manager.cleanup_projects_by_client_session(client_session_id)
|
|
660
675
|
|
|
661
676
|
logger.info("Client session cleanup completed: %s", client_session_id)
|
|
662
677
|
|
|
@@ -764,14 +779,13 @@ class ProjectStateDiffContentHandler(AsyncHandler):
|
|
|
764
779
|
if content is None or (content_type == "all" and not all([matching_tab.original_content, matching_tab.modified_content])):
|
|
765
780
|
if content_type in ["original", "modified", "all"]:
|
|
766
781
|
# Re-generate the diff content if needed
|
|
767
|
-
await manager.
|
|
768
|
-
source_client_session,
|
|
769
|
-
file_path,
|
|
770
|
-
from_ref,
|
|
771
|
-
to_ref,
|
|
772
|
-
from_hash,
|
|
773
|
-
to_hash
|
|
774
|
-
activate=False # Don't activate, just ensure content is loaded
|
|
782
|
+
await manager.open_diff_tab(
|
|
783
|
+
source_client_session,
|
|
784
|
+
file_path,
|
|
785
|
+
from_ref,
|
|
786
|
+
to_ref,
|
|
787
|
+
from_hash,
|
|
788
|
+
to_hash
|
|
775
789
|
)
|
|
776
790
|
|
|
777
791
|
# Try to get content again after regeneration (use same matching logic)
|
|
@@ -858,4 +872,4 @@ class ProjectStateDiffContentHandler(AsyncHandler):
|
|
|
858
872
|
# Add request_id if present in original message
|
|
859
873
|
if "request_id" in message:
|
|
860
874
|
error_response["request_id"] = message["request_id"]
|
|
861
|
-
await self.send_response(error_response, project_id=server_project_id)
|
|
875
|
+
await self.send_response(error_response, project_id=server_project_id)
|
|
@@ -11,6 +11,8 @@ import logging
|
|
|
11
11
|
import os
|
|
12
12
|
import threading
|
|
13
13
|
import time
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from asyncio import Lock
|
|
14
16
|
from dataclasses import asdict
|
|
15
17
|
from typing import Any, Dict, List, Optional, Set
|
|
16
18
|
|
|
@@ -34,9 +36,12 @@ class ProjectStateManager:
|
|
|
34
36
|
self.context = context
|
|
35
37
|
self.projects: Dict[str, ProjectState] = {}
|
|
36
38
|
self.git_managers: Dict[str, GitManager] = {}
|
|
39
|
+
self._shared_git_managers: Dict[str, Dict[str, Any]] = {}
|
|
40
|
+
self._shared_git_lock: Lock = Lock()
|
|
37
41
|
self.file_watcher = FileSystemWatcher(self)
|
|
38
42
|
self.debug_mode = False
|
|
39
43
|
self.debug_file_path: Optional[str] = None
|
|
44
|
+
self._session_locks: Dict[str, Lock] = {}
|
|
40
45
|
|
|
41
46
|
# Content caching optimization
|
|
42
47
|
self.use_content_caching = context.get("use_content_caching", False)
|
|
@@ -44,6 +49,7 @@ class ProjectStateManager:
|
|
|
44
49
|
# Debouncing for file changes
|
|
45
50
|
self._change_debounce_timer: Optional[asyncio.Task] = None
|
|
46
51
|
self._pending_changes: Set[str] = set()
|
|
52
|
+
self._pending_change_sources: Dict[str, Dict[str, Any]] = {}
|
|
47
53
|
|
|
48
54
|
def set_debug_mode(self, enabled: bool, debug_file_path: Optional[str] = None):
|
|
49
55
|
"""Enable or disable debug mode with JSON output."""
|
|
@@ -52,6 +58,14 @@ class ProjectStateManager:
|
|
|
52
58
|
if enabled:
|
|
53
59
|
logger.info("Project state debug mode enabled, output to: %s", debug_file_path)
|
|
54
60
|
|
|
61
|
+
def _get_session_lock(self, client_session_id: str) -> Lock:
|
|
62
|
+
"""Get or create an asyncio lock for a client session."""
|
|
63
|
+
lock = self._session_locks.get(client_session_id)
|
|
64
|
+
if not lock:
|
|
65
|
+
lock = Lock()
|
|
66
|
+
self._session_locks[client_session_id] = lock
|
|
67
|
+
return lock
|
|
68
|
+
|
|
55
69
|
def _write_debug_state(self):
|
|
56
70
|
"""Write current state to debug JSON file (thread-safe)."""
|
|
57
71
|
if not self.debug_mode or not self.debug_file_path:
|
|
@@ -127,65 +141,115 @@ class ProjectStateManager:
|
|
|
127
141
|
metadata.pop('diff_details', None)
|
|
128
142
|
|
|
129
143
|
return tab_dict
|
|
144
|
+
|
|
145
|
+
async def _handle_shared_git_change(self, project_folder_path: str):
|
|
146
|
+
"""Notify all sessions for a project when the shared git manager detects changes."""
|
|
147
|
+
async with self._shared_git_lock:
|
|
148
|
+
entry = self._shared_git_managers.get(project_folder_path)
|
|
149
|
+
target_sessions = list(entry["sessions"]) if entry else []
|
|
150
|
+
|
|
151
|
+
for session_id in target_sessions:
|
|
152
|
+
await self._refresh_project_state(session_id, git_only=True, reason="git_monitor")
|
|
153
|
+
|
|
154
|
+
async def _acquire_shared_git_manager(self, project_folder_path: str, client_session_id: str) -> GitManager:
|
|
155
|
+
"""Get or create a shared git manager for a project path."""
|
|
156
|
+
async with self._shared_git_lock:
|
|
157
|
+
entry = self._shared_git_managers.get(project_folder_path)
|
|
158
|
+
|
|
159
|
+
if not entry:
|
|
160
|
+
async def git_change_callback():
|
|
161
|
+
await self._handle_shared_git_change(project_folder_path)
|
|
162
|
+
|
|
163
|
+
git_manager = GitManager(
|
|
164
|
+
project_folder_path,
|
|
165
|
+
change_callback=git_change_callback,
|
|
166
|
+
owner_session_id=client_session_id,
|
|
167
|
+
)
|
|
168
|
+
entry = {"manager": git_manager, "sessions": set()}
|
|
169
|
+
self._shared_git_managers[project_folder_path] = entry
|
|
170
|
+
|
|
171
|
+
entry["sessions"].add(client_session_id)
|
|
172
|
+
self.git_managers[client_session_id] = entry["manager"]
|
|
173
|
+
return entry["manager"]
|
|
174
|
+
|
|
175
|
+
async def _release_shared_git_manager(self, project_folder_path: Optional[str], client_session_id: str):
|
|
176
|
+
"""Release a session's reference to a shared git manager and clean it up if unused."""
|
|
177
|
+
manager_to_cleanup: Optional[GitManager] = None
|
|
178
|
+
|
|
179
|
+
async with self._shared_git_lock:
|
|
180
|
+
manager = self.git_managers.pop(client_session_id, None)
|
|
181
|
+
|
|
182
|
+
if not project_folder_path:
|
|
183
|
+
manager_to_cleanup = manager
|
|
184
|
+
else:
|
|
185
|
+
entry = self._shared_git_managers.get(project_folder_path)
|
|
186
|
+
if entry:
|
|
187
|
+
entry["sessions"].discard(client_session_id)
|
|
188
|
+
if not entry["sessions"]:
|
|
189
|
+
self._shared_git_managers.pop(project_folder_path, None)
|
|
190
|
+
manager_to_cleanup = manager or entry["manager"]
|
|
191
|
+
else:
|
|
192
|
+
manager_to_cleanup = manager
|
|
193
|
+
|
|
194
|
+
if manager_to_cleanup:
|
|
195
|
+
manager_to_cleanup.cleanup()
|
|
130
196
|
|
|
131
197
|
async def initialize_project_state(self, client_session_id: str, project_folder_path: str) -> ProjectState:
|
|
132
198
|
"""Initialize project state for a client session."""
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
existing_project = self.projects
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
199
|
+
lock = self._get_session_lock(client_session_id)
|
|
200
|
+
async with lock:
|
|
201
|
+
existing_project = self.projects.get(client_session_id)
|
|
202
|
+
if existing_project:
|
|
203
|
+
if existing_project.project_folder_path == project_folder_path:
|
|
204
|
+
logger.info("Returning existing project state for client session: %s", client_session_id)
|
|
205
|
+
return existing_project
|
|
206
|
+
logger.info("Client session %s switching projects from %s to %s",
|
|
207
|
+
client_session_id, existing_project.project_folder_path, project_folder_path)
|
|
208
|
+
previous_path = self._cleanup_project_locked(client_session_id)
|
|
209
|
+
await self._release_shared_git_manager(previous_path, client_session_id)
|
|
210
|
+
|
|
211
|
+
logger.info("Initializing project state for client session: %s, folder: %s", client_session_id, project_folder_path)
|
|
212
|
+
git_manager = await self._acquire_shared_git_manager(project_folder_path, client_session_id)
|
|
213
|
+
|
|
214
|
+
loop = asyncio.get_event_loop()
|
|
215
|
+
is_git_repo = git_manager.is_git_repo
|
|
216
|
+
git_branch = await loop.run_in_executor(None, git_manager.get_branch_name)
|
|
217
|
+
git_status_summary = await loop.run_in_executor(None, git_manager.get_status_summary)
|
|
218
|
+
git_detailed_status = await loop.run_in_executor(None, git_manager.get_detailed_status)
|
|
219
|
+
|
|
220
|
+
project_state = ProjectState(
|
|
221
|
+
client_session_id=client_session_id,
|
|
222
|
+
project_folder_path=project_folder_path,
|
|
223
|
+
items=[],
|
|
224
|
+
is_git_repo=is_git_repo,
|
|
225
|
+
git_branch=git_branch,
|
|
226
|
+
git_status_summary=git_status_summary,
|
|
227
|
+
git_detailed_status=git_detailed_status,
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
await self._initialize_monitored_folders(project_state)
|
|
231
|
+
await self._sync_all_state_with_monitored_folders(project_state)
|
|
232
|
+
|
|
233
|
+
self.projects[client_session_id] = project_state
|
|
234
|
+
self._write_debug_state()
|
|
235
|
+
|
|
236
|
+
return project_state
|
|
160
237
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
git_detailed_status = await loop.run_in_executor(None, git_manager.get_detailed_status)
|
|
238
|
+
def get_diagnostics(self) -> Dict[str, Any]:
|
|
239
|
+
"""Return aggregate stats for health monitoring."""
|
|
240
|
+
git_diag = {}
|
|
241
|
+
for session_id, git_manager in self.git_managers.items():
|
|
242
|
+
git_diag[session_id] = git_manager.get_diagnostics()
|
|
167
243
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
)
|
|
178
|
-
|
|
179
|
-
# Initialize monitored folders with project root and its immediate subdirectories
|
|
180
|
-
await self._initialize_monitored_folders(project_state)
|
|
181
|
-
|
|
182
|
-
# Sync all dependent state (items, watchdog)
|
|
183
|
-
await self._sync_all_state_with_monitored_folders(project_state)
|
|
184
|
-
|
|
185
|
-
self.projects[client_session_id] = project_state
|
|
186
|
-
self._write_debug_state()
|
|
187
|
-
|
|
188
|
-
return project_state
|
|
244
|
+
watcher_diag = self.file_watcher.get_diagnostics() if self.file_watcher else {}
|
|
245
|
+
|
|
246
|
+
return {
|
|
247
|
+
"projects": len(self.projects),
|
|
248
|
+
"git_managers": len(self.git_managers),
|
|
249
|
+
"pending_file_changes": len(self._pending_changes),
|
|
250
|
+
"watcher": watcher_diag,
|
|
251
|
+
"git_sessions": git_diag,
|
|
252
|
+
}
|
|
189
253
|
|
|
190
254
|
async def _initialize_monitored_folders(self, project_state: ProjectState):
|
|
191
255
|
"""Initialize monitored folders with project root (expanded) and its immediate subdirectories (collapsed)."""
|
|
@@ -216,18 +280,12 @@ class ProjectStateManager:
|
|
|
216
280
|
for monitored_folder in project_state.monitored_folders:
|
|
217
281
|
self.file_watcher.start_watching(monitored_folder.folder_path)
|
|
218
282
|
|
|
219
|
-
#
|
|
283
|
+
# Intentionally avoid watching .git; Git status changes are polled separately
|
|
220
284
|
if project_state.is_git_repo:
|
|
221
285
|
git_dir_path = os.path.join(project_state.project_folder_path, '.git')
|
|
222
|
-
logger.debug("🔍 [TRACE] Project is git repo,
|
|
223
|
-
if os.path.exists(git_dir_path):
|
|
224
|
-
logger.debug("🔍 [TRACE] ✅ Starting to watch .git directory: %s", LogCategory.GIT, git_dir_path)
|
|
225
|
-
self.file_watcher.start_watching_git_directory(git_dir_path)
|
|
226
|
-
logger.debug("🔍 [TRACE] ✅ Started monitoring .git directory for git status changes: %s", LogCategory.GIT, git_dir_path)
|
|
227
|
-
else:
|
|
228
|
-
logger.error("🔍 [TRACE] ❌ .git directory does not exist: %s", LogCategory.GIT, git_dir_path)
|
|
286
|
+
logger.debug("🔍 [TRACE] Project is git repo, but skipping .git watcher registration: %s", LogCategory.GIT, git_dir_path)
|
|
229
287
|
else:
|
|
230
|
-
logger.debug("🔍 [TRACE] Project is NOT a git repo,
|
|
288
|
+
logger.debug("🔍 [TRACE] Project is NOT a git repo, no .git watcher needed", LogCategory.GIT)
|
|
231
289
|
|
|
232
290
|
# Watchdog synchronized
|
|
233
291
|
|
|
@@ -308,8 +366,8 @@ class ProjectStateManager:
|
|
|
308
366
|
with os.scandir(directory_path) as entries:
|
|
309
367
|
for entry in entries:
|
|
310
368
|
try:
|
|
311
|
-
# Skip .git
|
|
312
|
-
if entry.name == '.git'
|
|
369
|
+
# Skip .git metadata regardless of whether it's a dir or file (worktrees create files)
|
|
370
|
+
if entry.name == '.git':
|
|
313
371
|
continue
|
|
314
372
|
|
|
315
373
|
stat_info = entry.stat()
|
|
@@ -380,9 +438,10 @@ class ProjectStateManager:
|
|
|
380
438
|
child_paths = []
|
|
381
439
|
with os.scandir(monitored_folder.folder_path) as entries:
|
|
382
440
|
for entry in entries:
|
|
383
|
-
if entry.name
|
|
384
|
-
|
|
385
|
-
|
|
441
|
+
if entry.name == '.git':
|
|
442
|
+
continue
|
|
443
|
+
child_paths.append(entry.path)
|
|
444
|
+
all_file_paths.append(entry.path)
|
|
386
445
|
folder_to_paths[monitored_folder.folder_path] = child_paths
|
|
387
446
|
except (OSError, PermissionError) as e:
|
|
388
447
|
logger.error("Error scanning folder %s: %s", monitored_folder.folder_path, e)
|
|
@@ -465,8 +524,8 @@ class ProjectStateManager:
|
|
|
465
524
|
with os.scandir(directory_path) as entries:
|
|
466
525
|
for entry in entries:
|
|
467
526
|
try:
|
|
468
|
-
# Skip .git
|
|
469
|
-
if entry.name == '.git'
|
|
527
|
+
# Skip .git metadata regardless of whether it's a dir or file (worktrees create files)
|
|
528
|
+
if entry.name == '.git':
|
|
470
529
|
continue
|
|
471
530
|
|
|
472
531
|
stat_info = entry.stat()
|
|
@@ -816,8 +875,18 @@ class ProjectStateManager:
|
|
|
816
875
|
async def _handle_file_change(self, event):
|
|
817
876
|
"""Handle file system change events with debouncing."""
|
|
818
877
|
logger.debug("🔍 [TRACE] _handle_file_change called: %s - %s", LogCategory.FILE_SYSTEM, event.event_type, event.src_path)
|
|
878
|
+
is_git_event = ".git" in Path(event.src_path).parts
|
|
879
|
+
if is_git_event:
|
|
880
|
+
logger.info("File watcher event from .git: %s %s", LogCategory.FILE_SYSTEM, event.event_type, event.src_path)
|
|
881
|
+
else:
|
|
882
|
+
logger.debug("File watcher event: %s %s", LogCategory.FILE_SYSTEM, event.event_type, event.src_path)
|
|
819
883
|
|
|
820
884
|
self._pending_changes.add(event.src_path)
|
|
885
|
+
self._pending_change_sources[event.src_path] = {
|
|
886
|
+
"event_type": event.event_type,
|
|
887
|
+
"is_git_event": is_git_event,
|
|
888
|
+
"timestamp": time.time(),
|
|
889
|
+
}
|
|
821
890
|
logger.debug("🔍 [TRACE] Added to pending changes: %s (total pending: %d)", LogCategory.FILE_SYSTEM, event.src_path, len(self._pending_changes))
|
|
822
891
|
|
|
823
892
|
# Cancel existing timer
|
|
@@ -849,6 +918,16 @@ class ProjectStateManager:
|
|
|
849
918
|
return
|
|
850
919
|
|
|
851
920
|
logger.debug("🔍 [TRACE] Processing %d pending file changes: %s", len(self._pending_changes), list(self._pending_changes))
|
|
921
|
+
git_events = [path for path in self._pending_changes if self._pending_change_sources.get(path, {}).get("is_git_event")]
|
|
922
|
+
workspace_events = [path for path in self._pending_changes if path not in git_events]
|
|
923
|
+
logger.info(
|
|
924
|
+
"Pending change summary: total=%d git_events=%d workspace_events=%d sample_git=%s",
|
|
925
|
+
LogCategory.FILE_SYSTEM,
|
|
926
|
+
len(self._pending_changes),
|
|
927
|
+
len(git_events),
|
|
928
|
+
len(workspace_events),
|
|
929
|
+
git_events[:3],
|
|
930
|
+
)
|
|
852
931
|
|
|
853
932
|
# Process changes for each affected project
|
|
854
933
|
affected_projects = set()
|
|
@@ -872,13 +951,38 @@ class ProjectStateManager:
|
|
|
872
951
|
|
|
873
952
|
# Refresh affected projects
|
|
874
953
|
for client_session_id in affected_projects:
|
|
875
|
-
|
|
876
|
-
|
|
954
|
+
project_state = self.projects.get(client_session_id)
|
|
955
|
+
if not project_state:
|
|
956
|
+
continue
|
|
957
|
+
project_paths = [
|
|
958
|
+
path for path in self._pending_changes
|
|
959
|
+
if path.startswith(project_state.project_folder_path)
|
|
960
|
+
]
|
|
961
|
+
git_paths = [
|
|
962
|
+
path for path in project_paths
|
|
963
|
+
if self._pending_change_sources.get(path, {}).get("is_git_event")
|
|
964
|
+
]
|
|
965
|
+
is_git_only_batch = bool(project_paths) and len(project_paths) == len(git_paths)
|
|
966
|
+
logger.info(
|
|
967
|
+
"Refreshing project %s due to pending changes: total_paths=%d git_paths=%d git_only_batch=%s sample_paths=%s",
|
|
968
|
+
LogCategory.FILE_SYSTEM,
|
|
969
|
+
client_session_id,
|
|
970
|
+
len(project_paths),
|
|
971
|
+
len(git_paths),
|
|
972
|
+
is_git_only_batch,
|
|
973
|
+
project_paths[:3],
|
|
974
|
+
)
|
|
975
|
+
await self._refresh_project_state(
|
|
976
|
+
client_session_id,
|
|
977
|
+
git_only=is_git_only_batch,
|
|
978
|
+
reason="filesystem_watch_git_only" if is_git_only_batch else "filesystem_watch",
|
|
979
|
+
)
|
|
877
980
|
|
|
878
981
|
self._pending_changes.clear()
|
|
982
|
+
self._pending_change_sources.clear()
|
|
879
983
|
logger.debug("🔍 [TRACE] ✅ Finished processing file changes")
|
|
880
984
|
|
|
881
|
-
async def _refresh_project_state(self, client_session_id: str, git_only: bool = False):
|
|
985
|
+
async def _refresh_project_state(self, client_session_id: str, git_only: bool = False, reason: str = "unknown"):
|
|
882
986
|
"""Refresh project state after file changes.
|
|
883
987
|
|
|
884
988
|
Args:
|
|
@@ -887,8 +991,8 @@ class ProjectStateManager:
|
|
|
887
991
|
detecting new directories and syncing file state). Use this for
|
|
888
992
|
git operations (stage, unstage, revert) to avoid unnecessary work.
|
|
889
993
|
"""
|
|
890
|
-
logger.debug("🔍 [TRACE] _refresh_project_state called for session: %s (git_only=%s)",
|
|
891
|
-
client_session_id, git_only)
|
|
994
|
+
logger.debug("🔍 [TRACE] _refresh_project_state called for session: %s (git_only=%s, reason=%s)",
|
|
995
|
+
client_session_id, git_only, reason)
|
|
892
996
|
|
|
893
997
|
if client_session_id not in self.projects:
|
|
894
998
|
logger.debug("🔍 [TRACE] ❌ Session not found in projects: %s", client_session_id)
|
|
@@ -906,19 +1010,11 @@ class ProjectStateManager:
|
|
|
906
1010
|
# Git repo was created
|
|
907
1011
|
logger.debug("🔍 [TRACE] Git repo detected, reinitializing git manager for session: %s", client_session_id)
|
|
908
1012
|
git_manager.reinitialize()
|
|
909
|
-
|
|
910
|
-
# Start watching .git directory for git status changes
|
|
911
|
-
if git_manager.is_git_repo:
|
|
912
|
-
logger.debug("🔍 [TRACE] Starting to watch .git directory: %s", git_dir_path)
|
|
913
|
-
self.file_watcher.start_watching_git_directory(git_dir_path)
|
|
914
1013
|
elif git_manager.is_git_repo and not git_dir_exists:
|
|
915
1014
|
# Git repo was deleted
|
|
916
1015
|
logger.debug("🔍 [TRACE] Git repo removed, updating git manager for session: %s", client_session_id)
|
|
917
1016
|
git_manager.repo = None
|
|
918
1017
|
git_manager.is_git_repo = False
|
|
919
|
-
|
|
920
|
-
# Stop watching .git directory
|
|
921
|
-
self.file_watcher.stop_watching(git_dir_path)
|
|
922
1018
|
|
|
923
1019
|
# Update Git status
|
|
924
1020
|
if git_manager:
|
|
@@ -1063,10 +1159,33 @@ class ProjectStateManager:
|
|
|
1063
1159
|
except Exception as e:
|
|
1064
1160
|
logger.error("🔍 [TRACE] ❌ Failed to send project_state_update: %s", e)
|
|
1065
1161
|
|
|
1066
|
-
def cleanup_project(self, client_session_id: str):
|
|
1162
|
+
async def cleanup_project(self, client_session_id: str):
|
|
1067
1163
|
"""Clean up project state and resources."""
|
|
1068
|
-
|
|
1069
|
-
|
|
1164
|
+
lock = self._get_session_lock(client_session_id)
|
|
1165
|
+
async with lock:
|
|
1166
|
+
project_folder_path = self._cleanup_project_locked(client_session_id)
|
|
1167
|
+
await self._release_shared_git_manager(project_folder_path, client_session_id)
|
|
1168
|
+
|
|
1169
|
+
def _cleanup_project_locked(self, client_session_id: str) -> Optional[str]:
|
|
1170
|
+
"""Internal helper to release resources associated with a project state. Lock must be held."""
|
|
1171
|
+
project_state = self.projects.get(client_session_id)
|
|
1172
|
+
project_folder_path = project_state.project_folder_path if project_state else None
|
|
1173
|
+
|
|
1174
|
+
if project_state:
|
|
1175
|
+
# Cancel debounce timer to prevent pending refreshes running after cleanup
|
|
1176
|
+
if self._change_debounce_timer and not self._change_debounce_timer.done():
|
|
1177
|
+
try:
|
|
1178
|
+
self._change_debounce_timer.cancel()
|
|
1179
|
+
except Exception:
|
|
1180
|
+
pass
|
|
1181
|
+
# Remove pending file change events related to this project
|
|
1182
|
+
if self._pending_changes:
|
|
1183
|
+
removed = [path for path in list(self._pending_changes) if path.startswith(project_state.project_folder_path)]
|
|
1184
|
+
for path in removed:
|
|
1185
|
+
self._pending_changes.discard(path)
|
|
1186
|
+
self._pending_change_sources.pop(path, None)
|
|
1187
|
+
if removed:
|
|
1188
|
+
logger.debug("Removed %d pending change paths for session %s during cleanup", len(removed), client_session_id)
|
|
1070
1189
|
|
|
1071
1190
|
# Stop watching all monitored folders for this project
|
|
1072
1191
|
for monitored_folder in project_state.monitored_folders:
|
|
@@ -1077,34 +1196,34 @@ class ProjectStateManager:
|
|
|
1077
1196
|
git_dir_path = os.path.join(project_state.project_folder_path, '.git')
|
|
1078
1197
|
self.file_watcher.stop_watching(git_dir_path)
|
|
1079
1198
|
|
|
1080
|
-
# Clean up managers
|
|
1081
|
-
git_manager = self.git_managers.get(client_session_id)
|
|
1082
|
-
if git_manager:
|
|
1083
|
-
git_manager.cleanup()
|
|
1084
|
-
self.git_managers.pop(client_session_id, None)
|
|
1085
1199
|
self.projects.pop(client_session_id, None)
|
|
1086
|
-
|
|
1087
1200
|
logger.info("Cleaned up project state: %s", client_session_id)
|
|
1088
|
-
|
|
1201
|
+
else:
|
|
1202
|
+
logger.info("No project state found for client session: %s during cleanup", client_session_id)
|
|
1203
|
+
|
|
1204
|
+
# Clean up associated git manager even if project state was not registered
|
|
1205
|
+
# (actual cleanup occurs when shared git manager refcount drops to zero)
|
|
1206
|
+
self._write_debug_state()
|
|
1207
|
+
return project_folder_path
|
|
1089
1208
|
|
|
1090
|
-
def cleanup_projects_by_client_session(self, client_session_id: str):
|
|
1209
|
+
async def cleanup_projects_by_client_session(self, client_session_id: str):
|
|
1091
1210
|
"""Clean up project state for a specific client session when explicitly notified of disconnection."""
|
|
1092
1211
|
logger.info("Explicitly cleaning up project state for disconnected client session: %s", client_session_id)
|
|
1093
1212
|
|
|
1094
1213
|
# With the new design, each client session has only one project
|
|
1095
1214
|
if client_session_id in self.projects:
|
|
1096
|
-
self.cleanup_project(client_session_id)
|
|
1215
|
+
await self.cleanup_project(client_session_id)
|
|
1097
1216
|
logger.info("Cleaned up project state for client session: %s", client_session_id)
|
|
1098
1217
|
else:
|
|
1099
1218
|
logger.info("No project state found for client session: %s", client_session_id)
|
|
1100
1219
|
|
|
1101
|
-
def cleanup_all_projects(self):
|
|
1220
|
+
async def cleanup_all_projects(self):
|
|
1102
1221
|
"""Clean up all project states. Used for shutdown or reset."""
|
|
1103
1222
|
logger.info("Cleaning up all project states")
|
|
1104
1223
|
|
|
1105
1224
|
client_session_ids = list(self.projects.keys())
|
|
1106
1225
|
for client_session_id in client_session_ids:
|
|
1107
|
-
self.cleanup_project(client_session_id)
|
|
1226
|
+
await self.cleanup_project(client_session_id)
|
|
1108
1227
|
|
|
1109
1228
|
logger.info("Cleaned up %d project states", len(client_session_ids))
|
|
1110
1229
|
|
|
@@ -1121,13 +1240,13 @@ class ProjectStateManager:
|
|
|
1121
1240
|
Path(file_path).relative_to(project_folder)
|
|
1122
1241
|
# File is within this project, trigger refresh
|
|
1123
1242
|
logger.info(f"Refreshing project state for session {client_session_id} after file change: {file_path}")
|
|
1124
|
-
await self._refresh_project_state(client_session_id)
|
|
1243
|
+
await self._refresh_project_state(client_session_id, reason="manual_file_change")
|
|
1125
1244
|
break
|
|
1126
1245
|
except ValueError:
|
|
1127
1246
|
# File is not within this project
|
|
1128
1247
|
continue
|
|
1129
1248
|
|
|
1130
|
-
def cleanup_orphaned_project_states(self, current_client_sessions: List[str]):
|
|
1249
|
+
async def cleanup_orphaned_project_states(self, current_client_sessions: List[str]):
|
|
1131
1250
|
"""Clean up project states that don't match any current client session."""
|
|
1132
1251
|
current_sessions_set = set(current_client_sessions)
|
|
1133
1252
|
orphaned_keys = []
|
|
@@ -1139,7 +1258,7 @@ class ProjectStateManager:
|
|
|
1139
1258
|
if orphaned_keys:
|
|
1140
1259
|
logger.info("Found %d orphaned project states, cleaning up: %s", len(orphaned_keys), orphaned_keys)
|
|
1141
1260
|
for session_id in orphaned_keys:
|
|
1142
|
-
self.cleanup_project(session_id)
|
|
1261
|
+
await self.cleanup_project(session_id)
|
|
1143
1262
|
logger.info("Cleaned up %d orphaned project states", len(orphaned_keys))
|
|
1144
1263
|
else:
|
|
1145
1264
|
logger.debug("No orphaned project states found")
|
|
@@ -1183,6 +1302,12 @@ def get_or_create_project_state_manager(context: Dict[str, Any], control_channel
|
|
|
1183
1302
|
return _global_project_state_manager
|
|
1184
1303
|
|
|
1185
1304
|
|
|
1305
|
+
def get_global_project_state_manager() -> Optional['ProjectStateManager']:
|
|
1306
|
+
"""Return the current global project state manager if it exists."""
|
|
1307
|
+
with _manager_lock:
|
|
1308
|
+
return _global_project_state_manager
|
|
1309
|
+
|
|
1310
|
+
|
|
1186
1311
|
def reset_global_project_state_manager():
|
|
1187
1312
|
"""Reset the global project state manager (for testing/cleanup)."""
|
|
1188
1313
|
global _global_project_state_manager
|
|
@@ -1203,4 +1328,4 @@ def debug_global_manager_state():
|
|
|
1203
1328
|
logger.info("Active project states: %s", list(_global_project_state_manager.projects.keys()))
|
|
1204
1329
|
logger.info("Total project states: %d", len(_global_project_state_manager.projects))
|
|
1205
1330
|
else:
|
|
1206
|
-
logger.info("No global ProjectStateManager exists (PID: %s)", os.getpid())
|
|
1331
|
+
logger.info("No global ProjectStateManager exists (PID: %s)", os.getpid())
|