portacode 0.3.17.dev0__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.dev0 → portacode-0.3.17.dev2}/.claude/settings.local.json +2 -1
  2. {portacode-0.3.17.dev0 → portacode-0.3.17.dev2}/PKG-INFO +1 -1
  3. {portacode-0.3.17.dev0 → portacode-0.3.17.dev2}/portacode/_version.py +2 -2
  4. {portacode-0.3.17.dev0 → portacode-0.3.17.dev2}/portacode/connection/handlers/WEBSOCKET_PROTOCOL.md +13 -2
  5. {portacode-0.3.17.dev0 → portacode-0.3.17.dev2}/portacode/connection/handlers/project_state_handlers.py +85 -25
  6. {portacode-0.3.17.dev0 → portacode-0.3.17.dev2}/portacode.egg-info/PKG-INFO +1 -1
  7. {portacode-0.3.17.dev0 → portacode-0.3.17.dev2}/.claude/agents/communication-manager.md +0 -0
  8. {portacode-0.3.17.dev0 → portacode-0.3.17.dev2}/.gitignore +0 -0
  9. {portacode-0.3.17.dev0 → portacode-0.3.17.dev2}/.gitmodules +0 -0
  10. {portacode-0.3.17.dev0 → portacode-0.3.17.dev2}/LICENSE +0 -0
  11. {portacode-0.3.17.dev0 → portacode-0.3.17.dev2}/MANIFEST.in +0 -0
  12. {portacode-0.3.17.dev0 → portacode-0.3.17.dev2}/Makefile +0 -0
  13. {portacode-0.3.17.dev0 → portacode-0.3.17.dev2}/README.md +0 -0
  14. {portacode-0.3.17.dev0 → portacode-0.3.17.dev2}/backup.sh +0 -0
  15. {portacode-0.3.17.dev0 → portacode-0.3.17.dev2}/docker-compose.yaml +0 -0
  16. {portacode-0.3.17.dev0 → portacode-0.3.17.dev2}/portacode/README.md +0 -0
  17. {portacode-0.3.17.dev0 → portacode-0.3.17.dev2}/portacode/__init__.py +0 -0
  18. {portacode-0.3.17.dev0 → portacode-0.3.17.dev2}/portacode/__main__.py +0 -0
  19. {portacode-0.3.17.dev0 → portacode-0.3.17.dev2}/portacode/cli.py +0 -0
  20. {portacode-0.3.17.dev0 → portacode-0.3.17.dev2}/portacode/connection/README.md +0 -0
  21. {portacode-0.3.17.dev0 → portacode-0.3.17.dev2}/portacode/connection/__init__.py +0 -0
  22. {portacode-0.3.17.dev0 → portacode-0.3.17.dev2}/portacode/connection/client.py +0 -0
  23. {portacode-0.3.17.dev0 → portacode-0.3.17.dev2}/portacode/connection/handlers/README.md +0 -0
  24. {portacode-0.3.17.dev0 → portacode-0.3.17.dev2}/portacode/connection/handlers/__init__.py +0 -0
  25. {portacode-0.3.17.dev0 → portacode-0.3.17.dev2}/portacode/connection/handlers/base.py +0 -0
  26. {portacode-0.3.17.dev0 → portacode-0.3.17.dev2}/portacode/connection/handlers/file_handlers.py +0 -0
  27. {portacode-0.3.17.dev0 → portacode-0.3.17.dev2}/portacode/connection/handlers/registry.py +0 -0
  28. {portacode-0.3.17.dev0 → portacode-0.3.17.dev2}/portacode/connection/handlers/session.py +0 -0
  29. {portacode-0.3.17.dev0 → portacode-0.3.17.dev2}/portacode/connection/handlers/system_handlers.py +0 -0
  30. {portacode-0.3.17.dev0 → portacode-0.3.17.dev2}/portacode/connection/handlers/tab_factory.py +0 -0
  31. {portacode-0.3.17.dev0 → portacode-0.3.17.dev2}/portacode/connection/handlers/terminal_handlers.py +0 -0
  32. {portacode-0.3.17.dev0 → portacode-0.3.17.dev2}/portacode/connection/multiplex.py +0 -0
  33. {portacode-0.3.17.dev0 → portacode-0.3.17.dev2}/portacode/connection/terminal.py +0 -0
  34. {portacode-0.3.17.dev0 → portacode-0.3.17.dev2}/portacode/data.py +0 -0
  35. {portacode-0.3.17.dev0 → portacode-0.3.17.dev2}/portacode/keypair.py +0 -0
  36. {portacode-0.3.17.dev0 → portacode-0.3.17.dev2}/portacode/service.py +0 -0
  37. {portacode-0.3.17.dev0 → portacode-0.3.17.dev2}/portacode.egg-info/SOURCES.txt +0 -0
  38. {portacode-0.3.17.dev0 → portacode-0.3.17.dev2}/portacode.egg-info/dependency_links.txt +0 -0
  39. {portacode-0.3.17.dev0 → portacode-0.3.17.dev2}/portacode.egg-info/entry_points.txt +0 -0
  40. {portacode-0.3.17.dev0 → portacode-0.3.17.dev2}/portacode.egg-info/requires.txt +0 -0
  41. {portacode-0.3.17.dev0 → portacode-0.3.17.dev2}/portacode.egg-info/top_level.txt +0 -0
  42. {portacode-0.3.17.dev0 → portacode-0.3.17.dev2}/pyproject.toml +0 -0
  43. {portacode-0.3.17.dev0 → portacode-0.3.17.dev2}/restore.sh +0 -0
  44. {portacode-0.3.17.dev0 → portacode-0.3.17.dev2}/setup.cfg +0 -0
  45. {portacode-0.3.17.dev0 → 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.dev0
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.dev0'
21
- __version_tuple__ = version_tuple = (0, 3, 17, 'dev0')
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
 
@@ -172,27 +172,30 @@ class GitManager:
172
172
  return {"is_tracked": False, "status": None, "is_ignored": False}
173
173
 
174
174
  try:
175
- rel_path = os.path.relpath(file_path, self.repo.working_dir).replace('\\', '/')
175
+ rel_path = os.path.relpath(file_path, self.repo.working_dir)
176
176
 
177
- # Check if ignored
177
+ # Check if ignored - GitPython handles path normalization internally
178
178
  is_ignored = self.repo.ignored(rel_path)
179
179
  if is_ignored:
180
180
  return {"is_tracked": False, "status": "ignored", "is_ignored": True}
181
181
 
182
182
  # For directories, only report status if they contain tracked or untracked files
183
183
  if os.path.isdir(file_path):
184
- # Check if directory contains any untracked files
185
- has_untracked = any(f.startswith(rel_path + '/') for f in self.repo.untracked_files)
184
+ # Check if directory contains any untracked files using path.startswith()
185
+ # This handles cross-platform path separators correctly
186
+ has_untracked = any(
187
+ os.path.commonpath([f, rel_path]) == rel_path and f != rel_path
188
+ for f in self.repo.untracked_files
189
+ )
186
190
  if has_untracked:
187
191
  return {"is_tracked": False, "status": "untracked", "is_ignored": False}
188
192
 
189
- # Check if directory is dirty (has tracked files with changes)
193
+ # Check if directory is dirty - GitPython handles path normalization
190
194
  if self.repo.is_dirty(path=rel_path):
191
195
  return {"is_tracked": True, "status": "modified", "is_ignored": False}
192
196
 
193
- # Directory exists in git if it's clean (no changes) but not empty in index
197
+ # Check if directory has tracked files - let GitPython handle paths
194
198
  try:
195
- # Simple check: if ls-files returns anything, directory has tracked content
196
199
  tracked_files = self.repo.git.ls_files(rel_path)
197
200
  is_tracked = bool(tracked_files.strip())
198
201
  status = "clean" if is_tracked else None
@@ -202,15 +205,15 @@ class GitManager:
202
205
 
203
206
  # For files
204
207
  else:
205
- # Check if untracked
208
+ # Check if untracked - direct comparison works cross-platform
206
209
  if rel_path in self.repo.untracked_files:
207
210
  return {"is_tracked": False, "status": "untracked", "is_ignored": False}
208
211
 
209
- # Check if tracked and dirty
212
+ # Check if tracked and dirty - GitPython handles path normalization
210
213
  if self.repo.is_dirty(path=rel_path):
211
214
  return {"is_tracked": True, "status": "modified", "is_ignored": False}
212
215
 
213
- # Check if tracked and clean
216
+ # Check if tracked and clean - GitPython handles paths
214
217
  try:
215
218
  self.repo.git.ls_files(rel_path, error_unmatch=True)
216
219
  return {"is_tracked": True, "status": "clean", "is_ignored": False}
@@ -577,7 +580,7 @@ class ProjectStateManager:
577
580
  "git_branch": state.git_branch,
578
581
  "git_status_summary": state.git_status_summary,
579
582
  "git_detailed_status": asdict(state.git_detailed_status) if state.git_detailed_status else None,
580
- "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()],
581
584
  "active_tab": self._serialize_tab_info(state.active_tab) if state.active_tab else None,
582
585
  "monitored_folders": [asdict(mf) for mf in state.monitored_folders],
583
586
  "items": [self._serialize_file_item(item) for item in state.items]
@@ -950,9 +953,12 @@ class ProjectStateManager:
950
953
 
951
954
  project_state = self.projects[client_session_key]
952
955
 
956
+ # Generate unique key for file tab
957
+ tab_key = generate_tab_key('file', file_path)
958
+
953
959
  # Check if file is already open
954
- existing_tab = next((tab for tab in project_state.open_tabs if tab.file_path == file_path and tab.tab_type == 'file'), None)
955
- if existing_tab:
960
+ if tab_key in project_state.open_tabs:
961
+ existing_tab = project_state.open_tabs[tab_key]
956
962
  if set_active:
957
963
  project_state.active_tab = existing_tab
958
964
  self._write_debug_state()
@@ -964,7 +970,7 @@ class ProjectStateManager:
964
970
 
965
971
  try:
966
972
  new_tab = await tab_factory.create_file_tab(file_path)
967
- project_state.open_tabs.append(new_tab)
973
+ project_state.open_tabs[tab_key] = new_tab
968
974
  if set_active:
969
975
  project_state.active_tab = new_tab
970
976
 
@@ -982,17 +988,25 @@ class ProjectStateManager:
982
988
 
983
989
  project_state = self.projects[client_session_key]
984
990
 
985
- # Find and remove the tab
986
- 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
+
987
1000
  if not tab_to_remove:
988
1001
  return False
989
1002
 
990
- project_state.open_tabs.remove(tab_to_remove)
1003
+ del project_state.open_tabs[tab_key_to_remove]
991
1004
 
992
1005
  # Clear active tab if it was the closed tab
993
1006
  if project_state.active_tab and project_state.active_tab.tab_id == tab_id:
994
1007
  # Set active tab to the last remaining tab, or None if no tabs left
995
- 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
996
1010
 
997
1011
  self._write_debug_state()
998
1012
  return True
@@ -1005,8 +1019,12 @@ class ProjectStateManager:
1005
1019
  project_state = self.projects[client_session_key]
1006
1020
 
1007
1021
  if tab_id:
1008
- # Find the tab by ID
1009
- 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
1010
1028
  if not tab:
1011
1029
  return False
1012
1030
  project_state.active_tab = tab
@@ -1030,6 +1048,19 @@ class ProjectStateManager:
1030
1048
  logger.error("Cannot create diff tab: not a git repository")
1031
1049
  return False
1032
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
+
1033
1064
  try:
1034
1065
  # Get content based on the reference type
1035
1066
  original_content = ""
@@ -1100,7 +1131,7 @@ class ProjectStateManager:
1100
1131
  'diff_timeline': True
1101
1132
  })
