portacode 0.3.22__py3-none-any.whl → 0.3.24__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (25) hide show
  1. portacode/_version.py +16 -3
  2. portacode/connection/handlers/WEBSOCKET_PROTOCOL.md +188 -16
  3. portacode/connection/handlers/__init__.py +4 -0
  4. portacode/connection/handlers/base.py +9 -5
  5. portacode/connection/handlers/chunked_content.py +244 -0
  6. portacode/connection/handlers/file_handlers.py +68 -2
  7. portacode/connection/handlers/project_aware_file_handlers.py +143 -1
  8. portacode/connection/handlers/project_state/git_manager.py +326 -66
  9. portacode/connection/handlers/project_state/handlers.py +307 -31
  10. portacode/connection/handlers/project_state/manager.py +44 -1
  11. portacode/connection/handlers/project_state/models.py +7 -0
  12. portacode/connection/handlers/project_state/utils.py +17 -1
  13. portacode/connection/handlers/project_state_handlers.py +1 -0
  14. portacode/connection/handlers/tab_factory.py +60 -7
  15. portacode/connection/terminal.py +13 -7
  16. {portacode-0.3.22.dist-info → portacode-0.3.24.dist-info}/METADATA +14 -3
  17. {portacode-0.3.22.dist-info → portacode-0.3.24.dist-info}/RECORD +25 -24
  18. {portacode-0.3.22.dist-info → portacode-0.3.24.dist-info}/WHEEL +1 -1
  19. test_modules/test_git_status_ui.py +24 -66
  20. testing_framework/core/playwright_manager.py +23 -0
  21. testing_framework/core/runner.py +10 -2
  22. testing_framework/core/test_discovery.py +7 -3
  23. {portacode-0.3.22.dist-info → portacode-0.3.24.dist-info}/entry_points.txt +0 -0
  24. {portacode-0.3.22.dist-info → portacode-0.3.24.dist-info/licenses}/LICENSE +0 -0
  25. {portacode-0.3.22.dist-info → portacode-0.3.24.dist-info}/top_level.txt +0 -0
portacode/_version.py CHANGED
@@ -1,7 +1,14 @@
1
1
  # file generated by setuptools-scm
2
2
  # don't change, don't track in version control
3
3
 
4
- __all__ = ["__version__", "__version_tuple__", "version", "version_tuple"]
4
+ __all__ = [
5
+ "__version__",
6
+ "__version_tuple__",
7
+ "version",
8
+ "version_tuple",
9
+ "__commit_id__",
10
+ "commit_id",
11
+ ]
5
12
 
6
13
  TYPE_CHECKING = False
7
14
  if TYPE_CHECKING:
@@ -9,13 +16,19 @@ if TYPE_CHECKING:
9
16
  from typing import Union
10
17
 
11
18
  VERSION_TUPLE = Tuple[Union[int, str], ...]
19
+ COMMIT_ID = Union[str, None]
12
20
  else:
13
21
  VERSION_TUPLE = object
22
+ COMMIT_ID = object
14
23
 
15
24
  version: str
16
25
  __version__: str
17
26
  __version_tuple__: VERSION_TUPLE
18
27
  version_tuple: VERSION_TUPLE
28
+ commit_id: COMMIT_ID
29
+ __commit_id__: COMMIT_ID
19
30
 
