portacode 0.3.23__py3-none-any.whl → 0.3.25__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.
- portacode/_version.py +2 -2
- portacode/connection/handlers/WEBSOCKET_PROTOCOL.md +39 -12
- portacode/connection/handlers/project_state/git_manager.py +322 -24
- portacode/connection/handlers/project_state/handlers.py +114 -30
- portacode/connection/handlers/session.py +28 -1
- portacode/connection/handlers/tab_factory.py +1 -1
- portacode/connection/terminal.py +13 -4
- {portacode-0.3.23.dist-info → portacode-0.3.25.dist-info}/METADATA +14 -3
- {portacode-0.3.23.dist-info → portacode-0.3.25.dist-info}/RECORD +13 -13
- {portacode-0.3.23.dist-info → portacode-0.3.25.dist-info}/WHEEL +1 -1
- {portacode-0.3.23.dist-info → portacode-0.3.25.dist-info}/entry_points.txt +0 -0
- {portacode-0.3.23.dist-info → portacode-0.3.25.dist-info/licenses}/LICENSE +0 -0
- {portacode-0.3.23.dist-info → portacode-0.3.25.dist-info}/top_level.txt +0 -0
portacode/_version.py
CHANGED
|
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
|
|
|
28
28
|
commit_id: COMMIT_ID
|
|
29
29
|
__commit_id__: COMMIT_ID
|
|
30
30
|
|
|
31
|
-
__version__ = version = '0.3.
|
|
32
|
-
__version_tuple__ = version_tuple = (0, 3,
|
|
31
|
+
__version__ = version = '0.3.25'
|
|
32
|
+
__version_tuple__ = version_tuple = (0, 3, 25)
|
|
33
33
|
|
|
34
34
|
__commit_id__ = commit_id = None
|
|
@@ -458,12 +458,19 @@ Requests the content for a specific diff tab identified by its diff parameters.
|
|
|
458
458
|
|
|
459
459
|
### `project_state_git_stage`
|
|
460
460
|
|
|
461
|
-
Stages
|
|
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).
|
|
462
462
|
|
|
463
463
|
**Payload Fields:**
|
|
464
464
|
|
|
465
465
|
* `project_id` (string, mandatory): The project ID from the initialized project state.
|
|
466
|
-
* `file_path` (string,
|
|
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
|
|
467
474
|
|
|
468
475
|
**Responses:**
|
|
469
476
|
|
|
@@ -472,12 +479,19 @@ Stages a file for commit in the project's git repository. Handled by [`project_s
|
|
|
472
479
|
|
|
473
480
|
### `project_state_git_unstage`
|
|
474
481
|
|
|
475
|
-
Unstages
|
|
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).
|
|
476
483
|
|
|
477
484
|
**Payload Fields:**
|
|
478
485
|
|
|
479
486
|
* `project_id` (string, mandatory): The project ID from the initialized project state.
|
|
480
|
-
* `file_path` (string,
|
|
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
|
|
481
495
|
|
|
482
496
|
**Responses:**
|
|
483
497
|
|
|
@@ -486,12 +500,19 @@ Unstages a file (removes from staging area) in the project's git repository. Han
|
|
|
486
500
|
|
|
487
501
|
### `project_state_git_revert`
|
|
488
502
|
|
|
489
|
-
Reverts
|
|
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).
|
|
490
504
|
|
|
491
505
|
**Payload Fields:**
|
|
492
506
|
|
|
493
507
|
* `project_id` (string, mandatory): The project ID from the initialized project state.
|
|
494
|
-
* `file_path` (string,
|
|
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
|
|
495
516
|
|
|
496
517
|
**Responses:**
|
|
497
518
|
|
|
@@ -1019,34 +1040,40 @@ Returns the requested content for a specific diff tab, sent in response to a [`p
|
|
|
1019
1040
|
|
|
1020
1041
|
### <a name="project_state_git_stage_response"></a>`project_state_git_stage_response`
|
|
1021
1042
|
|
|
1022
|
-
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.
|
|
1023
1044
|
|
|
1024
1045
|
**Event Fields:**
|
|
1025
1046
|
|
|
1026
1047
|
* `project_id` (string, mandatory): The project ID the operation was performed on.
|
|
1027
|
-
* `file_path` (string,
|
|
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.
|
|
1028
1051
|
* `success` (boolean, mandatory): Whether the stage operation was successful.
|
|
1029
1052
|
* `error` (string, optional): Error message if the operation failed.
|
|
1030
1053
|
|
|
1031
1054
|
### <a name="project_state_git_unstage_response"></a>`project_state_git_unstage_response`
|
|
1032
1055
|
|
|
1033
|
-
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.
|
|
1034
1057
|
|
|
1035
1058
|
**Event Fields:**
|
|
1036
1059
|
|
|
1037
1060
|
* `project_id` (string, mandatory): The project ID the operation was performed on.
|
|
1038
|
-
* `file_path` (string,
|
|
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.
|
|
1039
1064
|
* `success` (boolean, mandatory): Whether the unstage operation was successful.
|
|
1040
1065
|
* `error` (string, optional): Error message if the operation failed.
|
|
1041
1066
|
|
|
1042
1067
|
### <a name="project_state_git_revert_response"></a>`project_state_git_revert_response`
|
|
1043
1068
|
|
|
1044
|
-
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.
|
|
1045
1070
|
|
|
1046
1071
|
**Event Fields:**
|
|
1047
1072
|
|
|
1048
1073
|
* `project_id` (string, mandatory): The project ID the operation was performed on.
|
|
1049
|
-
* `file_path` (string,
|
|
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.
|
|
1050
1077
|
* `success` (boolean, mandatory): Whether the revert operation was successful.
|
|
1051
1078
|
* `error` (string, optional): Error message if the operation failed.
|
|
1052
1079
|
|
|
@@ -181,22 +181,71 @@ class GitManager:
|
|
|
181
181
|
if is_ignored:
|
|
182
182
|
return {"is_tracked": False, "status": "ignored", "is_ignored": True, "is_staged": False}
|
|
183
183
|
|
|
184
|
-
# For directories,
|
|
184
|
+
# For directories, aggregate status from contained files
|
|
185
185
|
if os.path.isdir(file_path):
|
|
186
|
-
#
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
186
|
+
# Normalize the relative path for cross-platform compatibility
|
|
187
|
+
rel_path_normalized = rel_path.replace('\\', '/')
|
|
188
|
+
|
|
189
|
+
# Check for untracked files in this directory
|
|
190
|
+
has_untracked = False
|
|
191
|
+
for untracked_file in self.repo.untracked_files:
|
|
192
|
+
untracked_normalized = untracked_file.replace('\\', '/')
|
|
193
|
+
if untracked_normalized.startswith(rel_path_normalized + '/') or untracked_normalized == rel_path_normalized:
|
|
194
|
+
has_untracked = True
|
|
195
|
+
break
|
|
196
|
+
|
|
197
|
+
# Check for modified files in this directory using git status
|
|
198
|
+
has_modified = False
|
|
199
|
+
has_deleted = False
|
|
200
|
+
try:
|
|
201
|
+
# Get status for files in this directory
|
|
202
|
+
status_output = self.repo.git.status(rel_path, porcelain=True)
|
|
203
|
+
if status_output.strip():
|
|
204
|
+
for line in status_output.strip().split('\n'):
|
|
205
|
+
if len(line) >= 2:
|
|
206
|
+
# When filtering git status by path, GitPython strips the leading space
|
|
207
|
+
# So format is either "XY filename" or " XY filename"
|
|
208
|
+
if line.startswith(' '):
|
|
209
|
+
# Full status format: " XY filename"
|
|
210
|
+
index_status = line[0]
|
|
211
|
+
worktree_status = line[1]
|
|
212
|
+
file_path_from_status = line[3:] if len(line) > 3 else ""
|
|
213
|
+
else:
|
|
214
|
+
# Path-filtered format: "XY filename" (leading space stripped)
|
|
215
|
+
# Two possible formats:
|
|
216
|
+
# 1. Regular files: "M filename" (index + worktree + space + filename)
|
|
217
|
+
# 2. Submodules: "M filename" (index + space + filename)
|
|
218
|
+
index_status = line[0] if len(line) > 0 else ' '
|
|
219
|
+
worktree_status = line[1] if len(line) > 1 else ' '
|
|
220
|
+
|
|
221
|
+
# Detect format by checking if position 2 is a space
|
|
222
|
+
if len(line) > 2 and line[2] == ' ':
|
|
223
|
+
# Regular file format: "M filename"
|
|
224
|
+
file_path_from_status = line[3:] if len(line) > 3 else ""
|
|
225
|
+
else:
|
|
226
|
+
# Submodule format: "M filename"
|
|
227
|
+
file_path_from_status = line[2:] if len(line) > 2 else ""
|
|
228
|
+
|
|
229
|
+
# Check if this file is within our directory
|
|
230
|
+
file_normalized = file_path_from_status.replace('\\', '/')
|
|
231
|
+
if (file_normalized.startswith(rel_path_normalized + '/') or
|
|
232
|
+
file_normalized == rel_path_normalized):
|
|
233
|
+
if index_status in ['M', 'A', 'R', 'C'] or worktree_status in ['M', 'A', 'R', 'C']:
|
|
234
|
+
has_modified = True
|
|
235
|
+
elif index_status == 'D' or worktree_status == 'D':
|
|
236
|
+
has_deleted = True
|
|
237
|
+
except Exception as e:
|
|
238
|
+
logger.debug("Error checking directory git status for %s: %s", rel_path, e)
|
|
239
|
+
|
|
240
|
+
# Priority order: untracked > modified/deleted > clean
|
|
192
241
|
if has_untracked:
|
|
193
242
|
return {"is_tracked": False, "status": "untracked", "is_ignored": False, "is_staged": is_staged}
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
243
|
+
elif has_deleted:
|
|
244
|
+
return {"is_tracked": True, "status": "deleted", "is_ignored": False, "is_staged": is_staged}
|
|
245
|
+
elif has_modified:
|
|
197
246
|
return {"is_tracked": True, "status": "modified", "is_ignored": False, "is_staged": is_staged}
|
|
198
247
|
|
|
199
|
-
# Check if directory has tracked files
|
|
248
|
+
# Check if directory has tracked files to determine if it should show as clean
|
|
200
249
|
try:
|
|
201
250
|
tracked_files = self.repo.git.ls_files(rel_path)
|
|
202
251
|
is_tracked = bool(tracked_files.strip())
|
|
@@ -1134,6 +1183,41 @@ class GitManager:
|
|
|
1134
1183
|
logger.error("Error getting staged content for %s: %s", file_path, e)
|
|
1135
1184
|
return None
|
|
1136
1185
|
|
|
1186
|
+
def _is_submodule(self, file_path: str) -> bool:
|
|
1187
|
+
"""Check if the given path is a submodule."""
|
|
1188
|
+
if not self.is_git_repo or not self.repo:
|
|
1189
|
+
return False
|
|
1190
|
+
|
|
1191
|
+
try:
|
|
1192
|
+
# Convert to relative path from repo root
|
|
1193
|
+
rel_path = os.path.relpath(file_path, self.repo.working_dir)
|
|
1194
|
+
|
|
1195
|
+
# Check if this path is listed in .gitmodules
|
|
1196
|
+
gitmodules_path = os.path.join(self.repo.working_dir, '.gitmodules')
|
|
1197
|
+
if os.path.exists(gitmodules_path):
|
|
1198
|
+
try:
|
|
1199
|
+
with open(gitmodules_path, 'r') as f:
|
|
1200
|
+
content = f.read()
|
|
1201
|
+
# Simple check - look for path = rel_path in .gitmodules
|
|
1202
|
+
for line in content.splitlines():
|
|
1203
|
+
if line.strip().startswith('path ='):
|
|
1204
|
+
submodule_path = line.split('=', 1)[1].strip()
|
|
1205
|
+
if submodule_path == rel_path:
|
|
1206
|
+
return True
|
|
1207
|
+
except Exception as e:
|
|
1208
|
+
logger.warning("Error reading .gitmodules: %s", e)
|
|
1209
|
+
|
|
1210
|
+
# Alternative check: see if the path has a .git file (submodule indicator)
|
|
1211
|
+
git_path = os.path.join(file_path, '.git')
|
|
1212
|
+
if os.path.isfile(git_path): # Submodules have .git as a file, not directory
|
|
1213
|
+
return True
|
|
1214
|
+
|
|
1215
|
+
return False
|
|
1216
|
+
|
|
1217
|
+
except Exception as e:
|
|
1218
|
+
logger.warning("Error checking if %s is submodule: %s", file_path, e)
|
|
1219
|
+
return False
|
|
1220
|
+
|
|
1137
1221
|
def stage_file(self, file_path: str) -> bool:
|
|
1138
1222
|
"""Stage a file for commit."""
|
|
1139
1223
|
if not self.is_git_repo or not self.repo:
|
|
@@ -1143,8 +1227,15 @@ class GitManager:
|
|
|
1143
1227
|
# Convert to relative path from repo root
|
|
1144
1228
|
rel_path = os.path.relpath(file_path, self.repo.working_dir)
|
|
1145
1229
|
|
|
1146
|
-
#
|
|
1147
|
-
self.
|
|
1230
|
+
# Check if this is a submodule
|
|
1231
|
+
if self._is_submodule(file_path):
|
|
1232
|
+
logger.info("Detected submodule, using git add command directly: %s", rel_path)
|
|
1233
|
+
# For submodules, use git add directly to stage only the submodule reference
|
|
1234
|
+
self.repo.git.add(rel_path)
|
|
1235
|
+
else:
|
|
1236
|
+
# For regular files, use the index method
|
|
1237
|
+
self.repo.index.add([rel_path])
|
|
1238
|
+
|
|
1148
1239
|
logger.info("Successfully staged file: %s", rel_path)
|
|
1149
1240
|
return True
|
|
1150
1241
|
|
|
@@ -1161,19 +1252,25 @@ class GitManager:
|
|
|
1161
1252
|
# Convert to relative path from repo root
|
|
1162
1253
|
rel_path = os.path.relpath(file_path, self.repo.working_dir)
|
|
1163
1254
|
|
|
1164
|
-
# Check if
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
except Exception:
|
|
1169
|
-
has_head = False
|
|
1170
|
-
|
|
1171
|
-
if has_head:
|
|
1172
|
-
# Reset the file from HEAD (unstage) - for repos with commits
|
|
1255
|
+
# Check if this is a submodule
|
|
1256
|
+
if self._is_submodule(file_path):
|
|
1257
|
+
logger.info("Detected submodule, using git restore for unstaging: %s", rel_path)
|
|
1258
|
+
# For submodules, always use git restore --staged (works with submodules)
|
|
1173
1259
|
self.repo.git.restore('--staged', rel_path)
|
|
1174
1260
|
else:
|
|
1175
|
-
#
|
|
1176
|
-
|
|
1261
|
+
# Check if repository has any commits (HEAD exists)
|
|
1262
|
+
try:
|
|
1263
|
+
self.repo.head.commit
|
|
1264
|
+
has_head = True
|
|
1265
|
+
except Exception:
|
|
1266
|
+
has_head = False
|
|
1267
|
+
|
|
1268
|
+
if has_head:
|
|
1269
|
+
# Reset the file from HEAD (unstage) - for repos with commits
|
|
1270
|
+
self.repo.git.restore('--staged', rel_path)
|
|
1271
|
+
else:
|
|
1272
|
+
# For repositories with no commits, use git rm --cached to unstage
|
|
1273
|
+
self.repo.git.rm('--cached', rel_path)
|
|
1177
1274
|
|
|
1178
1275
|
logger.info("Successfully unstaged file: %s", rel_path)
|
|
1179
1276
|
return True
|
|
@@ -1217,6 +1314,207 @@ class GitManager:
|
|
|
1217
1314
|
logger.error("Error reverting file %s: %s", file_path, e)
|
|
1218
1315
|
raise RuntimeError(f"Failed to revert file: {e}")
|
|
1219
1316
|
|
|
1317
|
+
def stage_files(self, file_paths: List[str]) -> bool:
|
|
1318
|
+
"""Stage multiple files for commit in one atomic operation."""
|
|
1319
|
+
if not self.is_git_repo or not self.repo:
|
|
1320
|
+
raise RuntimeError("Not a git repository")
|
|
1321
|
+
|
|
1322
|
+
if not file_paths:
|
|
1323
|
+
logger.info("No files provided for staging")
|
|
1324
|
+
return True
|
|
1325
|
+
|
|
1326
|
+
try:
|
|
1327
|
+
# Convert all paths to relative paths from repo root
|
|
1328
|
+
rel_paths = []
|
|
1329
|
+
submodule_paths = []
|
|
1330
|
+
|
|
1331
|
+
for file_path in file_paths:
|
|
1332
|
+
rel_path = os.path.relpath(file_path, self.repo.working_dir)
|
|
1333
|
+
if self._is_submodule(file_path):
|
|
1334
|
+
submodule_paths.append(rel_path)
|
|
1335
|
+
else:
|
|
1336
|
+
rel_paths.append(rel_path)
|
|
1337
|
+
|
|
1338
|
+
# Stage submodules using git add directly
|
|
1339
|
+
if submodule_paths:
|
|
1340
|
+
logger.info("Staging submodules using git add directly: %s", submodule_paths)
|
|
1341
|
+
for submodule_path in submodule_paths:
|
|
1342
|
+
self.repo.git.add(submodule_path)
|
|
1343
|
+
|
|
1344
|
+
# Stage regular files using index.add for efficiency
|
|
1345
|
+
if rel_paths:
|
|
1346
|
+
logger.info("Staging regular files: %s", rel_paths)
|
|
1347
|
+
self.repo.index.add(rel_paths)
|
|
1348
|
+
|
|
1349
|
+
logger.info("Successfully staged %d files (%d submodules, %d regular)",
|
|
1350
|
+
len(file_paths), len(submodule_paths), len(rel_paths))
|
|
1351
|
+
return True
|
|
1352
|
+
|
|
1353
|
+
except Exception as e:
|
|
1354
|
+
logger.error("Error staging files %s: %s", file_paths, e)
|
|
1355
|
+
raise RuntimeError(f"Failed to stage files: {e}")
|
|
1356
|
+
|
|
1357
|
+
def unstage_files(self, file_paths: List[str]) -> bool:
|
|
1358
|
+
"""Unstage multiple files in one atomic operation."""
|
|
1359
|
+
if not self.is_git_repo or not self.repo:
|
|
1360
|
+
raise RuntimeError("Not a git repository")
|
|
1361
|
+
|
|
1362
|
+
if not file_paths:
|
|
1363
|
+
logger.info("No files provided for unstaging")
|
|
1364
|
+
return True
|
|
1365
|
+
|
|
1366
|
+
try:
|
|
1367
|
+
# Convert all paths to relative paths from repo root
|
|
1368
|
+
rel_paths = []
|
|
1369
|
+
submodule_paths = []
|
|
1370
|
+
|
|
1371
|
+
for file_path in file_paths:
|
|
1372
|
+
rel_path = os.path.relpath(file_path, self.repo.working_dir)
|
|
1373
|
+
if self._is_submodule(file_path):
|
|
1374
|
+
submodule_paths.append(rel_path)
|
|
1375
|
+
else:
|
|
1376
|
+
rel_paths.append(rel_path)
|
|
1377
|
+
|
|
1378
|
+
# Check if repository has any commits (HEAD exists)
|
|
1379
|
+
try:
|
|
1380
|
+
self.repo.head.commit
|
|
1381
|
+
has_head = True
|
|
1382
|
+
except Exception:
|
|
1383
|
+
has_head = False
|
|
1384
|
+
|
|
1385
|
+
# Unstage all files using appropriate method
|
|
1386
|
+
all_rel_paths = rel_paths + submodule_paths
|
|
1387
|
+
|
|
1388
|
+
if has_head:
|
|
1389
|
+
# Use git restore --staged for all files (works for both regular files and submodules)
|
|
1390
|
+
if all_rel_paths:
|
|
1391
|
+
self.repo.git.restore('--staged', *all_rel_paths)
|
|
1392
|
+
else:
|
|
1393
|
+
# For repositories with no commits, use git rm --cached
|
|
1394
|
+
if all_rel_paths:
|
|
1395
|
+
self.repo.git.rm('--cached', *all_rel_paths)
|
|
1396
|
+
|
|
1397
|
+
logger.info("Successfully unstaged %d files (%d submodules, %d regular)",
|
|
1398
|
+
len(file_paths), len(submodule_paths), len(rel_paths))
|
|
1399
|
+
return True
|
|
1400
|
+
|
|
1401
|
+
except Exception as e:
|
|
1402
|
+
logger.error("Error unstaging files %s: %s", file_paths, e)
|
|
1403
|
+
raise RuntimeError(f"Failed to unstage files: {e}")
|
|
1404
|
+
|
|
1405
|
+
def revert_files(self, file_paths: List[str]) -> bool:
|
|
1406
|
+
"""Revert multiple files to their HEAD version in one atomic operation."""
|
|
1407
|
+
if not self.is_git_repo or not self.repo:
|
|
1408
|
+
raise RuntimeError("Not a git repository")
|
|
1409
|
+
|
|
1410
|
+
if not file_paths:
|
|
1411
|
+
logger.info("No files provided for reverting")
|
|
1412
|
+
return True
|
|
1413
|
+
|
|
1414
|
+
try:
|
|
1415
|
+
# Check if repository has any commits (HEAD exists)
|
|
1416
|
+
try:
|
|
1417
|
+
self.repo.head.commit
|
|
1418
|
+
has_head = True
|
|
1419
|
+
except Exception:
|
|
1420
|
+
has_head = False
|
|
1421
|
+
|
|
1422
|
+
if has_head:
|
|
1423
|
+
# Convert to relative paths and restore all files at once
|
|
1424
|
+
rel_paths = [os.path.relpath(file_path, self.repo.working_dir) for file_path in file_paths]
|
|
1425
|
+
# Filter out submodules - we don't revert submodules as they don't have working directory changes
|
|
1426
|
+
regular_files = []
|
|
1427
|
+
for i, file_path in enumerate(file_paths):
|
|
1428
|
+
if not self._is_submodule(file_path):
|
|
1429
|
+
regular_files.append(rel_paths[i])
|
|
1430
|
+
|
|
1431
|
+
if regular_files:
|
|
1432
|
+
self.repo.git.restore(*regular_files)
|
|
1433
|
+
logger.info("Successfully reverted %d files", len(regular_files))
|
|
1434
|
+
else:
|
|
1435
|
+
# For repositories with no commits, remove files to "revert" them
|
|
1436
|
+
removed_count = 0
|
|
1437
|
+
for file_path in file_paths:
|
|
1438
|
+
if not self._is_submodule(file_path) and os.path.exists(file_path):
|
|
1439
|
+
os.remove(file_path)
|
|
1440
|
+
removed_count += 1
|
|
1441
|
+
logger.info("Successfully removed %d files (no HEAD to revert to)", removed_count)
|
|
1442
|
+
|
|
1443
|
+
return True
|
|
1444
|
+
|
|
1445
|
+
except Exception as e:
|
|
1446
|
+
logger.error("Error reverting files %s: %s", file_paths, e)
|
|
1447
|
+
raise RuntimeError(f"Failed to revert files: {e}")
|
|
1448
|
+
|
|
1449
|
+
def stage_all_changes(self) -> bool:
|
|
1450
|
+
"""Stage all changes (modified, deleted, untracked) in one atomic operation."""
|
|
1451
|
+
if not self.is_git_repo or not self.repo:
|
|
1452
|
+
raise RuntimeError("Not a git repository")
|
|
1453
|
+
|
|
1454
|
+
try:
|
|
1455
|
+
# Use git add . to stage everything - this is the most efficient way
|
|
1456
|
+
self.repo.git.add('.')
|
|
1457
|
+
logger.info("Successfully staged all changes using 'git add .'")
|
|
1458
|
+
return True
|
|
1459
|
+
|
|
1460
|
+
except Exception as e:
|
|
1461
|
+
logger.error("Error staging all changes: %s", e)
|
|
1462
|
+
raise RuntimeError(f"Failed to stage all changes: {e}")
|
|
1463
|
+
|
|
1464
|
+
def unstage_all_changes(self) -> bool:
|
|
1465
|
+
"""Unstage all staged changes in one atomic operation."""
|
|
1466
|
+
if not self.is_git_repo or not self.repo:
|
|
1467
|
+
raise RuntimeError("Not a git repository")
|
|
1468
|
+
|
|
1469
|
+
try:
|
|
1470
|
+
# Check if repository has any commits (HEAD exists)
|
|
1471
|
+
try:
|
|
1472
|
+
self.repo.head.commit
|
|
1473
|
+
has_head = True
|
|
1474
|
+
except Exception:
|
|
1475
|
+
has_head = False
|
|
1476
|
+
|
|
1477
|
+
if has_head:
|
|
1478
|
+
# Use git restore --staged . to unstage everything
|
|
1479
|
+
self.repo.git.restore('--staged', '.')
|
|
1480
|
+
else:
|
|
1481
|
+
# For repositories with no commits, remove everything from index
|
|
1482
|
+
self.repo.git.rm('--cached', '-r', '.')
|
|
1483
|
+
|
|
1484
|
+
logger.info("Successfully unstaged all changes")
|
|
1485
|
+
return True
|
|
1486
|
+
|
|
1487
|
+
except Exception as e:
|
|
1488
|
+
logger.error("Error unstaging all changes: %s", e)
|
|
1489
|
+
raise RuntimeError(f"Failed to unstage all changes: {e}")
|
|
1490
|
+
|
|
1491
|
+
def revert_all_changes(self) -> bool:
|
|
1492
|
+
"""Revert all working directory changes in one atomic operation."""
|
|
1493
|
+
if not self.is_git_repo or not self.repo:
|
|
1494
|
+
raise RuntimeError("Not a git repository")
|
|
1495
|
+
|
|
1496
|
+
try:
|
|
1497
|
+
# Check if repository has any commits (HEAD exists)
|
|
1498
|
+
try:
|
|
1499
|
+
self.repo.head.commit
|
|
1500
|
+
has_head = True
|
|
1501
|
+
except Exception:
|
|
1502
|
+
has_head = False
|
|
1503
|
+
|
|
1504
|
+
if has_head:
|
|
1505
|
+
# Use git restore . to revert all working directory changes
|
|
1506
|
+
self.repo.git.restore('.')
|
|
1507
|
+
logger.info("Successfully reverted all working directory changes")
|
|
1508
|
+
else:
|
|
1509
|
+
logger.warning("Cannot revert changes in repository with no commits")
|
|
1510
|
+
return False
|
|
1511
|
+
|
|
1512
|
+
return True
|
|
1513
|
+
|
|
1514
|
+
except Exception as e:
|
|
1515
|
+
logger.error("Error reverting all changes: %s", e)
|
|
1516
|
+
raise RuntimeError(f"Failed to revert all changes: {e}")
|
|
1517
|
+
|
|
1220
1518
|
def commit_changes(self, message: str) -> bool:
|
|
1221
1519
|
"""Commit staged changes with the given message."""
|
|
1222
1520
|
if not self.is_git_repo or not self.repo:
|
|
@@ -365,20 +365,33 @@ class ProjectStateGitStageHandler(AsyncHandler):
|
|
|
365
365
|
return "project_state_git_stage"
|
|
366
366
|
|
|
367
367
|
async def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
|
|
368
|
-
"""Stage
|
|
368
|
+
"""Stage file(s) in git for a project. Supports both single file and bulk operations."""
|
|
369
369
|
server_project_id = message.get("project_id")
|
|
370
|
-
file_path = message.get("file_path")
|
|
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
|
|
371
373
|
source_client_session = message.get("source_client_session")
|
|
372
374
|
|
|
373
375
|
if not server_project_id:
|
|
374
376
|
raise ValueError("project_id is required")
|
|
375
|
-
if not file_path:
|
|
376
|
-
raise ValueError("file_path is required")
|
|
377
377
|
if not source_client_session:
|
|
378
378
|
raise ValueError("source_client_session is required")
|
|
379
379
|
|
|
380
|
-
|
|
381
|
-
|
|
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)
|
|
382
395
|
|
|
383
396
|
# Get the project state manager
|
|
384
397
|
manager = get_or_create_project_state_manager(self.context, self.control_channel)
|
|
@@ -388,19 +401,34 @@ class ProjectStateGitStageHandler(AsyncHandler):
|
|
|
388
401
|
if not git_manager:
|
|
389
402
|
raise ValueError("No git repository found for this project")
|
|
390
403
|
|
|
391
|
-
#
|
|
392
|
-
|
|
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)
|
|
393
411
|
|
|
394
412
|
if success:
|
|
395
413
|
# Refresh entire project state to ensure consistency
|
|
396
414
|
await manager._refresh_project_state(source_client_session)
|
|
397
415
|
|
|
398
|
-
|
|
416
|
+
# Build response
|
|
417
|
+
response = {
|
|
399
418
|
"event": "project_state_git_stage_response",
|
|
400
419
|
"project_id": server_project_id,
|
|
401
|
-
"file_path": file_path,
|
|
402
420
|
"success": success
|
|
403
421
|
}
|
|
422
|
+
|
|
423
|
+
# Include appropriate file information in response for backward compatibility
|
|
424
|
+
if file_path:
|
|
425
|
+
response["file_path"] = file_path
|
|
426
|
+
if file_paths:
|
|
427
|
+
response["file_paths"] = file_paths
|
|
428
|
+
if stage_all:
|
|
429
|
+
response["stage_all"] = True
|
|
430
|
+
|
|
431
|
+
return response
|
|
404
432
|
|
|
405
433
|
|
|
406
434
|
class ProjectStateGitUnstageHandler(AsyncHandler):
|
|
@@ -411,20 +439,33 @@ class ProjectStateGitUnstageHandler(AsyncHandler):
|
|
|
411
439
|
return "project_state_git_unstage"
|
|
412
440
|
|
|
413
441
|
async def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
|
|
414
|
-
"""Unstage
|
|
442
|
+
"""Unstage file(s) in git for a project. Supports both single file and bulk operations."""
|
|
415
443
|
server_project_id = message.get("project_id")
|
|
416
|
-
file_path = message.get("file_path")
|
|
444
|
+
file_path = message.get("file_path") # Single file (backward compatibility)
|
|
445
|
+
file_paths = message.get("file_paths") # Multiple files (bulk operation)
|
|
446
|
+
unstage_all = message.get("unstage_all", False) # Unstage all changes
|
|
417
447
|
source_client_session = message.get("source_client_session")
|
|
418
448
|
|
|
419
449
|
if not server_project_id:
|
|
420
450
|
raise ValueError("project_id is required")
|
|
421
|
-
if not file_path:
|
|
422
|
-
raise ValueError("file_path is required")
|
|
423
451
|
if not source_client_session:
|
|
424
452
|
raise ValueError("source_client_session is required")
|
|
425
453
|
|
|
426
|
-
|
|
427
|
-
|
|
454
|
+
# Determine operation mode
|
|
455
|
+
if unstage_all:
|
|
456
|
+
operation_desc = "unstaging all changes"
|
|
457
|
+
file_paths_to_unstage = []
|
|
458
|
+
elif file_paths:
|
|
459
|
+
operation_desc = f"unstaging {len(file_paths)} files"
|
|
460
|
+
file_paths_to_unstage = file_paths
|
|
461
|
+
elif file_path:
|
|
462
|
+
operation_desc = f"unstaging file {file_path}"
|
|
463
|
+
file_paths_to_unstage = [file_path]
|
|
464
|
+
else:
|
|
465
|
+
raise ValueError("Either file_path, file_paths, or unstage_all must be provided")
|
|
466
|
+
|
|
467
|
+
logger.info("%s for project %s (client session: %s)",
|
|
468
|
+
operation_desc.capitalize(), server_project_id, source_client_session)
|
|
428
469
|
|
|
429
470
|
# Get the project state manager
|
|
430
471
|
manager = get_or_create_project_state_manager(self.context, self.control_channel)
|
|
@@ -434,19 +475,34 @@ class ProjectStateGitUnstageHandler(AsyncHandler):
|
|
|
434
475
|
if not git_manager:
|
|
435
476
|
raise ValueError("No git repository found for this project")
|
|
436
477
|
|
|
437
|
-
#
|
|
438
|
-
|
|
478
|
+
# Perform the unstaging operation
|
|
479
|
+
if unstage_all:
|
|
480
|
+
success = git_manager.unstage_all_changes()
|
|
481
|
+
elif len(file_paths_to_unstage) == 1:
|
|
482
|
+
success = git_manager.unstage_file(file_paths_to_unstage[0])
|
|
483
|
+
else:
|
|
484
|
+
success = git_manager.unstage_files(file_paths_to_unstage)
|
|
439
485
|
|
|
440
486
|
if success:
|
|
441
487
|
# Refresh entire project state to ensure consistency
|
|
442
488
|
await manager._refresh_project_state(source_client_session)
|
|
443
489
|
|
|
444
|
-
|
|
490
|
+
# Build response
|
|
491
|
+
response = {
|
|
445
492
|
"event": "project_state_git_unstage_response",
|
|
446
493
|
"project_id": server_project_id,
|
|
447
|
-
"file_path": file_path,
|
|
448
494
|
"success": success
|
|
449
495
|
}
|
|
496
|
+
|
|
497
|
+
# Include appropriate file information in response for backward compatibility
|
|
498
|
+
if file_path:
|
|
499
|
+
response["file_path"] = file_path
|
|
500
|
+
if file_paths:
|
|
501
|
+
response["file_paths"] = file_paths
|
|
502
|
+
if unstage_all:
|
|
503
|
+
response["unstage_all"] = True
|
|
504
|
+
|
|
505
|
+
return response
|
|
450
506
|
|
|
451
507
|
|
|
452
508
|
class ProjectStateGitRevertHandler(AsyncHandler):
|
|
@@ -457,20 +513,33 @@ class ProjectStateGitRevertHandler(AsyncHandler):
|
|
|
457
513
|
return "project_state_git_revert"
|
|
458
514
|
|
|
459
515
|
async def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
|
|
460
|
-
"""Revert
|
|
516
|
+
"""Revert file(s) in git for a project. Supports both single file and bulk operations."""
|
|
461
517
|
server_project_id = message.get("project_id")
|
|
462
|
-
file_path = message.get("file_path")
|
|
518
|
+
file_path = message.get("file_path") # Single file (backward compatibility)
|
|
519
|
+
file_paths = message.get("file_paths") # Multiple files (bulk operation)
|
|
520
|
+
revert_all = message.get("revert_all", False) # Revert all changes
|
|
463
521
|
source_client_session = message.get("source_client_session")
|
|
464
522
|
|
|
465
523
|
if not server_project_id:
|
|
466
524
|
raise ValueError("project_id is required")
|
|
467
|
-
if not file_path:
|
|
468
|
-
raise ValueError("file_path is required")
|
|
469
525
|
if not source_client_session:
|
|
470
526
|
raise ValueError("source_client_session is required")
|
|
471
527
|
|
|
472
|
-
|
|
473
|
-
|
|
528
|
+
# Determine operation mode
|
|
529
|
+
if revert_all:
|
|
530
|
+
operation_desc = "reverting all changes"
|
|
531
|
+
file_paths_to_revert = []
|
|
532
|
+
elif file_paths:
|
|
533
|
+
operation_desc = f"reverting {len(file_paths)} files"
|
|
534
|
+
file_paths_to_revert = file_paths
|
|
535
|
+
elif file_path:
|
|
536
|
+
operation_desc = f"reverting file {file_path}"
|
|
537
|
+
file_paths_to_revert = [file_path]
|
|
538
|
+
else:
|
|
539
|
+
raise ValueError("Either file_path, file_paths, or revert_all must be provided")
|
|
540
|
+
|
|
541
|
+
logger.info("%s for project %s (client session: %s)",
|
|
542
|
+
operation_desc.capitalize(), server_project_id, source_client_session)
|
|
474
543
|
|
|
475
544
|
# Get the project state manager
|
|
476
545
|
manager = get_or_create_project_state_manager(self.context, self.control_channel)
|
|
@@ -480,19 +549,34 @@ class ProjectStateGitRevertHandler(AsyncHandler):
|
|
|
480
549
|
if not git_manager:
|
|
481
550
|
raise ValueError("No git repository found for this project")
|
|
482
551
|
|
|
483
|
-
#
|
|
484
|
-
|
|
552
|
+
# Perform the revert operation
|
|
553
|
+
if revert_all:
|
|
554
|
+
success = git_manager.revert_all_changes()
|
|
555
|
+
elif len(file_paths_to_revert) == 1:
|
|
556
|
+
success = git_manager.revert_file(file_paths_to_revert[0])
|
|
557
|
+
else:
|
|
558
|
+
success = git_manager.revert_files(file_paths_to_revert)
|
|
485
559
|
|
|
486
560
|
if success:
|
|
487
561
|
# Refresh entire project state to ensure consistency
|
|
488
562
|
await manager._refresh_project_state(source_client_session)
|
|
489
563
|
|
|
490
|
-
|
|
564
|
+
# Build response
|
|
565
|
+
response = {
|
|
491
566
|
"event": "project_state_git_revert_response",
|
|
492
567
|
"project_id": server_project_id,
|
|
493
|
-
"file_path": file_path,
|
|
494
568
|
"success": success
|
|
495
569
|
}
|
|
570
|
+
|
|
571
|
+
# Include appropriate file information in response for backward compatibility
|
|
572
|
+
if file_path:
|
|
573
|
+
response["file_path"] = file_path
|
|
574
|
+
if file_paths:
|
|
575
|
+
response["file_paths"] = file_paths
|
|
576
|
+
if revert_all:
|
|
577
|
+
response["revert_all"] = True
|
|
578
|
+
|
|
579
|
+
return response
|
|
496
580
|
|
|
497
581
|
|
|
498
582
|
class ProjectStateGitCommitHandler(AsyncHandler):
|
|
@@ -161,6 +161,10 @@ class TerminalSession:
|
|
|
161
161
|
async def _send_terminal_data_now(self, data: str) -> None:
|
|
162
162
|
"""Send terminal data immediately and update last send time."""
|
|
163
163
|
self._last_send_time = time.time()
|
|
164
|
+
data_size = len(data.encode('utf-8'))
|
|
165
|
+
|
|
166
|
+
logger.info("session: Attempting to send terminal_data for terminal %s (data_size=%d bytes)",
|
|
167
|
+
self.id, data_size)
|
|
164
168
|
|
|
165
169
|
# Add to buffer for snapshots with size limiting
|
|
166
170
|
self._add_to_buffer(data)
|
|
@@ -174,18 +178,25 @@ class TerminalSession:
|
|
|
174
178
|
"data": data,
|
|
175
179
|
"project_id": self.project_id
|
|
176
180
|
}, project_id=self.project_id)
|
|
181
|
+
logger.info("session: Successfully queued terminal_data for terminal %s via terminal_manager", self.id)
|
|
177
182
|
else:
|
|
178
183
|
# Fallback to raw channel for backward compatibility
|
|
179
184
|
await self.channel.send(data)
|
|
185
|
+
logger.info("session: Successfully sent terminal_data for terminal %s via raw channel", self.id)
|
|
180
186
|
except Exception as exc:
|
|
181
|
-
logger.warning("Failed to forward terminal output: %s", exc)
|
|
187
|
+
logger.warning("session: Failed to forward terminal output for terminal %s: %s", self.id, exc)
|
|
182
188
|
|
|
183
189
|
async def _flush_pending_data(self) -> None:
|
|
184
190
|
"""Send accumulated pending data and reset pending buffer."""
|
|
185
191
|
if self._pending_data:
|
|
192
|
+
pending_size = len(self._pending_data.encode('utf-8'))
|
|
193
|
+
logger.info("session: Flushing pending terminal_data for terminal %s (pending_size=%d bytes)",
|
|
194
|
+
self.id, pending_size)
|
|
186
195
|
data_to_send = self._pending_data
|
|
187
196
|
self._pending_data = ""
|
|
188
197
|
await self._send_terminal_data_now(data_to_send)
|
|
198
|
+
else:
|
|
199
|
+
logger.debug("session: No pending data to flush for terminal %s", self.id)
|
|
189
200
|
|
|
190
201
|
# Clear the debounce task
|
|
191
202
|
self._debounce_task = None
|
|
@@ -194,6 +205,10 @@ class TerminalSession:
|
|
|
194
205
|
"""Handle new terminal data with rate limiting and debouncing."""
|
|
195
206
|
current_time = time.time()
|
|
196
207
|
time_since_last_send = (current_time - self._last_send_time) * 1000 # Convert to milliseconds
|
|
208
|
+
data_size = len(data.encode('utf-8'))
|
|
209
|
+
|
|
210
|
+
logger.info("session: Received terminal_data for terminal %s (data_size=%d bytes, time_since_last_send=%.1fms)",
|
|
211
|
+
self.id, data_size, time_since_last_send)
|
|
197
212
|
|
|
198
213
|
# Add new data to pending buffer with simple size limiting
|
|
199
214
|
# Always add the new data first
|
|
@@ -202,15 +217,19 @@ class TerminalSession:
|
|
|
202
217
|
# Simple size limiting - only trim if we exceed the 30KB limit significantly
|
|
203
218
|
pending_size = len(self._pending_data.encode('utf-8'))
|
|
204
219
|
if pending_size > TERMINAL_BUFFER_SIZE_LIMIT_BYTES:
|
|
220
|
+
logger.info("session: Buffer size limit exceeded for terminal %s (pending_size=%d bytes, limit=%d bytes), trimming",
|
|
221
|
+
self.id, pending_size, TERMINAL_BUFFER_SIZE_LIMIT_BYTES)
|
|
205
222
|
# Only do minimal ANSI-safe trimming from the beginning
|
|
206
223
|
excess_bytes = pending_size - TERMINAL_BUFFER_SIZE_LIMIT_BYTES
|
|
207
224
|
trim_pos = self._find_minimal_safe_trim_position(excess_bytes)
|
|
208
225
|
|
|
209
226
|
if trim_pos > 0:
|
|
210
227
|
self._pending_data = self._pending_data[trim_pos:]
|
|
228
|
+
logger.info("session: Trimmed %d bytes from pending buffer for terminal %s", trim_pos, self.id)
|
|
211
229
|
|
|
212
230
|
# Cancel existing debounce task if any
|
|
213
231
|
if self._debounce_task and not self._debounce_task.done():
|
|
232
|
+
logger.debug("session: Cancelling existing debounce task for terminal %s", self.id)
|
|
214
233
|
self._debounce_task.cancel()
|
|
215
234
|
|
|
216
235
|
# Always set up a debounce timer to catch rapid consecutive outputs
|
|
@@ -219,19 +238,27 @@ class TerminalSession:
|
|
|
219
238
|
if time_since_last_send >= TERMINAL_DATA_RATE_LIMIT_MS:
|
|
220
239
|
# Enough time has passed since last send, wait initial delay for more data
|
|
221
240
|
wait_time = TERMINAL_DATA_INITIAL_WAIT_MS / 1000
|
|
241
|
+
logger.info("session: Rate limit satisfied for terminal %s, waiting %.1fms for more data",
|
|
242
|
+
self.id, wait_time * 1000)
|
|
222
243
|
else:
|
|
223
244
|
# Too soon since last send, wait for either the rate limit period or max wait time
|
|
224
245
|
wait_time = min(
|
|
225
246
|
(TERMINAL_DATA_RATE_LIMIT_MS - time_since_last_send) / 1000,
|
|
226
247
|
TERMINAL_DATA_MAX_WAIT_MS / 1000
|
|
227
248
|
)
|
|
249
|
+
logger.info("session: Rate limit active for terminal %s, waiting %.1fms before send (time_since_last=%.1fms, rate_limit=%dms)",
|
|
250
|
+
self.id, wait_time * 1000, time_since_last_send, TERMINAL_DATA_RATE_LIMIT_MS)
|
|
251
|
+
|
|
228
252
|
await asyncio.sleep(wait_time)
|
|
253
|
+
logger.info("session: Debounce timer expired for terminal %s, flushing pending data", self.id)
|
|
229
254
|
await self._flush_pending_data()
|
|
230
255
|
except asyncio.CancelledError:
|
|
256
|
+
logger.debug("session: Debounce timer cancelled for terminal %s (new data arrived)", self.id)
|
|
231
257
|
# Timer was cancelled, another data event came in
|
|
232
258
|
pass
|
|
233
259
|
|
|
234
260
|
self._debounce_task = asyncio.create_task(_debounce_timer())
|
|
261
|
+
logger.info("session: Started debounce timer for terminal %s", self.id)
|
|
235
262
|
|
|
236
263
|
def _find_minimal_safe_trim_position(self, excess_bytes: int) -> int:
|
|
237
264
|
"""Find a minimal safe position to trim that only avoids breaking ANSI sequences."""
|
|
@@ -59,7 +59,7 @@ MEDIA_EXTENSIONS = {
|
|
|
59
59
|
'.ico', '.icns', '.cur', '.psd', '.ai', '.eps', '.raw', '.cr2', '.nef',
|
|
60
60
|
|
|
61
61
|
# Audio
|
|
62
|
-
'.mp3', '.wav', '.flac', '.aac', '.ogg', '.wma', '.m4a', '.opus', '.webm',
|
|
62
|
+
'.mp3', '.wav', '.flac', '.aac', '.ogg', '.oga', '.wma', '.m4a', '.opus', '.webm',
|
|
63
63
|
|
|
64
64
|
# Video
|
|
65
65
|
'.mp4', '.avi', '.mkv', '.mov', '.wmv', '.flv', '.webm', '.m4v', '.3gp',
|
portacode/connection/terminal.py
CHANGED
|
@@ -738,15 +738,17 @@ class TerminalManager:
|
|
|
738
738
|
payload: The message payload to send
|
|
739
739
|
project_id: Optional project filter for targeting specific sessions
|
|
740
740
|
"""
|
|
741
|
+
event_type = payload.get("event", "unknown")
|
|
742
|
+
|
|
741
743
|
# Check if there are any interested clients
|
|
742
744
|
if not self._client_session_manager.has_interested_clients():
|
|
743
|
-
logger.
|
|
745
|
+
logger.info("terminal_manager: No interested clients for %s event, skipping send", event_type)
|
|
744
746
|
return
|
|
745
747
|
|
|
746
748
|
# Get target sessions
|
|
747
749
|
target_sessions = self._client_session_manager.get_target_sessions(project_id)
|
|
748
750
|
if not target_sessions:
|
|
749
|
-
logger.
|
|
751
|
+
logger.info("terminal_manager: No target sessions found for %s event (project_id=%s), skipping send", event_type, project_id)
|
|
750
752
|
return
|
|
751
753
|
|
|
752
754
|
# Add session targeting information
|
|
@@ -758,8 +760,15 @@ class TerminalManager:
|
|
|
758
760
|
if reply_channel and "reply_channel" not in enhanced_payload:
|
|
759
761
|
enhanced_payload["reply_channel"] = reply_channel
|
|
760
762
|
|
|
761
|
-
|
|
762
|
-
|
|
763
|
+
# Log all event dispatches at INFO level, with data size for terminal_data
|
|
764
|
+
if event_type == "terminal_data":
|
|
765
|
+
data_size = len(payload.get("data", ""))
|
|
766
|
+
terminal_id = payload.get("channel", "unknown")
|
|
767
|
+
logger.info("terminal_manager: Dispatching %s event (terminal_id=%s, data_size=%d bytes) to %d client sessions",
|
|
768
|
+
event_type, terminal_id, data_size, len(target_sessions))
|
|
769
|
+
else:
|
|
770
|
+
logger.info("terminal_manager: Dispatching %s event to %d client sessions",
|
|
771
|
+
event_type, len(target_sessions))
|
|
763
772
|
|
|
764
773
|
await self._control_channel.send(enhanced_payload)
|
|
765
774
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: portacode
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.25
|
|
4
4
|
Summary: Portacode CLI client and SDK
|
|
5
5
|
Home-page: https://github.com/portacode/portacode
|
|
6
6
|
Author: Meena Erian
|
|
@@ -18,15 +18,26 @@ Requires-Dist: websockets>=12.0
|
|
|
18
18
|
Requires-Dist: pyperclip>=1.8
|
|
19
19
|
Requires-Dist: psutil>=5.9
|
|
20
20
|
Requires-Dist: pyte>=0.8
|
|
21
|
+
Requires-Dist: pywinpty>=2.0; platform_system == "Windows"
|
|
21
22
|
Requires-Dist: GitPython>=3.1.45
|
|
22
23
|
Requires-Dist: watchdog>=3.0
|
|
23
24
|
Requires-Dist: diff-match-patch>=20230430
|
|
24
25
|
Requires-Dist: Pygments>=2.14.0
|
|
25
|
-
Requires-Dist: pywinpty>=2.0; platform_system == "Windows"
|
|
26
26
|
Provides-Extra: dev
|
|
27
27
|
Requires-Dist: black; extra == "dev"
|
|
28
28
|
Requires-Dist: flake8; extra == "dev"
|
|
29
29
|
Requires-Dist: pytest; extra == "dev"
|
|
30
|
+
Dynamic: author
|
|
31
|
+
Dynamic: author-email
|
|
32
|
+
Dynamic: classifier
|
|
33
|
+
Dynamic: description
|
|
34
|
+
Dynamic: description-content-type
|
|
35
|
+
Dynamic: home-page
|
|
36
|
+
Dynamic: license-file
|
|
37
|
+
Dynamic: provides-extra
|
|
38
|
+
Dynamic: requires-dist
|
|
39
|
+
Dynamic: requires-python
|
|
40
|
+
Dynamic: summary
|
|
30
41
|
|
|
31
42
|
# Portacode
|
|
32
43
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
portacode/README.md,sha256=4dKtpvR8LNgZPVz37GmkQCMWIr_u25Ao63iW56s7Ke4,775
|
|
2
2
|
portacode/__init__.py,sha256=oB3sV1wXr-um-RXio73UG8E5Xx6cF2ZVJveqjNmC-vQ,1086
|
|
3
3
|
portacode/__main__.py,sha256=jmHTGC1hzmo9iKJLv-SSYe9BSIbPPZ2IOpecI03PlTs,296
|
|
4
|
-
portacode/_version.py,sha256=
|
|
4
|
+
portacode/_version.py,sha256=hLpn_M2C5NIFPhfMSw9x0kkUzdCUeI3MFt6ODjxDxdA,706
|
|
5
5
|
portacode/cli.py,sha256=Fcz8aXhgKhoWR9UbtmkN843DVuoJZtCTqBF3K-neVSc,16347
|
|
6
6
|
portacode/data.py,sha256=5-s291bv8J354myaHm1Y7CQZTZyRzMU3TGe5U4hb-FA,1591
|
|
7
7
|
portacode/keypair.py,sha256=PAcOYqlVLOoZTPYi6LvLjfsY6BkrWbLOhSZLb8r5sHs,3635
|
|
@@ -11,9 +11,9 @@ portacode/connection/README.md,sha256=f9rbuIEKa7cTm9C98rCiBbEtbiIXQU11esGSNhSMiJ
|
|
|
11
11
|
portacode/connection/__init__.py,sha256=atqcVGkViIEd7pRa6cP2do07RJOM0UWpbnz5zXjGktU,250
|
|
12
12
|
portacode/connection/client.py,sha256=tEM4rqzCRxIG5WXqYAT7s65NlEX2Z1sW42GosctBHIA,8013
|
|
13
13
|
portacode/connection/multiplex.py,sha256=L-TxqJ_ZEbfNEfu1cwxgJ5vUdyRzZjsMy2Kx1diiZys,5237
|
|
14
|
-
portacode/connection/terminal.py,sha256=
|
|
14
|
+
portacode/connection/terminal.py,sha256=euST6O_3hm9qsBk52xTXORTKfKqsMYnTHuZjvlitYSE,42307
|
|
15
15
|
portacode/connection/handlers/README.md,sha256=HsLZG1QK1JNm67HsgL6WoDg9nxzKXxwkc5fJPFJdX5g,12169
|
|
16
|
-
portacode/connection/handlers/WEBSOCKET_PROTOCOL.md,sha256=
|
|
16
|
+
portacode/connection/handlers/WEBSOCKET_PROTOCOL.md,sha256=0KBoJzqemXbvpu8Ps7LitLwRYhJNtzcTJ5WAHMGgAuc,62509
|
|
17
17
|
portacode/connection/handlers/__init__.py,sha256=4nv3Z4TGYjWcauKPWsbL_FbrTXApI94V7j6oiU1Vv-o,2144
|
|
18
18
|
portacode/connection/handlers/base.py,sha256=C-H61CUHM2k431CG0usd7eEqklDj9pnuXHujBwhTugk,6666
|
|
19
19
|
portacode/connection/handlers/chunked_content.py,sha256=h6hXRmxSeOgnIxoU8CkmvEf2Odv-ajPrpHIe_W3GKcA,9251
|
|
@@ -21,18 +21,19 @@ portacode/connection/handlers/file_handlers.py,sha256=CGMooOrfGbKx-bHA8vr8lmPG-v
|
|
|
21
21
|
portacode/connection/handlers/project_aware_file_handlers.py,sha256=n0M2WmBNWPwzigdIkyZiAsePUQGXVqYSsDyOxm-Nsok,9253
|
|
22
22
|
portacode/connection/handlers/project_state_handlers.py,sha256=v6ZefGW9i7n1aZLq2jOGumJIjYb6aHlPI4m1jkYewm8,1686
|
|
23
23
|
portacode/connection/handlers/registry.py,sha256=ebi0vhR1XXSYU7mJXlQJ4MjBYaMygGYqX7ReK7vsZ7o,5558
|
|
24
|
-
portacode/connection/handlers/session.py,sha256=
|
|
24
|
+
portacode/connection/handlers/session.py,sha256=O7TMI5cRziOiXEBWCfBshkMpEthhjvKqGL0hhNOG1wU,26716
|
|
25
25
|
portacode/connection/handlers/system_handlers.py,sha256=65V5ctT0dIBc-oWG91e62MbdvU0z6x6JCTQuIqCWmZ0,5242
|
|
26
|
-
portacode/connection/handlers/tab_factory.py,sha256=
|
|
26
|
+
portacode/connection/handlers/tab_factory.py,sha256=VBZnwtxgeNJCsfBzUjkFWAAGBdijvai4MS2dXnhFY8U,18000
|
|
27
27
|
portacode/connection/handlers/terminal_handlers.py,sha256=Yuo84zwKB5OiLuVtDLCQgMVrOS3T8ZOONxXpGnnougo,11019
|
|
28
28
|
portacode/connection/handlers/project_state/README.md,sha256=trdd4ig6ungmwH5SpbSLfyxbL-QgPlGNU-_XrMEiXtw,10114
|
|
29
29
|
portacode/connection/handlers/project_state/__init__.py,sha256=5ucIqk6Iclqg6bKkL8r_wVs5Tlt6B9J7yQH6yQUt7gc,2541
|
|
30
30
|
portacode/connection/handlers/project_state/file_system_watcher.py,sha256=w-93ioUZZKZxzPFr8djJnGhWjMVFVdDsmo0fVAukoKk,10150
|
|
31
|
-
portacode/connection/handlers/project_state/git_manager.py,sha256=
|
|
32
|
-
portacode/connection/handlers/project_state/handlers.py,sha256=
|
|
31
|
+
portacode/connection/handlers/project_state/git_manager.py,sha256=fA0RbWCblpJep13L4MdqnEP4sE1qWc7Y66vrIo_SWps,76575
|
|
32
|
+
portacode/connection/handlers/project_state/handlers.py,sha256=nkednSbCC-0n3ZtzesaWd9_NFfxNjS4lyVNnbsYs0Zk,37823
|
|
33
33
|
portacode/connection/handlers/project_state/manager.py,sha256=03mN0H9TqVa_ohD5U5-5ZywDGj0s8-y1IxGdb07dZn8,57636
|
|
34
34
|
portacode/connection/handlers/project_state/models.py,sha256=EZTKvxHKs8QlQUbzI0u2IqfzfRRXZixUIDBwTGCJATI,4313
|
|
35
35
|
portacode/connection/handlers/project_state/utils.py,sha256=LsbQr9TH9Bz30FqikmtTxco4PlB_n0kUIuPKQ6Fb_mo,1665
|
|
36
|
+
portacode-0.3.25.dist-info/licenses/LICENSE,sha256=2FGbCnUDgRYuQTkB1O1dUUpu5CVAjK1j4_p6ack9Z54,1066
|
|
36
37
|
test_modules/README.md,sha256=Do_agkm9WhSzueXjRAkV_xEj6Emy5zB3N3VKY5Roce8,9274
|
|
37
38
|
test_modules/__init__.py,sha256=1LcbHodIHsB0g-g4NGjSn6AMuCoGbymvXPYLOb6Z7F0,53
|
|
38
39
|
test_modules/test_device_online.py,sha256=yiSyVaMwKAugqIX_ZIxmLXiOlmA_8IRXiUp12YmpB98,1653
|
|
@@ -57,9 +58,8 @@ testing_framework/core/playwright_manager.py,sha256=9kGXJtRpRNEhaSlV7XVXvx4UQSHS
|
|
|
57
58
|
testing_framework/core/runner.py,sha256=j2QwNJmAxVBmJvcbVS7DgPJUKPNzqfLmt_4NNdaKmZU,19297
|
|
58
59
|
testing_framework/core/shared_cli_manager.py,sha256=BESSNtyQb7BOlaOvZmm04T8Uezjms4KCBs2MzTxvzYQ,8790
|
|
59
60
|
testing_framework/core/test_discovery.py,sha256=2FZ9fJ8Dp5dloA-fkgXoJ_gCMC_nYPBnA3Hs2xlagzM,4928
|
|
60
|
-
portacode-0.3.
|
|
61
|
-
portacode-0.3.
|
|
62
|
-
portacode-0.3.
|
|
63
|
-
portacode-0.3.
|
|
64
|
-
portacode-0.3.
|
|
65
|
-
portacode-0.3.23.dist-info/RECORD,,
|
|
61
|
+
portacode-0.3.25.dist-info/METADATA,sha256=cUX77zERmm5WZHrmzV1z52TAEJi8HPP4D31wNdbEXuM,7173
|
|
62
|
+
portacode-0.3.25.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
63
|
+
portacode-0.3.25.dist-info/entry_points.txt,sha256=lLUUL-BM6_wwe44Xv0__5NQ1BnAz6jWjSMFvZdWW3zU,48
|
|
64
|
+
portacode-0.3.25.dist-info/top_level.txt,sha256=TGhTYUxfW8SyVZc_zGgzjzc24gGT7nSw8Qf73liVRKM,41
|
|
65
|
+
portacode-0.3.25.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|