1102
1133
 
1103
- project_state.open_tabs.append(diff_tab)
1134
+ project_state.open_tabs[tab_key] = diff_tab
1104
1135
  project_state.active_tab = diff_tab
1105
1136
 
1106
1137
  logger.info(f"Created timeline diff tab for: {file_path} ({from_ref} → {to_ref})")
@@ -1194,7 +1225,7 @@ class ProjectStateManager:
1194
1225
  "git_branch": project_state.git_branch,
1195
1226
  "git_status_summary": project_state.git_status_summary,
1196
1227
  "git_detailed_status": str(project_state.git_detailed_status) if project_state.git_detailed_status else None,
1197
- "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()),
1198
1229
  "active_tab": project_state.active_tab.tab_id if project_state.active_tab else None,
1199
1230
  "items_count": len(project_state.items),
1200
1231
  "monitored_folders": tuple((mf.folder_path, mf.is_expanded) for mf in sorted(project_state.monitored_folders, key=lambda x: x.folder_path))
@@ -1218,7 +1249,7 @@ class ProjectStateManager:
1218
1249
  "git_branch": project_state.git_branch,
1219
1250
  "git_status_summary": project_state.git_status_summary,
1220
1251
  "git_detailed_status": asdict(project_state.git_detailed_status) if project_state.git_detailed_status else None,
1221
- "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()],
1222
1253
  "active_tab": self._serialize_tab_info(project_state.active_tab) if project_state.active_tab else None,
1223
1254
  "items": [self._serialize_file_item(item) for item in project_state.items],
1224
1255
  "timestamp": time.time(),
@@ -1271,6 +1302,35 @@ class ProjectStateManager:
1271
1302
  logger.info("Cleaned up %d project states", len(keys_to_remove))
1272
1303
 
1273
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
+
1274
1334
  # Helper function for other handlers to get/create project state manager
1275
1335
  def _get_or_create_project_state_manager(context: Dict[str, Any], control_channel) -> 'ProjectStateManager':
1276
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.dev0
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