portacode 0.3.4.dev0__py3-none-any.whl → 1.4.11.dev0__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 (93) hide show
  1. portacode/_version.py +16 -3
  2. portacode/cli.py +155 -19
  3. portacode/connection/client.py +152 -12
  4. portacode/connection/handlers/WEBSOCKET_PROTOCOL.md +1577 -0
  5. portacode/connection/handlers/__init__.py +43 -1
  6. portacode/connection/handlers/base.py +122 -18
  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 -0
  20. portacode/connection/handlers/proxmox_infra.py +307 -0
  21. portacode/connection/handlers/registry.py +53 -10
  22. portacode/connection/handlers/session.py +705 -53
  23. portacode/connection/handlers/system_handlers.py +142 -8
  24. portacode/connection/handlers/tab_factory.py +389 -0
  25. portacode/connection/handlers/terminal_handlers.py +150 -11
  26. portacode/connection/handlers/update_handler.py +61 -0
  27. portacode/connection/multiplex.py +60 -2
  28. portacode/connection/terminal.py +695 -28
  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/service.py +6 -0
  53. portacode/static/js/test-ntp-clock.html +63 -0
  54. portacode/static/js/utils/ntp-clock.js +232 -0
  55. portacode/utils/NTP_ARCHITECTURE.md +136 -0
  56. portacode/utils/__init__.py +1 -0
  57. portacode/utils/diff_apply.py +456 -0
  58. portacode/utils/diff_renderer.py +371 -0
  59. portacode/utils/ntp_clock.py +65 -0
  60. portacode-1.4.11.dev0.dist-info/METADATA +298 -0
  61. portacode-1.4.11.dev0.dist-info/RECORD +97 -0
  62. {portacode-0.3.4.dev0.dist-info → portacode-1.4.11.dev0.dist-info}/WHEEL +1 -1
  63. portacode-1.4.11.dev0.dist-info/top_level.txt +3 -0
  64. test_modules/README.md +296 -0
  65. test_modules/__init__.py +1 -0
  66. test_modules/test_device_online.py +44 -0
  67. test_modules/test_file_operations.py +743 -0
  68. test_modules/test_git_status_ui.py +370 -0
  69. test_modules/test_login_flow.py +50 -0
  70. test_modules/test_navigate_testing_folder.py +361 -0
  71. test_modules/test_play_store_screenshots.py +294 -0
  72. test_modules/test_terminal_buffer_performance.py +261 -0
  73. test_modules/test_terminal_interaction.py +80 -0
  74. test_modules/test_terminal_loading_race_condition.py +95 -0
  75. test_modules/test_terminal_start.py +56 -0
  76. testing_framework/.env.example +21 -0
  77. testing_framework/README.md +334 -0
  78. testing_framework/__init__.py +17 -0
  79. testing_framework/cli.py +326 -0
  80. testing_framework/core/__init__.py +1 -0
  81. testing_framework/core/base_test.py +336 -0
  82. testing_framework/core/cli_manager.py +177 -0
  83. testing_framework/core/hierarchical_runner.py +577 -0
  84. testing_framework/core/playwright_manager.py +520 -0
  85. testing_framework/core/runner.py +447 -0
  86. testing_framework/core/shared_cli_manager.py +234 -0
  87. testing_framework/core/test_discovery.py +112 -0
  88. testing_framework/requirements.txt +12 -0
  89. portacode-0.3.4.dev0.dist-info/METADATA +0 -236
  90. portacode-0.3.4.dev0.dist-info/RECORD +0 -27
  91. portacode-0.3.4.dev0.dist-info/top_level.txt +0 -1
  92. {portacode-0.3.4.dev0.dist-info → portacode-1.4.11.dev0.dist-info}/entry_points.txt +0 -0
  93. {portacode-0.3.4.dev0.dist-info → portacode-1.4.11.dev0.dist-info/licenses}/LICENSE +0 -0
