portacode 0.3.17.dev1__tar.gz → 0.3.17.dev2__tar.gz

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 (45) hide show
  1. {portacode-0.3.17.dev1 → portacode-0.3.17.dev2}/.claude/settings.local.json +2 -1
  2. {portacode-0.3.17.dev1 → portacode-0.3.17.dev2}/PKG-INFO +1 -1
  3. {portacode-0.3.17.dev1 → portacode-0.3.17.dev2}/portacode/_version.py +2 -2
  4. {portacode-0.3.17.dev1 → portacode-0.3.17.dev2}/portacode/connection/handlers/WEBSOCKET_PROTOCOL.md +13 -2
  5. {portacode-0.3.17.dev1 → portacode-0.3.17.dev2}/portacode/connection/handlers/project_state_handlers.py +72 -15
  6. {portacode-0.3.17.dev1 → portacode-0.3.17.dev2}/portacode.egg-info/PKG-INFO +1 -1
  7. {portacode-0.3.17.dev1 → portacode-0.3.17.dev2}/.claude/agents/communication-manager.md +0 -0
  8. {portacode-0.3.17.dev1 → portacode-0.3.17.dev2}/.gitignore +0 -0
  9. {portacode-0.3.17.dev1 → portacode-0.3.17.dev2}/.gitmodules +0 -0
  10. {portacode-0.3.17.dev1 → portacode-0.3.17.dev2}/LICENSE +0 -0
  11. {portacode-0.3.17.dev1 → portacode-0.3.17.dev2}/MANIFEST.in +0 -0
  12. {portacode-0.3.17.dev1 → portacode-0.3.17.dev2}/Makefile +0 -0
  13. {portacode-0.3.17.dev1 → portacode-0.3.17.dev2}/README.md +0 -0
  14. {portacode-0.3.17.dev1 → portacode-0.3.17.dev2}/backup.sh +0 -0
  15. {portacode-0.3.17.dev1 → portacode-0.3.17.dev2}/docker-compose.yaml +0 -0
  16. {portacode-0.3.17.dev1 → portacode-0.3.17.dev2}/portacode/README.md +0 -0
  17. {portacode-0.3.17.dev1 → portacode-0.3.17.dev2}/portacode/__init__.py +0 -0
  18. {portacode-0.3.17.dev1 → portacode-0.3.17.dev2}/portacode/__main__.py +0 -0
  19. {portacode-0.3.17.dev1 → portacode-0.3.17.dev2}/portacode/cli.py +0 -0
  20. {portacode-0.3.17.dev1 → portacode-0.3.17.dev2}/portacode/connection/README.md +0 -0
  21. {portacode-0.3.17.dev1 → portacode-0.3.17.dev2}/portacode/connection/__init__.py +0 -0
  22. {portacode-0.3.17.dev1 → portacode-0.3.17.dev2}/portacode/connection/client.py +0 -0
  23. {portacode-0.3.17.dev1 → portacode-0.3.17.dev2}/portacode/connection/handlers/README.md +0 -0
  24. {portacode-0.3.17.dev1 → portacode-0.3.17.dev2}/portacode/connection/handlers/__init__.py +0 -0
  25. {portacode-0.3.17.dev1 → portacode-0.3.17.dev2}/portacode/connection/handlers/base.py +0 -0
  26. {portacode-0.3.17.dev1 → portacode-0.3.17.dev2}/portacode/connection/handlers/file_handlers.py +0 -0
  27. {portacode-0.3.17.dev1 → portacode-0.3.17.dev2}/portacode/connection/handlers/registry.py +0 -0
  28. {portacode-0.3.17.dev1 → portacode-0.3.17.dev2}/portacode/connection/handlers/session.py +0 -0
  29. {portacode-0.3.17.dev1 → portacode-0.3.17.dev2}/portacode/connection/handlers/system_handlers.py +0 -0
  30. {portacode-0.3.17.dev1 → portacode-0.3.17.dev2}/portacode/connection/handlers/tab_factory.py +0 -0
  31. {portacode-0.3.17.dev1 → portacode-0.3.17.dev2}/portacode/connection/handlers/terminal_handlers.py +0 -0
  32. {portacode-0.3.17.dev1 → portacode-0.3.17.dev2}/portacode/connection/multiplex.py +0 -0
  33. {portacode-0.3.17.dev1 → portacode-0.3.17.dev2}/portacode/connection/terminal.py +0 -0
  34. {portacode-0.3.17.dev1 → portacode-0.3.17.dev2}/portacode/data.py +0 -0
  35. {portacode-0.3.17.dev1 → portacode-0.3.17.dev2}/portacode/keypair.py +0 -0
  36. {portacode-0.3.17.dev1 → portacode-0.3.17.dev2}/portacode/service.py +0 -0
  37. {portacode-0.3.17.dev1 → portacode-0.3.17.dev2}/portacode.egg-info/SOURCES.txt +0 -0
  38. {portacode-0.3.17.dev1 → portacode-0.3.17.dev2}/portacode.egg-info/dependency_links.txt +0 -0
  39. {portacode-0.3.17.dev1 → portacode-0.3.17.dev2}/portacode.egg-info/entry_points.txt +0 -0
  40. {portacode-0.3.17.dev1 → portacode-0.3.17.dev2}/portacode.egg-info/requires.txt +0 -0
  41. {portacode-0.3.17.dev1 → portacode-0.3.17.dev2}/portacode.egg-info/top_level.txt +0 -0
  42. {portacode-0.3.17.dev1 → portacode-0.3.17.dev2}/pyproject.toml +0 -0
  43. {portacode-0.3.17.dev1 → portacode-0.3.17.dev2}/restore.sh +0 -0
  44. {portacode-0.3.17.dev1 → portacode-0.3.17.dev2}/setup.cfg +0 -0
  45. {portacode-0.3.17.dev1 → portacode-0.3.17.dev2}/setup.py +0 -0
