portacode 0.3.16.dev10__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 +928 -42
  5. portacode/connection/handlers/__init__.py +34 -5
  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 -948
  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 +389 -0
  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 +256 -17
  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.16.dev10.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.16.dev10.dist-info/METADATA +0 -238
  89. portacode-0.3.16.dev10.dist-info/RECORD +0 -29
  90. portacode-0.3.16.dev10.dist-info/top_level.txt +0 -1
  91. {portacode-0.3.16.dev10.dist-info → portacode-1.4.11.dev1.dist-info}/entry_points.txt +0 -0
  92. {portacode-0.3.16.dev10.dist-info → portacode-1.4.11.dev1.dist-info/licenses}/LICENSE +0 -0
@@ -1,948 +1,45 @@
1
- """Project state handlers for maintaining project folder structure and git metadata."""
2
-
3
- import asyncio
4
- import json
5
- import logging
6
- import os
7
- import threading
8
- import time
9
- from pathlib import Path
10
- from typing import Any, Dict, List, Optional, Set, Union
11
- from dataclasses import dataclass, asdict
12
- import platform
13
-
14
- from .base import AsyncHandler, SyncHandler
15
-
16
- # Import GitPython with fallback
17
- try:
18
- import git
19
- from git import Repo, InvalidGitRepositoryError
20
- GIT_AVAILABLE = True
21
- except ImportError:
22
- GIT_AVAILABLE = False
23
- git = None
24
- Repo = None
25
- InvalidGitRepositoryError = Exception
26
-
27
- # Cross-platform file system monitoring
28
- try:
29
- from watchdog.observers import Observer
30
- from watchdog.events import FileSystemEventHandler
31
- WATCHDOG_AVAILABLE = True
32
- except ImportError:
33
- WATCHDOG_AVAILABLE = False
34
- Observer = None
35
- FileSystemEventHandler = None
36
-
37
- logger = logging.getLogger(__name__)
38
-
39
-
40
- @dataclass
41
- class MonitoredFolder:
42
- """Represents a folder that is being monitored for changes."""
43
- folder_path: str
44
- is_expanded: bool = False
45
-
46
- @dataclass
47
- class FileItem:
48
- """Represents a file or directory item with metadata."""
49
- name: str
50
- path: str
51
- is_directory: bool
52
- parent_path: str
53
- size: Optional[int] = None
54
- modified_time: Optional[float] = None
55
- is_git_tracked: Optional[bool] = None
56
- git_status: Optional[str] = None
57
- is_hidden: bool = False
58
- is_ignored: bool = False
59
- children: Optional[List['FileItem']] = None
60
- is_expanded: bool = False
61
- is_loaded: bool = False
62
-
63
-
64
- @dataclass
65
- class ProjectState:
66
- """Represents the complete state of a project."""
67
- project_id: str
68
- project_folder_path: str
69
- items: List[FileItem]
70
- monitored_folders: List[MonitoredFolder] = None
71
- is_git_repo: bool = False
72
- git_branch: Optional[str] = None
73
- git_status_summary: Optional[Dict[str, int]] = None
74
- open_files: Set[str] = None
75
- active_file: Optional[str] = None
76
-
77
- def __post_init__(self):
78
- if self.open_files is None:
79
- self.open_files = set()
80
- if self.monitored_folders is None:
81
- self.monitored_folders = []
82
-
83
-
84
- class GitManager:
85
- """Manages Git operations for project state."""
86
-
87
- def __init__(self, project_path: str):
88
- self.project_path = project_path
89
- self.repo: Optional[Repo] = None
90
- self.is_git_repo = False
91
- self._initialize_repo()
92
-
93
- def _initialize_repo(self):
94
- """Initialize Git repository if available."""
95
- if not GIT_AVAILABLE:
96
- logger.warning("GitPython not available, Git features disabled")
97
- return
98
-
99
- try:
100
- self.repo = Repo(self.project_path)
101
- self.is_git_repo = True
102
- logger.info("Initialized Git repo for project: %s", self.project_path)
103
- except (InvalidGitRepositoryError, Exception) as e:
104
- logger.debug("Not a Git repository or Git error: %s", e)
105
-
106
- def get_branch_name(self) -> Optional[str]:
107
- """Get current Git branch name."""
108
- if not self.is_git_repo or not self.repo:
109
- return None
110
-
111
- try:
112
- return self.repo.active_branch.name
113
- except Exception as e:
114
- logger.debug("Could not get Git branch: %s", e)
115
- return None
116
-
117
- def get_file_status(self, file_path: str) -> Dict[str, Any]:
118
- """Get Git status for a specific file."""
119
- if not self.is_git_repo or not self.repo:
120
- return {"is_tracked": False, "status": None, "is_ignored": False}
121
-
122
- try:
123
- # Convert to relative path from repo root
124
- rel_path = os.path.relpath(file_path, self.repo.working_dir)
125
-
126
- # Check if file is ignored
127
- is_ignored = False
128
- try:
129
- # Use git check-ignore to see if file is ignored
130
- self.repo.git.check_ignore(rel_path)
131
- is_ignored = True
132
- except Exception:
133
- is_ignored = False
134
-
135
- # Check if file is tracked
136
- try:
137
- self.repo.git.ls_files(rel_path, error_unmatch=True)
138
- is_tracked = True
139
- except Exception:
140
- is_tracked = False
141
-
142
- # Get status
143
- status = None
144
- if is_tracked:
145
- # Check for modifications
146
- if self.repo.is_dirty(path=rel_path):
147
- status = "modified"
148
- else:
149
- status = "clean"
150
- elif is_ignored:
151
- status = "ignored"
152
- else:
153
- # Check if it's untracked
154
- if os.path.exists(file_path):
155
- status = "untracked"
156
-
157
- return {"is_tracked": is_tracked, "status": status, "is_ignored": is_ignored}
158
-
159
- except Exception as e:
160
- logger.debug("Error getting Git status for %s: %s", file_path, e)
161
- return {"is_tracked": False, "status": None, "is_ignored": False}
162
-
163
- def get_status_summary(self) -> Dict[str, int]:
164
- """Get summary of Git status."""
165
- if not self.is_git_repo or not self.repo:
166
- return {}
167
-
168
- try:
169
- status = self.repo.git.status(porcelain=True).strip()
170
- if not status:
171
- return {"clean": 0}
172
-
173
- summary = {"modified": 0, "added": 0, "deleted": 0, "untracked": 0}
174
-
175
- for line in status.split('\n'):
176
- if len(line) >= 2:
177
- index_status = line[0]
178
- worktree_status = line[1]
179
-
180
- if index_status == 'A' or worktree_status == 'A':
181
- summary["added"] += 1
182
- elif index_status == 'M' or worktree_status == 'M':
183
- summary["modified"] += 1
184
- elif index_status == 'D' or worktree_status == 'D':
185
- summary["deleted"] += 1
186
- elif index_status == '?' and worktree_status == '?':
187
- summary["untracked"] += 1
188
-
189
- return summary
190
-
191
- except Exception as e:
192
- logger.debug("Error getting Git status summary: %s", e)
193
- return {}
194
-
195
-
196
- class FileSystemWatcher:
197
- """Watches file system changes for project folders."""
198
-
199
- def __init__(self, project_manager: 'ProjectStateManager'):
200
- self.project_manager = project_manager
201
- self.observer: Optional[Observer] = None
202
- self.event_handler: Optional[FileSystemEventHandler] = None
203
- self.watched_paths: Set[str] = set()
204
-
205
- if WATCHDOG_AVAILABLE:
206
- self._initialize_watcher()
207
-
208
- def _initialize_watcher(self):
209
- """Initialize file system watcher."""
210
- if not WATCHDOG_AVAILABLE:
211
- logger.warning("Watchdog not available, file monitoring disabled")
212
- return
213
-
214
- class ProjectEventHandler(FileSystemEventHandler):
215
- def __init__(self, manager):
216
- self.manager = manager
217
-
218
- def on_any_event(self, event):
219
- # Debounce rapid file changes
220
- asyncio.create_task(self.manager._handle_file_change(event))
221
-
222
- self.event_handler = ProjectEventHandler(self.project_manager)
223
- self.observer = Observer()
224
-
225
- def start_watching(self, path: str):
226
- """Start watching a specific path."""
227
- if not WATCHDOG_AVAILABLE or not self.observer:
228
- return
229
-
230
- if path not in self.watched_paths:
231
- try:
232
- self.observer.schedule(self.event_handler, path, recursive=False)
233
- self.watched_paths.add(path)
234
- logger.debug("Started watching path: %s", path)
235
-
236
- if not self.observer.is_alive():
237
- self.observer.start()
238
- except Exception as e:
239
- logger.error("Error starting file watcher for %s: %s", path, e)
240
-
241
- def stop_watching(self, path: str):
242
- """Stop watching a specific path."""
243
- if not WATCHDOG_AVAILABLE or not self.observer:
244
- return
245
-
246
- if path in self.watched_paths:
247
- # Note: watchdog doesn't have direct path removal, would need to recreate observer
248
- self.watched_paths.discard(path)
249
- logger.debug("Stopped watching path: %s", path)
250
-
251
- def stop_all(self):
252
- """Stop all file watching."""
253
- if self.observer and self.observer.is_alive():
254
- self.observer.stop()
255
- self.observer.join()
256
- self.watched_paths.clear()
257
-
258
-
259
- class ProjectStateManager:
260
- """Manages project state for client sessions."""
261
-
262
- def __init__(self, control_channel, context: Dict[str, Any]):
263
- self.control_channel = control_channel
264
- self.context = context
265
- self.projects: Dict[str, ProjectState] = {}
266
- self.git_managers: Dict[str, GitManager] = {}
267
- self.file_watcher = FileSystemWatcher(self)
268
- self.debug_mode = False
269
- self.debug_file_path: Optional[str] = None
270
-
271
- # Debouncing for file changes
272
- self._change_debounce_timer: Optional[asyncio.Task] = None
273
- self._pending_changes: Set[str] = set()
274
-
275
- def set_debug_mode(self, enabled: bool, debug_file_path: Optional[str] = None):
276
- """Enable or disable debug mode with JSON output."""
277
- self.debug_mode = enabled
278
- self.debug_file_path = debug_file_path
279
- if enabled:
280
- logger.info("Project state debug mode enabled, output to: %s", debug_file_path)
281
-
282
- def _write_debug_state(self):
283
- """Write current state to debug JSON file."""
284
- if not self.debug_mode or not self.debug_file_path:
285
- return
286
-
287
- try:
288
- debug_data = {}
289
- for project_id, state in self.projects.items():
290
- debug_data[project_id] = {
291
- "project_folder_path": state.project_folder_path,
292
- "is_git_repo": state.is_git_repo,
293
- "git_branch": state.git_branch,
294
- "git_status_summary": state.git_status_summary,
295
- "open_files": list(state.open_files),
296
- "active_file": state.active_file,
297
- "monitored_folders": [asdict(mf) for mf in state.monitored_folders],
298
- "items": [self._serialize_file_item(item) for item in state.items]
299
- }
300
-
301
- with open(self.debug_file_path, 'w', encoding='utf-8') as f:
302
- json.dump(debug_data, f, indent=2, default=str)
303
-
304
- except Exception as e:
305
- logger.error("Error writing debug state: %s", e)
306
-
307
- def _serialize_file_item(self, item: FileItem) -> Dict[str, Any]:
308
- """Serialize FileItem for JSON output."""
309
- result = asdict(item)
310
- if item.children:
311
- result["children"] = [self._serialize_file_item(child) for child in item.children]
312
- return result
313
-
314
- async def initialize_project_state(self, client_session: str, project_folder_path: str) -> ProjectState:
315
- """Initialize project state for a client session."""
316
- project_id = f"{client_session}_{hash(project_folder_path)}"
317
-
318
- if project_id in self.projects:
319
- return self.projects[project_id]
320
-
321
- logger.info("Initializing project state for: %s", project_folder_path)
322
-
323
- # Initialize Git manager
324
- git_manager = GitManager(project_folder_path)
325
- self.git_managers[project_id] = git_manager
326
-
327
- # Create project state
328
- project_state = ProjectState(
329
- project_id=project_id,
330
- project_folder_path=project_folder_path,
331
- items=[],
332
- is_git_repo=git_manager.is_git_repo,
333
- git_branch=git_manager.get_branch_name(),
334
- git_status_summary=git_manager.get_status_summary()
335
- )
336
-
337
- # Initialize monitored folders with project root and its immediate subdirectories
338
- await self._initialize_monitored_folders(project_state)
339
-
340
- # Sync all dependent state (items, watchdog)
341
- await self._sync_all_state_with_monitored_folders(project_state)
342
-
343
- self.projects[project_id] = project_state
344
- self._write_debug_state()
345
-
346
- return project_state
347
-
348
- async def _initialize_monitored_folders(self, project_state: ProjectState):
349
- """Initialize monitored folders with project root (expanded) and its immediate subdirectories (collapsed)."""
350
- # Add project root as expanded
351
- project_state.monitored_folders.append(
352
- MonitoredFolder(folder_path=project_state.project_folder_path, is_expanded=True)
353
- )
354
-
355
- # Scan project root for immediate subdirectories and add them as collapsed
356
- try:
357
- with os.scandir(project_state.project_folder_path) as entries:
358
- for entry in entries:
359
- if entry.is_dir() and entry.name != '.git' and not entry.name.startswith('.'):
360
- project_state.monitored_folders.append(
361
- MonitoredFolder(folder_path=entry.path, is_expanded=False)
362
- )
363
- except (OSError, PermissionError) as e:
364
- logger.error("Error scanning project root for subdirectories: %s", e)
365
-
366
- async def _start_watching_monitored_folders(self, project_state: ProjectState):
367
- """Start watching all monitored folders."""
368
- for monitored_folder in project_state.monitored_folders:
369
- self.file_watcher.start_watching(monitored_folder.folder_path)
370
-
371
- async def _sync_watchdog_with_monitored_folders(self, project_state: ProjectState):
372
- """Ensure watchdog is monitoring exactly the folders in monitored_folders."""
373
- # Get current monitored folder paths
374
- monitored_paths = {mf.folder_path for mf in project_state.monitored_folders}
375
-
376
- # Get currently watched paths for this project (approximate - watchdog doesn't give us exact list)
377
- # For now, we'll just ensure all monitored folders are being watched
378
- for monitored_folder in project_state.monitored_folders:
379
- self.file_watcher.start_watching(monitored_folder.folder_path)
380
-
381
- # Note: Watchdog library doesn't provide an easy way to stop watching specific paths
382
- # without recreating the entire observer, so we rely on the cleanup method for project cleanup
383
-
384
- async def _sync_all_state_with_monitored_folders(self, project_state: ProjectState):
385
- """Synchronize all dependent state (watchdog, items) with monitored_folders changes."""
386
- # Sync watchdog monitoring
387
- await self._sync_watchdog_with_monitored_folders(project_state)
388
-
389
- # Rebuild items structure from all monitored folders
390
- await self._build_flattened_items_structure(project_state)
391
-
392
- # Update debug state
393
- self._write_debug_state()
394
-
395
- async def _add_subdirectories_to_monitored(self, project_state: ProjectState, parent_folder_path: str):
396
- """Add all subdirectories of a folder to monitored_folders if not already present."""
397
- try:
398
- existing_paths = {mf.folder_path for mf in project_state.monitored_folders}
399
- added_any = False
400
-
401
- with os.scandir(parent_folder_path) as entries:
402
- for entry in entries:
403
- if entry.is_dir() and entry.name != '.git' and not entry.name.startswith('.'):
404
- if entry.path not in existing_paths:
405
- new_monitored = MonitoredFolder(folder_path=entry.path, is_expanded=False)
406
- project_state.monitored_folders.append(new_monitored)
407
- added_any = True
408
-
409
- # If we added any new folders, sync all dependent state
410
- if added_any:
411
- await self._sync_all_state_with_monitored_folders(project_state)
412
-
413
- except (OSError, PermissionError) as e:
414
- logger.error("Error scanning folder %s for subdirectories: %s", parent_folder_path, e)
415
-
416
- def _find_monitored_folder(self, project_state: ProjectState, folder_path: str) -> Optional[MonitoredFolder]:
417
- """Find a monitored folder by path."""
418
- for monitored_folder in project_state.monitored_folders:
419
- if monitored_folder.folder_path == folder_path:
420
- return monitored_folder
421
- return None
422
-
423
- async def _load_directory_items(self, project_state: ProjectState, directory_path: str, is_root: bool = False, parent_item: Optional[FileItem] = None):
424
- """Load directory items with Git metadata."""
425
- git_manager = self.git_managers.get(project_state.project_id)
426
-
427
- try:
428
- items = []
429
-
430
- # Use os.scandir for better performance
431
- with os.scandir(directory_path) as entries:
432
- for entry in entries:
433
- try:
434
- # Skip .git folders and their contents
435
- if entry.name == '.git' and entry.is_dir():
436
- continue
437
-
438
- stat_info = entry.stat()
439
- is_hidden = entry.name.startswith('.')
440
-
441
- # Get Git status if available
442
- git_info = {"is_tracked": False, "status": None, "is_ignored": False}
443
- if git_manager:
444
- git_info = git_manager.get_file_status(entry.path)
445
-
446
- # Check if this directory is expanded
447
- is_expanded = False
448
-
449
- file_item = FileItem(
450
- name=entry.name,
451
- path=entry.path,
452
- is_directory=entry.is_dir(),
453
- parent_path=directory_path,
454
- size=stat_info.st_size if entry.is_file() else None,
455
- modified_time=stat_info.st_mtime,
456
- is_git_tracked=git_info["is_tracked"],
457
- git_status=git_info["status"],
458
- is_hidden=is_hidden,
459
- is_ignored=git_info["is_ignored"],
460
- is_expanded=is_expanded,
461
- is_loaded=not entry.is_dir() # Files are always "loaded", directories need expansion
462
- )
463
-
464
- items.append(file_item)
465
-
466
- except (OSError, PermissionError) as e:
467
- logger.debug("Error reading entry %s: %s", entry.path, e)
468
- continue
469
-
470
- # Sort items: directories first, then files, both alphabetically
471
- items.sort(key=lambda x: (not x.is_directory, x.name.lower()))
472
-
473
- if is_root:
474
- project_state.items = items
475
- elif parent_item:
476
- parent_item.children = items
477
- parent_item.is_loaded = True
478
-
479
- except (OSError, PermissionError) as e:
480
- logger.error("Error loading directory %s: %s", directory_path, e)
481
-
482
- async def _build_flattened_items_structure(self, project_state: ProjectState):
483
- """Build a flattened items structure including ALL items from ALL monitored folders."""
484
- all_items = []
485
-
486
- # Create a set of expanded folder paths for quick lookup
487
- expanded_paths = {mf.folder_path for mf in project_state.monitored_folders if mf.is_expanded}
488
-
489
- # Load items from ALL monitored folders
490
- for monitored_folder in project_state.monitored_folders:
491
- # Load direct children of this monitored folder
492
- children = await self._load_directory_items_list(monitored_folder.folder_path, monitored_folder.folder_path)
493
-
494
- # Mark directories as expanded if they are in expanded_paths and add all items
495
- for child in children:
496
- if child.is_directory and child.path in expanded_paths:
497
- child.is_expanded = True
498
- all_items.append(child)
499
-
500
- # Remove duplicates (items might be loaded multiple times due to nested monitoring)
501
- # Use a dict to deduplicate by path while preserving the last loaded state
502
- items_dict = {}
503
- for item in all_items:
504
- items_dict[item.path] = item
505
-
506
- # Convert back to list and sort for consistent ordering
507
- project_state.items = list(items_dict.values())
508
- project_state.items.sort(key=lambda x: (x.parent_path, not x.is_directory, x.name.lower()))
509
-
510
- async def _load_directory_items_list(self, directory_path: str, parent_path: str) -> List[FileItem]:
511
- """Load directory items and return as a list with parent_path."""
512
- git_manager = None
513
- for manager in self.git_managers.values():
514
- if directory_path.startswith(manager.project_path):
515
- git_manager = manager
516
- break
517
-
518
- items = []
519
-
520
- try:
521
- with os.scandir(directory_path) as entries:
522
- for entry in entries:
523
- try:
524
- # Skip .git folders and their contents
525
- if entry.name == '.git' and entry.is_dir():
526
- continue
527
-
528
- stat_info = entry.stat()
529
- is_hidden = entry.name.startswith('.')
530
-
531
- # Get Git status if available
532
- git_info = {"is_tracked": False, "status": None, "is_ignored": False}
533
- if git_manager:
534
- git_info = git_manager.get_file_status(entry.path)
535
-
536
- file_item = FileItem(
537
- name=entry.name,
538
- path=entry.path,
539
- is_directory=entry.is_dir(),
540
- parent_path=parent_path,
541
- size=stat_info.st_size if entry.is_file() else None,
542
- modified_time=stat_info.st_mtime,
543
- is_git_tracked=git_info["is_tracked"],
544
- git_status=git_info["status"],
545
- is_hidden=is_hidden,
546
- is_ignored=git_info["is_ignored"],
547
- is_expanded=False,
548
- is_loaded=not entry.is_dir()
549
- )
550
-
551
- items.append(file_item)
552
-
553
- except (OSError, PermissionError) as e:
554
- logger.debug("Error reading entry %s: %s", entry.path, e)
555
- continue
556
-
557
- # Sort items: directories first, then files, both alphabetically
558
- items.sort(key=lambda x: (not x.is_directory, x.name.lower()))
559
-
560
- except (OSError, PermissionError) as e:
561
- logger.error("Error loading directory %s: %s", directory_path, e)
562
-
563
- return items
564
-
565
- async def expand_folder(self, project_id: str, folder_path: str) -> bool:
566
- """Expand a folder and load its contents."""
567
- if project_id not in self.projects:
568
- return False
569
-
570
- project_state = self.projects[project_id]
571
-
572
- # Update the monitored folder to expanded state
573
- monitored_folder = self._find_monitored_folder(project_state, folder_path)
574
- if not monitored_folder:
575
- return False
576
-
577
- monitored_folder.is_expanded = True
578
-
579
- # Add all subdirectories of the expanded folder to monitored folders
580
- await self._add_subdirectories_to_monitored(project_state, folder_path)
581
-
582
- # Sync all dependent state (this will update items and watchdog)
583
- await self._sync_all_state_with_monitored_folders(project_state)
584
-
585
- return True
586
-
587
- async def collapse_folder(self, project_id: str, folder_path: str) -> bool:
588
- """Collapse a folder."""
589
- if project_id not in self.projects:
590
- return False
591
-
592
- project_state = self.projects[project_id]
593
-
594
- # Update the monitored folder to collapsed state
595
- monitored_folder = self._find_monitored_folder(project_state, folder_path)
596
- if not monitored_folder:
597
- return False
598
-
599
- monitored_folder.is_expanded = False
600
-
601
- # Note: We keep monitoring collapsed folders for file changes
602
- # but don't stop watching them as we want to detect new files/folders
603
-
604
- # Sync all dependent state (this will update items with correct expansion state)
605
- await self._sync_all_state_with_monitored_folders(project_state)
606
-
607
- return True
608
-
609
- def _find_item_by_path(self, items: List[FileItem], target_path: str) -> Optional[FileItem]:
610
- """Find a file item by its path recursively."""
611
- for item in items:
612
- if item.path == target_path:
613
- return item
614
- if item.children:
615
- found = self._find_item_by_path(item.children, target_path)
616
- if found:
617
- return found
618
- return None
619
-
620
- async def open_file(self, project_id: str, file_path: str) -> bool:
621
- """Mark a file as open."""
622
- if project_id not in self.projects:
623
- return False
624
-
625
- project_state = self.projects[project_id]
626
- project_state.open_files.add(file_path)
627
- self._write_debug_state()
628
- return True
629
-
630
- async def close_file(self, project_id: str, file_path: str) -> bool:
631
- """Mark a file as closed."""
632
- if project_id not in self.projects:
633
- return False
634
-
635
- project_state = self.projects[project_id]
636
- project_state.open_files.discard(file_path)
637
-
638
- # Clear active file if it was the closed file
639
- if project_state.active_file == file_path:
640
- project_state.active_file = None
641
-
642
- self._write_debug_state()
643
- return True
644
-
645
- async def set_active_file(self, project_id: str, file_path: Optional[str]) -> bool:
646
- """Set the currently active file."""
647
- if project_id not in self.projects:
648
- return False
649
-
650
- project_state = self.projects[project_id]
651
- project_state.active_file = file_path
652
-
653
- # Ensure active file is also marked as open
654
- if file_path:
655
- project_state.open_files.add(file_path)
656
-
657
- self._write_debug_state()
658
- return True
659
-
660
- async def _handle_file_change(self, event):
661
- """Handle file system change events with debouncing."""
662
- self._pending_changes.add(event.src_path)
663
-
664
- # Cancel existing timer
665
- if self._change_debounce_timer:
666
- self._change_debounce_timer.cancel()
667
-
668
- # Set new timer
669
- self._change_debounce_timer = asyncio.create_task(self._process_pending_changes())
670
-
671
- async def _process_pending_changes(self):
672
- """Process pending file changes after debounce delay."""
673
- await asyncio.sleep(0.5) # Debounce delay
674
-
675
- if not self._pending_changes:
676
- return
677
-
678
- # Process changes for each affected project
679
- affected_projects = set()
680
- for change_path in self._pending_changes:
681
- for project_id, project_state in self.projects.items():
682
- if change_path.startswith(project_state.project_folder_path):
683
- affected_projects.add(project_id)
684
-
685
- # Refresh affected projects
686
- for project_id in affected_projects:
687
- await self._refresh_project_state(project_id)
688
-
689
- self._pending_changes.clear()
690
-
691
- async def _refresh_project_state(self, project_id: str):
692
- """Refresh project state after file changes."""
693
- if project_id not in self.projects:
694
- return
695
-
696
- project_state = self.projects[project_id]
697
- git_manager = self.git_managers[project_id]
698
-
699
- # Update Git status
700
- if git_manager:
701
- project_state.git_status_summary = git_manager.get_status_summary()
702
-
703
- # Check if any new directories were added that should be monitored
704
- await self._detect_and_add_new_directories(project_state)
705
-
706
- # Sync all dependent state (items, watchdog)
707
- await self._sync_all_state_with_monitored_folders(project_state)
708
-
709
- # Send update to clients
710
- await self._send_project_state_update(project_state)
711
-
712
- async def _detect_and_add_new_directories(self, project_state: ProjectState):
713
- """Detect new directories in monitored folders and add them to monitoring."""
714
- # For each currently monitored folder, check if new subdirectories appeared
715
- monitored_folder_paths = [mf.folder_path for mf in project_state.monitored_folders]
716
-
717
- for folder_path in monitored_folder_paths:
718
- if os.path.exists(folder_path) and os.path.isdir(folder_path):
719
- await self._add_subdirectories_to_monitored(project_state, folder_path)
720
-
721
- async def _reload_visible_structures(self, project_state: ProjectState):
722
- """Reload all visible structures with flattened items."""
723
- await self._build_flattened_items_structure(project_state)
724
-
725
- async def _send_project_state_update(self, project_state: ProjectState):
726
- """Send project state update to clients."""
727
- payload = {
728
- "event": "project_state_update",
729
- "project_id": project_state.project_id,
730
- "project_folder_path": project_state.project_folder_path,
731
- "is_git_repo": project_state.is_git_repo,
732
- "git_branch": project_state.git_branch,
733
- "git_status_summary": project_state.git_status_summary,
734
- "open_files": list(project_state.open_files),
735
- "active_file": project_state.active_file,
736
- "items": [self._serialize_file_item(item) for item in project_state.items],
737
- "timestamp": time.time()
738
- }
739
-
740
- # Send via control channel with client session awareness
741
- await self.control_channel.send(payload)
742
-
743
- def cleanup_project(self, project_id: str):
744
- """Clean up project state and resources."""
745
- if project_id in self.projects:
746
- project_state = self.projects[project_id]
747
-
748
- # Stop watching all monitored folders for this project
749
- for monitored_folder in project_state.monitored_folders:
750
- self.file_watcher.stop_watching(monitored_folder.folder_path)
751
-
752
- # Clean up managers
753
- self.git_managers.pop(project_id, None)
754
- self.projects.pop(project_id, None)
755
-
756
- logger.info("Cleaned up project state: %s", project_id)
757
- self._write_debug_state()
758
-
759
-
760
- # Helper function for other handlers to get/create project state manager
761
- def _get_or_create_project_state_manager(context: Dict[str, Any], control_channel) -> 'ProjectStateManager':
762
- """Get or create project state manager with debug setup."""
763
- if "project_state_manager" not in context:
764
- manager = ProjectStateManager(control_channel, context)
765
-
766
- # Set up debug mode if enabled
767
- if context.get("debug", False):
768
- debug_file_path = os.path.join(os.getcwd(), "project_state_debug.json")
769
- manager.set_debug_mode(True, debug_file_path)
770
-
771
- context["project_state_manager"] = manager
772
- return manager
773
- else:
774
- return context["project_state_manager"]
775
-
776
-
777
- # Handler classes
778
- class ProjectStateFolderExpandHandler(AsyncHandler):
779
- """Handler for expanding project folders."""
780
-
781
- @property
782
- def command_name(self) -> str:
783
- return "project_state_folder_expand"
784
-
785
- async def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
786
- """Expand a folder in project state."""
787
- project_id = message.get("project_id")
788
- folder_path = message.get("folder_path")
789
-
790
- if not project_id:
791
- raise ValueError("project_id is required")
792
- if not folder_path:
793
- raise ValueError("folder_path is required")
794
-
795
- manager = _get_or_create_project_state_manager(self.context, self.control_channel)
796
-
797
- success = await manager.expand_folder(project_id, folder_path)
798
-
799
- if success:
800
- # Send updated state
801
- project_state = manager.projects[project_id]
802
- await manager._send_project_state_update(project_state)
803
-
804
- return {
805
- "event": "project_state_folder_expand_response",
806
- "project_id": project_id,
807
- "folder_path": folder_path,
808
- "success": success
809
- }
810
-
811
-
812
- class ProjectStateFolderCollapseHandler(AsyncHandler):
813
- """Handler for collapsing project folders."""
814
-
815
- @property
816
- def command_name(self) -> str:
817
- return "project_state_folder_collapse"
818
-
819
- async def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
820
- """Collapse a folder in project state."""
821
- project_id = message.get("project_id")
822
- folder_path = message.get("folder_path")
823
-
824
- if not project_id:
825
- raise ValueError("project_id is required")
826
- if not folder_path:
827
- raise ValueError("folder_path is required")
828
-
829
- manager = _get_or_create_project_state_manager(self.context, self.control_channel)
830
-
831
- success = await manager.collapse_folder(project_id, folder_path)
832
-
833
- if success:
834
- # Send updated state
835
- project_state = manager.projects[project_id]
836
- await manager._send_project_state_update(project_state)
837
-
838
- return {
839
- "event": "project_state_folder_collapse_response",
840
- "project_id": project_id,
841
- "folder_path": folder_path,
842
- "success": success
843
- }
844
-
845
-
846
- class ProjectStateFileOpenHandler(AsyncHandler):
847
- """Handler for opening files in project state."""
848
-
849
- @property
850
- def command_name(self) -> str:
851
- return "project_state_file_open"
852
-
853
- async def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
854
- """Open a file in project state."""
855
- project_id = message.get("project_id")
856
- file_path = message.get("file_path")
857
- set_active = message.get("set_active", True)
858
-
859
- if not project_id:
860
- raise ValueError("project_id is required")
861
- if not file_path:
862
- raise ValueError("file_path is required")
863
-
864
- manager = _get_or_create_project_state_manager(self.context, self.control_channel)
865
-
866
- success = await manager.open_file(project_id, file_path)
867
-
868
- if success and set_active:
869
- await manager.set_active_file(project_id, file_path)
870
-
871
- if success:
872
- # Send updated state
873
- project_state = manager.projects[project_id]
874
- await manager._send_project_state_update(project_state)
875
-
876
- return {
877
- "event": "project_state_file_open_response",
878
- "project_id": project_id,
879
- "file_path": file_path,
880
- "success": success,
881
- "set_active": set_active
882
- }
883
-
884
-
885
- class ProjectStateFileCloseHandler(AsyncHandler):
886
- """Handler for closing files in project state."""
887
-
888
- @property
889
- def command_name(self) -> str:
890
- return "project_state_file_close"
891
-
892
- async def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
893
- """Close a file in project state."""
894
- project_id = message.get("project_id")
895
- file_path = message.get("file_path")
896
-
897
- if not project_id:
898
- raise ValueError("project_id is required")
899
- if not file_path:
900
- raise ValueError("file_path is required")
901
-
902
- manager = _get_or_create_project_state_manager(self.context, self.control_channel)
903
-
904
- success = await manager.close_file(project_id, file_path)
905
-
906
- if success:
907
- # Send updated state
908
- project_state = manager.projects[project_id]
909
- await manager._send_project_state_update(project_state)
910
-
911
- return {
912
- "event": "project_state_file_close_response",
913
- "project_id": project_id,
914
- "file_path": file_path,
915
- "success": success
916
- }
917
-
918
-
919
- class ProjectStateSetActiveFileHandler(AsyncHandler):
920
- """Handler for setting active file in project state."""
921
-
922
- @property
923
- def command_name(self) -> str:
924
- return "project_state_set_active_file"
925
-
926
- async def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
927
- """Set active file in project state."""
928
- project_id = message.get("project_id")
929
- file_path = message.get("file_path") # Can be None to clear active file
930
-
931
- if not project_id:
932
- raise ValueError("project_id is required")
933
-
934
- manager = _get_or_create_project_state_manager(self.context, self.control_channel)
935
-
936
- success = await manager.set_active_file(project_id, file_path)
937
-
938
- if success:
939
- # Send updated state
940
- project_state = manager.projects[project_id]
941
- await manager._send_project_state_update(project_state)
942
-
943
- return {
944
- "event": "project_state_set_active_file_response",
945
- "project_id": project_id,
946
- "file_path": file_path,
947
- "success": success
948
- }
1
+ """Project state handlers - modular architecture.
2
+
3
+ This module serves as a compatibility layer that imports all the project state
4
+ handlers from the new modular structure. This ensures existing code continues
5
+ to work while providing access to the new architecture.
6
+
7
+ The original monolithic file has been broken down into a modular structure
8
+ located in the project_state/ subdirectory. All functionality, logging, and
9
+ documentation has been preserved while improving maintainability.
10
+
11
+ For detailed information about the new structure, see:
12
+ project_state/README.md
13
+ """
14
+
15
+ # Import everything from the modular structure for backward compatibility
16
+ from .project_state import *
17
+
18
+ # Ensure all handlers are available at module level for existing imports
19
+ from .project_state.handlers import (
20
+ ProjectStateFolderExpandHandler,
21
+ ProjectStateFolderCollapseHandler,
22
+ ProjectStateFileOpenHandler,
23
+ ProjectStateTabCloseHandler,
24
+ ProjectStateSetActiveTabHandler,
25
+ ProjectStateDiffOpenHandler,
26
+ ProjectStateDiffContentHandler,
27
+ ProjectStateGitStageHandler,
28
+ ProjectStateGitUnstageHandler,
29
+ ProjectStateGitRevertHandler,
30
+ ProjectStateGitCommitHandler,
31
+ handle_client_session_cleanup
32
+ )
33
+
34
+ from .project_state.manager import (
35
+ get_or_create_project_state_manager,
36
+ reset_global_project_state_manager,
37
+ debug_global_manager_state
38
+ )
39
+
40
+ from .project_state.utils import generate_tab_key
41
+
42
+ # Re-export with the old private function names for backward compatibility
43
+ _get_or_create_project_state_manager = get_or_create_project_state_manager
44
+ _reset_global_project_state_manager = reset_global_project_state_manager
45
+ _debug_global_manager_state = debug_global_manager_state