@@ -0,0 +1,875 @@
1
+ """Request handlers for project state management operations.
2
+
3
+ This module contains all the AsyncHandler classes that handle different
4
+ project state operations like folder expansion/collapse, file operations,
5
+ tab management, and git operations.
6
+ """
7
+
8
+ import logging
9
+ from typing import Any, Dict, List
10
+
11
+ from ..base import AsyncHandler
12
+ from ..chunked_content import create_chunked_response
13
+ from .manager import get_or_create_project_state_manager
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ class ProjectStateFolderExpandHandler(AsyncHandler):
19
+ """Handler for expanding project folders."""
20
+
21
+ @property
22
+ def command_name(self) -> str:
23
+ return "project_state_folder_expand"
24
+
25
+ async def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
26
+ """Expand a folder in project state."""
27
+ logger.info("ProjectStateFolderExpandHandler.execute called with message: %s", message)
28
+
29
+ server_project_id = message.get("project_id") # Server-side UUID (for response)
30
+ folder_path = message.get("folder_path")
31
+ source_client_session = message.get("source_client_session") # This is our key
32
+
33
+ logger.info("Extracted server_project_id: %s, folder_path: %s, source_client_session: %s",
34
+ server_project_id, folder_path, source_client_session)
35
+
36
+ if not server_project_id:
37
+ raise ValueError("project_id is required")
38
+ if not folder_path:
39
+ raise ValueError("folder_path is required")
40
+ if not source_client_session:
41
+ raise ValueError("source_client_session is required")
42
+
43
+ logger.info("Getting project state manager...")
44
+ manager = get_or_create_project_state_manager(self.context, self.control_channel)
45
+ logger.info("Got manager: %s", manager)
46
+
47
+ # With the new design, client session ID maps directly to project state
48
+ if source_client_session not in manager.projects:
49
+ logger.error("No project state found for client session: %s. Available project states: %s",
50
+ source_client_session, list(manager.projects.keys()))
51
+ response = {
52
+ "event": "project_state_folder_expand_response",
53
+ "project_id": server_project_id,
54
+ "folder_path": folder_path,
55
+ "success": False,
56
+ "error": f"No project state found for client session: {source_client_session}"
57
+ }
58
+ logger.error("Returning error response: %s", response)
59
+ return response
60
+
61
+ logger.info("Found project state for client session: %s", source_client_session)
62
+
63
+ logger.info("Calling manager.expand_folder...")
64
+ success = await manager.expand_folder(source_client_session, folder_path)
65
+ logger.info("expand_folder returned: %s", success)
66
+
67
+ if success:
68
+ # Send updated state
69
+ logger.info("Sending project state update...")
70
+ project_state = manager.projects[source_client_session]
71
+ await manager._send_project_state_update(project_state, server_project_id)
72
+ logger.info("Project state update sent")
73
+
74
+ response = {
75
+ "event": "project_state_folder_expand_response",
76
+ "project_id": server_project_id, # Return the server-side project ID
77
+ "folder_path": folder_path,
78
+ "success": success
79
+ }
80
+
81
+ logger.info("Returning response: %s", response)
82
+ return response
83
+
84
+
85
+ class ProjectStateFolderCollapseHandler(AsyncHandler):
86
+ """Handler for collapsing project folders."""
87
+
88
+ @property
89
+ def command_name(self) -> str:
90
+ return "project_state_folder_collapse"
91
+
92
+ async def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
93
+ """Collapse a folder in project state."""
94
+ server_project_id = message.get("project_id") # Server-side UUID (for response)
95
+ folder_path = message.get("folder_path")
96
+ source_client_session = message.get("source_client_session") # This is our key
97
+
98
+ if not server_project_id:
99
+ raise ValueError("project_id is required")
100
+ if not folder_path:
101
+ raise ValueError("folder_path is required")
102
+ if not source_client_session:
103
+ raise ValueError("source_client_session is required")
104
+
105
+ manager = get_or_create_project_state_manager(self.context, self.control_channel)
106
+
107
+ # Find project state using client session
108
+ if source_client_session not in manager.projects:
109
+ logger.error("No project state found for client session: %s. Available project states: %s",
110
+ source_client_session, list(manager.projects.keys()))
111
+ return {
112
+ "event": "project_state_folder_collapse_response",
113
+ "project_id": server_project_id,
114
+ "folder_path": folder_path,
115
+ "success": False,
116
+ "error": f"No project state found for client session: {source_client_session}"
117
+ }
118
+
119
+ success = await manager.collapse_folder(source_client_session, folder_path)
120
+
121
+ if success:
122
+ # Send updated state
123
+ project_state = manager.projects[source_client_session]
124
+ await manager._send_project_state_update(project_state, server_project_id)
125
+
126
+ return {
127
+ "event": "project_state_folder_collapse_response",
128
+ "project_id": server_project_id, # Return the server-side project ID
129
+ "folder_path": folder_path,
130
+ "success": success
131
+ }
132
+
133
+
134
+ class ProjectStateFileOpenHandler(AsyncHandler):
135
+ """Handler for opening files in project state."""
136
+
137
+ @property
138
+ def command_name(self) -> str:
139
+ return "project_state_file_open"
140
+
141
+ async def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
142
+ """Open a file in project state."""
143
+ server_project_id = message.get("project_id") # Server-side UUID (for response)
144
+ file_path = message.get("file_path")
145
+ source_client_session = message.get("source_client_session") # This is our key
146
+ set_active = message.get("set_active", True)
147
+
148
+ if not server_project_id:
149
+ raise ValueError("project_id is required")
150
+ if not file_path:
151
+ raise ValueError("file_path is required")
152
+ if not source_client_session:
153
+ raise ValueError("source_client_session is required")
154
+
155
+ manager = get_or_create_project_state_manager(self.context, self.control_channel)
156
+
157
+ # Find project state using client session
158
+ if source_client_session not in manager.projects:
159
+ logger.error("No project state found for client session: %s. Available project states: %s",
160
+ source_client_session, list(manager.projects.keys()))
161
+ return {
162
+ "event": "project_state_file_open_response",
163
+ "project_id": server_project_id,
164
+ "file_path": file_path,
165
+ "success": False,
166
+ "set_active": set_active,
167
+ "error": f"No project state found for client session: {source_client_session}"
168
+ }
169
+
170
+ success = await manager.open_file(source_client_session, file_path, set_active)
171
+
172
+ if success:
173
+ # Send updated state
174
+ project_state = manager.projects[source_client_session]
175
+ await manager._send_project_state_update(project_state, server_project_id)
176
+
177
+ return {
178
+ "event": "project_state_file_open_response",
179
+ "project_id": server_project_id, # Return the server-side project ID
180
+ "file_path": file_path,
181
+ "success": success,
182
+ "set_active": set_active
183
+ }
184
+
185
+
186
+ class ProjectStateTabCloseHandler(AsyncHandler):
187
+ """Handler for closing tabs in project state."""
188
+
189
+ @property
190
+ def command_name(self) -> str:
191
+ return "project_state_tab_close"
192
+
193
+ async def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
194
+ """Close a tab in project state."""
195
+ server_project_id = message.get("project_id") # Server-side UUID (for response)
196
+ tab_id = message.get("tab_id")
197
+ source_client_session = message.get("source_client_session") # This is our key
198
+
199
+ if not server_project_id:
200
+ raise ValueError("project_id is required")
201
+ if not tab_id:
202
+ raise ValueError("tab_id is required")
203
+ if not source_client_session:
204
+ raise ValueError("source_client_session is required")
205
+
206
+ manager = get_or_create_project_state_manager(self.context, self.control_channel)
207
+
208
+ # Find project state using client session
209
+ if source_client_session not in manager.projects:
210
+ logger.error("No project state found for client session: %s. Available project states: %s",
211
+ source_client_session, list(manager.projects.keys()))
212
+ return {
213
+ "event": "project_state_tab_close_response",
214
+ "project_id": server_project_id,
215
+ "tab_id": tab_id,
216
+ "success": False,
217
+ "error": f"No project state found for client session: {source_client_session}"
218
+ }
219
+
220
+ success = await manager.close_tab(source_client_session, tab_id)
221
+
222
+ if success:
223
+ # Send updated state
224
+ project_state = manager.projects[source_client_session]
225
+ await manager._send_project_state_update(project_state, server_project_id)
226
+
227
+ return {
228
+ "event": "project_state_tab_close_response",
229
+ "project_id": server_project_id, # Return the server-side project ID
230
+ "tab_id": tab_id,
231
+ "success": success
232
+ }
233
+
234
+
235
+ class ProjectStateSetActiveTabHandler(AsyncHandler):
236
+ """Handler for setting active tab in project state."""
237
+
238
+ @property
239
+ def command_name(self) -> str:
240
+ return "project_state_set_active_tab"
241
+
242
+ async def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
243
+ """Set active tab in project state."""
244
+ server_project_id = message.get("project_id") # Server-side UUID (for response)
245
+ tab_id = message.get("tab_id") # Can be None to clear active tab
246
+ source_client_session = message.get("source_client_session") # This is our key
247
+
248
+ if not server_project_id:
249
+ raise ValueError("project_id is required")
250
+ if not source_client_session:
251
+ raise ValueError("source_client_session is required")
252
+
253
+ manager = get_or_create_project_state_manager(self.context, self.control_channel)
254
+
255
+ # Find project state using client session
256
+ if source_client_session not in manager.projects:
257
+ logger.error("No project state found for client session: %s. Available project states: %s",
258
+ source_client_session, list(manager.projects.keys()))
259
+ return {
260
+ "event": "project_state_set_active_tab_response",
261
+ "project_id": server_project_id,
262
+ "tab_id": tab_id,
263
+ "success": False,
264
+ "error": f"No project state found for client session: {source_client_session}"
265
+ }
266
+
267
+ success = await manager.set_active_tab(source_client_session, tab_id)
268
+
269
+ if success:
270
+ # Send updated state
271
+ project_state = manager.projects[source_client_session]
272
+ await manager._send_project_state_update(project_state, server_project_id)
273
+
274
+ return {
275
+ "event": "project_state_set_active_tab_response",
276
+ "project_id": server_project_id, # Return the server-side project ID
277
+ "tab_id": tab_id,
278
+ "success": success
279
+ }
280
+
281
+
282
+ class ProjectStateDiffOpenHandler(AsyncHandler):
283
+ """Handler for opening diff tabs based on git timeline references."""
284
+
285
+ @property
286
+ def command_name(self) -> str:
287
+ return "project_state_diff_open"
288
+
289
+ async def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
290
+ """Open a diff tab comparing file versions at different git timeline points."""
291
+ server_project_id = message.get("project_id") # Server-side UUID (for response)
292
+ file_path = message.get("file_path")
293
+ from_ref = message.get("from_ref") # 'head', 'staged', 'working', 'commit'
294
+ to_ref = message.get("to_ref") # 'head', 'staged', 'working', 'commit'
295
+ from_hash = message.get("from_hash") # Optional commit hash for from_ref='commit'
296
+ to_hash = message.get("to_hash") # Optional commit hash for to_ref='commit'
297
+ source_client_session = message.get("source_client_session") # This is our key
298
+
299
+ if not server_project_id:
300
+ raise ValueError("project_id is required")
301
+ if not file_path:
302
+ raise ValueError("file_path is required")
303
+ if not from_ref:
304
+ raise ValueError("from_ref is required")
305
+ if not to_ref:
306
+ raise ValueError("to_ref is required")
307
+ if not source_client_session:
308
+ raise ValueError("source_client_session is required")
309
+
310
+ # Validate reference types
311
+ valid_refs = {'head', 'staged', 'working', 'commit'}
312
+ if from_ref not in valid_refs:
313
+ raise ValueError(f"Invalid from_ref: {from_ref}. Must be one of {valid_refs}")
314
+ if to_ref not in valid_refs:
315
+ raise ValueError(f"Invalid to_ref: {to_ref}. Must be one of {valid_refs}")
316
+
317
+ # Validate commit hashes are provided when needed
318
+ if from_ref == 'commit' and not from_hash:
319
+ raise ValueError("from_hash is required when from_ref='commit'")
320
+ if to_ref == 'commit' and not to_hash:
321
+ raise ValueError("to_hash is required when to_ref='commit'")
322
+
323
+ manager = get_or_create_project_state_manager(self.context, self.control_channel)
324
+
325
+ # Find project state using client session
326
+ if source_client_session not in manager.projects:
327
+ logger.error("No project state found for client session: %s. Available project states: %s",
328
+ source_client_session, list(manager.projects.keys()))
329
+ return {
330
+ "event": "project_state_diff_open_response",
331
+ "project_id": server_project_id,
332
+ "file_path": file_path,
333
+ "from_ref": from_ref,
334
+ "to_ref": to_ref,
335
+ "success": False,
336
+ "error": f"No project state found for client session: {source_client_session}"
337
+ }
338
+
339
+ success = await manager.open_diff_tab(
340
+ source_client_session, file_path, from_ref, to_ref, from_hash, to_hash
341
+ )
342
+
343
+ if success:
344
+ # Send updated state
345
+ project_state = manager.projects[source_client_session]
346
+ await manager._send_project_state_update(project_state, server_project_id)
347
+
348
+ return {
349
+ "event": "project_state_diff_open_response",
350
+ "project_id": server_project_id, # Return the server-side project ID
351
+ "file_path": file_path,
352
+ "from_ref": from_ref,
353
+ "to_ref": to_ref,
354
+ "from_hash": from_hash,
355
+ "to_hash": to_hash,
356
+ "success": success
357
+ }
358
+
359
+
360
+ class ProjectStateGitStageHandler(AsyncHandler):
361
+ """Handler for staging files in git for a project."""
362
+
363
+ @property
364
+ def command_name(self) -> str:
365
+ return "project_state_git_stage"
366
+
367
+ async def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
368
+ """Stage file(s) in git for a project. Supports both single file and bulk operations."""
369
+ server_project_id = message.get("project_id")
370
+ file_path = message.get("file_path") # Single file (backward compatibility)
371
+ file_paths = message.get("file_paths") # Multiple files (bulk operation)
372
+ stage_all = message.get("stage_all", False) # Stage all changes
373
+ source_client_session = message.get("source_client_session")
374
+
375
+ if not server_project_id:
376
+ raise ValueError("project_id is required")
377
+ if not source_client_session:
378
+ raise ValueError("source_client_session is required")
379
+
380
+ # Determine operation mode
381
+ if stage_all:
382
+ operation_desc = "staging all changes"
383
+ file_paths_to_stage = []
384
+ elif file_paths:
385
+ operation_desc = f"staging {len(file_paths)} files"
386
+ file_paths_to_stage = file_paths
387
+ elif file_path:
388
+ operation_desc = f"staging file {file_path}"
389
+ file_paths_to_stage = [file_path]
390
+ else:
391
+ raise ValueError("Either file_path, file_paths, or stage_all must be provided")
392
+
393
+ logger.info("%s for project %s (client session: %s)",
394
+ operation_desc.capitalize(), server_project_id, source_client_session)
395
+
396
+ # Get the project state manager
397
+ manager = get_or_create_project_state_manager(self.context, self.control_channel)
398
+
399
+ # Get git manager for the client session
400
+ git_manager = manager.git_managers.get(source_client_session)
401
+ if not git_manager:
402
+ raise ValueError("No git repository found for this project")
403
+
404
+ # Perform the staging operation
405
+ if stage_all:
406
+ success = git_manager.stage_all_changes()
407
+ elif len(file_paths_to_stage) == 1:
408
+ success = git_manager.stage_file(file_paths_to_stage[0])
409
+ else:
410
+ success = git_manager.stage_files(file_paths_to_stage)
411
+
412
+ if success:
413
+ # Refresh git status only (no filesystem changes from staging)
414
+ await manager._refresh_project_state(
415
+ source_client_session,
416
+ git_only=True,
417
+ reason="git_stage",
418
+ )
419
+
420
+ # Build response
421
+ response = {
422
+ "event": "project_state_git_stage_response",
423
+ "project_id": server_project_id,
424
+ "success": success
425
+ }
426
+
427
+ # Include appropriate file information in response for backward compatibility
428
+ if file_path:
429
+ response["file_path"] = file_path
430
+ if file_paths:
431
+ response["file_paths"] = file_paths
432
+ if stage_all:
433
+ response["stage_all"] = True
434
+
435
+ return response
436
+
437
+
438
+ class ProjectStateGitUnstageHandler(AsyncHandler):
439
+ """Handler for unstaging files in git for a project."""
440
+
441
+ @property
442
+ def command_name(self) -> str:
443
+ return "project_state_git_unstage"
444
+
445
+ async def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
446
+ """Unstage file(s) in git for a project. Supports both single file and bulk operations."""
447
+ server_project_id = message.get("project_id")
448
+ file_path = message.get("file_path") # Single file (backward compatibility)
449
+ file_paths = message.get("file_paths") # Multiple files (bulk operation)
450
+ unstage_all = message.get("unstage_all", False) # Unstage all changes
451
+ source_client_session = message.get("source_client_session")
452
+
453
+ if not server_project_id:
454
+ raise ValueError("project_id is required")
455
+ if not source_client_session:
456
+ raise ValueError("source_client_session is required")
457
+
458
+ # Determine operation mode
459
+ if unstage_all:
460
+ operation_desc = "unstaging all changes"
461
+ file_paths_to_unstage = []
462
+ elif file_paths:
463
+ operation_desc = f"unstaging {len(file_paths)} files"
464
+ file_paths_to_unstage = file_paths
465
+ elif file_path:
466
+ operation_desc = f"unstaging file {file_path}"
467
+ file_paths_to_unstage = [file_path]
468
+ else:
469
+ raise ValueError("Either file_path, file_paths, or unstage_all must be provided")
470
+
471
+ logger.info("%s for project %s (client session: %s)",
472
+ operation_desc.capitalize(), server_project_id, source_client_session)
473
+
474
+ # Get the project state manager
475
+ manager = get_or_create_project_state_manager(self.context, self.control_channel)
476
+
477
+ # Get git manager for the client session
478
+ git_manager = manager.git_managers.get(source_client_session)
479
+ if not git_manager:
480
+ raise ValueError("No git repository found for this project")
481
+
482
+ # Perform the unstaging operation
483
+ if unstage_all:
484
+ success = git_manager.unstage_all_changes()
485
+ elif len(file_paths_to_unstage) == 1:
486
+ success = git_manager.unstage_file(file_paths_to_unstage[0])
487
+ else:
488
+ success = git_manager.unstage_files(file_paths_to_unstage)
489
+
490
+ if success:
491
+ # Refresh git status only (no filesystem changes from unstaging)
492
+ await manager._refresh_project_state(
493
+ source_client_session,
494
+ git_only=True,
495
+ reason="git_unstage",
496
+ )
497
+
498
+ # Build response
499
+ response = {
500
+ "event": "project_state_git_unstage_response",
501
+ "project_id": server_project_id,
502
+ "success": success
503
+ }
504
+
505
+ # Include appropriate file information in response for backward compatibility
506
+ if file_path:
507
+ response["file_path"] = file_path
508
+ if file_paths:
509
+ response["file_paths"] = file_paths
510
+ if unstage_all:
511
+ response["unstage_all"] = True
512
+
513
+ return response
514
+
515
+
516
+ class ProjectStateGitRevertHandler(AsyncHandler):
517
+ """Handler for reverting files in git for a project."""
518
+
519
+ @property
520
+ def command_name(self) -> str:
521
+ return "project_state_git_revert"
522
+
523
+ async def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
524
+ """Revert file(s) in git for a project. Supports both single file and bulk operations."""
525
+ server_project_id = message.get("project_id")
526
+ file_path = message.get("file_path") # Single file (backward compatibility)
527
+ file_paths = message.get("file_paths") # Multiple files (bulk operation)
528
+ revert_all = message.get("revert_all", False) # Revert all changes
529
+ source_client_session = message.get("source_client_session")
530
+
531
+ if not server_project_id:
532
+ raise ValueError("project_id is required")
533
+ if not source_client_session:
534
+ raise ValueError("source_client_session is required")
535
+
536
+ # Determine operation mode
537
+ if revert_all:
538
+ operation_desc = "reverting all changes"
539
+ file_paths_to_revert = []
540
+ elif file_paths:
541
+ operation_desc = f"reverting {len(file_paths)} files"
542
+ file_paths_to_revert = file_paths
543
+ elif file_path:
544
+ operation_desc = f"reverting file {file_path}"
545
+ file_paths_to_revert = [file_path]
546
+ else:
547
+ raise ValueError("Either file_path, file_paths, or revert_all must be provided")
548
+
549
+ logger.info("%s for project %s (client session: %s)",
550
+ operation_desc.capitalize(), server_project_id, source_client_session)
551
+
552
+ # Get the project state manager
553
+ manager = get_or_create_project_state_manager(self.context, self.control_channel)
554
+
555
+ # Get git manager for the client session
556
+ git_manager = manager.git_managers.get(source_client_session)
557
+ if not git_manager:
558
+ raise ValueError("No git repository found for this project")
559
+
560
+ # Perform the revert operation
561
+ if revert_all:
562
+ success = git_manager.revert_all_changes()
563
+ elif len(file_paths_to_revert) == 1:
564
+ success = git_manager.revert_file(file_paths_to_revert[0])
565
+ else:
566
+ success = git_manager.revert_files(file_paths_to_revert)
567
+
568
+ if success:
569
+ # Refresh entire project state to ensure consistency
570
+ await manager._refresh_project_state(
571
+ source_client_session,
572
+ reason="git_revert",
573
+ )
574
+
575
+ # Build response
576
+ response = {
577
+ "event": "project_state_git_revert_response",
578
+ "project_id": server_project_id,
579
+ "success": success
580
+ }
581
+
582
+ # Include appropriate file information in response for backward compatibility
583
+ if file_path:
584
+ response["file_path"] = file_path
585
+ if file_paths:
586
+ response["file_paths"] = file_paths
587
+ if revert_all:
588
+ response["revert_all"] = True
589
+
590
+ return response
591
+
592
+
593
+ class ProjectStateGitCommitHandler(AsyncHandler):
594
+ """Handler for committing staged changes in git for a project."""
595
+
596
+ @property
597
+ def command_name(self) -> str:
598
+ return "project_state_git_commit"
599
+
600
+ async def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
601
+ """Commit staged changes with the given commit message."""
602
+ server_project_id = message.get("project_id")
603
+ commit_message = message.get("commit_message")
604
+ source_client_session = message.get("source_client_session")
605
+
606
+ if not server_project_id:
607
+ raise ValueError("project_id is required")
608
+ if not commit_message:
609
+ raise ValueError("commit_message is required")
610
+ if not source_client_session:
611
+ raise ValueError("source_client_session is required")
612
+
613
+ logger.info("Committing changes for project %s (client session: %s) with message: %s",
614
+ server_project_id, source_client_session, commit_message[:50] + "..." if len(commit_message) > 50 else commit_message)
615
+
616
+ # Get the project state manager
617
+ manager = get_or_create_project_state_manager(self.context, self.control_channel)
618
+
619
+ # Get git manager for the client session
620
+ git_manager = manager.git_managers.get(source_client_session)
621
+ if not git_manager:
622
+ raise ValueError("No git repository found for this project")
623
+
624
+ # Commit the staged changes
625
+ success = False
626
+ error_message = None
627
+ commit_hash = None
628
+
629
+ try:
630
+ success = git_manager.commit_changes(commit_message)
631
+ if success:
632
+ # Get the commit hash of the new commit
633
+ commit_hash = git_manager.get_head_commit_hash()
634
+
635
+ # Refresh git status only (no filesystem changes from commit)
636
+ await manager._refresh_project_state(
637
+ source_client_session,
638
+ git_only=True,
639
+ reason="git_commit",
640
+ )
641
+ except Exception as e:
642
+ error_message = str(e)
643
+ logger.error("Error during commit: %s", error_message)
644
+
645
+ return {
646
+ "event": "project_state_git_commit_response",
647
+ "project_id": server_project_id,
648
+ "commit_message": commit_message,
649
+ "success": success,
650
+ "error": error_message,
651
+ "commit_hash": commit_hash
652
+ }
653
+
654
+
655
+ # Handler for explicit client session cleanup
656
+ async def handle_client_session_cleanup(handler, payload: Dict[str, Any], source_client_session: str) -> Dict[str, Any]:
657
+ """Handle explicit cleanup of a client session when server notifies of permanent disconnection."""
658
+ client_session_id = payload.get('client_session_id')
659
+
660
+ if not client_session_id:
661
+ logger.error("client_session_id is required for client session cleanup")
662
+ return {
663
+ "event": "client_session_cleanup_response",
664
+ "success": False,
665
+ "error": "client_session_id is required"
666
+ }
667
+
668
+ logger.info("Handling explicit cleanup for client session: %s", client_session_id)
669
+
670
+ # Get the project state manager
671
+ manager = get_or_create_project_state_manager(handler.context, handler.control_channel)
672
+
673
+ # Clean up the client session's project state
674
+ await manager.cleanup_projects_by_client_session(client_session_id)
675
+
676
+ logger.info("Client session cleanup completed: %s", client_session_id)
677
+
678
+ return {
679
+ "event": "client_session_cleanup_response",
680
+ "client_session_id": client_session_id,
681
+ "success": True
682
+ }
683
+
684
+
685
+ class ProjectStateDiffContentHandler(AsyncHandler):
686
+ """Handler for requesting specific diff content for diff tabs."""
687
+
688
+ @property
689
+ def command_name(self) -> str:
690
+ return "project_state_diff_content_request"
691
+
692
+ async def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
693
+ """Request specific content for a diff tab (original, modified, or html_diff)."""
694
+ server_project_id = message.get("project_id")
695
+ file_path = message.get("file_path")
696
+ from_ref = message.get("from_ref")
697
+ to_ref = message.get("to_ref")
698
+ from_hash = message.get("from_hash")
699
+ to_hash = message.get("to_hash")
700
+ content_type = message.get("content_type") # 'original', 'modified', 'html_diff'
701
+ source_client_session = message.get("source_client_session")
702
+
703
+ # Validate required fields
704
+ if not server_project_id:
705
+ raise ValueError("project_id is required")
706
+ if not file_path:
707
+ raise ValueError("file_path is required")
708
+ if not from_ref:
709
+ raise ValueError("from_ref is required")
710
+ if not to_ref:
711
+ raise ValueError("to_ref is required")
712
+ if not content_type:
713
+ raise ValueError("content_type is required")
714
+ if not source_client_session:
715
+ raise ValueError("source_client_session is required")
716
+
717
+ # Validate content_type
718
+ valid_content_types = ["original", "modified", "html_diff", "all"]
719
+ if content_type not in valid_content_types:
720
+ raise ValueError(f"content_type must be one of: {valid_content_types}")
721
+
722
+ # Get the project state manager
723
+ manager = get_or_create_project_state_manager(self.context, self.control_channel)
724
+
725
+ # Get the project state for this client session
726
+ if source_client_session not in manager.projects:
727
+ raise ValueError(f"No project state found for client session: {source_client_session}")
728
+
729
+ project_state = manager.projects[source_client_session]
730
+
731
+ try:
732
+ # Find the diff tab with matching parameters
733
+ matching_tab = None
734
+ for tab in project_state.open_tabs.values():
735
+ if tab.tab_type == "diff" and tab.file_path == file_path:
736
+ # Get diff parameters from metadata
737
+ tab_metadata = getattr(tab, 'metadata', {}) or {}
738
+ tab_from_ref = tab_metadata.get('from_ref')
739
+ tab_to_ref = tab_metadata.get('to_ref')
740
+ tab_from_hash = tab_metadata.get('from_hash')
741
+ tab_to_hash = tab_metadata.get('to_hash')
742
+
743
+ if (tab_from_ref == from_ref and
744
+ tab_to_ref == to_ref and
745
+ tab_from_hash == from_hash and
746
+ tab_to_hash == to_hash):
747
+ matching_tab = tab
748
+ break
749
+
750
+ if not matching_tab:
751
+ # Debug information
752
+ logger.error(f"No diff tab found for file_path={file_path}, from_ref={from_ref}, to_ref={to_ref}")
753
+ logger.error(f"Available diff tabs: {[(tab.file_path, getattr(tab, 'metadata', {})) for tab in project_state.open_tabs.values() if tab.tab_type == 'diff']}")
754
+ raise ValueError(f"No diff tab found matching the specified parameters: file_path={file_path}, from_ref={from_ref}, to_ref={to_ref}")
755
+
756
+ # Get the requested content based on type
757
+ content = None
758
+ if content_type == "original":
759
+ content = matching_tab.original_content
760
+ elif content_type == "modified":
761
+ content = matching_tab.modified_content
762
+ elif content_type == "html_diff":
763
+ # For html_diff, we need to get the HTML diff versions from metadata
764
+ html_diff_versions = getattr(matching_tab, 'metadata', {}).get('html_diff_versions')
765
+ if html_diff_versions:
766
+ import json
767
+ content = json.dumps(html_diff_versions)
768
+ elif content_type == "all":
769
+ # Return all content types as a JSON object
770
+ html_diff_versions = getattr(matching_tab, 'metadata', {}).get('html_diff_versions')
771
+ import json
772
+ content = json.dumps({
773
+ "original_content": matching_tab.original_content,
774
+ "modified_content": matching_tab.modified_content,
775
+ "html_diff_versions": html_diff_versions
776
+ })
777
+
778
+ # If content is None or incomplete for "all", regenerate if needed
779
+ if content is None or (content_type == "all" and not all([matching_tab.original_content, matching_tab.modified_content])):
780
+ if content_type in ["original", "modified", "all"]:
781
+ # Re-generate the diff content if needed
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
789
+ )
790
+
791
+ # Try to get content again after regeneration (use same matching logic)
792
+ updated_tab = None
793
+ for tab in project_state.open_tabs.values():
794
+ if tab.tab_type == "diff" and tab.file_path == file_path:
795
+ tab_metadata = getattr(tab, 'metadata', {}) or {}
796
+ if (tab_metadata.get('from_ref') == from_ref and
797
+ tab_metadata.get('to_ref') == to_ref and
798
+ tab_metadata.get('from_hash') == from_hash and
799
+ tab_metadata.get('to_hash') == to_hash):
800
+ updated_tab = tab
801
+ break
802
+
803
+ if updated_tab:
804
+ if content_type == "original":
805
+ content = updated_tab.original_content
806
+ elif content_type == "modified":
807
+ content = updated_tab.modified_content
808
+ elif content_type == "html_diff":
809
+ html_diff_versions = getattr(updated_tab, 'metadata', {}).get('html_diff_versions')
810
+ if html_diff_versions:
811
+ import json
812
+ content = json.dumps(html_diff_versions)
813
+ elif content_type == "all":
814
+ html_diff_versions = getattr(updated_tab, 'metadata', {}).get('html_diff_versions')
815
+ import json
816
+ content = json.dumps({
817
+ "original_content": updated_tab.original_content,
818
+ "modified_content": updated_tab.modified_content,
819
+ "html_diff_versions": html_diff_versions
820
+ })
821
+
822
+ success = content is not None
823
+ base_response = {
824
+ "event": "project_state_diff_content_response",
825
+ "project_id": server_project_id,
826
+ "file_path": file_path,
827
+ "from_ref": from_ref,
828
+ "to_ref": to_ref,
829
+ "content_type": content_type,
830
+ "success": success
831
+ }
832
+
833
+ # Add request_id if present in original message
834
+ if "request_id" in message:
835
+ base_response["request_id"] = message["request_id"]
836
+
837
+ if from_hash:
838
+ base_response["from_hash"] = from_hash
839
+ if to_hash:
840
+ base_response["to_hash"] = to_hash
841
+
842
+ if success:
843
+ # Create chunked responses for large content
844
+ responses = create_chunked_response(base_response, "content", content)
845
+
846
+ # Send all responses
847
+ for response in responses:
848
+ await self.send_response(response, project_id=server_project_id)
849
+
850
+ logger.info(f"Sent diff content response in {len(responses)} chunk(s) for {content_type} content")
851
+ else:
852
+ base_response["error"] = f"Failed to load {content_type} content for diff"
853
+ base_response["chunked"] = False
854
+ await self.send_response(base_response, project_id=server_project_id)
855
+
856
+ return # AsyncHandler doesn't return responses, it sends them
857
+
858
+ except Exception as e:
859
+ logger.error("Error processing diff content request: %s", e)
860
+ error_response = {
861
+ "event": "project_state_diff_content_response",
862
+ "project_id": server_project_id,
863
+ "file_path": file_path,
864
+ "from_ref": from_ref,
865
+ "to_ref": to_ref,
866
+ "content_type": content_type,
867
+ "success": False,
868
+ "error": str(e),
869
+ "chunked": False
870
+ }
871
+
872
+ # Add request_id if present in original message
873
+ if "request_id" in message:
874
+ error_response["request_id"] = message["request_id"]
875
+ await self.send_response(error_response, project_id=server_project_id)