@@ -7,7 +7,8 @@
7
7
  "Bash(python:*)",
8
8
  "Bash(find:*)",
9
9
  "Bash(touch:*)",
10
- "Bash(npm run lint)"
10
+ "Bash(npm run lint)",
11
+ "Bash(node:*)"
11
12
  ],
12
13
  "deny": []
13
14
  }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: portacode
3
- Version: 0.3.17.dev1
3
+ Version: 0.3.17.dev2
4
4
  Summary: Portacode CLI client and SDK
5
5
  Home-page: https://github.com/portacode/portacode
6
6
  Author: Meena Erian
@@ -17,5 +17,5 @@ __version__: str
17
17
  __version_tuple__: VERSION_TUPLE
18
18
  version_tuple: VERSION_TUPLE
19
19
 
20
- __version__ = version = '0.3.17.dev1'
21
- __version_tuple__ = version_tuple = (0, 3, 17, 'dev1')
20
+ __version__ = version = '0.3.17.dev2'
21
+ __version_tuple__ = version_tuple = (0, 3, 17, 'dev2')
@@ -247,6 +247,13 @@ Project state actions manage the state of project folders, including file struct
247
247
 
248
248
  **Note:** Project state is automatically initialized when a client session connects with a `project_folder_path` property. No manual initialization command is required.
249
249
 
250
+ **Tab Management:** Open tabs are internally managed using a dictionary structure with unique keys to prevent duplicates and race conditions:
251
+ - File tabs use `file_path` as the unique key
252
+ - Diff tabs use a composite key: `diff:{file_path}:{from_ref}:{to_ref}:{from_hash}:{to_hash}`
253
+ - Untitled tabs use their `tab_id` as the unique key
254
+
255
+ This ensures that sending the same command multiple times (e.g., `project_state_diff_open` with identical parameters) will not create duplicate tabs but will instead activate the existing tab.
256
+
250
257
  ### `project_state_folder_expand`
251
258
 
252
259
  Expands a folder in the project tree, loading its contents and enabling monitoring for that folder level. When a folder is expanded, the system proactively loads one level down for all subdirectories to enable immediate expansion in the UI. This action also scans items in the expanded folder and preloads content for any non-empty subdirectories.
@@ -279,6 +286,8 @@ Collapses a folder in the project tree, stopping monitoring for that folder leve
279
286
 
280
287
  Marks a file as open in the project state, tracking it as part of the current editing session.
281
288
 
289
+ **Duplicate Prevention:** This action prevents creating duplicate file tabs by using the `file_path` as a unique key. If a file tab with the same path already exists, it will be activated instead of creating a new one.
290
+
282
291
  **Payload Fields:**
