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.
- portacode/_version.py +16 -3
- portacode/cli.py +143 -17
- portacode/connection/client.py +149 -10
- portacode/connection/handlers/WEBSOCKET_PROTOCOL.md +928 -42
- portacode/connection/handlers/__init__.py +34 -5
- portacode/connection/handlers/base.py +78 -16
- portacode/connection/handlers/chunked_content.py +244 -0
- portacode/connection/handlers/diff_handlers.py +603 -0
- portacode/connection/handlers/file_handlers.py +902 -17
- portacode/connection/handlers/project_aware_file_handlers.py +226 -0
- portacode/connection/handlers/project_state/README.md +312 -0
- portacode/connection/handlers/project_state/__init__.py +92 -0
- portacode/connection/handlers/project_state/file_system_watcher.py +179 -0
- portacode/connection/handlers/project_state/git_manager.py +1502 -0
- portacode/connection/handlers/project_state/handlers.py +875 -0
- portacode/connection/handlers/project_state/manager.py +1331 -0
- portacode/connection/handlers/project_state/models.py +108 -0
- portacode/connection/handlers/project_state/utils.py +50 -0
- portacode/connection/handlers/project_state_handlers.py +45 -948
- portacode/connection/handlers/proxmox_infra.py +361 -0
- portacode/connection/handlers/registry.py +15 -4
- portacode/connection/handlers/session.py +483 -32
- portacode/connection/handlers/system_handlers.py +147 -8
- portacode/connection/handlers/tab_factory.py +389 -0
- portacode/connection/handlers/terminal_handlers.py +21 -8
- portacode/connection/handlers/update_handler.py +61 -0
- portacode/connection/multiplex.py +60 -2
- portacode/connection/terminal.py +256 -17
- 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/logging_categories.py +140 -0
- portacode/pairing.py +103 -0
- portacode/static/js/test-ntp-clock.html +63 -0
- portacode/static/js/utils/ntp-clock.js +232 -0
- portacode/utils/NTP_ARCHITECTURE.md +136 -0
- portacode/utils/__init__.py +1 -0
- portacode/utils/diff_apply.py +456 -0
- portacode/utils/diff_renderer.py +371 -0
- portacode/utils/ntp_clock.py +65 -0
- portacode-1.4.11.dev1.dist-info/METADATA +298 -0
- portacode-1.4.11.dev1.dist-info/RECORD +97 -0
- {portacode-0.3.16.dev10.dist-info → portacode-1.4.11.dev1.dist-info}/WHEEL +1 -1
- portacode-1.4.11.dev1.dist-info/top_level.txt +3 -0
- test_modules/README.md +296 -0
- test_modules/__init__.py +1 -0
- test_modules/test_device_online.py +44 -0
- test_modules/test_file_operations.py +743 -0
- test_modules/test_git_status_ui.py +370 -0
- test_modules/test_login_flow.py +50 -0
- test_modules/test_navigate_testing_folder.py +361 -0
- test_modules/test_play_store_screenshots.py +294 -0
- test_modules/test_terminal_buffer_performance.py +261 -0
- test_modules/test_terminal_interaction.py +80 -0
- test_modules/test_terminal_loading_race_condition.py +95 -0
- test_modules/test_terminal_start.py +56 -0
- testing_framework/.env.example +21 -0
- testing_framework/README.md +334 -0
- testing_framework/__init__.py +17 -0
- testing_framework/cli.py +326 -0
- testing_framework/core/__init__.py +1 -0
- testing_framework/core/base_test.py +336 -0
- testing_framework/core/cli_manager.py +177 -0
- testing_framework/core/hierarchical_runner.py +577 -0
- testing_framework/core/playwright_manager.py +520 -0
- testing_framework/core/runner.py +447 -0
- testing_framework/core/shared_cli_manager.py +234 -0
- testing_framework/core/test_discovery.py +112 -0
- testing_framework/requirements.txt +12 -0
- portacode-0.3.16.dev10.dist-info/METADATA +0 -238
- portacode-0.3.16.dev10.dist-info/RECORD +0 -29
- portacode-0.3.16.dev10.dist-info/top_level.txt +0 -1
- {portacode-0.3.16.dev10.dist-info → portacode-1.4.11.dev1.dist-info}/entry_points.txt +0 -0
- {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
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|