20
- __version__ = version = '0.3.22'
21
- __version_tuple__ = version_tuple = (0, 3, 22)
31
+ __version__ = version = '0.3.24'
32
+ __version_tuple__ = version_tuple = (0, 3, 24)
33
+
34
+ __commit_id__ = commit_id = None
@@ -22,6 +22,7 @@ This document outlines the WebSocket communication protocol between the Portacod
22
22
  - [`file_create`](#file_create)
23
23
  - [`folder_create`](#folder_create)
24
24
  - [`file_rename`](#file_rename)
25
+ - [`content_request`](#content_request)
25
26
  - [Project State Actions](#project-state-actions)
26
27
  - [`project_state_folder_expand`](#project_state_folder_expand)
27
28
  - [`project_state_folder_collapse`](#project_state_folder_collapse)
@@ -29,6 +30,7 @@ This document outlines the WebSocket communication protocol between the Portacod
29
30
  - [`project_state_tab_close`](#project_state_tab_close)
30
31
  - [`project_state_set_active_tab`](#project_state_set_active_tab)
31
32
  - [`project_state_diff_open`](#project_state_diff_open)
33
+ - [`project_state_diff_content_request`](#project_state_diff_content_request)
32
34
  - [`project_state_git_stage`](#project_state_git_stage)
33
35
  - [`project_state_git_unstage`](#project_state_git_unstage)
34
36
  - [`project_state_git_revert`](#project_state_git_revert)
@@ -57,6 +59,7 @@ This document outlines the WebSocket communication protocol between the Portacod
57
59
  - [`file_create_response`](#file_create_response)
58
60
  - [`folder_create_response`](#folder_create_response)
59
61
  - [`file_rename_response`](#file_rename_response)
62
+ - [`content_response`](#content_response)
60
63
  - [Project State Events](#project-state-events)
61
64
  - [`project_state_initialized`](#project_state_initialized)
62
65
  - [`project_state_update`](#project_state_update)
@@ -66,6 +69,7 @@ This document outlines the WebSocket communication protocol between the Portacod
66
69
  - [`project_state_tab_close_response`](#project_state_tab_close_response)
67
70
  - [`project_state_set_active_tab_response`](#project_state_set_active_tab_response)
68
71
  - [`project_state_diff_open_response`](#project_state_diff_open_response)
72
+ - [`project_state_diff_content_response`](#project_state_diff_content_response)
69
73
  - [`project_state_git_stage_response`](#project_state_git_stage_response)
70
74
  - [`project_state_git_unstage_response`](#project_state_git_unstage_response)
71
75
  - [`project_state_git_revert_response`](#project_state_git_revert_response)
@@ -298,6 +302,20 @@ Renames a file or folder. Handled by [`file_rename`](./file_handlers.py).
298
302
  * On success, the device will respond with a [`file_rename_response`](#file_rename_response) event.
299
303
  * On error, a generic [`error`](#error) event is sent.
300
304
 
305
+ ### `content_request`
306
+
307
+ Requests cached content by SHA-256 hash. This action is used to implement content caching for performance optimization, allowing clients to request large content (such as file content, HTML diffs, etc.) by hash instead of receiving it in every WebSocket message. For large content (>200KB), the response will be automatically chunked into multiple messages for reliable transmission. Handled by [`content_request`](./file_handlers.py).
308
+
309
+ **Payload Fields:**
310
+
311
+ * `content_hash` (string, mandatory): The SHA-256 hash of the content to retrieve (with "sha256:" prefix).
312
+ * `request_id` (string, mandatory): A unique identifier for this request, used to match with the response.
313
+
314
+ **Responses:**
315
+
316
+ * On success, the device will respond with one or more [`content_response`](#content_response) events containing the cached content. Large content is automatically chunked.
317
+ * On error (content not found), a [`content_response`](#content_response) event with `success: false` is sent.
318
+
301
319
  ## Project State Actions
302
320
 
303
321
  Project state actions manage the state of project folders, including file structures, Git metadata, open files, and folder expansion states. These actions provide real-time synchronization between the client and server for project management functionality.
@@ -408,14 +426,51 @@ Opens a diff tab for comparing file versions at different points in the git time
408
426
  * On success, the device will respond with a [`project_state_diff_open_response`](#project_state_diff_open_response) event, followed by a [`project_state_update`](#project_state_update) event.
409
427
  * On error, a generic [`error`](#error) event is sent.
410
428
 
429
+ ### `project_state_diff_content_request`
430
+
431
+ Requests the content for a specific diff tab identified by its diff parameters. This action is used to load the actual file content (original and modified) as well as HTML diff data for diff tabs after they have been created by [`project_state_diff_open`](#project_state_diff_open). For large content (>200KB), the response will be automatically chunked into multiple messages for reliable transmission.
432
+
433
+ **Content Types:** This action can request content for a diff:
434
+ - `original`: The original (from) content of the diff
435
+ - `modified`: The modified (to) content of the diff
436
+ - `html_diff`: The HTML diff versions for rich visual display
437
+ - `all`: All content types returned as a single JSON object (recommended for efficiency)
438
+
439
+ **Payload Fields:**
440
+
441
+ * `project_id` (string, mandatory): The project ID from the initialized project state.
442
+ * `file_path` (string, mandatory): The absolute path to the file the diff is for.
443
+ * `from_ref` (string, mandatory): The source reference point used in the diff. Must match the diff tab parameters.
444
+ * `to_ref` (string, mandatory): The target reference point used in the diff. Must match the diff tab parameters.
445
+ * `from_hash` (string, optional): The commit hash for `from_ref` if it was `"commit"`. Must match the diff tab parameters.
446
+ * `to_hash` (string, optional): The commit hash for `to_ref` if it was `"commit"`. Must match the diff tab parameters.
447
+ * `content_type` (string, mandatory): The type of content to request. Must be one of:
448
+ - `"original"`: Request the original (from) content
449
+ - `"modified"`: Request the modified (to) content
450
+ - `"html_diff"`: Request the HTML diff versions for visual display
451
+ - `"all"`: Request all content types as a single JSON object
452
+ * `request_id` (string, mandatory): Unique identifier for this request to match with the response.
453
+
454
+ **Responses:**
455
+
456
+ * On success, the device will respond with one or more [`project_state_diff_content_response`](#project_state_diff_content_response) events. Large content is automatically chunked.
457
+ * On error, a generic [`error`](#error) event is sent.
458
+
411
459
  ### `project_state_git_stage`
412
460
 
413
- Stages a file for commit in the project's git repository. Handled by [`project_state_git_stage`](./project_state_handlers.py).
461
+ Stages file(s) for commit in the project's git repository. Supports both single file and bulk operations. Handled by [`project_state_git_stage`](./project_state_handlers.py).
414
462
 
415
463
  **Payload Fields:**
416
464
 
417
465
  * `project_id` (string, mandatory): The project ID from the initialized project state.
418
- * `file_path` (string, mandatory): The absolute path to the file to stage.
466
+ * `file_path` (string, optional): The absolute path to a single file to stage. Used for backward compatibility.
467
+ * `file_paths` (array of strings, optional): Array of absolute paths to files to stage. Used for bulk operations.
468
+ * `stage_all` (boolean, optional): If true, stages all unstaged changes in the repository. Takes precedence over file_path/file_paths.
469
+
470
+ **Operation Modes:**
471
+ - Single file: Provide `file_path`
472
+ - Bulk operation: Provide `file_paths` array
473
+ - Stage all: Set `stage_all` to true
419
474
 
420
475
  **Responses:**
421
476
 
@@ -424,12 +479,19 @@ Stages a file for commit in the project's git repository. Handled by [`project_s
424
479
 
425
480
  ### `project_state_git_unstage`
426
481
 
427
- Unstages a file (removes from staging area) in the project's git repository. Handled by [`project_state_git_unstage`](./project_state_handlers.py).
482
+ Unstages file(s) (removes from staging area) in the project's git repository. Supports both single file and bulk operations. Handled by [`project_state_git_unstage`](./project_state_handlers.py).
428
483
 
429
484
  **Payload Fields:**
430
485
 
431
486
  * `project_id` (string, mandatory): The project ID from the initialized project state.
432
- * `file_path` (string, mandatory): The absolute path to the file to unstage.
487
+ * `file_path` (string, optional): The absolute path to a single file to unstage. Used for backward compatibility.
488
+ * `file_paths` (array of strings, optional): Array of absolute paths to files to unstage. Used for bulk operations.
489
+ * `unstage_all` (boolean, optional): If true, unstages all staged changes in the repository. Takes precedence over file_path/file_paths.
490
+
491
+ **Operation Modes:**
492
+ - Single file: Provide `file_path`
493
+ - Bulk operation: Provide `file_paths` array
494
+ - Unstage all: Set `unstage_all` to true
433
495
 
434
496
  **Responses:**
435
497
 
@@ -438,12 +500,19 @@ Unstages a file (removes from staging area) in the project's git repository. Han
438
500
 
439
501
  ### `project_state_git_revert`
440
502
 
441
- Reverts a file to its HEAD version, discarding local changes in the project's git repository. Handled by [`project_state_git_revert`](./project_state_handlers.py).
503
+ Reverts file(s) to their HEAD version, discarding local changes in the project's git repository. Supports both single file and bulk operations. Handled by [`project_state_git_revert`](./project_state_handlers.py).
442
504
 
443
505
  **Payload Fields:**
444
506
 
445
507
  * `project_id` (string, mandatory): The project ID from the initialized project state.
446
- * `file_path` (string, mandatory): The absolute path to the file to revert.
508
+ * `file_path` (string, optional): The absolute path to a single file to revert. Used for backward compatibility.
509
+ * `file_paths` (array of strings, optional): Array of absolute paths to files to revert. Used for bulk operations.
510
+ * `revert_all` (boolean, optional): If true, reverts all unstaged changes in the repository. Takes precedence over file_path/file_paths.
511
+
512
+ **Operation Modes:**
513
+ - Single file: Provide `file_path`
514
+ - Bulk operation: Provide `file_paths` array
515
+ - Revert all: Set `revert_all` to true
447
516
 
448
517
  **Responses:**
449
518
 
@@ -713,6 +782,68 @@ Confirms that a file or folder has been renamed successfully in response to a `f
713
782
  * `is_directory` (boolean, mandatory): Indicates whether the renamed item is a directory.
714
783
  * `success` (boolean, mandatory): Indicates whether the rename was successful.
715
784
 
785
+ ### <a name="content_response"></a>`content_response`
786
+
787
+ Returns cached content in response to a `content_request` action. This is part of the content caching system used for performance optimization. For large content (>200KB), the response is automatically chunked into multiple messages to ensure reliable transmission over WebSocket connections. Handled by [`content_request`](./file_handlers.py).
788
+
789
+ **Event Fields:**
790
+
791
+ * `request_id` (string, mandatory): The unique identifier from the corresponding request, used to match request and response.
792
+ * `content_hash` (string, mandatory): The SHA-256 hash that was requested.
793
+ * `content` (string, optional): The cached content or chunk content if found and `success` is true. Null if content was not found.
794
+ * `success` (boolean, mandatory): Indicates whether the content was found and returned successfully.
795
+ * `error` (string, optional): Error message if `success` is false (e.g., "Content not found in cache").
796
+ * `chunked` (boolean, mandatory): Indicates whether this response is part of a chunked transfer. False for single responses, true for chunked responses.
797
+
798
+ **Chunked Transfer Fields (when `chunked` is true):**
799
+
800
+ * `transfer_id` (string, mandatory): Unique identifier for the chunked transfer session.
801
+ * `chunk_index` (integer, mandatory): Zero-based index of this chunk in the sequence.
802
+ * `chunk_count` (integer, mandatory): Total number of chunks in the transfer.
803
+ * `chunk_size` (integer, mandatory): Size of this chunk in bytes.
804
+ * `total_size` (integer, mandatory): Total size of the complete content in bytes.
805
+ * `chunk_hash` (string, mandatory): SHA-256 hash of this chunk for verification.
806
+ * `is_final_chunk` (boolean, mandatory): Indicates if this is the last chunk in the sequence.
807
+
808
+ **Chunked Transfer Process:**
809
+
810
+ 1. **Size Check**: Content >200KB is automatically chunked into 64KB chunks
811
+ 2. **Sequential Delivery**: Chunks are sent in order with increasing `chunk_index`
812
+ 3. **Client Assembly**: Client collects all chunks and verifies integrity using hashes
813
+ 4. **Hash Verification**: Both individual chunk hashes and final content hash are verified
814
+ 5. **Error Handling**: Missing chunks or hash mismatches trigger request failure
815
+
816
+ **Example Non-Chunked Response:**
817
+ ```json
818
+ {
819
+ "event": "content_response",
820
+ "request_id": "req_abc123",
821
+ "content_hash": "sha256:...",
822
+ "content": "Small content here",
823
+ "success": true,
824
+ "chunked": false
825
+ }
826
+ ```
827
+
828
+ **Example Chunked Response (first chunk):**
829
+ ```json
830
+ {
831
+ "event": "content_response",
832
+ "request_id": "req_abc123",
833
+ "content_hash": "sha256:...",
834
+ "content": "First chunk content...",
835
+ "success": true,
836
+ "chunked": true,
837
+ "transfer_id": "transfer_xyz789",
838
+ "chunk_index": 0,
839
+ "chunk_count": 5,
840
+ "chunk_size": 65536,
841
+ "total_size": 300000,
842
+ "chunk_hash": "chunk_sha256:...",
843
+ "is_final_chunk": false
844
+ }
845
+ ```
846
+
716
847
  ### Project State Events
717
848
 
718
849
  ### <a name="project_state_initialized"></a>`project_state_initialized`
@@ -752,13 +883,17 @@ Confirms that project state has been successfully initialized for a client sessi
752
883
  * `tab_type` (string, mandatory): Type of tab ("file", "diff", "untitled", "image", "audio", "video").
753
884
  * `title` (string, mandatory): Display title for the tab.
754
885
  * `file_path` (string, optional): Path for file-based tabs.
755
- * `content` (string, optional): Text content or base64 for media.
756
- * `original_content` (string, optional): For diff tabs - original content.
757
- * `modified_content` (string, optional): For diff tabs - modified content.
886
+ * `content` (string, optional): Text content or base64 for media. When content caching is enabled, this field may be excluded from project state events if the content is available via `content_hash`.
887
+ * `original_content` (string, optional): For diff tabs - original content. When content caching is enabled, this field may be excluded from project state events if the content is available via `original_content_hash`.
888
+ * `modified_content` (string, optional): For diff tabs - modified content. When content caching is enabled, this field may be excluded from project state events if the content is available via `modified_content_hash`.
758
889
  * `is_dirty` (boolean, mandatory): Whether the tab has unsaved changes.
759
890
  * `mime_type` (string, optional): MIME type for media files.
760
891
  * `encoding` (string, optional): Content encoding (base64, utf-8, etc.).
761
- * `metadata` (object, optional): Additional metadata.
892
+ * `metadata` (object, optional): Additional metadata. When content caching is enabled, large metadata such as `html_diff_versions` may be excluded from project state events if available via `html_diff_hash`.
893
+ * `content_hash` (string, optional): SHA-256 hash of the tab content for content caching optimization. When present, the content can be retrieved via [`content_request`](#content_request) action.
894
+ * `original_content_hash` (string, optional): SHA-256 hash of the original content for diff tabs. When present, the original content can be retrieved via [`content_request`](#content_request) action.
895
+ * `modified_content_hash` (string, optional): SHA-256 hash of the modified content for diff tabs. When present, the modified content can be retrieved via [`content_request`](#content_request) action.
896
+ * `html_diff_hash` (string, optional): SHA-256 hash of the HTML diff versions JSON for diff tabs. When present, the HTML diff data can be retrieved via [`content_request`](#content_request) action as a JSON string.
762
897
  * `active_tab` (object, optional): The currently active tab object, or null if no tab is active.
763
898
  * `items` (array, mandatory): Flattened array of all visible file/folder items. Always includes root level items and one level down from the project root (since the project root is treated as expanded by default). Also includes items within explicitly expanded folders and one level down from each expanded folder. Each item object contains the following fields:
764
899
  * `name` (string, mandatory): The file or directory name.
@@ -872,36 +1007,73 @@ Confirms the result of opening a diff tab with git timeline references.
872
1007
  * `success` (boolean, mandatory): Whether the diff tab creation was successful.
873
1008
  * `error` (string, optional): Error message if the operation failed.
874
1009
 
1010
+ ### <a name="project_state_diff_content_response"></a>`project_state_diff_content_response`
1011
+
1012
+ Returns the requested content for a specific diff tab, sent in response to a [`project_state_diff_content_request`](#project_state_diff_content_request) action. For large content (>200KB), the response is automatically chunked into multiple messages to ensure reliable transmission over WebSocket connections.
1013
+
1014
+ **Event Fields:**
1015
+
1016
+ * `project_id` (string, mandatory): The project ID the operation was performed on.
1017
+ * `file_path` (string, mandatory): The path to the file the diff content is for.
1018
+ * `from_ref` (string, mandatory): The source reference point used in the diff.
1019
+ * `to_ref` (string, mandatory): The target reference point used in the diff.
1020
+ * `from_hash` (string, optional): The commit hash used for `from_ref` if it was `"commit"`.
1021
+ * `to_hash` (string, optional): The commit hash used for `to_ref` if it was `"commit"`.
1022
+ * `content_type` (string, mandatory): The type of content being returned (`"original"`, `"modified"`, `"html_diff"`, or `"all"`).
1023
+ * `request_id` (string, mandatory): The unique identifier from the request to match response with request.
1024
+ * `success` (boolean, mandatory): Whether the content retrieval was successful.
1025
+ * `content` (string, optional): The requested content or chunk content. For `html_diff` type, this is a JSON string containing the HTML diff versions object. For `all` type, this is a JSON string containing an object with `original_content`, `modified_content`, and `html_diff_versions` fields.
1026
+ * `error` (string, optional): Error message if the operation failed.
1027
+ * `chunked` (boolean, mandatory): Indicates whether this response is part of a chunked transfer. False for single responses, true for chunked responses.
1028
+
1029
+ **Chunked Transfer Fields (when `chunked` is true):**
1030
+
1031
+ * `transfer_id` (string, mandatory): Unique identifier for the chunked transfer session.
1032
+ * `chunk_index` (integer, mandatory): Zero-based index of this chunk in the sequence.
1033
+ * `chunk_count` (integer, mandatory): Total number of chunks in the transfer.
1034
+ * `chunk_size` (integer, mandatory): Size of this chunk in bytes.
1035
+ * `total_size` (integer, mandatory): Total size of the complete content in bytes.
1036
+ * `chunk_hash` (string, mandatory): SHA-256 hash of this chunk for verification.
1037
+ * `is_final_chunk` (boolean, mandatory): Indicates if this is the last chunk in the sequence.
1038
+
1039
+ **Note:** The chunked transfer process follows the same pattern as described in [`content_response`](#content_response), with content >200KB automatically split into 64KB chunks for reliable transmission.
1040
+
875
1041
  ### <a name="project_state_git_stage_response"></a>`project_state_git_stage_response`
876
1042
 
877
- Confirms the result of a git stage operation.
1043
+ Confirms the result of a git stage operation. Supports responses for both single file and bulk operations.
878
1044
 
879
1045
  **Event Fields:**
880
1046
 
881
1047
  * `project_id` (string, mandatory): The project ID the operation was performed on.
882
- * `file_path` (string, mandatory): The path to the file that was staged.
1048
+ * `file_path` (string, optional): The path to the file that was staged (for single file operations).
1049
+ * `file_paths` (array of strings, optional): Array of paths to files that were staged (for bulk operations).
1050
+ * `stage_all` (boolean, optional): Present if the operation was a "stage all" operation.
883
1051
  * `success` (boolean, mandatory): Whether the stage operation was successful.
884
1052
  * `error` (string, optional): Error message if the operation failed.
885
1053
 
886
1054
  ### <a name="project_state_git_unstage_response"></a>`project_state_git_unstage_response`
887
1055
 
888
- Confirms the result of a git unstage operation.
1056
+ Confirms the result of a git unstage operation. Supports responses for both single file and bulk operations.
889
1057
 
890
1058
  **Event Fields:**
891
1059
 
892
1060
  * `project_id` (string, mandatory): The project ID the operation was performed on.
893
- * `file_path` (string, mandatory): The path to the file that was unstaged.
1061
+ * `file_path` (string, optional): The path to the file that was unstaged (for single file operations).
1062
+ * `file_paths` (array of strings, optional): Array of paths to files that were unstaged (for bulk operations).
1063
+ * `unstage_all` (boolean, optional): Present if the operation was an "unstage all" operation.
894
1064
  * `success` (boolean, mandatory): Whether the unstage operation was successful.
895
1065
  * `error` (string, optional): Error message if the operation failed.
896
1066
 
897
1067
  ### <a name="project_state_git_revert_response"></a>`project_state_git_revert_response`
898
1068
 
899
- Confirms the result of a git revert operation.
1069
+ Confirms the result of a git revert operation. Supports responses for both single file and bulk operations.
900
1070
 
901
1071
  **Event Fields:**
902
1072
 
903
1073
  * `project_id` (string, mandatory): The project ID the operation was performed on.
904
- * `file_path` (string, mandatory): The path to the file that was reverted.
1074
+ * `file_path` (string, optional): The path to the file that was reverted (for single file operations).
1075
+ * `file_paths` (array of strings, optional): Array of paths to files that were reverted (for bulk operations).
1076
+ * `revert_all` (boolean, optional): Present if the operation was a "revert all" operation.
905
1077
  * `success` (boolean, mandatory): Whether the revert operation was successful.
906
1078
  * `error` (string, optional): Error message if the operation failed.
907
1079
 
@@ -23,6 +23,7 @@ from .file_handlers import (
23
23
  FileCreateHandler,
24
24
  FolderCreateHandler,
25
25
  FileRenameHandler,
26
+ ContentRequestHandler,
26
27
  )
27
28
  from .project_state_handlers import (
28
29
  ProjectStateFolderExpandHandler,
@@ -31,6 +32,7 @@ from .project_state_handlers import (
31
32
  ProjectStateTabCloseHandler,
32
33
  ProjectStateSetActiveTabHandler,
33
34
  ProjectStateDiffOpenHandler,
35
+ ProjectStateDiffContentHandler,
34
36
  ProjectStateGitStageHandler,
35
37
  ProjectStateGitUnstageHandler,
36
38
  ProjectStateGitRevertHandler,
@@ -56,6 +58,7 @@ __all__ = [
56
58
  "FileCreateHandler",
57
59
  "FolderCreateHandler",
58
60
  "FileRenameHandler",
61
+ "ContentRequestHandler",
59
62
  # Project state handlers
60
63
  "ProjectStateFolderExpandHandler",
61
64
  "ProjectStateFolderCollapseHandler",
@@ -63,6 +66,7 @@ __all__ = [
63
66
  "ProjectStateTabCloseHandler",
64
67
  "ProjectStateSetActiveTabHandler",
65
68
  "ProjectStateDiffOpenHandler",
69
+ "ProjectStateDiffContentHandler",
66
70
  "ProjectStateGitStageHandler",
67
71
  "ProjectStateGitUnstageHandler",
68
72
  "ProjectStateGitRevertHandler",
@@ -114,11 +114,15 @@ class AsyncHandler(BaseHandler):
114
114
  response = await self.execute(message)
115
115
  logger.info("handler: Command %s executed successfully", self.command_name)
116
116
 
117
- # Extract project_id from response for session targeting
118
- project_id = response.get("project_id")
119
- logger.info("handler: %s response project_id=%s, response=%s",
120
- self.command_name, project_id, response)
121
- await self.send_response(response, reply_channel, project_id)
117
+ # Handle cases where execute() sends responses directly and returns None
118
+ if response is not None:
119
+ # Extract project_id from response for session targeting
120
+ project_id = response.get("project_id")
121
+ logger.info("handler: %s response project_id=%s, response=%s",
122
+ self.command_name, project_id, response)
123
+ await self.send_response(response, reply_channel, project_id)
124
+ else:
125
+ logger.info("handler: %s handled response transmission directly", self.command_name)
122
126
  except Exception as exc:
123
127
  logger.exception("handler: Error in async handler %s: %s", self.command_name, exc)
124
128
  # Extract project_id from original message for error targeting
@@ -0,0 +1,244 @@
1
+ """
2
+ Chunked content transfer utilities for handling large content over WebSocket.
3
+
4
+ This module provides functionality to split large content into chunks for reliable
5
+ transmission over WebSocket connections, and to reassemble chunks on the client side.
6
+ """
7
+
8
+ import hashlib
9
+ import uuid
10
+ from typing import Dict, Any, List, Optional
11
+ import logging
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+ # Maximum size for content before chunking (200KB)
16
+ MAX_CONTENT_SIZE = 200 * 1024 # 200KB
17
+
18
+ # Maximum chunk size (64KB per chunk for reliable WebSocket transmission)
19
+ CHUNK_SIZE = 64 * 1024 # 64KB
20
+
21
+
22
+ def should_chunk_content(content: str) -> bool:
23
+ """Determine if content should be chunked based on size."""
24
+ if content is None:
25
+ return False
26
+
27
+ content_bytes = content.encode('utf-8')
28
+ return len(content_bytes) > MAX_CONTENT_SIZE
29
+
30
+
31
+ def calculate_content_hash(content: str) -> str:
32
+ """Calculate SHA-256 hash of content for verification."""
33
+ if content is None:
34
+ return ""
35
+
36
+ content_bytes = content.encode('utf-8')
37
+ return hashlib.sha256(content_bytes).hexdigest()
38
+
39
+
40
+ def split_content_into_chunks(content: str, transfer_id: Optional[str] = None) -> List[Dict[str, Any]]:
41
+ """
42
+ Split content into chunks for transmission.
43
+
44
+ Args:
45
+ content: The content to split
46
+ transfer_id: Optional transfer ID, will generate one if not provided
47
+
48
+ Returns:
49
+ List of chunk dictionaries ready for transmission
50
+ """
51
+ if content is None:
52
+ return []
53
+
54
+ if transfer_id is None:
55
+ transfer_id = str(uuid.uuid4())
56
+
57
+ content_bytes = content.encode('utf-8')
58
+ total_size = len(content_bytes)
59
+ content_hash = hashlib.sha256(content_bytes).hexdigest()
60
+
61
+ chunks = []
62
+ chunk_index = 0
63
+ offset = 0
64
+
65
+ while offset < len(content_bytes):
66
+ chunk_data = content_bytes[offset:offset + CHUNK_SIZE]
67
+ chunk_content = chunk_data.decode('utf-8')
68
+ chunk_hash = hashlib.sha256(chunk_data).hexdigest()
69
+
70
+ chunks.append({
71
+ "transfer_id": transfer_id,
72
+ "chunk_index": chunk_index,
73
+ "chunk_count": (total_size + CHUNK_SIZE - 1) // CHUNK_SIZE, # Ceiling division
74
+ "chunk_size": len(chunk_data),
75
+ "total_size": total_size,
76
+ "content_hash": content_hash,
77
+ "chunk_hash": chunk_hash,
78
+ "chunk_content": chunk_content,
79
+ "is_final_chunk": offset + CHUNK_SIZE >= len(content_bytes)
80
+ })
81
+
82
+ chunk_index += 1
83
+ offset += CHUNK_SIZE
84
+
85
+ logger.info(f"Split content into {len(chunks)} chunks (total size: {total_size} bytes, transfer_id: {transfer_id})")
86
+ return chunks
87
+
88
+
89
+ def create_chunked_response(base_response: Dict[str, Any], content_field: str, content: str) -> List[Dict[str, Any]]:
90
+ """
91
+ Create chunked response messages from a base response and content.
92
+
93
+ Args:
94
+ base_response: The base response dictionary
95
+ content_field: The field name where content should be placed
96
+ content: The content to chunk
97
+
98
+ Returns:
99
+ List of response dictionaries with chunked content
100
+ """
101
+ if not should_chunk_content(content):
102
+ # Content is small enough, return as single response
103
+ response = base_response.copy()
104
+ response[content_field] = content
105
+ response["chunked"] = False
106
+ return [response]
107
+
108
+ # Content needs chunking
109
+ transfer_id = str(uuid.uuid4())
110
+ chunks = split_content_into_chunks(content, transfer_id)
111
+ responses = []
112
+
113
+ for chunk in chunks:
114
+ response = base_response.copy()
115
+ response["chunked"] = True
116
+ response["transfer_id"] = chunk["transfer_id"]
117
+ response["chunk_index"] = chunk["chunk_index"]
118
+ response["chunk_count"] = chunk["chunk_count"]
119
+ response["chunk_size"] = chunk["chunk_size"]
120
+ response["total_size"] = chunk["total_size"]
121
+ response["content_hash"] = chunk["content_hash"]
122
+ response["chunk_hash"] = chunk["chunk_hash"]
123
+ response["is_final_chunk"] = chunk["is_final_chunk"]
124
+ response[content_field] = chunk["chunk_content"]
125
+
126
+ responses.append(response)
127
+
128
+ logger.info(f"Created chunked response with {len(responses)} chunks for transfer_id: {transfer_id}")
129
+ return responses
130
+
131
+
132
+ class ChunkAssembler:
133
+ """
134
+ Helper class to assemble chunked content on the receiving side.
135
+ """
136
+
137
+ def __init__(self):
138
+ self.transfers: Dict[str, Dict[str, Any]] = {}
139
+
140
+ def add_chunk(self, chunk_data: Dict[str, Any], content_field: str) -> Optional[str]:
141
+ """
142
+ Add a chunk to the assembler.
143
+
144
+ Args:
145
+ chunk_data: The chunk data dictionary
146
+ content_field: The field name containing the chunk content
147
+
148
+ Returns:
149
+ Complete content if all chunks received, None if more chunks needed
150
+
151
+ Raises:
152
+ ValueError: If chunk data is invalid or verification fails
153
+ """
154
+ transfer_id = chunk_data.get("transfer_id")
155
+ chunk_index = chunk_data.get("chunk_index")
156
+ chunk_count = chunk_data.get("chunk_count")
157
+ chunk_size = chunk_data.get("chunk_size")
158
+ total_size = chunk_data.get("total_size")
159
+ content_hash = chunk_data.get("content_hash")
160
+ chunk_hash = chunk_data.get("chunk_hash")
161
+ chunk_content = chunk_data.get(content_field)
162
+ is_final_chunk = chunk_data.get("is_final_chunk")
163
+
164
+ if not all([transfer_id, chunk_index is not None, chunk_count, chunk_size,
165
+ total_size, content_hash, chunk_hash, chunk_content is not None]):
166
+ raise ValueError("Missing required chunk fields")
167
+
168
+ # Verify chunk content hash
169
+ chunk_bytes = chunk_content.encode('utf-8')
170
+ if len(chunk_bytes) != chunk_size:
171
+ raise ValueError(f"Chunk size mismatch: expected {chunk_size}, got {len(chunk_bytes)}")
172
+
173
+ calculated_chunk_hash = hashlib.sha256(chunk_bytes).hexdigest()
174
+ if calculated_chunk_hash != chunk_hash:
175
+ raise ValueError(f"Chunk hash mismatch: expected {chunk_hash}, got {calculated_chunk_hash}")
176
+
177
+ # Initialize transfer if not exists
178
+ if transfer_id not in self.transfers:
179
+ self.transfers[transfer_id] = {
180
+ "chunk_count": chunk_count,
181
+ "total_size": total_size,
182
+ "content_hash": content_hash,
183
+ "chunks": {},
184
+ "received_chunks": 0
185
+ }
186
+
187
+ transfer = self.transfers[transfer_id]
188
+
189
+ # Verify transfer metadata consistency
190
+ if (transfer["chunk_count"] != chunk_count or
191
+ transfer["total_size"] != total_size or
192
+ transfer["content_hash"] != content_hash):
193
+ raise ValueError("Transfer metadata mismatch")
194
+
195
+ # Store chunk if not already received
196
+ if chunk_index not in transfer["chunks"]:
197
+ transfer["chunks"][chunk_index] = chunk_content
198
+ transfer["received_chunks"] += 1
199
+
200
+ logger.debug(f"Received chunk {chunk_index + 1}/{chunk_count} for transfer {transfer_id}")
201
+
202
+ # Check if all chunks received
203
+ if transfer["received_chunks"] == chunk_count:
204
+ # Assemble content
205
+ assembled_content = ""
206
+ for i in range(chunk_count):
207
+ if i not in transfer["chunks"]:
208
+ raise ValueError(f"Missing chunk {i} for transfer {transfer_id}")
209
+ assembled_content += transfer["chunks"][i]
210
+
211
+ # Verify final content hash
212
+ assembled_bytes = assembled_content.encode('utf-8')
213
+ if len(assembled_bytes) != total_size:
214
+ raise ValueError(f"Final content size mismatch: expected {total_size}, got {len(assembled_bytes)}")
215
+
216
+ calculated_hash = hashlib.sha256(assembled_bytes).hexdigest()
217
+ if calculated_hash != content_hash:
218
+ raise ValueError(f"Final content hash mismatch: expected {content_hash}, got {calculated_hash}")
219
+
220
+ # Clean up transfer
221
+ del self.transfers[transfer_id]
222
+
223
+ logger.info(f"Successfully assembled content from {chunk_count} chunks (transfer_id: {transfer_id}, size: {total_size} bytes)")
224
+ return assembled_content
225
+
226
+ return None # More chunks needed
227
+
228
+ def cleanup_stale_transfers(self, max_age_seconds: int = 300):
229
+ """Clean up transfers older than max_age_seconds."""
230
+ import time
231
+ current_time = time.time()
232
+
233
+ stale_transfers = []
234
+ for transfer_id, transfer in self.transfers.items():
235
+ # Add timestamp if not exists
236
+ if "start_time" not in transfer:
237
+ transfer["start_time"] = current_time
238
+
239
+ if current_time - transfer["start_time"] > max_age_seconds:
240
+ stale_transfers.append(transfer_id)
241
+
242
+ for transfer_id in stale_transfers:
243
+ logger.warning(f"Cleaning up stale transfer: {transfer_id}")
244
+ del self.transfers[transfer_id]