283
292
 
284
293
  * `project_id` (string, mandatory): The project ID from the initialized project state.
@@ -322,6 +331,8 @@ Sets the currently active tab in the project state. Only one tab can be active a
322
331
 
323
332
  Opens a diff tab for comparing file versions at different points in the git timeline. This replaces the previous `project_state_create_diff_tab` action with a more efficient approach that doesn't require the client to provide file content, instead using git timeline references.
324
333
 
334
+ **Duplicate Prevention:** This action prevents creating duplicate diff tabs by using a unique key based on `file_path`, `from_ref`, `to_ref`, `from_hash`, and `to_hash`. If a diff tab with the same parameters already exists, it will be activated instead of creating a new one.
335
+
325
336
  **Payload Fields:**
326
337
 
327
338
  * `project_id` (string, mandatory): The project ID from the initialized project state.
@@ -578,7 +589,7 @@ Confirms that project state has been successfully initialized for a client sessi
578
589
  * `is_staged` (boolean): Always true for staged changes.
579
590
  * `unstaged_changes` (array, optional): Array of unstaged file changes with same structure as staged_changes but `is_staged` is always false.
580
591
  * `untracked_files` (array, optional): Array of untracked files with same structure as staged_changes but `is_staged` is always false and `change_type` is always 'untracked'.
581
- * `open_tabs` (array, mandatory): Array of tab objects currently open. Each tab object contains:
592
+ * `open_tabs` (array, mandatory): Array of tab objects currently open. Internally stored as a dictionary with unique keys to prevent duplicates, but serialized as an array for API responses. Each tab object contains:
582
593
  * `tab_id` (string, mandatory): Unique identifier for the tab.
583
594
  * `tab_type` (string, mandatory): Type of tab ("file", "diff", "untitled", "image", "audio", "video").
584
595
  * `title` (string, mandatory): Display title for the tab.
@@ -619,7 +630,7 @@ Sent automatically when project state changes due to file system modifications,
619
630
  * `git_branch` (string, optional): The current Git branch name if available.
620
631
  * `git_status_summary` (object, optional): Updated summary of Git status counts.
621
632
  * `git_detailed_status` (object, optional): Updated detailed Git status with comprehensive file change information and content hashes (same structure as in `project_state_initialized`).
622
- * `open_tabs` (array, mandatory): Updated array of tab objects currently open.
633
+ * `open_tabs` (array, mandatory): Updated array of tab objects currently open. Internally stored as a dictionary with unique keys to prevent duplicates, but serialized as an array for API responses.
623
634
  * `active_tab` (object, optional): Updated active tab object.
624
635
  * `items` (array, mandatory): Updated 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:
625
636
  * `name` (string, mandatory): The file or directory name.
@@ -118,12 +118,12 @@ class ProjectState:
118
118
  git_branch: Optional[str] = None
119
119
  git_status_summary: Optional[Dict[str, int]] = None # Kept for backward compatibility
120
120
  git_detailed_status: Optional[GitDetailedStatus] = None # New detailed git state
121
- open_tabs: List['TabInfo'] = None
121
+ open_tabs: Dict[str, 'TabInfo'] = None # Changed from List to Dict with unique keys
122
122
  active_tab: Optional['TabInfo'] = None
123
123
 
124
124
  def __post_init__(self):
125
125
  if self.open_tabs is None:
126
- self.open_tabs = []
126
+ self.open_tabs = {}
127
127
  if self.monitored_folders is None:
128
128
  self.monitored_folders = []
129
129
 
@@ -580,7 +580,7 @@ class ProjectStateManager:
580
580
  "git_branch": state.git_branch,
581
581
  "git_status_summary": state.git_status_summary,
582
582
  "git_detailed_status": asdict(state.git_detailed_status) if state.git_detailed_status else None,
583
- "open_tabs": [self._serialize_tab_info(tab) for tab in state.open_tabs],
583
+ "open_tabs": [self._serialize_tab_info(tab) for tab in state.open_tabs.values()],
584
584
  "active_tab": self._serialize_tab_info(state.active_tab) if state.active_tab else None,
585
585
  "monitored_folders": [asdict(mf) for mf in state.monitored_folders],
586
586
  "items": [self._serialize_file_item(item) for item in state.items]
@@ -953,9 +953,12 @@ class ProjectStateManager:
953
953
 
954
954
  project_state = self.projects[client_session_key]
955
955
 
956
+ # Generate unique key for file tab
957
+ tab_key = generate_tab_key('file', file_path)
958
+
956
959
  # Check if file is already open
957
- existing_tab = next((tab for tab in project_state.open_tabs if tab.file_path == file_path and tab.tab_type == 'file'), None)
958
- if existing_tab:
960
+ if tab_key in project_state.open_tabs:
961
+ existing_tab = project_state.open_tabs[tab_key]
959
962
  if set_active:
960
963
  project_state.active_tab = existing_tab
961
964
  self._write_debug_state()
@@ -967,7 +970,7 @@ class ProjectStateManager:
967
970
 
968
971
  try:
969
972
  new_tab = await tab_factory.create_file_tab(file_path)
970
- project_state.open_tabs.append(new_tab)
973
+ project_state.open_tabs[tab_key] = new_tab
971
974
  if set_active:
972
975
  project_state.active_tab = new_tab
973
976
 
@@ -985,17 +988,25 @@ class ProjectStateManager:
985
988
 
986
989
  project_state = self.projects[client_session_key]
987
990
 
988
- # Find and remove the tab
989
- tab_to_remove = next((tab for tab in project_state.open_tabs if tab.tab_id == tab_id), None)
991
+ # Find and remove the tab by searching through the dictionary values
992
+ tab_key_to_remove = None
993
+ tab_to_remove = None
994
+ for key, tab in project_state.open_tabs.items():
995
+ if tab.tab_id == tab_id:
996
+ tab_key_to_remove = key
997
+ tab_to_remove = tab
998
+ break
999
+
990
1000
  if not tab_to_remove:
991
1001
  return False
992
1002
 
993
- project_state.open_tabs.remove(tab_to_remove)
1003
+ del project_state.open_tabs[tab_key_to_remove]
994
1004
 
995
1005
  # Clear active tab if it was the closed tab
996
1006
  if project_state.active_tab and project_state.active_tab.tab_id == tab_id:
997
1007
  # Set active tab to the last remaining tab, or None if no tabs left
998
- project_state.active_tab = project_state.open_tabs[-1] if project_state.open_tabs else None
1008
+ remaining_tabs = list(project_state.open_tabs.values())
1009
+ project_state.active_tab = remaining_tabs[-1] if remaining_tabs else None
999
1010
 
1000
1011
  self._write_debug_state()
1001
1012
  return True
@@ -1008,8 +1019,12 @@ class ProjectStateManager:
1008
1019
  project_state = self.projects[client_session_key]
1009
1020
 
1010
1021
  if tab_id:
1011
- # Find the tab by ID
1012
- tab = next((t for t in project_state.open_tabs if t.tab_id == tab_id), None)
1022
+ # Find the tab by ID in the dictionary values
1023
+ tab = None
1024
+ for t in project_state.open_tabs.values():
1025
+ if t.tab_id == tab_id:
1026
+ tab = t
1027
+ break
1013
1028
  if not tab:
1014
1029
  return False
1015
1030
  project_state.active_tab = tab
@@ -1033,6 +1048,19 @@ class ProjectStateManager:
1033
1048
  logger.error("Cannot create diff tab: not a git repository")
1034
1049
  return False
1035
1050
 
1051
+ # Generate unique key for diff tab
1052
+ tab_key = generate_tab_key('diff', file_path,
1053
+ from_ref=from_ref, to_ref=to_ref,
1054
+ from_hash=from_hash, to_hash=to_hash)
1055
+
1056
+ # Check if this diff tab is already open
1057
+ if tab_key in project_state.open_tabs:
1058
+ existing_tab = project_state.open_tabs[tab_key]
1059
+ project_state.active_tab = existing_tab
1060
+ logger.info(f"Diff tab already exists, activating: {tab_key}")
1061
+ self._write_debug_state()
1062
+ return True
1063
+
1036
1064
  try:
1037
1065
  # Get content based on the reference type
1038
1066
  original_content = ""
@@ -1103,7 +1131,7 @@ class ProjectStateManager:
1103
1131
  'diff_timeline': True
1104
1132
  })
1105
1133
 
1106
- project_state.open_tabs.append(diff_tab)
1134
+ project_state.open_tabs[tab_key] = diff_tab
1107
1135
  project_state.active_tab = diff_tab
1108
1136
 
1109
1137
  logger.info(f"Created timeline diff tab for: {file_path} ({from_ref} → {to_ref})")
@@ -1197,7 +1225,7 @@ class ProjectStateManager:
1197
1225
  "git_branch": project_state.git_branch,
1198
1226
  "git_status_summary": project_state.git_status_summary,
1199
1227
  "git_detailed_status": str(project_state.git_detailed_status) if project_state.git_detailed_status else None,
1200
- "open_tabs": tuple((tab.tab_id, tab.tab_type, tab.title) for tab in project_state.open_tabs),
1228
+ "open_tabs": tuple((tab.tab_id, tab.tab_type, tab.title) for tab in project_state.open_tabs.values()),
1201
1229
  "active_tab": project_state.active_tab.tab_id if project_state.active_tab else None,
1202
1230
  "items_count": len(project_state.items),
1203
1231
  "monitored_folders": tuple((mf.folder_path, mf.is_expanded) for mf in sorted(project_state.monitored_folders, key=lambda x: x.folder_path))
@@ -1221,7 +1249,7 @@ class ProjectStateManager:
1221
1249
  "git_branch": project_state.git_branch,
1222
1250
  "git_status_summary": project_state.git_status_summary,
1223
1251
  "git_detailed_status": asdict(project_state.git_detailed_status) if project_state.git_detailed_status else None,
1224
- "open_tabs": [self._serialize_tab_info(tab) for tab in project_state.open_tabs],
1252
+ "open_tabs": [self._serialize_tab_info(tab) for tab in project_state.open_tabs.values()],
1225
1253
  "active_tab": self._serialize_tab_info(project_state.active_tab) if project_state.active_tab else None,
1226
1254
  "items": [self._serialize_file_item(item) for item in project_state.items],
1227
1255
  "timestamp": time.time(),
@@ -1274,6 +1302,35 @@ class ProjectStateManager:
1274
1302
  logger.info("Cleaned up %d project states", len(keys_to_remove))
1275
1303
 
1276
1304
 
1305
+ def generate_tab_key(tab_type: str, file_path: str, **kwargs) -> str:
1306
+ """Generate a unique key for a tab.
1307
+
1308
+ Args:
1309
+ tab_type: Type of tab ('file', 'diff', 'untitled', etc.)
1310
+ file_path: Path to the file
1311
+ **kwargs: Additional parameters for diff tabs (from_ref, to_ref, from_hash, to_hash)
1312
+
1313
+ Returns:
1314
+ Unique string key for the tab
1315
+ """
1316
+ import uuid
1317
+
1318
+ if tab_type == 'file':
1319
+ return file_path
1320
+ elif tab_type == 'diff':
1321
+ from_ref = kwargs.get('from_ref', '')
1322
+ to_ref = kwargs.get('to_ref', '')
1323
+ from_hash = kwargs.get('from_hash', '')
1324
+ to_hash = kwargs.get('to_hash', '')
1325
+ return f"diff:{file_path}:{from_ref}:{to_ref}:{from_hash}:{to_hash}"
1326
+ elif tab_type == 'untitled':
1327
+ # For untitled tabs, use the tab_id as the key since they don't have a file path
1328
+ return kwargs.get('tab_id', str(uuid.uuid4()))
1329
+ else:
1330
+ # For other tab types, use file_path if available, otherwise tab_id
1331
+ return file_path if file_path else kwargs.get('tab_id', str(uuid.uuid4()))
1332
+
1333
+
1277
1334
  # Helper function for other handlers to get/create project state manager
1278
1335
  def _get_or_create_project_state_manager(context: Dict[str, Any], control_channel) -> 'ProjectStateManager':
1279
1336
  """Get or create project state manager with debug setup."""
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: portacode
3
- Version: 0.3.17.dev1
3
+ Version: 0.3.17.dev2
4
4
  Summary: Portacode CLI client and SDK
5
5
  Home-page: https://github.com/portacode/portacode
6
6
  Author: Meena Erian
File without changes