From f90fca1a57cfe0bd97d2bba3c56444aff4dadbad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=A8=8A=E5=9D=A4?= <13526358+fkwarrior@user.noreply.gitee.com> Date: Tue, 21 Jul 2026 13:52:00 +0800 Subject: [PATCH] Release TurboFile 1.155.0 Validated Windows x64 release snapshot. Excludes unavailable Copilot simulation cache LFS fixtures that are not part of TurboFile builds. --- .../workflows/turbofile-update-manifest.yml | 31 + .gitignore | 3 + build/buildfile.ts | 14 +- build/checker/layersChecker.ts | 6 - build/checker/tsconfig.electron-browser.json | 3 +- build/gulpfile.vscode.ts | 61 +- build/gulpfile.vscode.web.ts | 1 - build/gulpfile.vscode.win32.ts | 3 +- build/lib/extensions.ts | 10 +- build/lib/i18n.resources.json | 98 - build/lib/mangle/index.ts | 2 - build/lib/test/i18n.test.ts | 9 +- build/next/index.ts | 31 - build/next/working.md | 2 - build/turbofile-product.json | 52 + build/win32/code.iss | 73 +- .../2026-07-10-same-device-directory-merge.md | 206 + ...7-10-ssh-post-transfer-session-recovery.md | 395 + .../2026-07-14-turbofile-codebase-slimming.md | 300 + ...7-15-batch-stream-progress-direct-first.md | 236 + .../2026-07-15-drag-mode-compact-icon.md | 130 + .../plans/2026-07-15-drag-transfer-mode.md | 242 + ...6-07-15-image-grid-fast-correct-sorting.md | 246 + ...2026-07-15-non-single-file-unknown-size.md | 185 + ...-transfer-reliability-and-pane-controls.md | 514 + ...6-07-15-turbofile-document-tab-affinity.md | 359 + ...mediate-transfer-start-and-move-refresh.md | 483 + .../2026-07-16-pane-copy-cut-mode-on-148.md | 356 + .../2026-07-16-per-device-recycle-bin.md | 487 + .../2026-07-16-single-file-transfer-total.md | 426 + .../plans/2026-07-16-stable-directory-sort.md | 186 + ...sfer-148-pane-isolation-and-151-package.md | 350 + ...2026-07-17-image-grid-instant-file-sort.md | 60 + .../2026-07-17-large-batch-tar-path-list.md | 78 + ...-07-17-persistent-taskbar-with-terminal.md | 67 + ...recycle-bin-file-operations-and-sorting.md | 91 + ...ssh-device-form-focus-and-password-only.md | 111 + ...026-07-17-turbofile-company-value-slide.md | 187 + ...07-17-turbofile-leadership-presentation.md | 254 + ...dows-recycle-bin-icon-and-delete-dialog.md | 148 + .../2026-07-18-extreme-batch-stability.md | 91 + .../2026-07-21-same-directory-copy-name.md | 55 + ...7-10-same-device-directory-merge-design.md | 44 + ...h-post-transfer-session-recovery-design.md | 97 + .../2026-07-14-drag-transfer-mode-design.md | 95 + ...7-14-turbofile-codebase-slimming-design.md | 83 + ...tch-stream-progress-direct-first-design.md | 59 + ...026-07-15-drag-mode-compact-icon-design.md | 38 + ...-image-grid-fast-correct-sorting-design.md | 78 + ...-15-non-single-file-unknown-size-design.md | 47 + ...lience-pane-modes-conflict-alert-design.md | 236 + ...-turbofile-document-tab-affinity-design.md | 46 + ...-transfer-start-and-move-refresh-design.md | 119 + ...-07-16-pane-copy-cut-mode-on-148-design.md | 51 + ...026-07-16-per-device-recycle-bin-design.md | 366 + ...2026-07-16-stable-directory-sort-design.md | 75 + ...-transfer-148-and-pane-isolation-design.md | 100 + ...-17-image-grid-instant-file-sort-design.md | 31 + ...-07-17-large-batch-tar-path-list-design.md | 44 + ...persistent-taskbar-with-terminal-design.md | 24 + ...-bin-file-operations-and-sorting-design.md | 37 + ...ice-form-focus-and-password-only-design.md | 38 + ...17-turbofile-company-value-slide-design.md | 73 + ...urbofile-leadership-presentation-design.md | 220 + ...cycle-bin-icon-and-delete-dialog-design.md | 34 + ...26-07-18-extreme-batch-stability-design.md | 64 + ...6-07-21-same-directory-copy-name-design.md | 27 + ...15\346\236\204\350\256\276\350\256\241.md" | 0 .../turbofile/common/turbofileWindowsPath.js | 30 + .../electron-main/turbofileSshMainService.js | 9861 ++++++++++++ .../test/common/turbofileWindowsPath.test.js | 26 + .../turbofile/browser/media/turbofile.css | 6493 ++++++++ .../turbofile/browser/turbofileEditor.js | 13260 ++++++++++++++++ .../turbofile/common/turbofileDragDrop.js | 14 + .../turbofile/common/turbofileFileSort.js | 45 + .../common/turbofileImageDimensions.js | 28 + .../common/turbofileImageGridSizing.js | 52 + .../turbofile/common/turbofileImagePreview.js | 47 + .../common/turbofileImageResource.js | 53 + .../common/turbofileWindowsLocation.js | 14 + .../common/turbofileImageDimensions.test.js | 23 + package-lock.json | 350 +- package.json | 8 +- product.json | 13 + .../drag-ext/TurboFileDragSource.cpp | 110 + .../drag-ext/x64/TurboFileDragSource.exe | Bin 0 -> 102400 bytes scripts/build-turbofile-drag-ext.ps1 | 32 +- scripts/turbofile-update-manifest.mjs | 247 + src/tsec.exemptions.json | 4 +- src/vs/base/common/product.ts | 11 + .../workbench/workbench-dev.html | 1 + .../electron-browser/workbench/workbench.html | 1 + src/vs/code/electron-main/app.ts | 46 - .../sharedProcess/sharedProcessMain.ts | 52 - .../agentHost/browser/nullAgentHostService.ts | 58 - .../browser/nullSshRemoteAgentHostService.ts | 45 - .../browser/remoteAgentHostProtocolClient.ts | 528 - .../browser/remoteAgentHostServiceImpl.ts | 565 - .../browser/webSocketClientTransport.ts | 189 - .../electron-browser/agentHostService.ts | 229 - .../remoteAgentHostService.ts | 10 - .../electron-browser/sshRelayTransport.ts | 58 - .../sshRemoteAgentHostService.ts | 10 - .../sshRemoteAgentHostServiceImpl.ts | 261 - .../electron-browser/tunnelRelayTransport.ts | 81 - .../electron-main/electronAgentHostStarter.ts | 149 - .../node/agentConfigurationService.ts | 264 - .../agentHost/node/agentHostGitService.ts | 606 - .../platform/agentHost/node/agentHostMain.ts | 305 - .../agentHost/node/agentHostServerMain.ts | 298 - .../agentHost/node/agentHostService.ts | 90 - .../agentHost/node/agentHostStateManager.ts | 356 - .../node/agentHostTerminalManager.ts | 721 - .../agentHost/node/agentPluginManager.ts | 209 - .../platform/agentHost/node/agentService.ts | 995 -- .../agentHost/node/agentSideEffects.ts | 1063 -- .../platform/agentHost/node/claude/CONTEXT.md | 472 - .../agentHost/node/claude/anthropicBetas.ts | 46 - .../agentHost/node/claude/anthropicErrors.ts | 71 - .../agentHost/node/claude/claudeModelId.ts | 185 - .../agentHost/node/claude/claudeProxyAuth.ts | 58 - .../node/claude/claudeProxyService.ts | 761 - .../agentHost/node/claude/phase-plan.1.5.md | 610 - .../agentHost/node/claude/phase-plan.2.md | 808 - .../platform/agentHost/node/claude/roadmap.md | 722 - .../agentHost/node/commandAutoApprover.ts | 441 - .../agentHost/node/copilot/copilotAgent.ts | 1747 -- .../node/copilot/copilotAgentSession.ts | 1668 -- .../node/copilot/copilotGitProject.ts | 58 - .../node/copilot/copilotPluginConverters.ts | 350 - .../node/copilot/copilotSessionWrapper.ts | 222 - .../node/copilot/copilotShellTools.ts | 728 - .../node/copilot/copilotToolDisplay.ts | 743 - .../agentHost/node/copilot/fileEditTracker.ts | 197 - .../node/copilot/mapSessionEvents.ts | 507 - .../node/copilot/pendingEditContentStore.ts | 48 - .../agentHost/node/diffComputeService.ts | 94 - .../platform/agentHost/node/diffWorkerMain.ts | 54 - .../platform/agentHost/node/gitDiffContent.ts | 63 - .../agentHost/node/nodeAgentHostStarter.ts | 126 - .../platform/agentHost/node/osc633Parser.ts | 294 - .../agentHost/node/protocolServerHandler.ts | 757 - src/vs/platform/agentHost/node/serverUrls.ts | 48 - .../agentHost/node/sessionDataService.ts | 156 - .../agentHost/node/sessionDatabase.ts | 591 - .../agentHost/node/sessionDiffAggregator.ts | 234 - .../agentHost/node/sessionPermissions.ts | 282 - .../node/shared/copilotApiService.ts | 697 - .../node/sshRemoteAgentHostHelpers.ts | 210 - .../node/sshRemoteAgentHostService.ts | 1012 -- .../agentHost/node/tunnelAgentHostService.ts | 333 - .../agentHost/node/tunnelHostMainService.ts | 222 - .../agentHost/node/webSocketTransport.ts | 188 - src/vs/platform/agentHost/test/auth-rework.md | 454 - .../test/common/agentClientUri.test.ts | 54 - .../agentHostFileSystemProvider.test.ts | 289 - .../test/common/agentHostSchema.test.ts | 313 - .../test/common/agentService.test.ts | 36 - .../test/common/agentSubscription.test.ts | 652 - .../test/common/commandLineHelpers.test.ts | 142 - .../test/common/sessionTestHelpers.ts | 188 - .../test/common/sshConfigParsing.test.ts | 226 - .../remoteAgentHostProtocolClient.test.ts | 230 - .../remoteAgentHostService.test.ts | 493 - .../sshRelayTransport.test.ts | 177 - .../sshRemoteAgentHostService.test.ts | 306 - src/vs/platform/agentHost/test/node/AGENTS.md | 3 - .../node/agentConfigurationService.test.ts | 172 - .../agentHostGitService.integrationTest.ts | 333 - .../test/node/agentHostGitService.test.ts | 196 - .../test/node/agentHostStateManager.test.ts | 414 - .../node/agentHostTerminalManager.test.ts | 380 - .../test/node/agentPluginManager.test.ts | 175 - .../agentHost/test/node/agentService.test.ts | 1065 -- .../test/node/agentSideEffects.test.ts | 2572 --- .../test/node/anthropicBetas.test.ts | 55 - .../agentHost/test/node/claudeModelId.test.ts | 261 - .../test/node/claudeProxyAuth.test.ts | 81 - .../test/node/claudeProxyService.test.ts | 1343 -- .../test/node/commandAutoApprover.test.ts | 141 - .../agentHost/test/node/copilotAgent.test.ts | 899 -- .../test/node/copilotAgentSession.test.ts | 1759 -- .../test/node/copilotGitProject.test.ts | 103 - .../test/node/copilotPluginConverters.test.ts | 349 - .../test/node/copilotShellTools.test.ts | 89 - .../test/node/copilotToolDisplay.test.ts | 333 - .../test/node/fileEditTracker.test.ts | 179 - .../test/node/gitDiffContent.test.ts | 35 - .../test/node/historyRecordFixtures.test.ts | 402 - .../test/node/historyRecordFixtures.ts | 552 - .../platform/agentHost/test/node/mockAgent.ts | 866 - .../agentHost/test/node/osc633Parser.test.ts | 298 - .../agentHost/test/node/pluginStorage.test.ts | 7 - .../agentHostServer.integrationTest.ts | 33 - .../protocol/clientTools.integrationTest.ts | 190 - .../protocol/handshake.integrationTest.ts | 91 - .../protocol/multiClient.integrationTest.ts | 327 - .../protocol/sessionConfig.integrationTest.ts | 247 - .../protocol/sessionDiffs.integrationTest.ts | 104 - .../sessionDiffsRealSdk.integrationTest.ts | 188 - .../sessionFeatures.integrationTest.ts | 529 - .../sessionLifecycle.integrationTest.ts | 228 - .../test/node/protocol/testHelpers.ts | 313 - .../protocol/toolApproval.integrationTest.ts | 188 - .../toolApprovalRealSdk.integrationTest.ts | 1148 -- .../protocol/turnExecution.integrationTest.ts | 215 - .../test/node/protocolServerHandler.test.ts | 1023 -- .../agentHost/test/node/reducers.test.ts | 194 - .../agentHost/test/node/serverUrls.test.ts | 65 - .../test/node/sessionDataService.test.ts | 153 - .../test/node/sessionDatabase.test.ts | 527 - .../test/node/sessionDiffAggregator.test.ts | 492 - .../node/shared/copilotApiService.test.ts | 1563 -- .../node/sshRemoteAgentHostHelpers.test.ts | 496 - .../node/sshRemoteAgentHostService.test.ts | 1236 -- .../node/test-cases/subagent-session.jsonl | 26 - .../test/node/testRemoteAgentHost.sh | 431 - .../electron-browser/preload-browserView.ts | 139 - .../electron-main/browserSession.ts | 246 - .../electron-main/browserSessionTrust.ts | 317 - .../browserView/electron-main/browserView.ts | 770 - .../electron-main/browserViewCDPTarget.ts | 108 - .../electron-main/browserViewDebugger.ts | 274 - .../browserViewElementInspector.ts | 423 - .../electron-main/browserViewGroup.ts | 274 - .../browserViewGroupMainService.ts | 94 - .../electron-main/browserViewMainService.ts | 498 - .../node/browserViewGroupRemoteService.ts | 109 - .../browserView/node/playwrightChannel.ts | 84 - .../browserView/node/playwrightService.ts | 698 - .../browserView/node/playwrightTab.ts | 282 - .../electron-main/browserSessionTrust.test.ts | 340 - src/vs/platform/turbofile/common/turbofile.ts | 157 + .../common/turbofileArchiveExtract.ts | 63 + .../turbofile/common/turbofileBatchTarPlan.ts | 67 +- .../common/turbofileImageMetadata.ts | 285 + .../common/turbofileImageMetadataBatch.ts | 79 + .../common/turbofileKeyedGeneration.ts | 31 + .../common/turbofileOperationQueue.ts | 179 + .../common/turbofilePosixHeartbeat.ts | 13 + .../common/turbofileRemoteDirectoryRetry.ts | 24 + .../common/turbofileRemoteNativeBatchPlan.ts | 73 + .../turbofile/common/turbofileRemoteStat.ts | 45 + .../common/turbofileRsyncBatchPlan.ts | 80 + .../turbofile/common/turbofileTarProgress.ts | 23 + .../turbofile/common/turbofileTimeout.ts | 34 + .../turbofile/common/turbofileTrash.ts | 85 + .../turbofile/common/turbofileWindowsPath.ts | 61 + .../electron-main/turbofileSshMainService.ts | 4604 +++++- .../common/turbofileArchiveExtract.test.ts | 32 + .../turbofileBatchStabilityStatic.test.ts | 160 + .../test/common/turbofileBatchTarPlan.test.ts | 128 +- .../common/turbofileImageMetadata.test.ts | 163 + .../turbofileImageMetadataBatch.test.ts | 70 + .../common/turbofileKeyedGeneration.test.ts | 27 + .../turbofileMutationStabilityStatic.test.ts | 283 + .../common/turbofileOperationQueue.test.ts | 297 + .../turbofileRemoteDirectoryRetry.test.ts | 29 + .../turbofileRemoteNativeBatchPlan.test.ts | 84 + .../test/common/turbofileRemoteStat.test.ts | 27 + .../common/turbofileRsyncBatchPlan.test.ts | 67 + .../turbofileSshMainServiceStatic.test.ts | 373 + .../test/common/turbofileTarProgress.test.ts | 22 + .../test/common/turbofileTimeout.test.ts | 20 + .../turbofileTransfer148Baseline.test.ts | 42 + .../test/common/turbofileTrash.test.ts | 83 + .../test/common/turbofileWindowsPath.test.ts | 46 + .../platform/update/common/turbofileUpdate.ts | 315 + .../electron-main/abstractUpdateService.ts | 22 +- .../electron-main/updateService.win32.ts | 311 +- .../test/common/turbofileUpdate.test.ts | 154 + .../windows/electron-main/windowImpl.ts | 2 - src/vs/server/node/serverAgentHostManager.ts | 143 - src/vs/server/node/serverServices.ts | 43 +- .../test/node/serverAgentHostManager.test.ts | 217 - src/vs/sessions/AI_CUSTOMIZATIONS.md | 292 - src/vs/sessions/LAYOUT.md | 740 - src/vs/sessions/MOBILE.md | 154 - src/vs/sessions/README.md | 383 - src/vs/sessions/SESSIONS_PROVIDER.md | 474 - .../sessions/browser/accountTitleBarState.ts | 220 - .../sessions/browser/chatDashboardService.ts | 27 - src/vs/sessions/browser/layoutActions.ts | 163 - src/vs/sessions/browser/layoutPolicy.ts | 159 - .../sessions/browser/media/sessions-icon.svg | 24 - .../browser/media/sessions-logo-dark.svg | 6 - .../browser/media/sessions-logo-light.svg | 6 - .../browser/media/sidebarActionButton.css | 49 - src/vs/sessions/browser/media/style.css | 933 -- src/vs/sessions/browser/menus.ts | 34 - .../sessions/browser/mobileNavigationStack.ts | 113 - .../browser/paneCompositePartService.ts | 115 - .../browser/parts/auxiliaryBarPart.ts | 305 - src/vs/sessions/browser/parts/chatBarPart.ts | 208 - .../browser/parts/chatCompositeBar.ts | 236 - src/vs/sessions/browser/parts/editorPart.ts | 29 - src/vs/sessions/browser/parts/editorParts.ts | 18 - .../browser/parts/media/auxiliaryBarPart.css | 53 - .../browser/parts/media/chatBarPart.css | 88 - .../browser/parts/media/chatCompositeBar.css | 94 - .../browser/parts/media/editorPart.css | 71 - .../browser/parts/media/panelPart.css | 68 - .../browser/parts/media/projectBarPart.css | 263 - .../browser/parts/media/sidebarPart.css | 127 - .../browser/parts/media/titlebarpart.css | 174 - .../browser/parts/menubar.contribution.ts | 69 - .../media/mobileOverlayViews.css | 293 - .../mobile/contributions/mobileDiffView.ts | 282 - .../mobile/media/mobileSessionFilterChips.css | 84 - .../mobile/media/mobileSortGroupSheet.css | 216 - .../parts/mobile/mobileAuxiliaryBarPart.ts | 54 - .../browser/parts/mobile/mobileChatBarPart.ts | 58 - .../browser/parts/mobile/mobileChatShell.css | 762 - .../browser/parts/mobile/mobileLayout.ts | 26 - .../browser/parts/mobile/mobilePanelPart.ts | 50 - .../parts/mobile/mobileSessionFilterChips.ts | 299 - .../browser/parts/mobile/mobileSidebarPart.ts | 37 - .../parts/mobile/mobileSortGroupSheet.ts | 173 - .../parts/mobile/mobileTitlebarPart.ts | 593 - src/vs/sessions/browser/parts/panelPart.ts | 204 - src/vs/sessions/browser/parts/parts.ts | 8 - .../sessions/browser/parts/projectBarPart.ts | 584 - src/vs/sessions/browser/parts/sidebarPart.ts | 333 - src/vs/sessions/browser/parts/titlebarPart.ts | 470 - src/vs/sessions/browser/web.factory.ts | 49 - src/vs/sessions/browser/web.main.ts | 94 - .../browser/widget/AGENTS_CHAT_WIDGET.md | 503 - src/vs/sessions/browser/workbench.ts | 1871 --- .../common/agentHostSessionWorkspace.ts | 129 - .../common/agentHostSessionsProvider.ts | 189 - src/vs/sessions/common/categories.ts | 10 - src/vs/sessions/common/contextkeys.ts | 59 - src/vs/sessions/common/sessionsTelemetry.ts | 279 - src/vs/sessions/common/theme.ts | 163 - src/vs/sessions/common/welcome.ts | 6 - .../browser/account.contribution.ts | 949 -- .../browser/media/accountTitleBarWidget.css | 584 - .../browser/media/accountWidget.css | 268 - .../browser/media/updateHoverWidget.css | 65 - .../accountMenu/browser/updateHoverWidget.ts | 188 - .../test/browser/account.contribution.test.ts | 7 - .../test/browser/accountTitleBarState.test.ts | 159 - .../test/browser/updateHoverWidget.fixture.ts | 83 - .../browser/agentFeedback.contribution.ts | 100 - .../browser/agentFeedbackAttachment.ts | 130 - .../browser/agentFeedbackAttachmentWidget.ts | 78 - .../browser/agentFeedbackEditorActions.ts | 273 - .../agentFeedbackEditorInputContribution.ts | 643 - .../browser/agentFeedbackEditorOverlay.ts | 277 - .../browser/agentFeedbackEditorUtils.ts | 348 - .../agentFeedbackEditorWidgetContribution.ts | 830 - .../browser/agentFeedbackHover.ts | 415 - .../agentFeedbackOverviewRulerContribution.ts | 100 - .../browser/agentFeedbackService.ts | 470 - .../browser/media/agentFeedbackAttachment.css | 52 - .../media/agentFeedbackEditorInput.css | 85 - .../media/agentFeedbackEditorOverlay.css | 72 - .../media/agentFeedbackEditorWidget.css | 310 - .../nullAgentFeedbackService.contribution.ts | 52 - .../browser/sessionEditorComments.ts | 138 - ...gentFeedbackEditorOverlayWidget.fixture.ts | 136 - .../agentFeedbackEditorWidget.fixture.ts | 385 - .../test/browser/agentFeedbackService.test.ts | 213 - .../browser/sessionEditorComments.test.ts | 146 - .../agentHost/browser/agentHostDiffs.ts | 106 - .../browser/agentHostSettings.contribution.ts | 74 - .../agentHostSettingsFileSystemProvider.ts | 157 - .../browser/agentHostSettingsShared.ts | 512 - .../browser/agentHostSkillButtons.ts | 218 - .../agentSessionSettings.contribution.ts | 74 - .../agentSessionSettingsFileSystemProvider.ts | 225 - .../browser/baseAgentHostSessionsProvider.ts | 1507 -- .../browser/localAgentHost.contribution.ts | 76 - .../browser/localAgentHostSessionsProvider.ts | 156 - .../browser/agentHostSkillButtons.test.ts | 221 - ...tSessionSettingsFileSystemProvider.test.ts | 314 - .../localAgentHostSessionsProvider.test.ts | 1154 -- .../browser/aiCustomizationOverviewView.ts | 242 - .../aiCustomizationTreeView.contribution.ts | 314 - .../browser/aiCustomizationTreeView.ts | 40 - .../browser/aiCustomizationTreeViewViews.ts | 819 - .../browser/media/aiCustomizationTreeView.css | 116 - .../browser/applyChangesToParentRepo.ts | 173 - .../aquarium/browser/aquarium.contribution.ts | 26 - .../aquarium/browser/aquariumOverlay.ts | 745 - .../sessions/contrib/aquarium/browser/fish.ts | 269 - .../aquarium/browser/media/aquarium.css | 294 - .../aquarium/browser/vscodeLogoPath.ts | 13 - .../sessionBrowserView.contribution.ts | 9 - .../browserView/browser/sessionBrowserView.ts | 106 - .../changes/browser/changes.contribution.ts | 62 - .../browser/changesMultiDiffSourceResolver.ts | 105 - .../changes/browser/changesTitleBarWidget.ts | 108 - .../contrib/changes/browser/changesView.ts | 1480 -- .../changes/browser/changesViewActions.ts | 173 - .../changes/browser/changesViewModel.ts | 581 - .../changes/browser/changesViewRenderer.ts | 420 - .../contrib/changes/browser/checksActions.ts | 207 - .../changes/browser/checksViewModel.ts | 72 - .../contrib/changes/browser/checksWidget.ts | 521 - .../browser/media/changesTitleBarWidget.css | 12 - .../changes/browser/media/changesView.css | 389 - .../browser/media/changesViewActions.css | 32 - .../changes/browser/media/checksWidget.css | 252 - .../contrib/changes/common/changes.ts | 47 - .../browser/agentHost/agentHostModePicker.ts | 215 - .../browser/agentHost/agentHostModelPicker.ts | 186 - .../agentHostPermissionPickerActionItem.ts | 82 - .../agentHostPermissionPickerDelegate.ts | 160 - .../agentHost/agentHostSessionConfigPicker.ts | 626 - .../aiCustomizationWorkspaceService.ts | 290 - .../chat/browser/branchChatSessionAction.ts | 109 - .../contrib/chat/browser/chat.contribution.ts | 162 - .../browser/customizationHarnessService.ts | 22 - .../customizationsDebugLog.contribution.ts | 178 - .../contrib/chat/browser/media/chatInput.css | 579 - .../contrib/chat/browser/media/chatWidget.css | 391 - .../media/letterpress-sessions-dark.svg | 6 - .../media/letterpress-sessions-light.svg | 6 - .../chat/browser/media/newChatInSession.css | 182 - .../chat/browser/media/openInVSCode.css | 109 - .../chat/browser/media/runScriptAction.css | 186 - .../chat/browser/media/vscode-icon.svg | 52 - .../chat/browser/newChatContextAttachments.ts | 611 - .../chat/browser/newChatInSessionViewPane.ts | 220 - .../contrib/chat/browser/newChatInput.ts | 611 - .../contrib/chat/browser/newChatViewPane.ts | 356 - .../contrib/chat/browser/newSession.ts | 16 - .../chat/browser/nullChatTipService.ts | 34 - .../browser/nullInlineChatSessionService.ts | 33 - .../chat/browser/openInVSCode.contribution.ts | 108 - .../contrib/chat/browser/openInVSCodeUtils.ts | 68 - .../chat/browser/openInVSCodeWidget.ts | 87 - .../contrib/chat/browser/promptsService.ts | 164 - .../contrib/chat/browser/repoPicker.ts | 256 - .../contrib/chat/browser/runScriptAction.ts | 783 - .../chat/browser/runScriptCustomTaskWidget.ts | 245 - .../chat/browser/scopedWorkspacePicker.ts | 144 - .../chat/browser/sessionTargetPicker.ts | 4 - .../contrib/chat/browser/sessionTypePicker.ts | 176 - .../chat/browser/sessionWorkspacePicker.ts | 1083 -- .../browser/sessionsChatAccessibilityHelp.ts | 46 - .../browser/sessionsConfigurationService.ts | 460 - .../chat/browser/sessionsOpenerParticipant.ts | 42 - .../contrib/chat/browser/slashCommands.ts | 339 - .../chat/browser/variableCompletions.ts | 377 - .../chat/common/builtinPromptsStorage.ts | 23 - .../openInVSCode.contribution.ts | 154 - .../agentHostPermissionPickerDelegate.test.ts | 244 - .../browser/resolveRemoteAuthority.test.ts | 233 - .../chat/test/browser/runScriptAction.test.ts | 31 - .../runScriptCustomTaskWidget.fixture.ts | 124 - .../browser/sessionWorkspacePicker.test.ts | 658 - .../sessionsConfigurationService.test.ts | 651 - .../browser/chatDebug.contribution.ts | 88 - .../browser/codeReview.contributions.ts | 193 - .../codeReview/browser/codeReviewService.ts | 762 - .../test/browser/codeReviewService.test.ts | 1287 -- .../browser/configuration.contribution.ts | 88 - .../COPILOT_CHAT_SESSIONS_PROVIDER.md | 159 - .../browser/branchPicker.ts | 160 - .../browser/claudePermissionModePicker.ts | 173 - .../copilotChatSessions.contribution.ts | 58 - .../browser/copilotChatSessionsActions.ts | 517 - .../browser/copilotChatSessionsProvider.ts | 2686 ---- .../browser/isolationPicker.ts | 214 - .../copilotChatSessions/browser/modePicker.ts | 248 - .../browser/modelPicker.ts | 222 - .../browser/permissionPicker.ts | 378 - .../claudePermissionModePicker.test.ts | 158 - .../copilotChatSessionsProvider.test.ts | 1311 -- .../test/browser/isolationPicker.test.ts | 120 - .../test/browser/modelPickerDelegate.test.ts | 231 - .../editor/browser/editor.contribution.ts | 265 - .../test/browser/editor.contribution.test.ts | 199 - .../browser/fileTreeView.contribution.ts | 36 - .../browser/githubFileSystemProvider.ts | 358 - .../files/browser/files.contribution.ts | 105 - .../contrib/files/browser/filesView.ts | 71 - .../contrib/files/browser/media/filesView.css | 35 - .../browser/fetchers/githubChangesFetcher.ts | 45 - .../browser/fetchers/githubPRCIFetcher.ts | 222 - .../browser/fetchers/githubPRFetcher.ts | 407 - .../fetchers/githubRepositoryFetcher.ts | 50 - .../github/browser/github.contribution.ts | 127 - .../contrib/github/browser/githubApiClient.ts | 170 - .../contrib/github/browser/githubService.ts | 162 - .../models/githubPullRequestCIModel.ts | 145 - .../browser/models/githubPullRequestModel.ts | 137 - .../githubPullRequestReviewThreadsModel.ts | 120 - .../browser/models/githubRepositoryModel.ts | 57 - .../sessions/contrib/github/common/types.ts | 184 - .../sessions/contrib/github/common/utils.ts | 30 - .../test/browser/githubContribution.test.ts | 264 - .../test/browser/githubFetchers.test.ts | 473 - .../github/test/browser/githubModels.test.ts | 704 - .../github/test/browser/githubService.test.ts | 176 - .../layout/browser/layout.contribution.ts | 9 - .../layout/browser/layoutController.ts | 159 - .../browser/media/sessionsPolicyBlocked.css | 125 - .../browser/policyBlocked.contribution.ts | 100 - .../browser/sessionsPolicyBlocked.ts | 165 - .../browser/sessionsPolicyBlocked.fixture.ts | 52 - .../REMOTE_AGENT_HOST_SESSIONS_PROVIDER.md | 92 - .../browser/agentHostFilterService.ts | 216 - .../browser/hostFilter.contribution.ts | 123 - .../browser/hostFilterActionViewItem.ts | 321 - .../browser/manageRemoteAgentHosts.ts | 178 - .../browser/media/hostFilter.css | 204 - .../browser/media/hostPickerDropdown.css | 351 - .../browser/media/hostPickerSheet.css | 351 - .../browser/mobileHostFilterActionViewItem.ts | 295 - .../browser/remoteAgentHost.contribution.ts | 635 - .../browser/remoteAgentHostActions.ts | 996 -- .../remoteAgentHostCustomizationHarness.ts | 575 - .../remoteAgentHostSessionsProvider.ts | 572 - .../remoteAgentHostTerminal.contribution.ts | 67 - .../browser/remoteHostOptions.ts | 143 - .../browser/syncedCustomizationBundler.ts | 9 - .../browser/tunnelAgentHost.contribution.ts | 822 - .../webTunnelAgentHostService.contribution.ts | 10 - .../browser/webTunnelAgentHostService.ts | 302 - .../remoteAgentHost/common/agentHostFilter.ts | 94 - .../common/remoteAgentHostSessionType.ts | 33 - .../tunnelAgentHostService.ts | 10 - .../tunnelAgentHostServiceImpl.ts | 276 - .../browser/agentHostFilterService.test.ts | 173 - ...emoteAgentHostCustomizationHarness.test.ts | 719 - .../remoteAgentHostSessionsProvider.test.ts | 1096 -- .../common/remoteAgentHostSessionType.test.ts | 28 - .../browser/aiCustomizationShortcutsWidget.ts | 203 - .../customizationsToolbar.contribution.ts | 299 - .../browser/media/customizationsToolbar.css | 183 - .../sessions/browser/media/sessionsList.css | 442 - .../browser/media/sessionsTitleBarWidget.css | 140 - .../browser/media/sessionsViewPane.css | 282 - .../mobile/mobileOverlayContribution.ts | 36 - .../sessions/browser/sessions.contribution.ts | 55 - .../sessions/browser/sessionsActions.ts | 98 - .../browser/sessionsTitleBarWidget.ts | 362 - .../sessions/browser/views/sessionsList.ts | 1469 -- .../browser/views/sessionsListModelService.ts | 186 - .../sessions/browser/views/sessionsView.ts | 629 - .../browser/views/sessionsViewActions.ts | 847 - .../aiCustomizationShortcutsWidget.fixture.ts | 249 - .../aiCustomizationShortcutsWidget.test.ts | 216 - .../test/browser/sessionsList.test.ts | 188 - .../browser/sessionsListModelService.test.ts | 325 - .../browser/sessionsTerminalContribution.ts | 491 - .../sessionsTerminalContribution.test.ts | 821 - .../contrib/tunnelHost/common/tunnelHost.ts | 32 - .../electron-browser/media/tunnelHost.css | 66 - .../toggleRemoteConnectionsActionViewItem.ts | 159 - .../tunnelHost.contribution.ts | 140 - .../electron-browser/tunnelHostService.ts | 242 - .../welcome/browser/media/apple-dark.svg | 3 - .../welcome/browser/media/apple-light.svg | 3 - .../welcome/browser/media/github-mark.svg | 1 - .../contrib/welcome/browser/media/google.svg | 6 - .../browser/media/sessionsWalkthrough.css | 603 - .../themePreviews/theme-preview-dark-2026.svg | 69 - .../themePreviews/theme-preview-hc-dark.svg | 68 - .../themePreviews/theme-preview-hc-light.svg | 68 - .../theme-preview-light-2026.svg | 69 - .../welcome/browser/sessionsWalkthrough.ts | 773 - .../welcome/browser/welcome.contribution.ts | 283 - .../browser/workingSet.contribution.ts | 9 - .../contrib/workingSet/browser/workingSet.ts | 200 - .../browser/workspace.contribution.ts | 9 - .../browser/workspaceFolderManagement.ts | 113 - .../sessions/copilot-customizations-spec.md | 356 - .../electron-browser/parts/titlebarPart.ts | 176 - .../electron-browser/sessions-dev.html | 80 - .../sessions/electron-browser/sessions.html | 79 - .../electron-browser/sessions.main.ts | 405 - src/vs/sessions/electron-browser/sessions.ts | 364 - .../browser/configurationService.ts | 377 - .../test/browser/configurationService.test.ts | 339 - .../browser/sessionsManagementService.ts | 551 - .../browser/sessionsProvidersService.ts | 62 - .../services/sessions/common/session.ts | 299 - .../sessions/common/sessionsManagement.ts | 138 - .../sessions/common/sessionsProvider.ts | 177 - .../browser/sessionsManagementService.test.ts | 105 - .../sessions/test/common/session.test.ts | 33 - .../services/title/browser/titleService.ts | 10 - .../title/electron-browser/titleService.ts | 10 - .../services/vscode/common/themeImporter.ts | 44 - .../electron-browser/themeImporterService.ts | 234 - .../browser/workspaceContextService.ts | 165 - src/vs/sessions/sessions.common.main.ts | 464 - src/vs/sessions/sessions.desktop.main.ts | 228 - src/vs/sessions/sessions.web.main.internal.ts | 24 - src/vs/sessions/sessions.web.main.ts | 180 - .../sessions/skills/act-on-feedback/SKILL.md | 14 - src/vs/sessions/skills/commit/SKILL.md | 80 - .../sessions/skills/create-draft-pr/SKILL.md | 16 - src/vs/sessions/skills/create-pr/SKILL.md | 16 - .../skills/generate-run-commands/SKILL.md | 53 - src/vs/sessions/skills/merge/SKILL.md | 72 - src/vs/sessions/skills/sync-upstream/SKILL.md | 31 - src/vs/sessions/skills/sync/SKILL.md | 73 - src/vs/sessions/skills/update-pr/SKILL.md | 16 - src/vs/sessions/skills/update-skills/SKILL.md | 114 - .../sessions/test/ai-customizations.test.md | 207 - .../test/browser/auxiliaryBarPart.test.ts | 70 - .../test/browser/layoutActions.test.ts | 27 - .../common/agentHostSessionsProvider.test.ts | 47 - src/vs/sessions/test/e2e/.gitignore | 3 - src/vs/sessions/test/e2e/README.md | 392 - src/vs/sessions/test/e2e/common.cjs | 151 - .../extensions/sessions-e2e-mock/extension.js | 176 - .../extensions/sessions-e2e-mock/package.json | 110 - src/vs/sessions/test/e2e/generate.cjs | 243 - src/vs/sessions/test/e2e/package-lock.json | 84 - src/vs/sessions/test/e2e/package.json | 14 - .../scenarios/01-chat-response.scenario.md | 6 - .../02-chat-with-changes.scenario.md | 10 - .../03-session-in-sidebar.scenario.md | 8 - .../04-navigate-sessions.scenario.md | 12 - .../scenarios/05-full-workflow.scenario.md | 24 - .../test/e2e/scenarios/generated/.gitkeep | 0 .../generated/01-chat-response.commands.json | 26 - .../02-chat-with-changes.commands.json | 53 - .../03-session-in-sidebar.commands.json | 33 - .../04-navigate-sessions.commands.json | 65 - .../generated/05-full-workflow.commands.json | 134 - src/vs/sessions/test/e2e/test.cjs | 297 - .../test/sessions.web.test.internal.ts | 23 - src/vs/sessions/test/web.test.factory.ts | 34 - src/vs/sessions/test/web.test.ts | 598 - .../api/browser/extensionHost.contribution.ts | 21 +- .../mainThreadTurboFileOptionalServices.ts | 102 + .../api/browser/mainThreadWebviewManager.ts | 3 - .../workbench/api/common/extHost.api.impl.ts | 15 +- src/vs/workbench/browser/editor.ts | 18 +- .../browser/parts/editor/editorPanes.ts | 18 + .../parts/editor/multiEditorTabsControl.ts | 6 +- src/vs/workbench/common/editor/editorInput.ts | 16 +- .../notebook/browser/notebook.contribution.ts | 3 - .../contrib/scm/browser/scm.contribution.ts | 51 +- .../scm/browser/scmHistoryTransfer.ts} | 17 +- .../contrib/scm/browser/scmHistoryViewPane.ts | 2 +- .../workbench/contrib/scm/browser/scmInput.ts | 53 +- .../search/browser/AISearch/aiSearchModel.ts | 399 - .../browser/AISearch/aiSearchModelBase.ts | 16 - .../search/browser/anythingQuickAccess.ts | 27 +- .../quickTextSearch/textSearchQuickAccess.ts | 4 +- .../contrib/search/browser/replaceService.ts | 2 +- .../search/browser/search.contribution.ts | 20 +- .../search/browser/searchActionsCopy.ts | 35 +- .../browser/searchActionsRemoveReplace.ts | 9 - .../search/browser/searchActionsTopBar.ts | 28 +- .../contrib/search/browser/searchCompare.ts | 5 - .../contrib/search/browser/searchFindInput.ts | 3 - .../contrib/search/browser/searchIcons.ts | 3 - .../search/browser/searchResultsView.ts | 37 +- .../browser/searchTreeModel/searchModel.ts | 113 +- .../browser/searchTreeModel/searchResult.ts | 97 +- .../searchTreeModel/searchTreeCommon.ts | 27 +- .../contrib/search/browser/searchView.ts | 334 +- .../search/browser/symbolsQuickAccess.ts | 22 +- .../tasks/browser/abstractTaskService.ts | 42 +- .../tasks/electron-browser/taskService.ts | 6 - .../browser/telemetry.contribution.ts | 463 - .../turbofile/browser/media/turbofile.css | 550 +- .../browser/turbofile.contribution.ts | 138 +- .../turbofile/browser/turbofileDeviceStore.ts | 122 +- .../browser/turbofileDocumentTabAffinity.ts | 40 + .../turbofile/browser/turbofileEditor.ts | 5345 +++++-- .../browser/turbofileRecycleBinIcons.ts | 13 + .../browser/turbofileSlimOptionalServices.ts | 69 + .../turbofile/browser/turbofileTabRename.ts | 40 + .../turbofile/common/turbofileCopyName.ts | 41 + .../common/turbofileDirectoryInflight.ts | 56 + .../common/turbofileDirectoryPrefetchQueue.ts | 78 + .../turbofile/common/turbofileDragDrop.ts | 32 + .../turbofile/common/turbofileEditorInput.ts | 69 +- .../turbofile/common/turbofileFileSort.ts | 74 + .../common/turbofileImageDimensions.ts | 39 + .../common/turbofileImageGridClipboard.ts} | 9 +- .../common/turbofileImageGridSizing.ts | 66 + .../common/turbofileImageGridSort.ts | 70 + .../turbofile/common/turbofileImagePreview.ts | 51 + .../common/turbofileImageResource.ts | 65 + .../turbofile/common/turbofileTitlebar.ts | 28 + .../common/turbofileTransferDisplay.ts | 28 + .../common/turbofileTransferRefresh.ts | 17 + .../turbofile/common/turbofileTrashView.ts | 93 + .../common/turbofileWindowsLocation.ts | 16 + .../test/common/turbofileCopyName.test.ts | 67 + .../common/turbofileDefaultDevices.test.ts | 106 + .../test/common/turbofileDeviceForm.test.ts | 68 + .../common/turbofileDirectoryInflight.test.ts | 57 + .../turbofileDirectoryNavigation.test.ts | 92 + .../turbofileDirectoryPrefetchQueue.test.ts | 90 + .../turbofileDocumentTabAffinity.test.ts | 96 + .../test/common/turbofileDragDrop.test.ts | 170 + .../test/common/turbofileEditorInput.test.ts | 80 + .../turbofileEditorPaneLifecycle.test.ts | 49 + .../test/common/turbofileFileListIcon.test.ts | 26 + .../test/common/turbofileFileSort.test.ts | 98 + .../common/turbofileImageDimensions.test.ts | 25 + .../common/turbofileImageGridPane.test.ts | 269 + .../common/turbofileImageGridSizing.test.ts | 54 + .../common/turbofileImageGridSort.test.ts | 74 + .../test/common/turbofileImagePreview.test.ts | 37 + .../common/turbofileImageResource.test.ts | 51 + .../common/turbofileInlineRenameCss.test.ts | 26 + .../common/turbofileRecycleBinIcons.test.ts | 31 + .../turbofileRecycleBinInteraction.test.ts | 74 + .../turbofileRendererBatchHotPath.test.ts | 112 + .../test/common/turbofileSlimShell.test.ts | 288 + .../common/turbofileTabPresentation.test.ts | 77 + .../test/common/turbofileTabRename.test.ts | 84 + .../common/turbofileTerminalPanel.test.ts | 69 + .../test/common/turbofileTitlebar.test.ts | 106 + .../common/turbofileTransferDisplay.test.ts | 22 +- .../common/turbofileTransferRefresh.test.ts | 21 + .../test/common/turbofileTrashView.test.ts | 127 + .../common/turbofileWindowsLocation.test.ts | 25 + .../browser/media/updateTitleBarEntry.css | 64 +- .../update/browser/updateTitleBarEntry.ts | 25 + .../common/updateTitleBarPresentation.ts | 20 + .../common/updateTitleBarPresentation.test.ts | 28 + .../electron-browser/desktop.contribution.ts | 1 - .../playwrightWorkbenchService.ts | 28 - .../turbofileExtensionContributionService.ts | 271 - .../\346\261\211\350\257\255.txt" | 0 .../browser/componentFixtures/fixtureUtils.ts | 45 - .../sessions/agentSessionsViewer.fixture.ts | 730 - .../aiCustomizationListWidget.fixture.ts | 236 - ...aiCustomizationManagementEditor.fixture.ts | 1593 -- .../aiCustomizationWelcomePages.fixture.ts | 108 - .../sessions/mockCodeReviewService.ts | 35 - .../browser/parts/editor/editorPane.test.ts | 14 +- src/vs/workbench/workbench.common.main.ts | 73 +- src/vs/workbench/workbench.desktop.main.ts | 30 +- src/vs/workbench/workbench.web.main.ts | 6 - 739 files changed, 55801 insertions(+), 149110 deletions(-) create mode 100644 .github/workflows/turbofile-update-manifest.yml create mode 100644 build/turbofile-product.json create mode 100644 docs/superpowers/plans/2026-07-10-same-device-directory-merge.md create mode 100644 docs/superpowers/plans/2026-07-10-ssh-post-transfer-session-recovery.md create mode 100644 docs/superpowers/plans/2026-07-14-turbofile-codebase-slimming.md create mode 100644 docs/superpowers/plans/2026-07-15-batch-stream-progress-direct-first.md create mode 100644 docs/superpowers/plans/2026-07-15-drag-mode-compact-icon.md create mode 100644 docs/superpowers/plans/2026-07-15-drag-transfer-mode.md create mode 100644 docs/superpowers/plans/2026-07-15-image-grid-fast-correct-sorting.md create mode 100644 docs/superpowers/plans/2026-07-15-non-single-file-unknown-size.md create mode 100644 docs/superpowers/plans/2026-07-15-transfer-reliability-and-pane-controls.md create mode 100644 docs/superpowers/plans/2026-07-15-turbofile-document-tab-affinity.md create mode 100644 docs/superpowers/plans/2026-07-16-immediate-transfer-start-and-move-refresh.md create mode 100644 docs/superpowers/plans/2026-07-16-pane-copy-cut-mode-on-148.md create mode 100644 docs/superpowers/plans/2026-07-16-per-device-recycle-bin.md create mode 100644 docs/superpowers/plans/2026-07-16-single-file-transfer-total.md create mode 100644 docs/superpowers/plans/2026-07-16-stable-directory-sort.md create mode 100644 docs/superpowers/plans/2026-07-16-transfer-148-pane-isolation-and-151-package.md create mode 100644 docs/superpowers/plans/2026-07-17-image-grid-instant-file-sort.md create mode 100644 docs/superpowers/plans/2026-07-17-large-batch-tar-path-list.md create mode 100644 docs/superpowers/plans/2026-07-17-persistent-taskbar-with-terminal.md create mode 100644 docs/superpowers/plans/2026-07-17-recycle-bin-file-operations-and-sorting.md create mode 100644 docs/superpowers/plans/2026-07-17-ssh-device-form-focus-and-password-only.md create mode 100644 docs/superpowers/plans/2026-07-17-turbofile-company-value-slide.md create mode 100644 docs/superpowers/plans/2026-07-17-turbofile-leadership-presentation.md create mode 100644 docs/superpowers/plans/2026-07-17-windows-recycle-bin-icon-and-delete-dialog.md create mode 100644 docs/superpowers/plans/2026-07-18-extreme-batch-stability.md create mode 100644 docs/superpowers/plans/2026-07-21-same-directory-copy-name.md create mode 100644 docs/superpowers/specs/2026-07-10-same-device-directory-merge-design.md create mode 100644 docs/superpowers/specs/2026-07-10-ssh-post-transfer-session-recovery-design.md create mode 100644 docs/superpowers/specs/2026-07-14-drag-transfer-mode-design.md create mode 100644 docs/superpowers/specs/2026-07-14-turbofile-codebase-slimming-design.md create mode 100644 docs/superpowers/specs/2026-07-15-batch-stream-progress-direct-first-design.md create mode 100644 docs/superpowers/specs/2026-07-15-drag-mode-compact-icon-design.md create mode 100644 docs/superpowers/specs/2026-07-15-image-grid-fast-correct-sorting-design.md create mode 100644 docs/superpowers/specs/2026-07-15-non-single-file-unknown-size-design.md create mode 100644 docs/superpowers/specs/2026-07-15-tar-resilience-pane-modes-conflict-alert-design.md create mode 100644 docs/superpowers/specs/2026-07-15-turbofile-document-tab-affinity-design.md create mode 100644 docs/superpowers/specs/2026-07-16-immediate-transfer-start-and-move-refresh-design.md create mode 100644 docs/superpowers/specs/2026-07-16-pane-copy-cut-mode-on-148-design.md create mode 100644 docs/superpowers/specs/2026-07-16-per-device-recycle-bin-design.md create mode 100644 docs/superpowers/specs/2026-07-16-stable-directory-sort-design.md create mode 100644 docs/superpowers/specs/2026-07-16-transfer-148-and-pane-isolation-design.md create mode 100644 docs/superpowers/specs/2026-07-17-image-grid-instant-file-sort-design.md create mode 100644 docs/superpowers/specs/2026-07-17-large-batch-tar-path-list-design.md create mode 100644 docs/superpowers/specs/2026-07-17-persistent-taskbar-with-terminal-design.md create mode 100644 docs/superpowers/specs/2026-07-17-recycle-bin-file-operations-and-sorting-design.md create mode 100644 docs/superpowers/specs/2026-07-17-ssh-device-form-focus-and-password-only-design.md create mode 100644 docs/superpowers/specs/2026-07-17-turbofile-company-value-slide-design.md create mode 100644 docs/superpowers/specs/2026-07-17-turbofile-leadership-presentation-design.md create mode 100644 docs/superpowers/specs/2026-07-17-windows-recycle-bin-icon-and-delete-dialog-design.md create mode 100644 docs/superpowers/specs/2026-07-18-extreme-batch-stability-design.md create mode 100644 docs/superpowers/specs/2026-07-21-same-directory-copy-name-design.md rename "docs/\346\265\274\347\212\272\347\267\255\347\273\257\350\215\244\347\262\272\351\226\262\345\266\206\347\200\257\347\222\201\346\215\220\356\205\270.md" => "docs/\344\274\240\350\276\223\347\263\273\347\273\237\351\207\215\346\236\204\350\256\276\350\256\241.md" (100%) create mode 100644 out/vs/platform/turbofile/common/turbofileWindowsPath.js create mode 100644 out/vs/platform/turbofile/electron-main/turbofileSshMainService.js create mode 100644 out/vs/platform/turbofile/test/common/turbofileWindowsPath.test.js create mode 100644 out/vs/workbench/contrib/turbofile/browser/media/turbofile.css create mode 100644 out/vs/workbench/contrib/turbofile/browser/turbofileEditor.js create mode 100644 out/vs/workbench/contrib/turbofile/common/turbofileDragDrop.js create mode 100644 out/vs/workbench/contrib/turbofile/common/turbofileFileSort.js create mode 100644 out/vs/workbench/contrib/turbofile/common/turbofileImageDimensions.js create mode 100644 out/vs/workbench/contrib/turbofile/common/turbofileImageGridSizing.js create mode 100644 out/vs/workbench/contrib/turbofile/common/turbofileImagePreview.js create mode 100644 out/vs/workbench/contrib/turbofile/common/turbofileImageResource.js create mode 100644 out/vs/workbench/contrib/turbofile/common/turbofileWindowsLocation.js create mode 100644 out/vs/workbench/contrib/turbofile/test/common/turbofileImageDimensions.test.js create mode 100644 resources/turbofile/drag-ext/TurboFileDragSource.cpp create mode 100644 resources/turbofile/drag-ext/x64/TurboFileDragSource.exe create mode 100644 scripts/turbofile-update-manifest.mjs delete mode 100644 src/vs/platform/agentHost/browser/nullAgentHostService.ts delete mode 100644 src/vs/platform/agentHost/browser/nullSshRemoteAgentHostService.ts delete mode 100644 src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts delete mode 100644 src/vs/platform/agentHost/browser/remoteAgentHostServiceImpl.ts delete mode 100644 src/vs/platform/agentHost/browser/webSocketClientTransport.ts delete mode 100644 src/vs/platform/agentHost/electron-browser/agentHostService.ts delete mode 100644 src/vs/platform/agentHost/electron-browser/remoteAgentHostService.ts delete mode 100644 src/vs/platform/agentHost/electron-browser/sshRelayTransport.ts delete mode 100644 src/vs/platform/agentHost/electron-browser/sshRemoteAgentHostService.ts delete mode 100644 src/vs/platform/agentHost/electron-browser/sshRemoteAgentHostServiceImpl.ts delete mode 100644 src/vs/platform/agentHost/electron-browser/tunnelRelayTransport.ts delete mode 100644 src/vs/platform/agentHost/electron-main/electronAgentHostStarter.ts delete mode 100644 src/vs/platform/agentHost/node/agentConfigurationService.ts delete mode 100644 src/vs/platform/agentHost/node/agentHostGitService.ts delete mode 100644 src/vs/platform/agentHost/node/agentHostMain.ts delete mode 100644 src/vs/platform/agentHost/node/agentHostServerMain.ts delete mode 100644 src/vs/platform/agentHost/node/agentHostService.ts delete mode 100644 src/vs/platform/agentHost/node/agentHostStateManager.ts delete mode 100644 src/vs/platform/agentHost/node/agentHostTerminalManager.ts delete mode 100644 src/vs/platform/agentHost/node/agentPluginManager.ts delete mode 100644 src/vs/platform/agentHost/node/agentService.ts delete mode 100644 src/vs/platform/agentHost/node/agentSideEffects.ts delete mode 100644 src/vs/platform/agentHost/node/claude/CONTEXT.md delete mode 100644 src/vs/platform/agentHost/node/claude/anthropicBetas.ts delete mode 100644 src/vs/platform/agentHost/node/claude/anthropicErrors.ts delete mode 100644 src/vs/platform/agentHost/node/claude/claudeModelId.ts delete mode 100644 src/vs/platform/agentHost/node/claude/claudeProxyAuth.ts delete mode 100644 src/vs/platform/agentHost/node/claude/claudeProxyService.ts delete mode 100644 src/vs/platform/agentHost/node/claude/phase-plan.1.5.md delete mode 100644 src/vs/platform/agentHost/node/claude/phase-plan.2.md delete mode 100644 src/vs/platform/agentHost/node/claude/roadmap.md delete mode 100644 src/vs/platform/agentHost/node/commandAutoApprover.ts delete mode 100644 src/vs/platform/agentHost/node/copilot/copilotAgent.ts delete mode 100644 src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts delete mode 100644 src/vs/platform/agentHost/node/copilot/copilotGitProject.ts delete mode 100644 src/vs/platform/agentHost/node/copilot/copilotPluginConverters.ts delete mode 100644 src/vs/platform/agentHost/node/copilot/copilotSessionWrapper.ts delete mode 100644 src/vs/platform/agentHost/node/copilot/copilotShellTools.ts delete mode 100644 src/vs/platform/agentHost/node/copilot/copilotToolDisplay.ts delete mode 100644 src/vs/platform/agentHost/node/copilot/fileEditTracker.ts delete mode 100644 src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts delete mode 100644 src/vs/platform/agentHost/node/copilot/pendingEditContentStore.ts delete mode 100644 src/vs/platform/agentHost/node/diffComputeService.ts delete mode 100644 src/vs/platform/agentHost/node/diffWorkerMain.ts delete mode 100644 src/vs/platform/agentHost/node/gitDiffContent.ts delete mode 100644 src/vs/platform/agentHost/node/nodeAgentHostStarter.ts delete mode 100644 src/vs/platform/agentHost/node/osc633Parser.ts delete mode 100644 src/vs/platform/agentHost/node/protocolServerHandler.ts delete mode 100644 src/vs/platform/agentHost/node/serverUrls.ts delete mode 100644 src/vs/platform/agentHost/node/sessionDataService.ts delete mode 100644 src/vs/platform/agentHost/node/sessionDatabase.ts delete mode 100644 src/vs/platform/agentHost/node/sessionDiffAggregator.ts delete mode 100644 src/vs/platform/agentHost/node/sessionPermissions.ts delete mode 100644 src/vs/platform/agentHost/node/shared/copilotApiService.ts delete mode 100644 src/vs/platform/agentHost/node/sshRemoteAgentHostHelpers.ts delete mode 100644 src/vs/platform/agentHost/node/sshRemoteAgentHostService.ts delete mode 100644 src/vs/platform/agentHost/node/tunnelAgentHostService.ts delete mode 100644 src/vs/platform/agentHost/node/tunnelHostMainService.ts delete mode 100644 src/vs/platform/agentHost/node/webSocketTransport.ts delete mode 100644 src/vs/platform/agentHost/test/auth-rework.md delete mode 100644 src/vs/platform/agentHost/test/common/agentClientUri.test.ts delete mode 100644 src/vs/platform/agentHost/test/common/agentHostFileSystemProvider.test.ts delete mode 100644 src/vs/platform/agentHost/test/common/agentHostSchema.test.ts delete mode 100644 src/vs/platform/agentHost/test/common/agentService.test.ts delete mode 100644 src/vs/platform/agentHost/test/common/agentSubscription.test.ts delete mode 100644 src/vs/platform/agentHost/test/common/commandLineHelpers.test.ts delete mode 100644 src/vs/platform/agentHost/test/common/sessionTestHelpers.ts delete mode 100644 src/vs/platform/agentHost/test/common/sshConfigParsing.test.ts delete mode 100644 src/vs/platform/agentHost/test/electron-browser/remoteAgentHostProtocolClient.test.ts delete mode 100644 src/vs/platform/agentHost/test/electron-browser/remoteAgentHostService.test.ts delete mode 100644 src/vs/platform/agentHost/test/electron-browser/sshRelayTransport.test.ts delete mode 100644 src/vs/platform/agentHost/test/electron-browser/sshRemoteAgentHostService.test.ts delete mode 100644 src/vs/platform/agentHost/test/node/AGENTS.md delete mode 100644 src/vs/platform/agentHost/test/node/agentConfigurationService.test.ts delete mode 100644 src/vs/platform/agentHost/test/node/agentHostGitService.integrationTest.ts delete mode 100644 src/vs/platform/agentHost/test/node/agentHostGitService.test.ts delete mode 100644 src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts delete mode 100644 src/vs/platform/agentHost/test/node/agentHostTerminalManager.test.ts delete mode 100644 src/vs/platform/agentHost/test/node/agentPluginManager.test.ts delete mode 100644 src/vs/platform/agentHost/test/node/agentService.test.ts delete mode 100644 src/vs/platform/agentHost/test/node/agentSideEffects.test.ts delete mode 100644 src/vs/platform/agentHost/test/node/anthropicBetas.test.ts delete mode 100644 src/vs/platform/agentHost/test/node/claudeModelId.test.ts delete mode 100644 src/vs/platform/agentHost/test/node/claudeProxyAuth.test.ts delete mode 100644 src/vs/platform/agentHost/test/node/claudeProxyService.test.ts delete mode 100644 src/vs/platform/agentHost/test/node/commandAutoApprover.test.ts delete mode 100644 src/vs/platform/agentHost/test/node/copilotAgent.test.ts delete mode 100644 src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts delete mode 100644 src/vs/platform/agentHost/test/node/copilotGitProject.test.ts delete mode 100644 src/vs/platform/agentHost/test/node/copilotPluginConverters.test.ts delete mode 100644 src/vs/platform/agentHost/test/node/copilotShellTools.test.ts delete mode 100644 src/vs/platform/agentHost/test/node/copilotToolDisplay.test.ts delete mode 100644 src/vs/platform/agentHost/test/node/fileEditTracker.test.ts delete mode 100644 src/vs/platform/agentHost/test/node/gitDiffContent.test.ts delete mode 100644 src/vs/platform/agentHost/test/node/historyRecordFixtures.test.ts delete mode 100644 src/vs/platform/agentHost/test/node/historyRecordFixtures.ts delete mode 100644 src/vs/platform/agentHost/test/node/mockAgent.ts delete mode 100644 src/vs/platform/agentHost/test/node/osc633Parser.test.ts delete mode 100644 src/vs/platform/agentHost/test/node/pluginStorage.test.ts delete mode 100644 src/vs/platform/agentHost/test/node/protocol/agentHostServer.integrationTest.ts delete mode 100644 src/vs/platform/agentHost/test/node/protocol/clientTools.integrationTest.ts delete mode 100644 src/vs/platform/agentHost/test/node/protocol/handshake.integrationTest.ts delete mode 100644 src/vs/platform/agentHost/test/node/protocol/multiClient.integrationTest.ts delete mode 100644 src/vs/platform/agentHost/test/node/protocol/sessionConfig.integrationTest.ts delete mode 100644 src/vs/platform/agentHost/test/node/protocol/sessionDiffs.integrationTest.ts delete mode 100644 src/vs/platform/agentHost/test/node/protocol/sessionDiffsRealSdk.integrationTest.ts delete mode 100644 src/vs/platform/agentHost/test/node/protocol/sessionFeatures.integrationTest.ts delete mode 100644 src/vs/platform/agentHost/test/node/protocol/sessionLifecycle.integrationTest.ts delete mode 100644 src/vs/platform/agentHost/test/node/protocol/testHelpers.ts delete mode 100644 src/vs/platform/agentHost/test/node/protocol/toolApproval.integrationTest.ts delete mode 100644 src/vs/platform/agentHost/test/node/protocol/toolApprovalRealSdk.integrationTest.ts delete mode 100644 src/vs/platform/agentHost/test/node/protocol/turnExecution.integrationTest.ts delete mode 100644 src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts delete mode 100644 src/vs/platform/agentHost/test/node/reducers.test.ts delete mode 100644 src/vs/platform/agentHost/test/node/serverUrls.test.ts delete mode 100644 src/vs/platform/agentHost/test/node/sessionDataService.test.ts delete mode 100644 src/vs/platform/agentHost/test/node/sessionDatabase.test.ts delete mode 100644 src/vs/platform/agentHost/test/node/sessionDiffAggregator.test.ts delete mode 100644 src/vs/platform/agentHost/test/node/shared/copilotApiService.test.ts delete mode 100644 src/vs/platform/agentHost/test/node/sshRemoteAgentHostHelpers.test.ts delete mode 100644 src/vs/platform/agentHost/test/node/sshRemoteAgentHostService.test.ts delete mode 100644 src/vs/platform/agentHost/test/node/test-cases/subagent-session.jsonl delete mode 100644 src/vs/platform/agentHost/test/node/testRemoteAgentHost.sh delete mode 100644 src/vs/platform/browserView/electron-browser/preload-browserView.ts delete mode 100644 src/vs/platform/browserView/electron-main/browserSession.ts delete mode 100644 src/vs/platform/browserView/electron-main/browserSessionTrust.ts delete mode 100644 src/vs/platform/browserView/electron-main/browserView.ts delete mode 100644 src/vs/platform/browserView/electron-main/browserViewCDPTarget.ts delete mode 100644 src/vs/platform/browserView/electron-main/browserViewDebugger.ts delete mode 100644 src/vs/platform/browserView/electron-main/browserViewElementInspector.ts delete mode 100644 src/vs/platform/browserView/electron-main/browserViewGroup.ts delete mode 100644 src/vs/platform/browserView/electron-main/browserViewGroupMainService.ts delete mode 100644 src/vs/platform/browserView/electron-main/browserViewMainService.ts delete mode 100644 src/vs/platform/browserView/node/browserViewGroupRemoteService.ts delete mode 100644 src/vs/platform/browserView/node/playwrightChannel.ts delete mode 100644 src/vs/platform/browserView/node/playwrightService.ts delete mode 100644 src/vs/platform/browserView/node/playwrightTab.ts delete mode 100644 src/vs/platform/browserView/test/electron-main/browserSessionTrust.test.ts create mode 100644 src/vs/platform/turbofile/common/turbofileArchiveExtract.ts create mode 100644 src/vs/platform/turbofile/common/turbofileImageMetadata.ts create mode 100644 src/vs/platform/turbofile/common/turbofileImageMetadataBatch.ts create mode 100644 src/vs/platform/turbofile/common/turbofileKeyedGeneration.ts create mode 100644 src/vs/platform/turbofile/common/turbofileOperationQueue.ts create mode 100644 src/vs/platform/turbofile/common/turbofilePosixHeartbeat.ts create mode 100644 src/vs/platform/turbofile/common/turbofileRemoteDirectoryRetry.ts create mode 100644 src/vs/platform/turbofile/common/turbofileRemoteNativeBatchPlan.ts create mode 100644 src/vs/platform/turbofile/common/turbofileRemoteStat.ts create mode 100644 src/vs/platform/turbofile/common/turbofileRsyncBatchPlan.ts create mode 100644 src/vs/platform/turbofile/common/turbofileTarProgress.ts create mode 100644 src/vs/platform/turbofile/common/turbofileTimeout.ts create mode 100644 src/vs/platform/turbofile/common/turbofileTrash.ts create mode 100644 src/vs/platform/turbofile/common/turbofileWindowsPath.ts create mode 100644 src/vs/platform/turbofile/test/common/turbofileArchiveExtract.test.ts create mode 100644 src/vs/platform/turbofile/test/common/turbofileBatchStabilityStatic.test.ts create mode 100644 src/vs/platform/turbofile/test/common/turbofileImageMetadata.test.ts create mode 100644 src/vs/platform/turbofile/test/common/turbofileImageMetadataBatch.test.ts create mode 100644 src/vs/platform/turbofile/test/common/turbofileKeyedGeneration.test.ts create mode 100644 src/vs/platform/turbofile/test/common/turbofileMutationStabilityStatic.test.ts create mode 100644 src/vs/platform/turbofile/test/common/turbofileOperationQueue.test.ts create mode 100644 src/vs/platform/turbofile/test/common/turbofileRemoteDirectoryRetry.test.ts create mode 100644 src/vs/platform/turbofile/test/common/turbofileRemoteNativeBatchPlan.test.ts create mode 100644 src/vs/platform/turbofile/test/common/turbofileRemoteStat.test.ts create mode 100644 src/vs/platform/turbofile/test/common/turbofileRsyncBatchPlan.test.ts create mode 100644 src/vs/platform/turbofile/test/common/turbofileSshMainServiceStatic.test.ts create mode 100644 src/vs/platform/turbofile/test/common/turbofileTarProgress.test.ts create mode 100644 src/vs/platform/turbofile/test/common/turbofileTimeout.test.ts create mode 100644 src/vs/platform/turbofile/test/common/turbofileTransfer148Baseline.test.ts create mode 100644 src/vs/platform/turbofile/test/common/turbofileTrash.test.ts create mode 100644 src/vs/platform/turbofile/test/common/turbofileWindowsPath.test.ts create mode 100644 src/vs/platform/update/common/turbofileUpdate.ts create mode 100644 src/vs/platform/update/test/common/turbofileUpdate.test.ts delete mode 100644 src/vs/server/node/serverAgentHostManager.ts delete mode 100644 src/vs/server/test/node/serverAgentHostManager.test.ts delete mode 100644 src/vs/sessions/AI_CUSTOMIZATIONS.md delete mode 100644 src/vs/sessions/LAYOUT.md delete mode 100644 src/vs/sessions/MOBILE.md delete mode 100644 src/vs/sessions/README.md delete mode 100644 src/vs/sessions/SESSIONS_PROVIDER.md delete mode 100644 src/vs/sessions/browser/accountTitleBarState.ts delete mode 100644 src/vs/sessions/browser/chatDashboardService.ts delete mode 100644 src/vs/sessions/browser/layoutActions.ts delete mode 100644 src/vs/sessions/browser/layoutPolicy.ts delete mode 100644 src/vs/sessions/browser/media/sessions-icon.svg delete mode 100644 src/vs/sessions/browser/media/sessions-logo-dark.svg delete mode 100644 src/vs/sessions/browser/media/sessions-logo-light.svg delete mode 100644 src/vs/sessions/browser/media/sidebarActionButton.css delete mode 100644 src/vs/sessions/browser/media/style.css delete mode 100644 src/vs/sessions/browser/menus.ts delete mode 100644 src/vs/sessions/browser/mobileNavigationStack.ts delete mode 100644 src/vs/sessions/browser/paneCompositePartService.ts delete mode 100644 src/vs/sessions/browser/parts/auxiliaryBarPart.ts delete mode 100644 src/vs/sessions/browser/parts/chatBarPart.ts delete mode 100644 src/vs/sessions/browser/parts/chatCompositeBar.ts delete mode 100644 src/vs/sessions/browser/parts/editorPart.ts delete mode 100644 src/vs/sessions/browser/parts/editorParts.ts delete mode 100644 src/vs/sessions/browser/parts/media/auxiliaryBarPart.css delete mode 100644 src/vs/sessions/browser/parts/media/chatBarPart.css delete mode 100644 src/vs/sessions/browser/parts/media/chatCompositeBar.css delete mode 100644 src/vs/sessions/browser/parts/media/editorPart.css delete mode 100644 src/vs/sessions/browser/parts/media/panelPart.css delete mode 100644 src/vs/sessions/browser/parts/media/projectBarPart.css delete mode 100644 src/vs/sessions/browser/parts/media/sidebarPart.css delete mode 100644 src/vs/sessions/browser/parts/media/titlebarpart.css delete mode 100644 src/vs/sessions/browser/parts/menubar.contribution.ts delete mode 100644 src/vs/sessions/browser/parts/mobile/contributions/media/mobileOverlayViews.css delete mode 100644 src/vs/sessions/browser/parts/mobile/contributions/mobileDiffView.ts delete mode 100644 src/vs/sessions/browser/parts/mobile/media/mobileSessionFilterChips.css delete mode 100644 src/vs/sessions/browser/parts/mobile/media/mobileSortGroupSheet.css delete mode 100644 src/vs/sessions/browser/parts/mobile/mobileAuxiliaryBarPart.ts delete mode 100644 src/vs/sessions/browser/parts/mobile/mobileChatBarPart.ts delete mode 100644 src/vs/sessions/browser/parts/mobile/mobileChatShell.css delete mode 100644 src/vs/sessions/browser/parts/mobile/mobileLayout.ts delete mode 100644 src/vs/sessions/browser/parts/mobile/mobilePanelPart.ts delete mode 100644 src/vs/sessions/browser/parts/mobile/mobileSessionFilterChips.ts delete mode 100644 src/vs/sessions/browser/parts/mobile/mobileSidebarPart.ts delete mode 100644 src/vs/sessions/browser/parts/mobile/mobileSortGroupSheet.ts delete mode 100644 src/vs/sessions/browser/parts/mobile/mobileTitlebarPart.ts delete mode 100644 src/vs/sessions/browser/parts/panelPart.ts delete mode 100644 src/vs/sessions/browser/parts/parts.ts delete mode 100644 src/vs/sessions/browser/parts/projectBarPart.ts delete mode 100644 src/vs/sessions/browser/parts/sidebarPart.ts delete mode 100644 src/vs/sessions/browser/parts/titlebarPart.ts delete mode 100644 src/vs/sessions/browser/web.factory.ts delete mode 100644 src/vs/sessions/browser/web.main.ts delete mode 100644 src/vs/sessions/browser/widget/AGENTS_CHAT_WIDGET.md delete mode 100644 src/vs/sessions/browser/workbench.ts delete mode 100644 src/vs/sessions/common/agentHostSessionWorkspace.ts delete mode 100644 src/vs/sessions/common/agentHostSessionsProvider.ts delete mode 100644 src/vs/sessions/common/categories.ts delete mode 100644 src/vs/sessions/common/contextkeys.ts delete mode 100644 src/vs/sessions/common/sessionsTelemetry.ts delete mode 100644 src/vs/sessions/common/theme.ts delete mode 100644 src/vs/sessions/common/welcome.ts delete mode 100644 src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts delete mode 100644 src/vs/sessions/contrib/accountMenu/browser/media/accountTitleBarWidget.css delete mode 100644 src/vs/sessions/contrib/accountMenu/browser/media/accountWidget.css delete mode 100644 src/vs/sessions/contrib/accountMenu/browser/media/updateHoverWidget.css delete mode 100644 src/vs/sessions/contrib/accountMenu/browser/updateHoverWidget.ts delete mode 100644 src/vs/sessions/contrib/accountMenu/test/browser/account.contribution.test.ts delete mode 100644 src/vs/sessions/contrib/accountMenu/test/browser/accountTitleBarState.test.ts delete mode 100644 src/vs/sessions/contrib/accountMenu/test/browser/updateHoverWidget.fixture.ts delete mode 100644 src/vs/sessions/contrib/agentFeedback/browser/agentFeedback.contribution.ts delete mode 100644 src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackAttachment.ts delete mode 100644 src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackAttachmentWidget.ts delete mode 100644 src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorActions.ts delete mode 100644 src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorInputContribution.ts delete mode 100644 src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorOverlay.ts delete mode 100644 src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorUtils.ts delete mode 100644 src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorWidgetContribution.ts delete mode 100644 src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackHover.ts delete mode 100644 src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackOverviewRulerContribution.ts delete mode 100644 src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackService.ts delete mode 100644 src/vs/sessions/contrib/agentFeedback/browser/media/agentFeedbackAttachment.css delete mode 100644 src/vs/sessions/contrib/agentFeedback/browser/media/agentFeedbackEditorInput.css delete mode 100644 src/vs/sessions/contrib/agentFeedback/browser/media/agentFeedbackEditorOverlay.css delete mode 100644 src/vs/sessions/contrib/agentFeedback/browser/media/agentFeedbackEditorWidget.css delete mode 100644 src/vs/sessions/contrib/agentFeedback/browser/nullAgentFeedbackService.contribution.ts delete mode 100644 src/vs/sessions/contrib/agentFeedback/browser/sessionEditorComments.ts delete mode 100644 src/vs/sessions/contrib/agentFeedback/test/browser/agentFeedbackEditorOverlayWidget.fixture.ts delete mode 100644 src/vs/sessions/contrib/agentFeedback/test/browser/agentFeedbackEditorWidget.fixture.ts delete mode 100644 src/vs/sessions/contrib/agentFeedback/test/browser/agentFeedbackService.test.ts delete mode 100644 src/vs/sessions/contrib/agentFeedback/test/browser/sessionEditorComments.test.ts delete mode 100644 src/vs/sessions/contrib/agentHost/browser/agentHostDiffs.ts delete mode 100644 src/vs/sessions/contrib/agentHost/browser/agentHostSettings.contribution.ts delete mode 100644 src/vs/sessions/contrib/agentHost/browser/agentHostSettingsFileSystemProvider.ts delete mode 100644 src/vs/sessions/contrib/agentHost/browser/agentHostSettingsShared.ts delete mode 100644 src/vs/sessions/contrib/agentHost/browser/agentHostSkillButtons.ts delete mode 100644 src/vs/sessions/contrib/agentHost/browser/agentSessionSettings.contribution.ts delete mode 100644 src/vs/sessions/contrib/agentHost/browser/agentSessionSettingsFileSystemProvider.ts delete mode 100644 src/vs/sessions/contrib/agentHost/browser/baseAgentHostSessionsProvider.ts delete mode 100644 src/vs/sessions/contrib/agentHost/browser/localAgentHost.contribution.ts delete mode 100644 src/vs/sessions/contrib/agentHost/browser/localAgentHostSessionsProvider.ts delete mode 100644 src/vs/sessions/contrib/agentHost/test/browser/agentHostSkillButtons.test.ts delete mode 100644 src/vs/sessions/contrib/agentHost/test/browser/agentSessionSettingsFileSystemProvider.test.ts delete mode 100644 src/vs/sessions/contrib/agentHost/test/browser/localAgentHostSessionsProvider.test.ts delete mode 100644 src/vs/sessions/contrib/aiCustomizationTreeView/browser/aiCustomizationOverviewView.ts delete mode 100644 src/vs/sessions/contrib/aiCustomizationTreeView/browser/aiCustomizationTreeView.contribution.ts delete mode 100644 src/vs/sessions/contrib/aiCustomizationTreeView/browser/aiCustomizationTreeView.ts delete mode 100644 src/vs/sessions/contrib/aiCustomizationTreeView/browser/aiCustomizationTreeViewViews.ts delete mode 100644 src/vs/sessions/contrib/aiCustomizationTreeView/browser/media/aiCustomizationTreeView.css delete mode 100644 src/vs/sessions/contrib/applyCommitsToParentRepo/browser/applyChangesToParentRepo.ts delete mode 100644 src/vs/sessions/contrib/aquarium/browser/aquarium.contribution.ts delete mode 100644 src/vs/sessions/contrib/aquarium/browser/aquariumOverlay.ts delete mode 100644 src/vs/sessions/contrib/aquarium/browser/fish.ts delete mode 100644 src/vs/sessions/contrib/aquarium/browser/media/aquarium.css delete mode 100644 src/vs/sessions/contrib/aquarium/browser/vscodeLogoPath.ts delete mode 100644 src/vs/sessions/contrib/browserView/browser/sessionBrowserView.contribution.ts delete mode 100644 src/vs/sessions/contrib/browserView/browser/sessionBrowserView.ts delete mode 100644 src/vs/sessions/contrib/changes/browser/changes.contribution.ts delete mode 100644 src/vs/sessions/contrib/changes/browser/changesMultiDiffSourceResolver.ts delete mode 100644 src/vs/sessions/contrib/changes/browser/changesTitleBarWidget.ts delete mode 100644 src/vs/sessions/contrib/changes/browser/changesView.ts delete mode 100644 src/vs/sessions/contrib/changes/browser/changesViewActions.ts delete mode 100644 src/vs/sessions/contrib/changes/browser/changesViewModel.ts delete mode 100644 src/vs/sessions/contrib/changes/browser/changesViewRenderer.ts delete mode 100644 src/vs/sessions/contrib/changes/browser/checksActions.ts delete mode 100644 src/vs/sessions/contrib/changes/browser/checksViewModel.ts delete mode 100644 src/vs/sessions/contrib/changes/browser/checksWidget.ts delete mode 100644 src/vs/sessions/contrib/changes/browser/media/changesTitleBarWidget.css delete mode 100644 src/vs/sessions/contrib/changes/browser/media/changesView.css delete mode 100644 src/vs/sessions/contrib/changes/browser/media/changesViewActions.css delete mode 100644 src/vs/sessions/contrib/changes/browser/media/checksWidget.css delete mode 100644 src/vs/sessions/contrib/changes/common/changes.ts delete mode 100644 src/vs/sessions/contrib/chat/browser/agentHost/agentHostModePicker.ts delete mode 100644 src/vs/sessions/contrib/chat/browser/agentHost/agentHostModelPicker.ts delete mode 100644 src/vs/sessions/contrib/chat/browser/agentHost/agentHostPermissionPickerActionItem.ts delete mode 100644 src/vs/sessions/contrib/chat/browser/agentHost/agentHostPermissionPickerDelegate.ts delete mode 100644 src/vs/sessions/contrib/chat/browser/agentHost/agentHostSessionConfigPicker.ts delete mode 100644 src/vs/sessions/contrib/chat/browser/aiCustomizationWorkspaceService.ts delete mode 100644 src/vs/sessions/contrib/chat/browser/branchChatSessionAction.ts delete mode 100644 src/vs/sessions/contrib/chat/browser/chat.contribution.ts delete mode 100644 src/vs/sessions/contrib/chat/browser/customizationHarnessService.ts delete mode 100644 src/vs/sessions/contrib/chat/browser/customizationsDebugLog.contribution.ts delete mode 100644 src/vs/sessions/contrib/chat/browser/media/chatInput.css delete mode 100644 src/vs/sessions/contrib/chat/browser/media/chatWidget.css delete mode 100644 src/vs/sessions/contrib/chat/browser/media/letterpress-sessions-dark.svg delete mode 100644 src/vs/sessions/contrib/chat/browser/media/letterpress-sessions-light.svg delete mode 100644 src/vs/sessions/contrib/chat/browser/media/newChatInSession.css delete mode 100644 src/vs/sessions/contrib/chat/browser/media/openInVSCode.css delete mode 100644 src/vs/sessions/contrib/chat/browser/media/runScriptAction.css delete mode 100644 src/vs/sessions/contrib/chat/browser/media/vscode-icon.svg delete mode 100644 src/vs/sessions/contrib/chat/browser/newChatContextAttachments.ts delete mode 100644 src/vs/sessions/contrib/chat/browser/newChatInSessionViewPane.ts delete mode 100644 src/vs/sessions/contrib/chat/browser/newChatInput.ts delete mode 100644 src/vs/sessions/contrib/chat/browser/newChatViewPane.ts delete mode 100644 src/vs/sessions/contrib/chat/browser/newSession.ts delete mode 100644 src/vs/sessions/contrib/chat/browser/nullChatTipService.ts delete mode 100644 src/vs/sessions/contrib/chat/browser/nullInlineChatSessionService.ts delete mode 100644 src/vs/sessions/contrib/chat/browser/openInVSCode.contribution.ts delete mode 100644 src/vs/sessions/contrib/chat/browser/openInVSCodeUtils.ts delete mode 100644 src/vs/sessions/contrib/chat/browser/openInVSCodeWidget.ts delete mode 100644 src/vs/sessions/contrib/chat/browser/promptsService.ts delete mode 100644 src/vs/sessions/contrib/chat/browser/repoPicker.ts delete mode 100644 src/vs/sessions/contrib/chat/browser/runScriptAction.ts delete mode 100644 src/vs/sessions/contrib/chat/browser/runScriptCustomTaskWidget.ts delete mode 100644 src/vs/sessions/contrib/chat/browser/scopedWorkspacePicker.ts delete mode 100644 src/vs/sessions/contrib/chat/browser/sessionTargetPicker.ts delete mode 100644 src/vs/sessions/contrib/chat/browser/sessionTypePicker.ts delete mode 100644 src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts delete mode 100644 src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts delete mode 100644 src/vs/sessions/contrib/chat/browser/sessionsConfigurationService.ts delete mode 100644 src/vs/sessions/contrib/chat/browser/sessionsOpenerParticipant.ts delete mode 100644 src/vs/sessions/contrib/chat/browser/slashCommands.ts delete mode 100644 src/vs/sessions/contrib/chat/browser/variableCompletions.ts delete mode 100644 src/vs/sessions/contrib/chat/common/builtinPromptsStorage.ts delete mode 100644 src/vs/sessions/contrib/chat/electron-browser/openInVSCode.contribution.ts delete mode 100644 src/vs/sessions/contrib/chat/test/browser/agentHost/agentHostPermissionPickerDelegate.test.ts delete mode 100644 src/vs/sessions/contrib/chat/test/browser/resolveRemoteAuthority.test.ts delete mode 100644 src/vs/sessions/contrib/chat/test/browser/runScriptAction.test.ts delete mode 100644 src/vs/sessions/contrib/chat/test/browser/runScriptCustomTaskWidget.fixture.ts delete mode 100644 src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts delete mode 100644 src/vs/sessions/contrib/chat/test/browser/sessionsConfigurationService.test.ts delete mode 100644 src/vs/sessions/contrib/chatDebug/browser/chatDebug.contribution.ts delete mode 100644 src/vs/sessions/contrib/codeReview/browser/codeReview.contributions.ts delete mode 100644 src/vs/sessions/contrib/codeReview/browser/codeReviewService.ts delete mode 100644 src/vs/sessions/contrib/codeReview/test/browser/codeReviewService.test.ts delete mode 100644 src/vs/sessions/contrib/configuration/browser/configuration.contribution.ts delete mode 100644 src/vs/sessions/contrib/copilotChatSessions/COPILOT_CHAT_SESSIONS_PROVIDER.md delete mode 100644 src/vs/sessions/contrib/copilotChatSessions/browser/branchPicker.ts delete mode 100644 src/vs/sessions/contrib/copilotChatSessions/browser/claudePermissionModePicker.ts delete mode 100644 src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessions.contribution.ts delete mode 100644 src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsActions.ts delete mode 100644 src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsProvider.ts delete mode 100644 src/vs/sessions/contrib/copilotChatSessions/browser/isolationPicker.ts delete mode 100644 src/vs/sessions/contrib/copilotChatSessions/browser/modePicker.ts delete mode 100644 src/vs/sessions/contrib/copilotChatSessions/browser/modelPicker.ts delete mode 100644 src/vs/sessions/contrib/copilotChatSessions/browser/permissionPicker.ts delete mode 100644 src/vs/sessions/contrib/copilotChatSessions/test/browser/claudePermissionModePicker.test.ts delete mode 100644 src/vs/sessions/contrib/copilotChatSessions/test/browser/copilotChatSessionsProvider.test.ts delete mode 100644 src/vs/sessions/contrib/copilotChatSessions/test/browser/isolationPicker.test.ts delete mode 100644 src/vs/sessions/contrib/copilotChatSessions/test/browser/modelPickerDelegate.test.ts delete mode 100644 src/vs/sessions/contrib/editor/browser/editor.contribution.ts delete mode 100644 src/vs/sessions/contrib/editor/test/browser/editor.contribution.test.ts delete mode 100644 src/vs/sessions/contrib/fileTreeView/browser/fileTreeView.contribution.ts delete mode 100644 src/vs/sessions/contrib/fileTreeView/browser/githubFileSystemProvider.ts delete mode 100644 src/vs/sessions/contrib/files/browser/files.contribution.ts delete mode 100644 src/vs/sessions/contrib/files/browser/filesView.ts delete mode 100644 src/vs/sessions/contrib/files/browser/media/filesView.css delete mode 100644 src/vs/sessions/contrib/github/browser/fetchers/githubChangesFetcher.ts delete mode 100644 src/vs/sessions/contrib/github/browser/fetchers/githubPRCIFetcher.ts delete mode 100644 src/vs/sessions/contrib/github/browser/fetchers/githubPRFetcher.ts delete mode 100644 src/vs/sessions/contrib/github/browser/fetchers/githubRepositoryFetcher.ts delete mode 100644 src/vs/sessions/contrib/github/browser/github.contribution.ts delete mode 100644 src/vs/sessions/contrib/github/browser/githubApiClient.ts delete mode 100644 src/vs/sessions/contrib/github/browser/githubService.ts delete mode 100644 src/vs/sessions/contrib/github/browser/models/githubPullRequestCIModel.ts delete mode 100644 src/vs/sessions/contrib/github/browser/models/githubPullRequestModel.ts delete mode 100644 src/vs/sessions/contrib/github/browser/models/githubPullRequestReviewThreadsModel.ts delete mode 100644 src/vs/sessions/contrib/github/browser/models/githubRepositoryModel.ts delete mode 100644 src/vs/sessions/contrib/github/common/types.ts delete mode 100644 src/vs/sessions/contrib/github/common/utils.ts delete mode 100644 src/vs/sessions/contrib/github/test/browser/githubContribution.test.ts delete mode 100644 src/vs/sessions/contrib/github/test/browser/githubFetchers.test.ts delete mode 100644 src/vs/sessions/contrib/github/test/browser/githubModels.test.ts delete mode 100644 src/vs/sessions/contrib/github/test/browser/githubService.test.ts delete mode 100644 src/vs/sessions/contrib/layout/browser/layout.contribution.ts delete mode 100644 src/vs/sessions/contrib/layout/browser/layoutController.ts delete mode 100644 src/vs/sessions/contrib/policyBlocked/browser/media/sessionsPolicyBlocked.css delete mode 100644 src/vs/sessions/contrib/policyBlocked/browser/policyBlocked.contribution.ts delete mode 100644 src/vs/sessions/contrib/policyBlocked/browser/sessionsPolicyBlocked.ts delete mode 100644 src/vs/sessions/contrib/policyBlocked/test/browser/sessionsPolicyBlocked.fixture.ts delete mode 100644 src/vs/sessions/contrib/remoteAgentHost/REMOTE_AGENT_HOST_SESSIONS_PROVIDER.md delete mode 100644 src/vs/sessions/contrib/remoteAgentHost/browser/agentHostFilterService.ts delete mode 100644 src/vs/sessions/contrib/remoteAgentHost/browser/hostFilter.contribution.ts delete mode 100644 src/vs/sessions/contrib/remoteAgentHost/browser/hostFilterActionViewItem.ts delete mode 100644 src/vs/sessions/contrib/remoteAgentHost/browser/manageRemoteAgentHosts.ts delete mode 100644 src/vs/sessions/contrib/remoteAgentHost/browser/media/hostFilter.css delete mode 100644 src/vs/sessions/contrib/remoteAgentHost/browser/media/hostPickerDropdown.css delete mode 100644 src/vs/sessions/contrib/remoteAgentHost/browser/media/hostPickerSheet.css delete mode 100644 src/vs/sessions/contrib/remoteAgentHost/browser/mobileHostFilterActionViewItem.ts delete mode 100644 src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts delete mode 100644 src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostActions.ts delete mode 100644 src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostCustomizationHarness.ts delete mode 100644 src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts delete mode 100644 src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostTerminal.contribution.ts delete mode 100644 src/vs/sessions/contrib/remoteAgentHost/browser/remoteHostOptions.ts delete mode 100644 src/vs/sessions/contrib/remoteAgentHost/browser/syncedCustomizationBundler.ts delete mode 100644 src/vs/sessions/contrib/remoteAgentHost/browser/tunnelAgentHost.contribution.ts delete mode 100644 src/vs/sessions/contrib/remoteAgentHost/browser/webTunnelAgentHostService.contribution.ts delete mode 100644 src/vs/sessions/contrib/remoteAgentHost/browser/webTunnelAgentHostService.ts delete mode 100644 src/vs/sessions/contrib/remoteAgentHost/common/agentHostFilter.ts delete mode 100644 src/vs/sessions/contrib/remoteAgentHost/common/remoteAgentHostSessionType.ts delete mode 100644 src/vs/sessions/contrib/remoteAgentHost/electron-browser/tunnelAgentHostService.ts delete mode 100644 src/vs/sessions/contrib/remoteAgentHost/electron-browser/tunnelAgentHostServiceImpl.ts delete mode 100644 src/vs/sessions/contrib/remoteAgentHost/test/browser/agentHostFilterService.test.ts delete mode 100644 src/vs/sessions/contrib/remoteAgentHost/test/browser/remoteAgentHostCustomizationHarness.test.ts delete mode 100644 src/vs/sessions/contrib/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts delete mode 100644 src/vs/sessions/contrib/remoteAgentHost/test/common/remoteAgentHostSessionType.test.ts delete mode 100644 src/vs/sessions/contrib/sessions/browser/aiCustomizationShortcutsWidget.ts delete mode 100644 src/vs/sessions/contrib/sessions/browser/customizationsToolbar.contribution.ts delete mode 100644 src/vs/sessions/contrib/sessions/browser/media/customizationsToolbar.css delete mode 100644 src/vs/sessions/contrib/sessions/browser/media/sessionsList.css delete mode 100644 src/vs/sessions/contrib/sessions/browser/media/sessionsTitleBarWidget.css delete mode 100644 src/vs/sessions/contrib/sessions/browser/media/sessionsViewPane.css delete mode 100644 src/vs/sessions/contrib/sessions/browser/mobile/mobileOverlayContribution.ts delete mode 100644 src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts delete mode 100644 src/vs/sessions/contrib/sessions/browser/sessionsActions.ts delete mode 100644 src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts delete mode 100644 src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts delete mode 100644 src/vs/sessions/contrib/sessions/browser/views/sessionsListModelService.ts delete mode 100644 src/vs/sessions/contrib/sessions/browser/views/sessionsView.ts delete mode 100644 src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts delete mode 100644 src/vs/sessions/contrib/sessions/test/browser/aiCustomizationShortcutsWidget.fixture.ts delete mode 100644 src/vs/sessions/contrib/sessions/test/browser/aiCustomizationShortcutsWidget.test.ts delete mode 100644 src/vs/sessions/contrib/sessions/test/browser/sessionsList.test.ts delete mode 100644 src/vs/sessions/contrib/sessions/test/browser/sessionsListModelService.test.ts delete mode 100644 src/vs/sessions/contrib/terminal/browser/sessionsTerminalContribution.ts delete mode 100644 src/vs/sessions/contrib/terminal/test/browser/sessionsTerminalContribution.test.ts delete mode 100644 src/vs/sessions/contrib/tunnelHost/common/tunnelHost.ts delete mode 100644 src/vs/sessions/contrib/tunnelHost/electron-browser/media/tunnelHost.css delete mode 100644 src/vs/sessions/contrib/tunnelHost/electron-browser/toggleRemoteConnectionsActionViewItem.ts delete mode 100644 src/vs/sessions/contrib/tunnelHost/electron-browser/tunnelHost.contribution.ts delete mode 100644 src/vs/sessions/contrib/tunnelHost/electron-browser/tunnelHostService.ts delete mode 100644 src/vs/sessions/contrib/welcome/browser/media/apple-dark.svg delete mode 100644 src/vs/sessions/contrib/welcome/browser/media/apple-light.svg delete mode 100644 src/vs/sessions/contrib/welcome/browser/media/github-mark.svg delete mode 100644 src/vs/sessions/contrib/welcome/browser/media/google.svg delete mode 100644 src/vs/sessions/contrib/welcome/browser/media/sessionsWalkthrough.css delete mode 100644 src/vs/sessions/contrib/welcome/browser/media/themePreviews/theme-preview-dark-2026.svg delete mode 100644 src/vs/sessions/contrib/welcome/browser/media/themePreviews/theme-preview-hc-dark.svg delete mode 100644 src/vs/sessions/contrib/welcome/browser/media/themePreviews/theme-preview-hc-light.svg delete mode 100644 src/vs/sessions/contrib/welcome/browser/media/themePreviews/theme-preview-light-2026.svg delete mode 100644 src/vs/sessions/contrib/welcome/browser/sessionsWalkthrough.ts delete mode 100644 src/vs/sessions/contrib/welcome/browser/welcome.contribution.ts delete mode 100644 src/vs/sessions/contrib/workingSet/browser/workingSet.contribution.ts delete mode 100644 src/vs/sessions/contrib/workingSet/browser/workingSet.ts delete mode 100644 src/vs/sessions/contrib/workspace/browser/workspace.contribution.ts delete mode 100644 src/vs/sessions/contrib/workspace/browser/workspaceFolderManagement.ts delete mode 100644 src/vs/sessions/copilot-customizations-spec.md delete mode 100644 src/vs/sessions/electron-browser/parts/titlebarPart.ts delete mode 100644 src/vs/sessions/electron-browser/sessions-dev.html delete mode 100644 src/vs/sessions/electron-browser/sessions.html delete mode 100644 src/vs/sessions/electron-browser/sessions.main.ts delete mode 100644 src/vs/sessions/electron-browser/sessions.ts delete mode 100644 src/vs/sessions/services/configuration/browser/configurationService.ts delete mode 100644 src/vs/sessions/services/configuration/test/browser/configurationService.test.ts delete mode 100644 src/vs/sessions/services/sessions/browser/sessionsManagementService.ts delete mode 100644 src/vs/sessions/services/sessions/browser/sessionsProvidersService.ts delete mode 100644 src/vs/sessions/services/sessions/common/session.ts delete mode 100644 src/vs/sessions/services/sessions/common/sessionsManagement.ts delete mode 100644 src/vs/sessions/services/sessions/common/sessionsProvider.ts delete mode 100644 src/vs/sessions/services/sessions/test/browser/sessionsManagementService.test.ts delete mode 100644 src/vs/sessions/services/sessions/test/common/session.test.ts delete mode 100644 src/vs/sessions/services/title/browser/titleService.ts delete mode 100644 src/vs/sessions/services/title/electron-browser/titleService.ts delete mode 100644 src/vs/sessions/services/vscode/common/themeImporter.ts delete mode 100644 src/vs/sessions/services/vscode/electron-browser/themeImporterService.ts delete mode 100644 src/vs/sessions/services/workspace/browser/workspaceContextService.ts delete mode 100644 src/vs/sessions/sessions.common.main.ts delete mode 100644 src/vs/sessions/sessions.desktop.main.ts delete mode 100644 src/vs/sessions/sessions.web.main.internal.ts delete mode 100644 src/vs/sessions/sessions.web.main.ts delete mode 100644 src/vs/sessions/skills/act-on-feedback/SKILL.md delete mode 100644 src/vs/sessions/skills/commit/SKILL.md delete mode 100644 src/vs/sessions/skills/create-draft-pr/SKILL.md delete mode 100644 src/vs/sessions/skills/create-pr/SKILL.md delete mode 100644 src/vs/sessions/skills/generate-run-commands/SKILL.md delete mode 100644 src/vs/sessions/skills/merge/SKILL.md delete mode 100644 src/vs/sessions/skills/sync-upstream/SKILL.md delete mode 100644 src/vs/sessions/skills/sync/SKILL.md delete mode 100644 src/vs/sessions/skills/update-pr/SKILL.md delete mode 100644 src/vs/sessions/skills/update-skills/SKILL.md delete mode 100644 src/vs/sessions/test/ai-customizations.test.md delete mode 100644 src/vs/sessions/test/browser/auxiliaryBarPart.test.ts delete mode 100644 src/vs/sessions/test/browser/layoutActions.test.ts delete mode 100644 src/vs/sessions/test/common/agentHostSessionsProvider.test.ts delete mode 100644 src/vs/sessions/test/e2e/.gitignore delete mode 100644 src/vs/sessions/test/e2e/README.md delete mode 100644 src/vs/sessions/test/e2e/common.cjs delete mode 100644 src/vs/sessions/test/e2e/extensions/sessions-e2e-mock/extension.js delete mode 100644 src/vs/sessions/test/e2e/extensions/sessions-e2e-mock/package.json delete mode 100644 src/vs/sessions/test/e2e/generate.cjs delete mode 100644 src/vs/sessions/test/e2e/package-lock.json delete mode 100644 src/vs/sessions/test/e2e/package.json delete mode 100644 src/vs/sessions/test/e2e/scenarios/01-chat-response.scenario.md delete mode 100644 src/vs/sessions/test/e2e/scenarios/02-chat-with-changes.scenario.md delete mode 100644 src/vs/sessions/test/e2e/scenarios/03-session-in-sidebar.scenario.md delete mode 100644 src/vs/sessions/test/e2e/scenarios/04-navigate-sessions.scenario.md delete mode 100644 src/vs/sessions/test/e2e/scenarios/05-full-workflow.scenario.md delete mode 100644 src/vs/sessions/test/e2e/scenarios/generated/.gitkeep delete mode 100644 src/vs/sessions/test/e2e/scenarios/generated/01-chat-response.commands.json delete mode 100644 src/vs/sessions/test/e2e/scenarios/generated/02-chat-with-changes.commands.json delete mode 100644 src/vs/sessions/test/e2e/scenarios/generated/03-session-in-sidebar.commands.json delete mode 100644 src/vs/sessions/test/e2e/scenarios/generated/04-navigate-sessions.commands.json delete mode 100644 src/vs/sessions/test/e2e/scenarios/generated/05-full-workflow.commands.json delete mode 100644 src/vs/sessions/test/e2e/test.cjs delete mode 100644 src/vs/sessions/test/sessions.web.test.internal.ts delete mode 100644 src/vs/sessions/test/web.test.factory.ts delete mode 100644 src/vs/sessions/test/web.test.ts create mode 100644 src/vs/workbench/api/browser/mainThreadTurboFileOptionalServices.ts rename src/vs/{sessions/contrib/chat/browser/media/agentHostSessionConfigPicker.css => workbench/contrib/scm/browser/scmHistoryTransfer.ts} (57%) delete mode 100644 src/vs/workbench/contrib/search/browser/AISearch/aiSearchModel.ts delete mode 100644 src/vs/workbench/contrib/search/browser/AISearch/aiSearchModelBase.ts delete mode 100644 src/vs/workbench/contrib/telemetry/browser/telemetry.contribution.ts create mode 100644 src/vs/workbench/contrib/turbofile/browser/turbofileDocumentTabAffinity.ts create mode 100644 src/vs/workbench/contrib/turbofile/browser/turbofileRecycleBinIcons.ts create mode 100644 src/vs/workbench/contrib/turbofile/browser/turbofileSlimOptionalServices.ts create mode 100644 src/vs/workbench/contrib/turbofile/browser/turbofileTabRename.ts create mode 100644 src/vs/workbench/contrib/turbofile/common/turbofileCopyName.ts create mode 100644 src/vs/workbench/contrib/turbofile/common/turbofileDirectoryInflight.ts create mode 100644 src/vs/workbench/contrib/turbofile/common/turbofileDirectoryPrefetchQueue.ts create mode 100644 src/vs/workbench/contrib/turbofile/common/turbofileDragDrop.ts create mode 100644 src/vs/workbench/contrib/turbofile/common/turbofileFileSort.ts create mode 100644 src/vs/workbench/contrib/turbofile/common/turbofileImageDimensions.ts rename src/vs/{sessions/common/sessionConfig.ts => workbench/contrib/turbofile/common/turbofileImageGridClipboard.ts} (51%) create mode 100644 src/vs/workbench/contrib/turbofile/common/turbofileImageGridSizing.ts create mode 100644 src/vs/workbench/contrib/turbofile/common/turbofileImageGridSort.ts create mode 100644 src/vs/workbench/contrib/turbofile/common/turbofileImagePreview.ts create mode 100644 src/vs/workbench/contrib/turbofile/common/turbofileImageResource.ts create mode 100644 src/vs/workbench/contrib/turbofile/common/turbofileTitlebar.ts create mode 100644 src/vs/workbench/contrib/turbofile/common/turbofileTransferRefresh.ts create mode 100644 src/vs/workbench/contrib/turbofile/common/turbofileTrashView.ts create mode 100644 src/vs/workbench/contrib/turbofile/common/turbofileWindowsLocation.ts create mode 100644 src/vs/workbench/contrib/turbofile/test/common/turbofileCopyName.test.ts create mode 100644 src/vs/workbench/contrib/turbofile/test/common/turbofileDefaultDevices.test.ts create mode 100644 src/vs/workbench/contrib/turbofile/test/common/turbofileDeviceForm.test.ts create mode 100644 src/vs/workbench/contrib/turbofile/test/common/turbofileDirectoryInflight.test.ts create mode 100644 src/vs/workbench/contrib/turbofile/test/common/turbofileDirectoryNavigation.test.ts create mode 100644 src/vs/workbench/contrib/turbofile/test/common/turbofileDirectoryPrefetchQueue.test.ts create mode 100644 src/vs/workbench/contrib/turbofile/test/common/turbofileDocumentTabAffinity.test.ts create mode 100644 src/vs/workbench/contrib/turbofile/test/common/turbofileDragDrop.test.ts create mode 100644 src/vs/workbench/contrib/turbofile/test/common/turbofileEditorInput.test.ts create mode 100644 src/vs/workbench/contrib/turbofile/test/common/turbofileEditorPaneLifecycle.test.ts create mode 100644 src/vs/workbench/contrib/turbofile/test/common/turbofileFileListIcon.test.ts create mode 100644 src/vs/workbench/contrib/turbofile/test/common/turbofileFileSort.test.ts create mode 100644 src/vs/workbench/contrib/turbofile/test/common/turbofileImageDimensions.test.ts create mode 100644 src/vs/workbench/contrib/turbofile/test/common/turbofileImageGridPane.test.ts create mode 100644 src/vs/workbench/contrib/turbofile/test/common/turbofileImageGridSizing.test.ts create mode 100644 src/vs/workbench/contrib/turbofile/test/common/turbofileImageGridSort.test.ts create mode 100644 src/vs/workbench/contrib/turbofile/test/common/turbofileImagePreview.test.ts create mode 100644 src/vs/workbench/contrib/turbofile/test/common/turbofileImageResource.test.ts create mode 100644 src/vs/workbench/contrib/turbofile/test/common/turbofileInlineRenameCss.test.ts create mode 100644 src/vs/workbench/contrib/turbofile/test/common/turbofileRecycleBinIcons.test.ts create mode 100644 src/vs/workbench/contrib/turbofile/test/common/turbofileRecycleBinInteraction.test.ts create mode 100644 src/vs/workbench/contrib/turbofile/test/common/turbofileRendererBatchHotPath.test.ts create mode 100644 src/vs/workbench/contrib/turbofile/test/common/turbofileSlimShell.test.ts create mode 100644 src/vs/workbench/contrib/turbofile/test/common/turbofileTabPresentation.test.ts create mode 100644 src/vs/workbench/contrib/turbofile/test/common/turbofileTabRename.test.ts create mode 100644 src/vs/workbench/contrib/turbofile/test/common/turbofileTerminalPanel.test.ts create mode 100644 src/vs/workbench/contrib/turbofile/test/common/turbofileTitlebar.test.ts create mode 100644 src/vs/workbench/contrib/turbofile/test/common/turbofileTransferRefresh.test.ts create mode 100644 src/vs/workbench/contrib/turbofile/test/common/turbofileTrashView.test.ts create mode 100644 src/vs/workbench/contrib/turbofile/test/common/turbofileWindowsLocation.test.ts create mode 100644 src/vs/workbench/contrib/update/common/updateTitleBarPresentation.ts create mode 100644 src/vs/workbench/contrib/update/test/common/updateTitleBarPresentation.test.ts delete mode 100644 src/vs/workbench/services/browserView/electron-browser/playwrightWorkbenchService.ts delete mode 100644 src/vs/workbench/services/extensions/common/turbofileExtensionContributionService.ts rename "src/vs/workbench/services/search/test/node/fixtures/\347\234\211m laut\345\247\271\345\244\216\356\207\242/\345\247\271\345\244\216\356\207\242.txt" => "src/vs/workbench/services/search/test/node/fixtures/\303\274m laut\346\261\211\350\257\255/\346\261\211\350\257\255.txt" (100%) delete mode 100644 src/vs/workbench/test/browser/componentFixtures/sessions/agentSessionsViewer.fixture.ts delete mode 100644 src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationListWidget.fixture.ts delete mode 100644 src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts delete mode 100644 src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationWelcomePages.fixture.ts delete mode 100644 src/vs/workbench/test/browser/componentFixtures/sessions/mockCodeReviewService.ts diff --git a/.github/workflows/turbofile-update-manifest.yml b/.github/workflows/turbofile-update-manifest.yml new file mode 100644 index 00000000..eb57638d --- /dev/null +++ b/.github/workflows/turbofile-update-manifest.yml @@ -0,0 +1,31 @@ +name: TurboFile Update Manifest + +on: + workflow_dispatch: + inputs: + proxy-prefix: + description: Optional download proxy prefix for update-proxy.json + required: false + type: string + sha256: + description: Optional installer SHA256 override + required: false + type: string + +permissions: + contents: write + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-node@v6 + with: + node-version-file: .nvmrc + - name: Publish update manifest + run: npm run turbofile:update-manifest + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TURBOFILE_UPDATE_PROXY_PREFIX: ${{ inputs.proxy-prefix }} + TURBOFILE_UPDATE_SHA256: ${{ inputs.sha256 }} diff --git a/.gitignore b/.gitignore index 0acd0a3c..3e3cc376 100644 --- a/.gitignore +++ b/.gitignore @@ -34,6 +34,9 @@ dist .playwright-cli .playwright-mcp .claude/ +/.superpowers/ +/docs/presentations/ +**/__pycache__/ .agents/agents/*.local.md .github/agents/*.local.md .agents/agents/*.local.agent.md diff --git a/build/buildfile.ts b/build/buildfile.ts index 8920d123..691e723b 100644 --- a/build/buildfile.ts +++ b/build/buildfile.ts @@ -16,25 +16,18 @@ export const workerExtensionHost = createModuleDescription('vs/workbench/api/wor export const workerNotebook = createModuleDescription('vs/workbench/contrib/notebook/common/services/notebookWebWorkerMain'); export const workerLanguageDetection = createModuleDescription('vs/workbench/services/languageDetection/browser/languageDetectionWebWorkerMain'); export const workerLocalFileSearch = createModuleDescription('vs/workbench/services/search/worker/localFileSearchMain'); -export const workerProfileAnalysis = createModuleDescription('vs/platform/profiling/electron-browser/profileAnalysisWorkerMain'); export const workerOutputLinks = createModuleDescription('vs/workbench/contrib/output/common/outputLinkComputerMain'); export const workerBackgroundTokenization = createModuleDescription('vs/workbench/services/textMate/browser/backgroundTokenization/worker/textMateTokenizationWorker.workerMain'); export const workbenchDesktop = [ - createModuleDescription('vs/workbench/contrib/debug/node/telemetryApp'), createModuleDescription('vs/platform/files/node/watcher/watcherMain'), createModuleDescription('vs/platform/terminal/node/ptyHostMain'), - createModuleDescription('vs/platform/agentHost/node/agentHostMain'), - createModuleDescription('vs/platform/agentHost/node/diffWorkerMain'), createModuleDescription('vs/workbench/api/node/extensionHostProcess'), - createModuleDescription('vs/workbench/workbench.desktop.main'), - createModuleDescription('vs/sessions/sessions.desktop.main') + createModuleDescription('vs/workbench/workbench.desktop.main') ]; export const workbenchWeb = createModuleDescription('vs/workbench/workbench.web.main.internal'); -export const sessionsWeb = createModuleDescription('vs/sessions/sessions.web.main.internal'); - export const keyboardMaps = [ createModuleDescription('vs/workbench/services/keybinding/browser/keyboardLayouts/layout.contribution.linux'), createModuleDescription('vs/workbench/services/keybinding/browser/keyboardLayouts/layout.contribution.darwin'), @@ -47,7 +40,6 @@ export const code = [ createModuleDescription('vs/code/node/cliProcessMain'), createModuleDescription('vs/code/electron-utility/sharedProcess/sharedProcessMain'), createModuleDescription('vs/code/electron-browser/workbench/workbench'), - createModuleDescription('vs/sessions/electron-browser/sessions'), ]; export const codeWeb = createModuleDescription('vs/code/browser/workbench/workbench'); @@ -58,8 +50,6 @@ export const codeServer = [ createModuleDescription('vs/workbench/api/node/extensionHostProcess'), createModuleDescription('vs/platform/files/node/watcher/watcherMain'), createModuleDescription('vs/platform/terminal/node/ptyHostMain'), - createModuleDescription('vs/platform/agentHost/node/agentHostMain'), - createModuleDescription('vs/platform/agentHost/node/diffWorkerMain'), ]; export const entrypoint = createModuleDescription; @@ -70,12 +60,10 @@ const buildfile = { workerNotebook, workerLanguageDetection, workerLocalFileSearch, - workerProfileAnalysis, workerOutputLinks, workerBackgroundTokenization, workbenchDesktop, workbenchWeb, - sessionsWeb, keyboardMaps, code, codeWeb, diff --git a/build/checker/layersChecker.ts b/build/checker/layersChecker.ts index eea323ed..7dd967f6 100644 --- a/build/checker/layersChecker.ts +++ b/build/checker/layersChecker.ts @@ -58,12 +58,6 @@ const RULES: IRule[] = [ disallowedTypes: NATIVE_TYPES, }, - // Browser view preload script - { - target: '**/vs/platform/browserView/electron-browser/preload-browserView.ts', - disallowedTypes: NATIVE_TYPES, - }, - // Common { target: '**/vs/**/common/**', diff --git a/build/checker/tsconfig.electron-browser.json b/build/checker/tsconfig.electron-browser.json index 80828443..2cbe3d3b 100644 --- a/build/checker/tsconfig.electron-browser.json +++ b/build/checker/tsconfig.electron-browser.json @@ -16,7 +16,6 @@ "../../src/**/test/**", "../../src/**/fixtures/**", "../../src/vs/base/parts/sandbox/electron-browser/preload.ts", // Preload scripts for Electron sandbox - "../../src/vs/base/parts/sandbox/electron-browser/preload-aux.ts", // have limited access to node.js APIs - "../../src/vs/platform/browserView/electron-browser/preload-browserView.ts" // Browser view preload script + "../../src/vs/base/parts/sandbox/electron-browser/preload-aux.ts" // have limited access to node.js APIs ] } diff --git a/build/gulpfile.vscode.ts b/build/gulpfile.vscode.ts index c5aceb35..11418e4a 100644 --- a/build/gulpfile.vscode.ts +++ b/build/gulpfile.vscode.ts @@ -45,6 +45,12 @@ const glob = promisify(globCallback); const rcedit = promisify(rceditCallback); const root = path.dirname(import.meta.dirname); const commit = getVersion(root); +interface TurboFileProductPolicy { + readonly excludedProductionDependencies: readonly string[]; + readonly electronLocales: readonly string[]; +} +const turboFileProductPolicy = JSON.parse(fs.readFileSync(path.join(root, 'build', 'turbofile-product.json'), 'utf8')) as TurboFileProductPolicy; +const electronLocales = new Set(turboFileProductPolicy.electronLocales.map(locale => locale.toLowerCase())); // Build const vscodeEntryPoints = [ @@ -53,7 +59,6 @@ const vscodeEntryPoints = [ buildfile.workerNotebook, buildfile.workerLanguageDetection, buildfile.workerLocalFileSearch, - buildfile.workerProfileAnalysis, buildfile.workerOutputLinks, buildfile.workerBackgroundTokenization, buildfile.workbenchDesktop, @@ -68,19 +73,16 @@ const vscodeResourceIncludes = [ // Workbench 'out-build/vs/code/electron-browser/workbench/workbench.html', - 'out-build/vs/sessions/electron-browser/sessions.html', // Electron Preload 'out-build/vs/base/parts/sandbox/electron-browser/preload.js', 'out-build/vs/base/parts/sandbox/electron-browser/preload-aux.js', - 'out-build/vs/platform/browserView/electron-browser/preload-browserView.js', // Node Scripts 'out-build/vs/base/node/{terminateProcess.sh,cpuUsage.sh,ps.sh}', // Touchbar 'out-build/vs/workbench/browser/parts/editor/media/*.png', - 'out-build/vs/workbench/contrib/debug/browser/media/*.png', // External Terminal 'out-build/vs/workbench/contrib/externalTerminal/**/*.scpt', @@ -96,17 +98,6 @@ const vscodeResourceIncludes = [ // Accessibility Signals 'out-build/vs/platform/accessibilitySignal/browser/media/*.mp3', - // Welcome - 'out-build/vs/workbench/contrib/welcomeGettingStarted/common/media/**/*.{svg,png}', - 'out-build/vs/workbench/contrib/welcomeOnboarding/browser/media/*.svg', - - // Sessions - 'out-build/vs/sessions/contrib/chat/browser/media/*.svg', - 'out-build/vs/sessions/contrib/welcome/browser/media/*.svg', - 'out-build/vs/sessions/contrib/welcome/browser/media/themePreviews/*.svg', - 'out-build/vs/sessions/prompts/*.prompt.md', - 'out-build/vs/sessions/skills/**/SKILL.md', - // Extensions 'out-build/vs/workbench/contrib/extensions/browser/media/{theme-icon.png,language-icon.svg}', 'out-build/vs/workbench/services/extensionManagement/common/media/*.{svg,png}', @@ -159,7 +150,7 @@ const bundleVSCodeTask = task.define('bundle-vscode', task.series( ...bootstrapEntryPoints ], resources: vscodeResources, - skipTSBoilerplateRemoval: entryPoint => entryPoint === 'vs/code/electron-browser/workbench/workbench' || entryPoint === 'vs/sessions/electron-browser/sessions' + skipTSBoilerplateRemoval: entryPoint => entryPoint === 'vs/code/electron-browser/workbench/workbench' } } ) @@ -226,7 +217,7 @@ function runEsbuildBundle(outDir: string, minify: boolean, nls: boolean, target: const sourceMappingURLBase = `https://main.vscode-cdn.net/sourcemaps/${commit}`; const isCI = !!process.env['CI'] || !!process.env['BUILD_ARTIFACTSTAGINGDIRECTORY'] || !!process.env['GITHUB_WORKSPACE']; const useCdnSourceMapsForPackagingTasks = isCI; -const stripSourceMapsInPackagingTasks = isCI; +const stripSourceMapsInPackagingTasks = true; const minifyVSCodeTask = task.define('minify-vscode', task.series( bundleVSCodeTask, util.rimraf('out-vscode-min'), @@ -319,10 +310,6 @@ function packageTask(platform: string, arch: string, sourceFolderName: string, d 'vs/workbench/api/node/extensionHostProcess.js', 'vs/code/electron-browser/workbench/workbench.html', 'vs/code/electron-browser/workbench/workbench.js', - 'vs/sessions/sessions.desktop.main.js', - 'vs/sessions/sessions.desktop.main.css', - 'vs/sessions/electron-browser/sessions.html', - 'vs/sessions/electron-browser/sessions.js' ]); const src = gulp.src(out + '/**', { base: '.' }) @@ -342,7 +329,7 @@ function packageTask(platform: string, arch: string, sourceFolderName: string, d const extensions = gulp.src(['.build/extensions/**', ...platformSpecificBuiltInExtensionsExclusions], { base: '.build', dot: true }); const sourceFilterPattern = stripSourceMapsInPackagingTasks - ? ['**', '!**/*.{js,css}.map'] + ? ['**', '!**/*.map'] : ['**']; const sources = es.merge(src, extensions) .pipe(filter(sourceFilterPattern, { dot: true })); @@ -430,9 +417,16 @@ function packageTask(platform: string, arch: string, sourceFolderName: string, d const productionDependencies = getProductionDependencies(root); const dependenciesSrc = productionDependencies.map(d => path.relative(root, d)).map(d => [`${d}/**`, `!${d}/**/{test,tests}/**`]).flat().concat('!**/*.mk'); - const depFilterPattern = ['**', `!**/${config.version}/**`, '!**/bin/darwin-arm64-87/**', '!**/package-lock.json', '!**/yarn.lock']; + const depFilterPattern = [ + '**', + `!**/${config.version}/**`, + '!**/bin/darwin-arm64-87/**', + '!**/package-lock.json', + '!**/yarn.lock', + ...turboFileProductPolicy.excludedProductionDependencies.map(dependency => `!node_modules/${dependency}/**`) + ]; if (stripSourceMapsInPackagingTasks) { - depFilterPattern.push('!**/*.{js,css}.map'); + depFilterPattern.push('!**/*.map'); } const deps = gulp.src(dependenciesSrc, { base: '.', dot: true }) @@ -562,7 +556,19 @@ function packageTask(platform: string, arch: string, sourceFolderName: string, d '!version', ...(platform === 'darwin' && !isInsiderOrExploration ? ['!**/Contents/Applications', '!**/Contents/Applications/**'] : []), ...(platform === 'win32' && !isInsiderOrExploration ? ['!**/electron_proxy.exe'] : []), - ], { dot: true })); + ], { dot: true })) + .pipe(util.filter(file => { + if (platform !== 'win32') { + return true; + } + + const relativePath = file.relative.replace(/\\/g, '/'); + if (!relativePath.startsWith('locales/')) { + return true; + } + + return electronLocales.has(path.posix.basename(relativePath).toLowerCase()); + })); if (platform === 'linux') { result = es.merge(result, gulp.src('resources/completions/bash/code', { base: '.' }) @@ -616,7 +622,8 @@ function packageTask(platform: string, arch: string, sourceFolderName: string, d result = es.merge(result, gulp.src('.build/policies/win32/**', { base: '.build/policies/win32' }) .pipe(rename(f => f.dirname = `policies/${f.dirname}`))); - if (quality === 'stable' || quality === 'insider') { + const win32ContextMenu = (product as { win32ContextMenu?: Record }).win32ContextMenu; + if ((quality === 'stable' || quality === 'insider') && win32ContextMenu?.[arch]) { result = es.merge(result, gulp.src('.build/win32/appx/**', { base: '.build/win32' })); const rawVersion = version.replace(/-\w+$/, '').split('.'); const appxVersion = `${rawVersion[0]}.0.${rawVersion[1]}.${rawVersion[2]}`; @@ -628,7 +635,7 @@ function packageTask(platform: string, arch: string, sourceFolderName: string, d .pipe(replace('@@ApplicationIdShort@@', product.win32RegValueName)) .pipe(replace('@@ApplicationExe@@', product.nameShort + '.exe')) .pipe(replace('@@FileExplorerContextMenuID@@', quality === 'stable' ? 'OpenWithCode' : 'OpenWithCodeInsiders')) - .pipe(replace('@@FileExplorerContextMenuCLSID@@', (product as { win32ContextMenu?: Record }).win32ContextMenu![arch].clsid)) + .pipe(replace('@@FileExplorerContextMenuCLSID@@', win32ContextMenu[arch].clsid)) .pipe(replace('@@FileExplorerContextMenuDLL@@', `${quality === 'stable' ? 'code' : 'code_insider'}_explorer_command_${arch}.dll`)) .pipe(rename(f => f.dirname = `appx/manifest`))); } diff --git a/build/gulpfile.vscode.web.ts b/build/gulpfile.vscode.web.ts index 5e0fb6da..9af2afec 100644 --- a/build/gulpfile.vscode.web.ts +++ b/build/gulpfile.vscode.web.ts @@ -116,7 +116,6 @@ const vscodeWebEntryPoints = [ buildfile.workerBackgroundTokenization, buildfile.keyboardMaps, buildfile.workbenchWeb, - buildfile.sessionsWeb, ].flat(); /** diff --git a/build/gulpfile.vscode.win32.ts b/build/gulpfile.vscode.win32.ts index 600eeaac..114d4c92 100644 --- a/build/gulpfile.vscode.win32.ts +++ b/build/gulpfile.vscode.win32.ts @@ -131,7 +131,8 @@ function buildWin32Setup(arch: string, target: string): task.CallbackTask { definitions['ProxyMutex'] = embedded.win32MutexName; } - if (quality === 'stable' || quality === 'insider') { + const win32ContextMenu = (product as typeof product & { win32ContextMenu?: Record }).win32ContextMenu; + if ((quality === 'stable' || quality === 'insider') && win32ContextMenu?.[arch]) { definitions['AppxPackage'] = `${quality === 'stable' ? 'code' : 'code_insider'}_${arch}.appx`; definitions['AppxPackageDll'] = `${quality === 'stable' ? 'code' : 'code_insider'}_explorer_command_${arch}.dll`; definitions['AppxPackageName'] = `${product.win32AppUserModelId}`; diff --git a/build/lib/extensions.ts b/build/lib/extensions.ts index 05f73dd5..ff8a797f 100644 --- a/build/lib/extensions.ts +++ b/build/lib/extensions.ts @@ -30,6 +30,11 @@ import { createRequire } from 'module'; const require = createRequire(import.meta.url); const root = path.dirname(path.dirname(import.meta.dirname)); +interface TurboFileProductPolicy { + readonly excludedLocalExtensions: readonly string[]; +} +const turboFileProductPolicy = JSON.parse(fs.readFileSync(path.join(root, 'build', 'turbofile-product.json'), 'utf8')) as TurboFileProductPolicy; +const excludedLocalExtensions = new Set(turboFileProductPolicy.excludedLocalExtensions); // const commit = getVersion(root); // const sourceMappingURLBase = `https://main.vscode-cdn.net/sourcemaps/${commit}`; @@ -427,6 +432,7 @@ function doPackageLocalExtensionsStream(forWeb: boolean, disableMangle: boolean, }) .filter(({ name }) => native ? nativeExtensionsSet.has(name) : !nativeExtensionsSet.has(name)) .filter(({ name }) => excludedExtensions.indexOf(name) === -1) + .filter(({ name }) => !excludedLocalExtensions.has(name)) .filter(({ name }) => builtInExtensions.every(b => b.name !== name)) .filter(({ manifestPath }) => (forWeb ? isWebExtension(require(manifestPath)) : true)) ); @@ -633,7 +639,9 @@ const esbuildMediaScripts = [ ]; export function buildExtensionMedia(isWatch: boolean, outputRoot?: string): Promise { - return esbuildExtensions('esbuilding extension media', isWatch, esbuildMediaScripts.map(p => ({ + return esbuildExtensions('esbuilding extension media', isWatch, esbuildMediaScripts + .filter(script => !excludedLocalExtensions.has(script.split('/')[0])) + .map(p => ({ script: path.join(extensionsPath, p), outputRoot: outputRoot ? path.join(root, outputRoot, path.dirname(p)) : undefined }))); diff --git a/build/lib/i18n.resources.json b/build/lib/i18n.resources.json index 8e8e57d0..b136eb08 100644 --- a/build/lib/i18n.resources.json +++ b/build/lib/i18n.resources.json @@ -626,103 +626,5 @@ "name": "vs/workbench/services/request", "project": "vscode-workbench" } - ], - "sessions": [ - { - "name": "vs/sessions", - "project": "vscode-sessions" - }, - { - "name": "vs/sessions/contrib/accountMenu", - "project": "vscode-sessions" - }, - { - "name": "vs/sessions/contrib/agentFeedback", - "project": "vscode-sessions" - }, - { - "name": "vs/sessions/contrib/agentHost", - "project": "vscode-sessions" - }, - { - "name": "vs/sessions/contrib/aiCustomizationTreeView", - "project": "vscode-sessions" - }, - { - "name": "vs/sessions/contrib/applyCommitsToParentRepo", - "project": "vscode-sessions" - }, - { - "name": "vs/sessions/contrib/changes", - "project": "vscode-sessions" - }, - { - "name": "vs/sessions/contrib/chat", - "project": "vscode-sessions" - }, - { - "name": "vs/sessions/contrib/copilotChatSessions", - "project": "vscode-sessions" - }, - { - "name": "vs/sessions/contrib/configuration", - "project": "vscode-sessions" - }, - { - "name": "vs/sessions/contrib/codeReview", - "project": "vscode-sessions" - }, - { - "name": "vs/sessions/contrib/fileTreeView", - "project": "vscode-sessions" - }, - { - "name": "vs/sessions/contrib/files", - "project": "vscode-sessions" - }, - { - "name": "vs/sessions/contrib/policyBlocked", - "project": "vscode-sessions" - }, - { - "name": "vs/sessions/contrib/git", - "project": "vscode-sessions" - }, - { - "name": "vs/sessions/contrib/logs", - "project": "vscode-sessions" - }, - { - "name": "vs/sessions/contrib/remoteAgentHost", - "project": "vscode-sessions" - }, - { - "name": "vs/sessions/contrib/sessions", - "project": "vscode-sessions" - }, - { - "name": "vs/sessions/contrib/terminal", - "project": "vscode-sessions" - }, - { - "name": "vs/sessions/contrib/welcome", - "project": "vscode-sessions" - }, - { - "name": "vs/sessions/contrib/aquarium", - "project": "vscode-sessions" - }, - { - "name": "vs/sessions/contrib/chatDebug", - "project": "vscode-sessions" - }, - { - "name": "vs/sessions/contrib/tunnelHost", - "project": "vscode-sessions" - }, - { - "name": "vs/sessions/contrib/editor", - "project": "vscode-sessions" - } ] } diff --git a/build/lib/mangle/index.ts b/build/lib/mangle/index.ts index b4f4f83a..20d7c1a3 100644 --- a/build/lib/mangle/index.ts +++ b/build/lib/mangle/index.ts @@ -316,12 +316,10 @@ const skippedExportMangledFiles = [ buildfile.workerNotebook, buildfile.workerLanguageDetection, buildfile.workerLocalFileSearch, - buildfile.workerProfileAnalysis, buildfile.workerOutputLinks, buildfile.workerBackgroundTokenization, buildfile.workbenchDesktop, buildfile.workbenchWeb, - buildfile.sessionsWeb, buildfile.code, buildfile.codeWeb ].flat().map(x => x.name), diff --git a/build/lib/test/i18n.test.ts b/build/lib/test/i18n.test.ts index 9d4594ab..cb2ceea4 100644 --- a/build/lib/test/i18n.test.ts +++ b/build/lib/test/i18n.test.ts @@ -32,8 +32,7 @@ suite('XLF Parser Tests', () => { test('JSON file source path to Transifex resource match', () => { const editorProject: string = 'vscode-editor', - workbenchProject: string = 'vscode-workbench', - sessionsProject: string = 'vscode-sessions'; + workbenchProject: string = 'vscode-workbench'; const platform: i18n.Resource = { name: 'vs/platform', project: editorProject }, editorContrib = { name: 'vs/editor/contrib', project: editorProject }, @@ -42,9 +41,7 @@ suite('XLF Parser Tests', () => { code = { name: 'vs/code', project: workbenchProject }, workbenchParts = { name: 'vs/workbench/contrib/html', project: workbenchProject }, workbenchServices = { name: 'vs/workbench/services/textfile', project: workbenchProject }, - workbench = { name: 'vs/workbench', project: workbenchProject }, - sessionsContrib = { name: 'vs/sessions/contrib/chat', project: sessionsProject }, - sessions = { name: 'vs/sessions', project: sessionsProject }; + workbench = { name: 'vs/workbench', project: workbenchProject }; assert.deepStrictEqual(i18n.getResource('vs/platform/actions/browser/menusExtensionPoint'), platform); assert.deepStrictEqual(i18n.getResource('vs/editor/contrib/clipboard/browser/clipboard'), editorContrib); @@ -54,7 +51,5 @@ suite('XLF Parser Tests', () => { assert.deepStrictEqual(i18n.getResource('vs/workbench/contrib/html/browser/webview'), workbenchParts); assert.deepStrictEqual(i18n.getResource('vs/workbench/services/textfile/node/testFileService'), workbenchServices); assert.deepStrictEqual(i18n.getResource('vs/workbench/browser/parts/panel/panelActions'), workbench); - assert.deepStrictEqual(i18n.getResource('vs/sessions/contrib/chat/browser/chatWidget'), sessionsContrib); - assert.deepStrictEqual(i18n.getResource('vs/sessions/browser/layoutActions'), sessions); }); }); diff --git a/build/next/index.ts b/build/next/index.ts index 1993dbf6..9c6c9bfb 100644 --- a/build/next/index.ts +++ b/build/next/index.ts @@ -89,20 +89,11 @@ const workerEntryPoints = [ 'vs/workbench/services/textMate/browser/backgroundTokenization/worker/textMateTokenizationWorker.workerMain', ]; -// Desktop-only workers (use electron-browser) -const desktopWorkerEntryPoints = [ - 'vs/platform/profiling/electron-browser/profileAnalysisWorkerMain', -]; - // Desktop workbench and code entry points const desktopEntryPoints = [ 'vs/workbench/workbench.desktop.main', - 'vs/sessions/sessions.desktop.main', - 'vs/workbench/contrib/debug/node/telemetryApp', 'vs/platform/files/node/watcher/watcherMain', 'vs/platform/terminal/node/ptyHostMain', - 'vs/platform/agentHost/node/agentHostMain', - 'vs/platform/agentHost/node/diffWorkerMain', 'vs/workbench/api/node/extensionHostProcess', ]; @@ -110,7 +101,6 @@ const codeEntryPoints = [ 'vs/code/node/cliProcessMain', 'vs/code/electron-utility/sharedProcess/sharedProcessMain', 'vs/code/electron-browser/workbench/workbench', - 'vs/sessions/electron-browser/sessions', ]; // Web entry points (used in server-web and vscode-web) @@ -119,11 +109,6 @@ const webEntryPoints = [ 'vs/code/browser/workbench/workbench', ]; -// Additional web-only entry points (CDN build only, not in server-web) -const webOnlyEntryPoints = [ - 'vs/sessions/sessions.web.main.internal', -]; - const keyboardMapEntryPoints = [ 'vs/workbench/services/keybinding/browser/keyboardLayouts/layout.contribution.linux', 'vs/workbench/services/keybinding/browser/keyboardLayouts/layout.contribution.darwin', @@ -135,8 +120,6 @@ const serverEntryPoints = [ 'vs/workbench/api/node/extensionHostProcess', 'vs/platform/files/node/watcher/watcherMain', 'vs/platform/terminal/node/ptyHostMain', - 'vs/platform/agentHost/node/agentHostMain', - 'vs/platform/agentHost/node/diffWorkerMain', ]; // Bootstrap files per target @@ -160,7 +143,6 @@ function getEntryPointsForTarget(target: BuildTarget): string[] { case 'desktop': return [ ...workerEntryPoints, - ...desktopWorkerEntryPoints, ...desktopEntryPoints, ...codeEntryPoints, ]; @@ -178,7 +160,6 @@ function getEntryPointsForTarget(target: BuildTarget): string[] { case 'web': return [ ...workerEntryPoints, - ...webOnlyEntryPoints, 'vs/workbench/workbench.web.main.internal', // web workbench only (no browser shell) ...keyboardMapEntryPoints, ]; @@ -213,8 +194,6 @@ function getCssBundleEntryPointsForTarget(target: BuildTarget): Set { return new Set([ 'vs/workbench/workbench.desktop.main', 'vs/code/electron-browser/workbench/workbench', - 'vs/sessions/sessions.desktop.main', - 'vs/sessions/electron-browser/sessions', ]); case 'server': return new Set(); // Server has no UI @@ -226,7 +205,6 @@ function getCssBundleEntryPointsForTarget(target: BuildTarget): Set { case 'web': return new Set([ 'vs/workbench/workbench.web.main.internal', - 'vs/sessions/sessions.web.main.internal', ]); default: throw new Error(`Unknown target: ${target}`); @@ -246,7 +224,6 @@ const commonResourcePatterns = [ // SVGs referenced from CSS (needed for transpile/dev builds where CSS is copied as-is) 'vs/workbench/browser/media/code-icon.svg', 'vs/workbench/browser/parts/editor/media/letterpress*.svg', - 'vs/sessions/contrib/chat/browser/media/*.svg' ]; // Resources for desktop target @@ -256,8 +233,6 @@ const desktopResourcePatterns = [ // HTML 'vs/code/electron-browser/workbench/workbench.html', 'vs/code/electron-browser/workbench/workbench-dev.html', - 'vs/sessions/electron-browser/sessions.html', - 'vs/sessions/electron-browser/sessions-dev.html', 'vs/workbench/services/extensions/worker/webWorkerExtensionHostIframe.html', 'vs/workbench/contrib/webview/browser/pre/*.html', @@ -290,11 +265,6 @@ const desktopResourcePatterns = [ 'vs/workbench/services/extensionManagement/common/media/*.svg', 'vs/workbench/services/extensionManagement/common/media/*.png', 'vs/workbench/browser/parts/editor/media/*.png', - 'vs/workbench/contrib/debug/browser/media/*.png', - - // Sessions - built-in prompts and skills - 'vs/sessions/prompts/*.prompt.md', - 'vs/sessions/skills/**/SKILL.md', ]; // Resources for server target (minimal - no UI) @@ -491,7 +461,6 @@ async function copyFile(srcPath: string, destPath: string): Promise { const desktopStandaloneFiles = [ 'vs/base/parts/sandbox/electron-browser/preload.ts', 'vs/base/parts/sandbox/electron-browser/preload-aux.ts', - 'vs/platform/browserView/electron-browser/preload-browserView.ts', ]; async function compileStandaloneFiles(outDir: string, doMinify: boolean, target: BuildTarget): Promise { diff --git a/build/next/working.md b/build/next/working.md index a7ea64db..a03e68e7 100644 --- a/build/next/working.md +++ b/build/next/working.md @@ -154,7 +154,6 @@ npm run gulp vscode-reh-web-darwin-arm64-min | File | Why Missing | Fix | |------|-------------|-----| -| `out/vs/platform/browserView/electron-browser/preload-browserView.js` | Not listed in `desktopStandaloneFiles` in index.ts. Only `preload.ts` and `preload-aux.ts` are compiled as standalone files. | **Add** `'vs/platform/browserView/electron-browser/preload-browserView.ts'` to the `desktopStandaloneFiles` array in `index.ts`. | ### 309 Extra Files (in NEW, not in OLD) — Breakdown @@ -191,7 +190,6 @@ npm run gulp vscode-reh-web-darwin-arm64-min ### Action Items -1. **[CRITICAL] Missing `preload-browserView.ts`** — Add to `desktopStandaloneFiles` in index.ts. Without it, BrowserView (used for Simple Browser) may fail. 2. **[SIZE] Web bundles in desktop build** — `workbench.web.main.internal.js` and `vs/code/browser/workbench/workbench.js` together add ~31 MB. These are written by the esbuild bundler and not filtered out. Consider: either don't bundle web entry points for the desktop target, or ensure the packaging step excludes them (currently `packageTask` takes `out-vscode-min/**` without filtering). 3. **[SIZE] No mangling** — The desktop main bundle is 2.5 MB larger due to no property mangling. Known open item. 4. **[MINOR] Duplicate codicon.ttf** — Exists at both `out/media/codicon.ttf` (from esbuild `file` loader) and `out/vs/base/browser/ui/codicons/codicon/codicon.ttf` (from `commonResourcePatterns`). Consider removing from `commonResourcePatterns` if it's already handled by the loader. diff --git a/build/turbofile-product.json b/build/turbofile-product.json new file mode 100644 index 00000000..74f5c106 --- /dev/null +++ b/build/turbofile-product.json @@ -0,0 +1,52 @@ +{ + "excludedLocalExtensions": [ + "debug-auto-launch", + "debug-server-ready", + "git", + "git-base", + "github", + "github-authentication", + "mermaid-chat-features", + "microsoft-authentication", + "tunnel-forwarding" + ], + "excludedProductionDependencies": [ + "@anthropic-ai/sandbox-runtime", + "@anthropic-ai/sdk", + "@github/copilot", + "@github/copilot-sdk", + "@microsoft/1ds-core-js", + "@microsoft/1ds-post-js", + "@microsoft/applicationinsights-core-js", + "@microsoft/applicationinsights-shims", + "@microsoft/dev-tunnels-connections", + "@microsoft/dev-tunnels-contracts", + "@microsoft/dev-tunnels-management", + "@microsoft/dev-tunnels-ssh", + "@microsoft/dev-tunnels-ssh-tcp", + "chrome-remote-interface", + "playwright-core" + ], + "electronLocales": [ + "en-US.pak", + "zh-CN.pak" + ], + "forbiddenDesktopEntrypoints": [ + "vs/sessions/", + "vs/platform/agentHost/", + "vs/workbench/contrib/debug/node/telemetryApp" + ], + "forbiddenWorkbenchImports": [ + "contrib/authentication", + "contrib/processExplorer", + "contrib/share", + "contrib/speech", + "contrib/userDataSync", + "contrib/userDataProfile", + "platform/agentHost", + "platform/remoteTunnel", + "services/browserView", + "services/mcp", + "services/userDataSync" + ] +} diff --git a/build/win32/code.iss b/build/win32/code.iss index aaf1fbb6..79ac1ad8 100644 --- a/build/win32/code.iss +++ b/build/win32/code.iss @@ -16,7 +16,7 @@ DefaultGroupName={#NameLong} AllowNoIcons=yes OutputDir={#OutputDir} OutputBaseFilename={#NameShort}Setup -Compression=lzma +Compression=lzma2/ultra64 SolidCompression=yes AppMutex={code:GetAppMutex} SetupMutex={code:GetSetupMutex} @@ -39,6 +39,16 @@ WizardStyle=modern // to shutdown VS Code. We rely on the fact that the update signals // that VS Code is ready to be shutdown, so we're good to use `force` here. CloseApplications=force +// Only ever force-close TurboFile's own executables. Without this filter the default mask (*.exe) +// makes the Restart Manager force-close ANY process holding a file under {app} open - including +// explorer.exe, which loads our drag CopyHook (resources\...\TurboFileDragExt.dll). That made +// installing/updating close the user's File Explorer. Restricting the filter leaves Explorer (and +// any unrelated process) untouched while still closing the running app for in-place updates. +#ifdef ProxyExeBasename +CloseApplicationsFilter={#ExeBasename}.exe,new_{#ExeBasename}.exe,{#ProxyExeBasename}.exe,new_{#ProxyExeBasename}.exe +#else +CloseApplicationsFilter={#ExeBasename}.exe,new_{#ExeBasename}.exe +#endif #ifdef Sign SignTool=esrp @@ -99,7 +109,13 @@ Name: "runcode"; Description: "{cm:RunAfter,{#NameShort}}"; GroupDescription: "{ Name: "{app}"; AfterInstall: DisableAppDirInheritance [Files] -Source: "*"; Excludes: "\CodeSignSummary*.md,\tools,\tools\*,\policies,\policies\*,\appx,\appx\*,\resources\app\product.json,\{#ExeBasename}.exe,{#ifdef ProxyExeBasename}\{#ProxyExeBasename}.exe,{#endif}\{#ExeBasename}.VisualElementsManifest.xml,\bin,\bin\*"; DestDir: "{code:GetDestDir}"; Flags: ignoreversion recursesubdirs createallsubdirs +Source: "*"; Excludes: "\CodeSignSummary*.md,\tools,\tools\*,\policies,\policies\*,\appx,\appx\*,\resources\app\product.json,\resources\app\resources\turbofile\drag-ext,\resources\app\resources\turbofile\drag-ext\*,\{#ExeBasename}.exe,{#ifdef ProxyExeBasename}\{#ProxyExeBasename}.exe,{#endif}\{#ExeBasename}.VisualElementsManifest.xml,\bin,\bin\*"; DestDir: "{code:GetDestDir}"; Flags: ignoreversion recursesubdirs createallsubdirs +; Explorer can keep the drag CopyHook DLL loaded. If it is locked, schedule replacement instead of prompting the user to skip the file. +#if VersionedResourcesFolder != "" +Source: "{#VersionedResourcesFolder}\resources\app\resources\turbofile\drag-ext\*"; DestDir: "{code:GetDestDir}\{#VersionedResourcesFolder}\resources\app\resources\turbofile\drag-ext"; Flags: ignoreversion recursesubdirs createallsubdirs restartreplace uninsrestartdelete +#else +Source: "resources\app\resources\turbofile\drag-ext\*"; DestDir: "{code:GetDestDir}\{#VersionedResourcesFolder}\resources\app\resources\turbofile\drag-ext"; Flags: ignoreversion recursesubdirs createallsubdirs restartreplace uninsrestartdelete +#endif Source: "{#ExeBasename}.exe"; DestDir: "{code:GetDestDir}"; DestName: "{code:GetExeBasename}"; Flags: ignoreversion Source: "{#ExeBasename}.VisualElementsManifest.xml"; DestDir: "{code:GetDestDir}"; DestName: "{code:GetVisualElementsManifest}"; Flags: ignoreversion #ifdef ProxyExeBasename @@ -1345,6 +1361,57 @@ begin Result := '{#VersionedResourcesFolder}' <> ''; end; +function IsLegacyVersionedDirectoryName(DirectoryName: String): Boolean; +var + Index: Integer; + Character: Char; +begin + Result := False; + if Length(DirectoryName) <> 10 then + Exit; + + for Index := 1 to Length(DirectoryName) do begin + Character := DirectoryName[Index]; + if not (((Character >= '0') and (Character <= '9')) or + ((Character >= 'a') and (Character <= 'f')) or + ((Character >= 'A') and (Character <= 'F'))) then + Exit; + end; + + Result := True; +end; + +procedure CleanupLegacyVersionedResources(); +var + FindRec: TFindRec; + DirectoryName: String; + LegacyPath: String; +begin + if not DirExists(ExpandConstant('{app}')) then + Exit; + + if FindFirst(ExpandConstant('{app}\*'), FindRec) then begin + try + repeat + DirectoryName := FindRec.Name; + LegacyPath := ExpandConstant('{app}\') + DirectoryName; + + if IsLegacyVersionedDirectoryName(DirectoryName) and + (DirectoryName <> '{#VersionedResourcesFolder}') and + DirExists(LegacyPath) and + FileExists(LegacyPath + '\version') and + FileExists(LegacyPath + '\resources\app\product.json') then begin + Log('Removing validated legacy versioned resources directory: ' + LegacyPath); + if not DelTree(LegacyPath, True, True, True) then + Log('Failed to remove legacy versioned resources directory: ' + LegacyPath); + end; + until not FindNext(FindRec); + finally + FindClose(FindRec); + end; + end; +end; + // Don't allow installing conflicting architectures function InitializeSetup(): Boolean; var @@ -1876,6 +1943,8 @@ begin end; end; + CleanupLegacyVersionedResources(); + if ShouldRestartTunnelService then begin // start the tunnel service diff --git a/docs/superpowers/plans/2026-07-10-same-device-directory-merge.md b/docs/superpowers/plans/2026-07-10-same-device-directory-merge.md new file mode 100644 index 00000000..2915ce2d --- /dev/null +++ b/docs/superpowers/plans/2026-07-10-same-device-directory-merge.md @@ -0,0 +1,206 @@ +# Same-Device Directory Merge Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Merge existing same-name directories only for ordinary copies on the same device, while preserving non-rsync conflict rejection everywhere else. + +**Architecture:** Put the exception decision in a pure common policy so single and batch entry points share one rule. Keep the actual merge in the native same-device copy implementations: local copies already merge, while POSIX and Windows remote-native commands will copy directory contents into the exact target directory. All tar, SCP, and SFTP cross-device guards remain unchanged. + +**Tech Stack:** TypeScript, Node.js filesystem APIs, remote POSIX shell/PowerShell commands, Mocha TDD tests. + +## Global Constraints + +- Same local device and same SSH connection copies merge existing same-name directories. +- Same-name files are overwritten; target-only files remain. +- Same-device move/cut into an existing same-name directory is rejected. +- Different-device non-rsync transfers continue to reject existing same-name directories. +- Rsync-only plans retain incremental merge behavior. +- No transfer path may recursively delete an existing target directory as preparation. +- Preserve all unrelated image, browser, terminal, and transfer behavior. + +--- + +### Task 1: Centralize The Conflict Policy + +**Files:** +- Create: `src/vs/platform/turbofile/common/turbofileTransferConflictPolicy.ts` +- Create: `src/vs/platform/turbofile/test/common/turbofileTransferConflictPolicy.test.ts` +- Modify: `src/vs/platform/turbofile/electron-main/turbofileSshMainService.ts:2647` + +**Interfaces:** +- Consumes: `ITurboFileTransferEndpoint`, `ITurboFileTransferPlan`, `TurboFileFileType`. +- Produces: `shouldRejectDirectoryTargetConflict(options): boolean`. + +- [ ] **Step 1: Write the failing policy matrix test** + +```ts +assert.deepStrictEqual([ + shouldRejectDirectoryTargetConflict({ source: local, target: local, sourceType: 'directory', operation: 'copy', plan: scpPlan }), + shouldRejectDirectoryTargetConflict({ source: sshA, target: sshASecondPane, sourceType: 'directory', operation: 'copy', plan: scpPlan }), + shouldRejectDirectoryTargetConflict({ source: sshA, target: sshASecondPane, sourceType: 'directory', operation: 'move', plan: scpPlan }), + shouldRejectDirectoryTargetConflict({ source: sshA, target: sshB, sourceType: 'directory', operation: 'copy', plan: tarPlan }), + shouldRejectDirectoryTargetConflict({ source: sshA, target: sshB, sourceType: 'directory', operation: 'copy', plan: rsyncPlan }) +], [false, false, true, true, false]); +``` + +- [ ] **Step 2: Run the test and verify RED** + +Run: `npm run test-node -- --run src/vs/platform/turbofile/test/common/turbofileTransferConflictPolicy.test.ts` + +Expected: FAIL because `turbofileTransferConflictPolicy.js` does not exist. + +- [ ] **Step 3: Implement the pure policy** + +```ts +export function shouldRejectDirectoryTargetConflict(options: ITransferConflictPolicyOptions): boolean { + if (options.sourceType !== 'directory' || isRsyncOnly(options.plan)) { + return false; + } + if (options.operation === 'copy' && isSameNativeDevice(options.source, options.target)) { + return false; + } + return true; +} +``` + +`isSameNativeDevice` returns true for local/local endpoints or SSH endpoints with identical host, normalized port, and username. + +- [ ] **Step 4: Use the policy in both service preflight methods** + +Single transfers pass `operation: 'copy'`. Batch transfers pass `request.operation === 'move' ? 'move' : 'copy'`. When the policy returns false, no target conflict stat is performed. + +- [ ] **Step 5: Run compilation and the policy test** + +Run: `npm run compile-check-ts-native -- --pretty false` + +Run: `npm run gulp compile-client` + +Run: `npm run test-node -- --run src/vs/platform/turbofile/test/common/turbofileTransferConflictPolicy.test.ts` + +Expected: compilation exits 0 and the policy test passes. + +- [ ] **Step 6: Commit the policy change** + +```powershell +git add -- src/vs/platform/turbofile/common/turbofileTransferConflictPolicy.ts src/vs/platform/turbofile/test/common/turbofileTransferConflictPolicy.test.ts src/vs/platform/turbofile/electron-main/turbofileSshMainService.ts out/vs/platform/turbofile +git commit -m "Allow same-device directory copy merges" +``` + +### Task 2: Merge POSIX Same-Device Directory Contents + +**Files:** +- Modify: `src/vs/platform/turbofile/common/turbofileRemoteNativeBatchPlan.ts` +- Modify: `src/vs/platform/turbofile/test/common/turbofileRemoteNativeBatchPlan.test.ts` +- Modify: `src/vs/platform/turbofile/electron-main/turbofileSshMainService.ts:4546` + +**Interfaces:** +- Consumes: batch item `sourcePath`, `targetName`, and `sourceType`. +- Produces: a null-delimited native command plan that merges directory contents into `targetDirectory/targetName`. + +- [ ] **Step 1: Write failing native plan tests** + +Add assertions that a directory copy plan contains an exact destination and content-copy form, while a move plan remains `mv` based: + +```ts +const plan = createPosixNativeBatchTransferPlan([ + { sourcePath: '/tmp/source/11', targetName: '11', sourceType: 'directory' } +], '/tmp/target', 'copy'); + +assert.ok(plan.command.includes('src/.')); +assert.ok(plan.command.includes('target/$name')); +assert.deepStrictEqual(plan.input.toString('utf8').split('\0'), ['/tmp/source/11', '11', 'directory', '']); +``` + +- [ ] **Step 2: Run the native plan test and verify RED** + +Run: `npm run test-node -- --run src/vs/platform/turbofile/test/common/turbofileRemoteNativeBatchPlan.test.ts` + +Expected: FAIL because the current input contains only source paths and the command copies the directory itself. + +- [ ] **Step 3: Implement exact-target POSIX merge commands** + +Encode triples of `sourcePath`, `targetName`, and `sourceType`. For directory copies, reject an existing non-directory target, create the exact target directory, and run: + +```sh +cp -a --reflink=auto --sparse=always -- "$src"/. "$dst"/ 2>/dev/null || cp -a -- "$src"/. "$dst"/ +``` + +For files, copy to the exact `$dst` with overwrite semantics. For moves, retain native `mv -f` and rely on preflight to reject an existing same-name directory. + +- [ ] **Step 4: Update single POSIX same-device copy** + +Pass `sourceType` into `buildPosixCopyCommand`. Directory copies use the same `source/. -> target/` merge rule; file copies retain the current exact-file overwrite command. + +- [ ] **Step 5: Compile and run native plan tests** + +Run: `npm run compile-check-ts-native -- --pretty false` + +Run: `npm run gulp compile-client` + +Run: `npm run test-node -- --run src/vs/platform/turbofile/test/common/turbofileRemoteNativeBatchPlan.test.ts` + +Expected: compilation exits 0 and all native plan tests pass. + +- [ ] **Step 6: Commit POSIX merge behavior** + +```powershell +git add -- src/vs/platform/turbofile/common/turbofileRemoteNativeBatchPlan.ts src/vs/platform/turbofile/test/common/turbofileRemoteNativeBatchPlan.test.ts src/vs/platform/turbofile/electron-main/turbofileSshMainService.ts out/vs/platform/turbofile +git commit -m "Merge same-device POSIX directory copies" +``` + +### Task 3: Preserve Windows Same-Device Merge Semantics + +**Files:** +- Modify: `src/vs/platform/turbofile/electron-main/turbofileSshMainService.ts:9796` +- Modify: `src/vs/platform/turbofile/test/common/turbofileSshMainServiceStatic.test.ts` + +**Interfaces:** +- Consumes: source type and exact target path for single copies; batch item names/types for remote Windows copies. +- Produces: PowerShell commands that create the exact target directory and copy children recursively with `-Force`. + +- [ ] **Step 1: Write failing static tests for Windows and SFTP boundaries** + +Assert that directory-capable SFTP fallback paths still call target conflict checks, and that the Windows native directory copy command copies children into the exact target rather than nesting the source folder. + +- [ ] **Step 2: Run the static test and verify RED** + +Run: `npm run gulp compile-client` + +Run: `npm run test-node -- --run src/vs/platform/turbofile/test/common/turbofileSshMainServiceStatic.test.ts` + +Expected: the new Windows exact-target merge assertion fails. The unrelated pre-existing directory-listing assertion may remain separately reported. + +- [ ] **Step 3: Implement Windows exact-target copy commands** + +For a source directory, create `$dst`, enumerate source children with `Get-ChildItem -LiteralPath $src -Force`, and pipe them to `Copy-Item -Destination $dst -Recurse -Force`. For a source file, retain exact-path overwrite. Batch remote Windows commands use each item's `targetName` and `sourceType`. + +- [ ] **Step 4: Keep lower-level protocol guards intact** + +Verify tar, SCP, local SFTP, direct SFTP, and SFTP relay directory execution paths reject an existing target unless they are running the native same-device copy path. Do not weaken `buildPrepareDirectoryTransferTargetCommand` or `ensureLocalDirectoryTransferTargetAvailable`. + +- [ ] **Step 5: Compile and run focused tests** + +Run: `npm run compile-check-ts-native -- --pretty false` + +Run: `npm run gulp compile-client` + +Run: `npm run test-node -- --run src/vs/platform/turbofile/test/common/turbofileTransferConflictPolicy.test.ts` + +Run: `npm run test-node -- --run src/vs/platform/turbofile/test/common/turbofileRemoteNativeBatchPlan.test.ts` + +Run: `npm run test-node -- --run src/vs/platform/turbofile/test/common/turbofileSshMainServiceStatic.test.ts` + +Expected: new conflict/merge tests pass; any unrelated pre-existing static test failure is documented exactly. + +- [ ] **Step 6: Run final source safety audit** + +Run: `rg -n "rm -rf|Remove-Item.*-Recurse|fsp\.rm\([^\n]*recursive" src/vs/platform/turbofile/electron-main/turbofileSshMainService.ts` + +Expected: recursive removals are limited to source cleanup after successful move, explicit delete actions, temporary paths, and rsync-only cleanup; no non-rsync target preparation deletes an existing directory. + +- [ ] **Step 7: Commit the completed implementation** + +```powershell +git add -- src/vs/platform/turbofile/electron-main/turbofileSshMainService.ts src/vs/platform/turbofile/test/common/turbofileSshMainServiceStatic.test.ts out/vs/platform/turbofile +git commit -m "Complete same-device directory merge protection" +``` diff --git a/docs/superpowers/plans/2026-07-10-ssh-post-transfer-session-recovery.md b/docs/superpowers/plans/2026-07-10-ssh-post-transfer-session-recovery.md new file mode 100644 index 00000000..078a4a3e --- /dev/null +++ b/docs/superpowers/plans/2026-07-10-ssh-post-transfer-session-recovery.md @@ -0,0 +1,395 @@ +# SSH Post-Transfer Directory Recovery Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Prevent multi-item transfers and overlapping directory refreshes from leaving an SSH pane in `No response from server` by making shared sessions generation-safe, refreshing only changed panes, and rejecting stale cache writes. + +**Architecture:** Keep the existing shared SSH/SFTP fast path, but associate every shared directory session and listing request with a keyed generation. Cleanup and cache writes are conditional on that generation still being current. Renderer refresh policy becomes operation-aware, and in-flight pane loads may return to their caller but may cache only while they remain the current request. + +**Tech Stack:** TypeScript, VS Code workbench services, ssh2/SFTP, Mocha TDD tests, Gulp client compilation. + +## Global Constraints + +- Do not change transfer protocol selection or transfer data paths. +- Do not change SSH credentials or device persistence. +- Do not hide authentication, permission, missing-path, or not-a-directory errors. +- Do not include transfer cancellation changes in this implementation. +- Keep the shared SSH/SFTP directory session fast path. +- Use tabs in TypeScript sources and follow the repository's existing imports and test style. + +--- + +### Task 1: Generation-Safe Shared Directory Sessions + +**Files:** +- Create: `src/vs/platform/turbofile/common/turbofileKeyedGeneration.ts` +- Create: `src/vs/platform/turbofile/test/common/turbofileKeyedGeneration.test.ts` +- Modify: `src/vs/platform/turbofile/electron-main/turbofileSshMainService.ts:219-224,403-406,591-625,5665-5764,6036-6064,6553-6591` +- Modify: `src/vs/platform/turbofile/test/common/turbofileSshMainServiceStatic.test.ts:11-24` + +**Interfaces:** +- Produces: `TurboFileKeyedGeneration.next(key): number`, `current(key): number | undefined`, `isCurrent(key, generation): boolean`, and `clearIfCurrent(key, generation): boolean`. +- Produces: `IRemoteDirectorySftpSession.generation: number`. +- Consumes: Existing directory session key, session promise map, and idle timer map. + +- [ ] **Step 1: Write the failing generation tests** + +```ts +suite('TurboFileKeyedGeneration', () => { + test('does not clear a replacement generation from a stale request', () => { + const generations = new TurboFileKeyedGeneration(); + const first = generations.next('64'); + const replacement = generations.next('64'); + + assert.strictEqual(generations.clearIfCurrent('64', first), false); + assert.strictEqual(generations.current('64'), replacement); + }); + + test('clears only the current generation', () => { + const generations = new TurboFileKeyedGeneration(); + const current = generations.next('64'); + + assert.strictEqual(generations.clearIfCurrent('64', current), true); + assert.strictEqual(generations.current('64'), undefined); + }); +}); +``` + +Replace the obsolete fresh-client static assertion with assertions that the service disposes and touches a specific session generation: + +```ts +assert.ok(service.includes('this.disposeDirectorySftpSession(session.key, session.generation)')); +assert.ok(service.includes('this.touchDirectorySftpSession(session.key, session.generation)')); +assert.ok(service.includes('generation: number;')); +``` + +- [ ] **Step 2: Run tests and verify RED** + +Run: + +```powershell +npm run compile-check-ts-native -- --pretty false +``` + +Expected: FAIL because `turbofileKeyedGeneration.js` does not exist yet. + +- [ ] **Step 3: Implement the keyed generation helper** + +```ts +export class TurboFileKeyedGeneration { + private sequence = 0; + private readonly currentByKey = new Map(); + + next(key: string): number { + const generation = ++this.sequence; + this.currentByKey.set(key, generation); + return generation; + } + + current(key: string): number | undefined { + return this.currentByKey.get(key); + } + + isCurrent(key: string, generation: number): boolean { + return this.currentByKey.get(key) === generation; + } + + clearIfCurrent(key: string, generation: number): boolean { + if (!this.isCurrent(key, generation)) { + return false; + } + this.currentByKey.delete(key); + return true; + } +} +``` + +- [ ] **Step 4: Make session creation, failure, close callbacks, idle timers, and explicit disposal generation-aware** + +Add `generation` to every `IRemoteDirectorySftpSession`. Allocate it before creating the session promise. Change cleanup to: + +```ts +private disposeDirectorySftpSession(key: string, generation: number): void { + if (!this.directorySftpSessionGenerations.clearIfCurrent(key, generation)) { + return; + } + const sessionPromise = this.directorySftpSessions.get(key); + this.directorySftpSessions.delete(key); + this.clearDirectorySftpIdleTimer(key); + // Existing best-effort SFTP/client close body remains unchanged. +} +``` + +Pass `session.generation` from list, stat, `withDirectorySftp`, `withDirectorySshClient`, transfer growth monitoring, and every `touchDirectorySftpSession` call. Close/error callbacks call `clearIfCurrent` before removing the map entry or timer. + +- [ ] **Step 5: Run focused tests and verify GREEN** + +Run: + +```powershell +npm run compile-check-ts-native -- --pretty false +npm run gulp compile-client +npm run test-node -- --run src/vs/platform/turbofile/test/common/turbofileKeyedGeneration.test.ts +npm run test-node -- --run src/vs/platform/turbofile/test/common/turbofileSshMainServiceStatic.test.ts +``` + +Expected: compile commands exit 0; generation tests pass; SSH static suite no longer contains the obsolete fresh-client failure. + +- [ ] **Step 6: Commit** + +```powershell +git add src/vs/platform/turbofile/common/turbofileKeyedGeneration.ts src/vs/platform/turbofile/test/common/turbofileKeyedGeneration.test.ts src/vs/platform/turbofile/electron-main/turbofileSshMainService.ts src/vs/platform/turbofile/test/common/turbofileSshMainServiceStatic.test.ts out/vs/platform/turbofile/electron-main/turbofileSshMainService.js +git commit -m "Protect replacement SSH directory sessions" +``` + +### Task 2: Bounded Recovery for Transient Directory Reads + +**Files:** +- Modify: `src/vs/platform/turbofile/common/turbofileRemoteDirectoryRetry.ts` +- Modify: `src/vs/platform/turbofile/test/common/turbofileRemoteDirectoryRetry.test.ts` +- Modify: `src/vs/platform/turbofile/electron-main/turbofileSshMainService.ts:607-624` + +**Interfaces:** +- Produces: `REMOTE_DIRECTORY_READ_MAX_ATTEMPTS` with value `3`. +- Produces: `getRemoteDirectoryRetryDelayMs(failedAttemptIndex): number | undefined`, returning `100`, `250`, then `undefined`. +- Consumes: `shouldRetryRemoteDirectoryRead(error)` and existing `delay(milliseconds)`. + +- [ ] **Step 1: Add failing retry policy tests** + +```ts +assert.strictEqual(REMOTE_DIRECTORY_READ_MAX_ATTEMPTS, 3); +assert.strictEqual(getRemoteDirectoryRetryDelayMs(0), 100); +assert.strictEqual(getRemoteDirectoryRetryDelayMs(1), 250); +assert.strictEqual(getRemoteDirectoryRetryDelayMs(2), undefined); +assert.strictEqual(shouldRetryRemoteDirectoryRead(new Error('Permission denied')), false); +``` + +- [ ] **Step 2: Run the retry test and verify RED** + +Run: + +```powershell +npm run test-node -- --run src/vs/platform/turbofile/test/common/turbofileRemoteDirectoryRetry.test.ts +``` + +Expected: FAIL because the max-attempt and delay exports are missing. + +- [ ] **Step 3: Implement bounded retry policy and service loop** + +```ts +export const REMOTE_DIRECTORY_READ_MAX_ATTEMPTS = 3; +const retryDelays = [100, 250] as const; + +export function getRemoteDirectoryRetryDelayMs(failedAttemptIndex: number): number | undefined { + return retryDelays[failedAttemptIndex]; +} +``` + +The service loop disposes only `session.generation`, retries only transient errors, and waits the configured delay before the next attempt. Non-retryable errors throw immediately. + +- [ ] **Step 4: Run focused tests and verify GREEN** + +Run: + +```powershell +npm run compile-check-ts-native -- --pretty false +npm run gulp compile-client +npm run test-node -- --run src/vs/platform/turbofile/test/common/turbofileRemoteDirectoryRetry.test.ts +``` + +Expected: all commands exit 0 and the retry suite passes. + +- [ ] **Step 5: Commit** + +```powershell +git add src/vs/platform/turbofile/common/turbofileRemoteDirectoryRetry.ts src/vs/platform/turbofile/test/common/turbofileRemoteDirectoryRetry.test.ts src/vs/platform/turbofile/electron-main/turbofileSshMainService.ts out/vs/platform/turbofile/electron-main/turbofileSshMainService.js +git commit -m "Retry transient SSH directory disconnects" +``` + +### Task 3: Operation-Aware Post-Transfer Pane Refresh + +**Files:** +- Create: `src/vs/workbench/contrib/turbofile/common/turbofileTransferRefresh.ts` +- Create: `src/vs/workbench/contrib/turbofile/test/common/turbofileTransferRefresh.test.ts` +- Modify: `src/vs/workbench/contrib/turbofile/browser/turbofileEditor.ts:9283-9287,9446-9450` + +**Interfaces:** +- Produces: `getTurboFilePostTransferRefreshBatches(operation, sourceSide, targetSide, sameDevice): readonly (readonly TurboFilePaneSide[])[]`. +- Consumes: Existing `isSameDeviceForNativeOperation`, `refreshPane`, and transfer operation. + +- [ ] **Step 1: Write failing refresh policy tests** + +```ts +assert.deepStrictEqual(getTurboFilePostTransferRefreshBatches('copy', 'left', 'right', true), [['right']]); +assert.deepStrictEqual(getTurboFilePostTransferRefreshBatches('move', 'left', 'right', true), [['left'], ['right']]); +assert.deepStrictEqual(getTurboFilePostTransferRefreshBatches('move', 'left', 'right', false), [['left', 'right']]); +``` + +- [ ] **Step 2: Run compile check and verify RED** + +Run: + +```powershell +npm run compile-check-ts-native -- --pretty false +``` + +Expected: FAIL because `turbofileTransferRefresh.js` does not exist. + +- [ ] **Step 3: Implement the pure refresh policy** + +```ts +export type TurboFilePaneSide = 'left' | 'right'; + +export function getTurboFilePostTransferRefreshBatches(operation: 'copy' | 'move', sourceSide: TurboFilePaneSide, targetSide: TurboFilePaneSide, sameDevice: boolean): readonly (readonly TurboFilePaneSide[])[] { + if (operation === 'copy' || sourceSide === targetSide) { + return [[targetSide]]; + } + return sameDevice ? [[sourceSide], [targetSide]] : [[sourceSide, targetSide]]; +} +``` + +- [ ] **Step 4: Route editor refreshes through the policy** + +Add: + +```ts +private async refreshTransferPanesAfterOperation(operation: 'copy' | 'move', sourceSide: 'left' | 'right', targetSide: 'left' | 'right', sourceDevice: TurboFileDevice, targetDevice: TurboFileDevice): Promise { + const batches = getTurboFilePostTransferRefreshBatches(operation, sourceSide, targetSide, this.isSameDeviceForNativeOperation(sourceDevice, targetDevice)); + for (const batch of batches) { + await Promise.all(batch.map(side => this.refreshPane(side, { force: true }))); + } +} +``` + +Use it after single-item copy and batch copy/move. A copy no longer refreshes its unchanged source pane. + +- [ ] **Step 5: Run focused tests and verify GREEN** + +Run: + +```powershell +npm run compile-check-ts-native -- --pretty false +npm run gulp compile-client +npm run test-node -- --run src/vs/workbench/contrib/turbofile/test/common/turbofileTransferRefresh.test.ts +``` + +Expected: compile commands exit 0 and all refresh-policy cases pass. + +- [ ] **Step 6: Commit** + +```powershell +git add src/vs/workbench/contrib/turbofile/common/turbofileTransferRefresh.ts src/vs/workbench/contrib/turbofile/test/common/turbofileTransferRefresh.test.ts src/vs/workbench/contrib/turbofile/browser/turbofileEditor.ts out/vs/workbench/contrib/turbofile/browser/turbofileEditor.js +git commit -m "Refresh only changed transfer panes" +``` + +### Task 4: Reject Stale Directory Cache Writes + +**Files:** +- Modify: `src/vs/workbench/contrib/turbofile/common/turbofileDirectoryInflight.ts` +- Modify: `src/vs/workbench/contrib/turbofile/test/common/turbofileDirectoryInflight.test.ts` +- Modify: `src/vs/workbench/contrib/turbofile/browser/turbofileEditor.ts:7688-7752` +- Modify: `src/vs/platform/turbofile/electron-main/turbofileSshMainService.ts:591-643,5802-5827` + +**Interfaces:** +- Produces: `TurboFileDirectoryInflightTracker.isCurrent(key, promise): boolean`. +- Consumes: `TurboFileKeyedGeneration` for main-process listing cache request generations. + +- [ ] **Step 1: Write the failing in-flight ownership test** + +```ts +const first = Promise.resolve('old'); +const replacement = Promise.resolve('new'); +tracker.set('64:/target', 'prefetch', first); +tracker.set('64:/target', 'foreground', replacement); +assert.strictEqual(tracker.isCurrent('64:/target', first), false); +assert.strictEqual(tracker.isCurrent('64:/target', replacement), true); +``` + +- [ ] **Step 2: Run the test and verify RED** + +Run: + +```powershell +npm run test-node -- --run src/vs/workbench/contrib/turbofile/test/common/turbofileDirectoryInflight.test.ts +``` + +Expected: FAIL because `isCurrent` is missing. + +- [ ] **Step 3: Implement renderer stale-write protection** + +```ts +isCurrent(key: string, promise: Promise): boolean { + return this.entries.get(key)?.promise === promise; +} +``` + +In `loadPaneDirectory`, declare the promise before its async body and update `paneDirectoryCache` only when `paneDirectoryInflight.isCurrent(cacheKey, promise)` remains true. Return the listing to the original caller even when its cache write is rejected. + +- [ ] **Step 4: Implement main-process listing cache generations** + +Allocate a keyed generation for every uncached listing request. Pass it to `createRemoteDirectoryListingResult` and write cache entries only while that generation is current. `clearRemoteDirectoryListingCache` advances the generation before deleting each affected key, preventing an older request from repopulating it. + +- [ ] **Step 5: Run focused tests and verify GREEN** + +Run: + +```powershell +npm run compile-check-ts-native -- --pretty false +npm run gulp compile-client +npm run test-node -- --run src/vs/workbench/contrib/turbofile/test/common/turbofileDirectoryInflight.test.ts +npm run test-node -- --run src/vs/workbench/contrib/turbofile/test/common/turbofileDirectoryNavigation.test.ts +npm run test-node -- --run src/vs/platform/turbofile/test/common/turbofileSshMainServiceStatic.test.ts +``` + +Expected: compile commands exit 0 and all focused suites pass. + +- [ ] **Step 6: Commit** + +```powershell +git add src/vs/workbench/contrib/turbofile/common/turbofileDirectoryInflight.ts src/vs/workbench/contrib/turbofile/test/common/turbofileDirectoryInflight.test.ts src/vs/workbench/contrib/turbofile/browser/turbofileEditor.ts src/vs/platform/turbofile/electron-main/turbofileSshMainService.ts out/vs/workbench/contrib/turbofile/browser/turbofileEditor.js out/vs/platform/turbofile/electron-main/turbofileSshMainService.js +git commit -m "Ignore stale SSH directory listings" +``` + +### Task 5: Full Verification + +**Files:** +- Verify only; no planned source changes. + +**Interfaces:** +- Consumes: All behavior from Tasks 1-4. + +- [ ] **Step 1: Run TypeScript and client compilation** + +```powershell +npm run compile-check-ts-native -- --pretty false +npm run gulp compile-client +``` + +Expected: both commands exit 0 and client compilation reports 0 errors. + +- [ ] **Step 2: Run all focused regression suites** + +```powershell +npm run test-node -- --run src/vs/platform/turbofile/test/common/turbofileKeyedGeneration.test.ts +npm run test-node -- --run src/vs/platform/turbofile/test/common/turbofileRemoteDirectoryRetry.test.ts +npm run test-node -- --run src/vs/platform/turbofile/test/common/turbofileSshMainServiceStatic.test.ts +npm run test-node -- --run src/vs/workbench/contrib/turbofile/test/common/turbofileTransferRefresh.test.ts +npm run test-node -- --run src/vs/workbench/contrib/turbofile/test/common/turbofileDirectoryInflight.test.ts +npm run test-node -- --run src/vs/workbench/contrib/turbofile/test/common/turbofileDirectoryNavigation.test.ts +npm run test-node -- --run src/vs/platform/turbofile/test/common/turbofileTransferPlan.test.ts +``` + +Expected: every focused suite exits 0. + +- [ ] **Step 3: Run source and Git safety checks** + +```powershell +git diff --check +git status --short +``` + +Expected: no whitespace errors; only intentional tracked changes before the final commit, then a clean worktree. + +- [ ] **Step 4: Exercise the reported workflow** + +Run the development build, transfer multiple files, and observe target refresh behavior on a shared SSH device. Repeat the transfer and refresh sequence. Expected: copy refreshes only the target pane, stale prefetches do not replace the fresh listing, and no pane displays `No response from server` from an old-session cleanup race. diff --git a/docs/superpowers/plans/2026-07-14-turbofile-codebase-slimming.md b/docs/superpowers/plans/2026-07-14-turbofile-codebase-slimming.md new file mode 100644 index 00000000..9c5c7311 --- /dev/null +++ b/docs/superpowers/plans/2026-07-14-turbofile-codebase-slimming.md @@ -0,0 +1,300 @@ +# TurboFile Codebase Slimming Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Remove VS Code capabilities unrelated to TurboFile, documents, default language intelligence, terminal, and the Windows install/release pipeline while preserving every approved behavior. + +**Architecture:** Treat retained capability as an explicit allowlist. First restore the native language-extension runtime and add regression contracts, then remove unused runtime registrations, bundle entrypoints, packaged extensions/dependencies/resources, and finally source trees that have become unreachable. Each layer is independently compiled and tested before the next layer is removed. + +**Tech Stack:** TypeScript, VS Code workbench/extension host, esbuild/Gulp, Electron, Inno Setup, Mocha, PowerShell. + +## Global Constraints + +- Preserve all TurboFile transfer, device, task, image, clipboard, drag/drop, terminal, tab, theme, settings, and update behavior. +- Preserve all document-related source and VS Code default document/language capabilities, including Explorer, Search, Markdown/media/Webview/custom editors, Notebook, Diff/Merge, history, and code intelligence. +- Preserve the current Windows x64 build, package, install, update, uninstall, manifest, checksum, and GitHub Release workflows. +- Preserve all existing dirty-worktree changes; never reset or overwrite them. +- Run `npm run compile-check-ts-native` before any unit-test command. +- Remove source only after its runtime entrypoint and packaged output are gone and `rg` shows no retained-source import. +- Keep third-party licenses and notices. + +--- + +### Task 1: Add the slimming capability contract and regression guardrails + +**Files:** +- Create: `build/turbofile-product.json` +- Create: `src/vs/workbench/contrib/turbofile/test/common/turbofileSlimShell.test.ts` + +**Interfaces:** +- Produces: JSON arrays `excludedLocalExtensions`, `excludedProductionDependencies`, `electronLocales`, `forbiddenDesktopEntrypoints`, and `forbiddenWorkbenchImports` consumed by later build tasks and static tests. + +- [ ] **Step 1: Write a failing static contract test** + +The test must read `build/turbofile-product.json`, `workbench.common.main.ts`, `workbench.desktop.main.ts`, `build/buildfile.ts`, `build/next/index.ts`, and `build/gulpfile.vscode.ts`. Assert that required document/TurboFile entrypoints remain, forbidden entries are absent, `zh-CN.pak` and `en-US.pak` are retained, and language/document extensions are not excluded. + +- [ ] **Step 2: Verify RED** + +Run `npm run compile-check-ts-native`, then `npm run test-node -- --grep TurboFileSlimShell`. Expected: failure because `build/turbofile-product.json` does not exist. + +- [ ] **Step 3: Add the contract** + +Use this initial policy: + +```json +{ + "excludedLocalExtensions": [ + "debug-auto-launch", "debug-server-ready", "git", "git-base", "github", + "github-authentication", "mermaid-chat-features", "microsoft-authentication", + "tunnel-forwarding" + ], + "excludedProductionDependencies": [ + "@anthropic-ai/sandbox-runtime", "@anthropic-ai/sdk", "@github/copilot", + "@github/copilot-sdk", "@microsoft/1ds-core-js", "@microsoft/1ds-post-js", + "@microsoft/applicationinsights-core-js", "@microsoft/applicationinsights-shims", + "@microsoft/dev-tunnels-connections", "@microsoft/dev-tunnels-contracts", + "@microsoft/dev-tunnels-management", "@microsoft/dev-tunnels-ssh", + "@microsoft/dev-tunnels-ssh-tcp", "chrome-remote-interface", "playwright-core" + ], + "electronLocales": ["en-US.pak", "zh-CN.pak"], + "forbiddenDesktopEntrypoints": [ + "vs/sessions/", "vs/platform/agentHost/", "vs/workbench/contrib/debug/node/telemetryApp" + ], + "forbiddenWorkbenchImports": [ + "contrib/authentication", "contrib/processExplorer", "contrib/share", + "contrib/speech", "contrib/userDataSync", "contrib/userDataProfile", + "platform/agentHost", "platform/remoteTunnel", "services/browserView", + "services/mcp", "services/userDataSync" + ] +} +``` + +Do not exclude `@vscode/ripgrep`, `@vscode/vscode-languagedetection`, `katex`, `node-pty`, `ssh2`, TypeScript, TextMate, Oniguruma, xterm, or document/language extensions. + +- [ ] **Step 4: Verify GREEN** + +Run compile-check and the focused test. Expected: PASS against the current required-entry contract, with later-task forbidden-entry assertions expressed as explicit expected failures per task rather than disabled assertions. + +- [ ] **Step 5: Commit** + +Commit only the contract and its test as `test: define TurboFile slimming capability contract`. + +### Task 2: Restore VS Code default language intelligence + +**Files:** +- Modify: `src/vs/workbench/workbench.common.main.ts` +- Modify: `src/vs/workbench/workbench.desktop.main.ts` +- Modify: `src/vs/workbench/contrib/turbofile/test/common/turbofileSlimShell.test.ts` +- Delete after GREEN: `src/vs/workbench/services/extensions/common/turbofileExtensionContributionService.ts` + +**Interfaces:** +- Produces: native `IExtensionService` activation for bundled default language/document extensions. +- Preserves: extension-host process, extension scanning, TextMate/Tree-sitter contributions, and user language-extension compatibility. + +- [ ] **Step 1: Add failing assertions** + +Assert that `workbench.common.main.ts` no longer registers `TurboFileExtensionContributionService`, that `workbench.desktop.main.ts` imports `./services/extensions/electron-browser/nativeExtensionService.js`, and that `typescript-language-features`, `json-language-features`, `html-language-features`, `css-language-features`, `markdown-language-features`, and `notebook-renderers` are not excluded. + +- [ ] **Step 2: Verify RED** + +Run compile-check and the focused test. Expected: failure on the custom extension service registration/native service import. + +- [ ] **Step 3: Restore native activation** + +Remove the custom `IExtensionService` registration and import from `workbench.common.main.ts`. Add the native extension service side-effect import next to `extensionHostStarter` in `workbench.desktop.main.ts`. Keep extension management/scanning services needed by the native service, but do not restore Extensions UI or marketplace contributions. + +- [ ] **Step 4: Verify GREEN** + +Run compile-check, focused slim-shell tests, language service tests, and extension service tests. Expected: all PASS. + +- [ ] **Step 5: Delete the obsolete custom service** + +Use `rg TurboFileExtensionContributionService src build`; when only the service file remains, delete it and rerun compile-check/tests. + +- [ ] **Step 6: Commit** + +Commit as `fix: restore default language extension activation`. + +### Task 3: Remove unused workbench registrations without touching document systems + +**Files:** +- Modify: `src/vs/workbench/workbench.common.main.ts` +- Modify: `src/vs/workbench/workbench.desktop.main.ts` +- Modify: `src/vs/workbench/contrib/turbofile/test/common/turbofileSlimShell.test.ts` + +**Interfaces:** +- Removes only side-effect registration of approved-unused services/contributions. +- Retains editor, files, search, workspace, notebook, webview, Markdown, merge/diff, history, output, terminal, theme, accessibility, quick input, clipboard, update, encryption, secrets, and TurboFile imports. + +- [ ] **Step 1: Add failing forbidden-import assertions** + +Cover AI/MCP/browser automation, agent host, remote tunnel, online sync/profile/authentication, speech/share/telemetry UI, issue/process explorer, policy/keybinding export, emergency alert, and VS Code Tasks/Debug/Testing/SCM contributions. + +- [ ] **Step 2: Verify RED** + +Run compile-check and focused tests. Expected: failures identifying the currently loaded forbidden imports. + +- [ ] **Step 3: Remove common-main imports** + +Remove only imports for MCP resource scanning, AI embedding/related/settings services, chat entitlement, account policy, telemetry contribution, speech, process explorer, authentication, user-data sync/profile, share, remote explorer/scanner, and associated online services. Keep document-facing inline completions, code actions, comments, timeline/history, language detection, outline, Webview/custom editor, Explorer/Search, and accessibility. + +- [ ] **Step 4: Remove desktop-main imports** + +Remove MCP, telemetry, user-data sync, tunnels/remote tunnel, diagnostics/profiling UI, agent host, Playwright Browser View, issue/process explorer, tags, emergency alert, policy export, keybindings export, and duplicate disabled contributions. Keep lifecycle, update, request, clipboard, context menu, path, themes, encryption, secrets, extension runtime, files/search, terminal, Webview, local history, merge editor, and TurboFile. + +- [ ] **Step 5: Verify and commit** + +Run compile-check plus TurboFile/editor/workbench tests. Commit as `refactor: remove unused workbench registrations`. + +### Task 4: Remove AI/Sessions/AgentHost build entrypoints and resources + +**Files:** +- Modify: `build/buildfile.ts` +- Modify: `build/next/index.ts` +- Modify: `build/gulpfile.vscode.ts` +- Modify: `src/vs/platform/windows/electron-main/windowImpl.ts` +- Modify: `src/vs/workbench/contrib/turbofile/test/common/turbofileSlimShell.test.ts` + +**Interfaces:** +- Desktop bundle continues to produce workbench, extension host, file watcher, pty host, document/search/notebook/language workers, CLI, shared process, and workbench bootstrap. +- Desktop bundle stops producing Sessions, AgentHost, AgentHost diff worker, and debug telemetry artifacts. + +- [ ] **Step 1: Add failing bundle assertions** + +Assert forbidden entrypoints/resources are absent in both legacy and esbuild configurations, while required document/terminal/extension-host entrypoints remain. + +- [ ] **Step 2: Verify RED** + +Run compile-check and focused tests. Expected: failures listing Sessions/AgentHost/debug entrypoints. + +- [ ] **Step 3: Prune both bundle configurations** + +Remove Sessions, AgentHost, AgentHost diff worker, debug telemetry app, browser-view preload, Sessions HTML/media/prompts/skills, debug media, and Sessions checksum paths. Keep all document workers and Webview/extension-host resources. + +- [ ] **Step 4: Disable Sessions-window routing** + +Make built TurboFile windows always load `vs/code/electron-browser/workbench/workbench.html`; remove the Sessions HTML branch only after proving no retained caller can set `isSessionsWindow` for TurboFile. + +- [ ] **Step 5: Verify and commit** + +Run build typecheck (`npm --prefix build run typecheck`), compile-check, focused tests, then `npm run gulp -- esbuild-vscode-min`. Confirm `out-vscode-min/vs/sessions` and AgentHost bundles are absent. Commit as `build: remove unused AI and sessions entrypoints`. + +### Task 5: Slim packaged extensions, dependencies, source maps, locales, and installer compression + +**Files:** +- Modify: `build/lib/extensions.ts` +- Modify: `build/gulpfile.vscode.ts` +- Modify: `build/win32/code.iss` +- Modify: `src/vs/workbench/contrib/turbofile/test/common/turbofileSlimShell.test.ts` + +**Interfaces:** +- Consumes: `build/turbofile-product.json`. +- Produces: a Windows app containing only approved extensions/dependencies/locales and no JS/CSS source maps. + +- [ ] **Step 1: Add failing packaging-policy assertions** + +Assert local extension enumeration applies `excludedLocalExtensions`, production dependency streams apply `excludedProductionDependencies`, packaging always strips `*.js.map`/`*.css.map`, Electron locales are filtered to the configured two files, and Inno uses `lzma2/ultra64` with solid compression. + +- [ ] **Step 2: Verify RED** + +Run compile-check and focused tests. Expected: failures for each unapplied policy. + +- [ ] **Step 3: Apply extension/dependency filters** + +Load the JSON once in the build process. Filter local extensions by name. Convert excluded dependency package names into anchored `node_modules//**` exclusions before `createAsar`; remove Copilot-specific ASAR unpack rules. Do not filter a transitive package unless its top-level package name is explicitly in the contract. + +- [ ] **Step 4: Strip maps and locales** + +Set packaging source-map stripping independently of CI, remove sourceMappingURL comments, and filter Electron output to `locales/en-US.pak` and `locales/zh-CN.pak` on Windows. Do not remove licenses, ICU, Chromium PAKs, V8 snapshots, ffmpeg, or GPU DLLs. + +- [ ] **Step 5: Optimize installer compression** + +Change only `Compression=lzma2/ultra64`; retain `SolidCompression=yes` and every installer task/section. + +- [ ] **Step 6: Verify and commit** + +Run build typecheck, compile-check, focused tests, `npm run gulp -- vscode-win32-x64-min`, and scan `D:/Work/Code/VSCode-win32-x64` for forbidden extensions/dependencies/maps/locales. Commit as `build: slim TurboFile packaged payload`. + +### Task 6: Safely clean legacy versioned update directories + +**Files:** +- Modify: `build/win32/code.iss` +- Modify: `src/vs/workbench/contrib/turbofile/test/common/turbofileSlimShell.test.ts` + +**Interfaces:** +- Adds: Inno Setup post-install cleanup limited to legacy ten-hex-character directories containing both a `version` marker and `resources/app/product.json`. +- Never deletes: `{app}`, the current versioned resources folder, user data, arbitrary directories, or locked directories. + +- [ ] **Step 1: Add failing safety assertions** + +Assert the installer validates directory name, marker file, product JSON, and current-folder inequality before calling `DelTree`; assert failures are logged and do not fail installation. + +- [ ] **Step 2: Verify RED** + +Run compile-check and focused tests. Expected: failure because cleanup procedure is absent. + +- [ ] **Step 3: Implement cleanup** + +Add `IsHexCharacter`, `IsLegacyVersionedDirectoryName`, and `CleanupLegacyVersionedDirectories` Pascal procedures. Enumerate only direct children of `{app}`; skip `.`/`..` and `{#VersionedResourcesFolder}`; call cleanup from the successful post-install path after updater GC. + +- [ ] **Step 4: Verify and commit** + +Run compile-check, focused tests, `npm run gulp -- vscode-win32-x64-inno-updater`, and `npm run gulp -- vscode-win32-x64-user-setup`. Test install over a fixture containing one valid legacy directory and one similarly named unrelated directory. Commit as `fix: clean legacy TurboFile update payloads`. + +### Task 7: Delete unreachable non-document source families and root dependencies + +**Files:** +- Delete when unreachable: `src/vs/sessions/**` +- Delete when unreachable: `src/vs/platform/agentHost/**` +- Delete when unreachable: `src/vs/platform/browserView/**` +- Delete when unreachable: unused AI/MCP/remote/debug/testing/SCM/task source families identified by the contract +- Delete: excluded local extension directories from Task 1 +- Modify: `package.json` +- Modify: `package-lock.json` +- Modify: remaining importers discovered by `rg` + +**Interfaces:** +- Leaves all document, language, extension-host, terminal, TurboFile, update, build, installer, and release sources intact. + +- [ ] **Step 1: Process one source family at a time** + +For each candidate, run `rg` for its module path across retained sources and build scripts. Remove obsolete importers/registrations first. If any importer belongs to the approved document/install set, retain the shared source or move only the shared neutral type to an existing non-feature module before deletion. + +- [ ] **Step 2: Verify RED/GREEN per family** + +Before deleting a family, add its path to the forbidden-source assertion and observe failure. Delete it, then run compile-check and focused tests. Do not batch families in one verification cycle. + +- [ ] **Step 3: Remove production dependencies** + +After all compile-time importers are gone, remove the excluded top-level dependencies from `package.json` and regenerate `package-lock.json` with `npm install --package-lock-only --ignore-scripts`. Confirm `npm ls --omit=dev` no longer lists them. + +- [ ] **Step 4: Verify and commit by coherent family** + +Use separate commits for AI/Sessions, browser automation, remote/tunnels, debug/testing/SCM/tasks, and online account/sync/telemetry families. Every commit requires compile-check and relevant tests. + +### Task 8: Full build, package, installation, and functional acceptance + +**Files:** +- Create: `.build/win32-x64/turbofile-size-report.json` (artifact only, do not commit) +- Modify only if a verified regression requires a focused fix. + +- [ ] **Step 1: Run automated verification** + +Run compile-check, TurboFile tests, editor/language/extension tests, build tests, and the Windows minified package tasks in the established order. + +- [ ] **Step 2: Scan artifacts** + +Fail if the app contains source maps, forbidden extensions/dependencies, Sessions/AgentHost bundles, or locales outside `en-US`/`zh-CN`. Record total and top-level sizes against 788.8 MiB app and 163.9 MiB installer baselines. + +- [ ] **Step 3: Desktop regression** + +Using the VS Code launch automation, verify TurboFile pages/transfers/clipboard/drag/terminal/tabs and document open/edit/save/search/diff/merge/Markdown/media/Webview/Notebook. Verify Ctrl+click, F12, references, completion, and hover for every language where stock VS Code provides those capabilities. + +- [ ] **Step 4: Installer regression** + +Verify clean install, overwrite install, background update, failed-update recovery, uninstall, reinstall, shortcuts, PATH, protocol, file associations, context menu, and legacy-directory cleanup. + +- [ ] **Step 5: Final review and packaging handoff** + +Run `git diff --check`, inspect all retained dirty changes, report exact size deltas and any source family retained due to a documented dependency. Do not claim completion until all verification commands pass. + diff --git a/docs/superpowers/plans/2026-07-15-batch-stream-progress-direct-first.md b/docs/superpowers/plans/2026-07-15-batch-stream-progress-direct-first.md new file mode 100644 index 00000000..3e2cc27a --- /dev/null +++ b/docs/superpowers/plans/2026-07-15-batch-stream-progress-direct-first.md @@ -0,0 +1,236 @@ +# Batch Stream Progress and Direct-First Routing Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Start tar-based directory and multi-item transfers without recursive size scans, show streamed bytes/speed/elapsed time, and always try SSH-to-SSH direct routes before relay. + +**Architecture:** Make unknown-total behavior authoritative in both the renderer and main process. Count tar archive bytes at the stream boundary; for remote direct pipelines, insert a lightweight `dd` counter whose marked stderr is parsed without walking either filesystem. Keep transfer-plan protocol selection intact and change route cooldowns from exclusion to ordering. + +**Tech Stack:** TypeScript, Electron main process, Node.js streams, SSH2, GNU/BusyBox shell tools, VS Code Mocha unit tests. + +## Global Constraints + +- A single regular file keeps the existing SCP expected-size behavior. +- Any directory tar transfer or multi-item transfer starts with an unknown total. +- Unknown-total UI shows transferred bytes, current speed, and elapsed time; it does not show total, percentage-derived ETA, or a partial denominator. +- Failed or canceled move operations must never delete source items. +- Automatic SSH-to-SSH transfers try direct routes before relay; explicit relay hints remain respected. +- Preserve unrelated dirty-worktree changes and do not edit generated `out/` files manually. + +--- + +### Task 1: Unknown-Total Batch Policy + +**Files:** +- Modify: `src/vs/workbench/contrib/turbofile/browser/turbofileEditor.ts:9372-9420, 9678-9780` +- Modify: `src/vs/platform/turbofile/electron-main/turbofileSshMainService.ts:2530-2565, 3680-3762` +- Test: `src/vs/platform/turbofile/test/common/turbofileSshMainServiceStatic.test.ts` +- Test: `src/vs/workbench/contrib/turbofile/test/common/turbofileTransferDisplay.test.ts` + +**Interfaces:** +- Consumes: existing `ITurboFileBatchTransferRequest` and `ITurboFileTransferProgressEvent`. +- Produces: batch requests whose request-level and item-level `expectedBytes` are `0`, plus aggregate progress events whose `totalBytes` remain `0` while running. + +- [ ] **Step 1: Write failing static and display-policy tests** + +Add assertions that both renderer batch request blocks set `expectedBytes: 0`, that `fillBatchDirectorySizesInBackground` is absent, and that `getBatchExpectedBytes` returns zero for more than one item. Retain the existing unknown-total formatting behavior by asserting the renderer contains the transferred-only branch. + +- [ ] **Step 2: Run the targeted tests and verify RED** + +Run after compiling the unchanged baseline output needed by the test runner: + +```powershell +npm run compile-check-ts-native +scripts\test.bat --grep "TurboFileSshMainServiceStatic|TurboFileTransferDisplay" +``` + +Expected: compilation succeeds; new assertions fail because batch expected sizes and the background directory scan still exist. + +- [ ] **Step 3: Implement the renderer policy** + +In both `transferItemsToWindows` batch handling and `transferItemsBatch`: + +```ts +task.totalBytes = 0; +// ... +items: items.map(item => ({ + sourcePath: item.path!, + targetName: item.name, + displayName: item.name, + sourceType: item.type, + expectedBytes: 0 +})), +expectedBytes: 0, +``` + +Remove `fillBatchDirectorySizesInBackground` and its call completely. + +- [ ] **Step 4: Make the main process authoritative** + +Change `getBatchExpectedBytes` and aggregate reporting so multi-item batches cannot regain a denominator from child transfers: + +```ts +private getBatchExpectedBytes(request: ITurboFileBatchTransferRequest, items: readonly ITurboFileBatchTransferItem[]): number { + if (items.length > 1 || items.some(item => item.sourceType === 'directory')) { + return 0; + } + const explicit = Math.max(0, request.expectedBytes ?? 0); + return explicit > 0 ? explicit : Math.max(0, items[0]?.expectedBytes ?? 0); +} +``` + +When the policy returns zero, `fireBatchAggregateProgress` must publish `totalBytes = 0` even if child SCP/SFTP transfers discover their individual totals. + +- [ ] **Step 5: Compile and run targeted tests for GREEN** + +```powershell +npm run compile-check-ts-native +scripts\test.bat --grep "TurboFileSshMainServiceStatic|TurboFileTransferDisplay" +``` + +Expected: exit code 0 and all matching tests pass. + +### Task 2: Streamed Tar Progress Without Tree Scans + +**Files:** +- Create: `src/vs/platform/turbofile/common/turbofileTarProgress.ts` +- Create: `src/vs/platform/turbofile/test/common/turbofileTarProgress.test.ts` +- Modify: `src/vs/platform/turbofile/electron-main/turbofileSshMainService.ts:1484-1610, 3267-3670, 4828-4935, 5544-5605, 9758-9785` +- Test: `src/vs/platform/turbofile/test/common/turbofileSshMainServiceStatic.test.ts` + +**Interfaces:** +- Produces: `TURBOFILE_TAR_PROGRESS_MARKER: string` and `consumeTarProgressStderr(remainder: string, chunk: string): { remainder: string; transferredBytes?: number }`. +- Consumes: existing `createProgressPassThrough`, `fireTransferProgress`, and `execRemote` stderr callbacks. + +- [ ] **Step 1: Write parser and no-scan failing tests** + +Test split chunks, carriage-return records, unrelated stderr, and monotonically usable byte values such as: + +```ts +const first = consumeTarProgressStderr('', '__TURBOFILE_TAR_BYTES__1048'); +const second = consumeTarProgressStderr(first.remainder, '576 bytes copied\r'); +assert.deepStrictEqual(second, { remainder: '', transferredBytes: 1048576 }); +``` + +Add static assertions that tar methods do not invoke `getRemotePathContentSizeByCommand`, `getRemoteBatchSourceContentSize`, `beginRemotePathGrowthTiming`, or `beginRemoteBatchTargetGrowthTiming`, and that relay pipelines contain `createProgressPassThrough`. + +- [ ] **Step 2: Compile and verify RED** + +```powershell +npm run compile-check-ts-native +scripts\test.bat --grep "TurboFileTarProgress|TurboFileSshMainServiceStatic" +``` + +Expected: the new parser import/function is missing or the no-scan assertions fail. + +- [ ] **Step 3: Implement the marked stderr parser** + +Implement a pure parser that buffers an incomplete final line, splits on `\r` or `\n`, accepts only lines beginning with `TURBOFILE_TAR_PROGRESS_MARKER`, and returns the largest `([0-9]+) bytes` value in the completed lines. + +- [ ] **Step 4: Remove tar size scans and count existing streams** + +For local upload/download and batch upload/download, keep `archiveBytes += delta`, always publish `totalBytes = 0`, and use `archiveBytes` as `result.bytes`. Remove all `sizePromise` and post-transfer logical-size fallbacks used only for display. + +For single and batch SSH relay tar pipelines: + +```ts +let archiveBytes = 0; +const onArchiveDelta = (delta: number): void => { + archiveBytes += Math.max(0, delta); + this.fireTransferProgress(request, archiveBytes, 0, 'relay-upload', 'Tar relay transfer in progress.', false, metadata); +}; +await pipeline( + sourceStream.stream as unknown as NodeJS.ReadableStream, + createProgressPassThrough(onArchiveDelta), + targetStream.stream as unknown as NodeJS.WritableStream +); +``` + +- [ ] **Step 5: Add direct-pipeline byte reporting** + +Insert a `dd bs=4M` filter into direct tar source-push and target-pull pipelines. Run it under `bash -o pipefail`, periodically signal it with `USR1`, and prefix its stderr records with `TURBOFILE_TAR_PROGRESS_MARKER`. In `tryDirectTarTransferRoute` and `tryDirectBatchTarTransferRoute`, feed `onStderr` into `consumeTarProgressStderr`, publish parsed bytes with total zero, and remove recursive target growth probes and completion scans. + +- [ ] **Step 6: Compile and run targeted tests for GREEN** + +```powershell +npm run compile-check-ts-native +scripts\test.bat --grep "TurboFileTarProgress|TurboFileSshMainServiceStatic" +``` + +Expected: exit code 0 and all matching tests pass. + +### Task 3: Direct Routes Always Precede Relay + +**Files:** +- Modify: `src/vs/platform/turbofile/common/turbofileTransferPlan.ts` +- Modify: `src/vs/platform/turbofile/electron-main/turbofileSshMainService.ts:5048-5105` +- Test: `src/vs/platform/turbofile/test/common/turbofileTransferPlan.test.ts` + +**Interfaces:** +- Produces: `TurboFileDirectSshRoute` and `prioritizeDirectSshRoutes(routes, isCoolingDown)`. +- Consumes: route cache entries and cooldown timestamps from the main service. + +- [ ] **Step 1: Write failing route-priority tests** + +Test that active routes remain first, cooling routes move to the end, both cooling routes are still returned, ordering is stable, and automatic SSH-to-SSH plans remain `[direct, relay]`. + +- [ ] **Step 2: Compile and verify RED** + +```powershell +npm run compile-check-ts-native +scripts\test.bat --grep "TurboFileTransferPlan" +``` + +Expected: the new exported route prioritizer does not exist. + +- [ ] **Step 3: Implement stable direct-route prioritization** + +Export the direction type and a stable partition helper from `turbofileTransferPlan.ts`. In `resolveDirectSshRoutes`, merge a cached successful route with `['source-push', 'target-pull']`, deduplicate, and call the helper. Do not filter cooling routes out. + +- [ ] **Step 4: Compile and run route tests for GREEN** + +```powershell +npm run compile-check-ts-native +scripts\test.bat --grep "TurboFileTransferPlan" +``` + +Expected: exit code 0 and all matching tests pass. + +### Task 4: Full Verification + +**Files:** +- Verify all files changed in Tasks 1-3. + +**Interfaces:** +- Consumes: completed implementation. +- Produces: verification evidence only. + +- [ ] **Step 1: Check the diff for accidental generated or unrelated edits** + +```powershell +git diff --check +git status --short +git diff -- src/vs/platform/turbofile/common/turbofileTransferPlan.ts src/vs/platform/turbofile/common/turbofileTarProgress.ts src/vs/platform/turbofile/electron-main/turbofileSshMainService.ts src/vs/workbench/contrib/turbofile/browser/turbofileEditor.ts src/vs/platform/turbofile/test/common/turbofileTransferPlan.test.ts src/vs/platform/turbofile/test/common/turbofileTarProgress.test.ts src/vs/platform/turbofile/test/common/turbofileSshMainServiceStatic.test.ts src/vs/workbench/contrib/turbofile/test/common/turbofileTransferDisplay.test.ts +``` + +Expected: no whitespace errors and only intended source/test changes in the scoped diff. + +- [ ] **Step 2: Run mandatory TypeScript compilation** + +```powershell +npm run compile-check-ts-native +``` + +Expected: exit code 0. + +- [ ] **Step 3: Run all relevant TurboFile tests** + +```powershell +scripts\test.bat --grep "TurboFile(TarProgress|TransferPlan|SshMainServiceStatic|TransferDisplay|BatchTarPlan|SameDeviceMergeStatic)" +``` + +Expected: exit code 0 with zero failures. + +- [ ] **Step 4: Review requirements against implementation** + +Confirm single-file SCP tests still pass, no batch or directory tar path performs a recursive size scan for progress, streamed bytes drive speed, elapsed time remains visible for unknown totals, direct routes remain ahead of relay, and source cleanup still occurs only after successful tar completion. diff --git a/docs/superpowers/plans/2026-07-15-drag-mode-compact-icon.md b/docs/superpowers/plans/2026-07-15-drag-mode-compact-icon.md new file mode 100644 index 00000000..b82fcce0 --- /dev/null +++ b/docs/superpowers/plans/2026-07-15-drag-mode-compact-icon.md @@ -0,0 +1,130 @@ +# Compact Drag Mode Icon Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the wide copy/cut toolbar control with a standard-size icon whose complete Chinese label is drawn inside one small colored frame. + +**Architecture:** Keep the existing per-pane operation state and click handler unchanged. Replace only the button's child markup and CSS presentation, using one text badge inside the existing `24×24px` toolbar button. + +**Tech Stack:** TypeScript DOM helpers, CSS theme variables, Mocha static regression tests. + +## Global Constraints + +- The outer button is `24×24px` and has no border or persistent selected outline. +- The inner frame contains the complete text “复制” or “剪切”. +- Copy uses bright cyan-green; cut uses bright red. +- Left and right pane states remain independent and default to copy. +- Transfer behavior and source-deletion safety are unchanged. +- Preserve all unrelated dirty-worktree changes. +- Run `npm run compile-check-ts-native` before tests; never run `npm run compile`. + +--- + +### Task 1: Compact Mode Badge + +**Files:** +- Modify: `src/vs/workbench/contrib/turbofile/browser/turbofileEditor.ts:7441-7460` +- Modify: `src/vs/workbench/contrib/turbofile/browser/media/turbofile.css:2016-2047,5285-5297` +- Test: `src/vs/workbench/contrib/turbofile/test/common/turbofileDragDrop.test.ts:108-135` + +**Interfaces:** +- Consumes: `TurboFileEditorInput.toggleDragOperation(side)` and existing localized copy/cut labels. +- Produces: `.turbofile-drag-operation-icon`, a single inner badge element. + +- [ ] **Step 1: Write the failing markup and CSS assertions** + +```ts +const css = fs.readFileSync(path.join(process.cwd(), 'src/vs/workbench/contrib/turbofile/browser/media/turbofile.css'), 'utf8'); +assert.deepStrictEqual({ + usesSingleBadge: appendButton.includes("$('span.turbofile-drag-operation-icon', undefined, label)"), + removesSeparateIcon: !appendButton.includes('renderTurboFileIcon('), + removesSeparateLabel: !appendButton.includes('turbofile-drag-operation-label'), + standardButtonWidth: css.includes('width: 24px;') && css.includes('min-width: 24px;'), + innerFrame: css.includes('.turbofile-drag-operation-icon') && css.includes('border: 1px solid currentColor;'), + copyColor: css.includes('var(--vscode-terminal-ansiBrightCyan, var(--vscode-charts-cyan))'), + moveColor: css.includes('var(--vscode-terminal-ansiBrightRed, var(--vscode-charts-red))'), + removesWideButton: !css.includes('min-width: 52px;') +}, { + usesSingleBadge: true, + removesSeparateIcon: true, + removesSeparateLabel: true, + standardButtonWidth: true, + innerFrame: true, + copyColor: true, + moveColor: true, + removesWideButton: true +}); +``` + +- [ ] **Step 2: Verify the assertions fail against the current wide control** + +Run a PowerShell source assertion matching Step 1. + +Expected: FAIL because the button still contains a separate icon and `min-width: 52px`. + +- [ ] **Step 3: Replace the two children with one badge** + +```ts +button.appendChild($('span.turbofile-drag-operation-icon', undefined, label)); +``` + +Keep the existing title, aria label, click handler, side argument, and operation toggle unchanged. + +- [ ] **Step 4: Replace the wide-button CSS** + +```css +.turbofile-toolbar-button.turbofile-drag-operation-button { + width: 24px; + min-width: 24px; + height: 24px; + padding: 0; + border: 0; + background: transparent; + box-shadow: none; +} + +.turbofile-drag-operation-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 18px; + box-sizing: border-box; + border: 1px solid currentColor; + border-radius: 2px; + font-size: 10px; + font-weight: 600; + line-height: 1; + white-space: nowrap; +} + +.turbofile-drag-operation-button.is-copy { + color: var(--vscode-terminal-ansiBrightCyan, var(--vscode-charts-cyan)); +} + +.turbofile-drag-operation-button.is-move { + color: var(--vscode-terminal-ansiBrightRed, var(--vscode-charts-red)); +} +``` + +Remove the light-theme outer-border override and keep `border: 0` on the outer button. Preserve ordinary toolbar hover behavior. + +- [ ] **Step 5: Run the mandatory type-check** + +Run: `npm run compile-check-ts-native` + +Expected: PASS. + +- [ ] **Step 6: Refresh generated test output and run TurboFile tests** + +Run: `node build/next/index.ts transpile` + +Run: `scripts\test.bat --grep TurboFile` with `VSCODE_SKIP_PRELAUNCH=1` when a local Electron runtime is available. + +Expected: all TurboFile tests pass, including the compact badge assertions. + +- [ ] **Step 7: Inspect the scoped diff** + +Run: `git diff --check -- src/vs/workbench/contrib/turbofile/browser/turbofileEditor.ts src/vs/workbench/contrib/turbofile/browser/media/turbofile.css src/vs/workbench/contrib/turbofile/test/common/turbofileDragDrop.test.ts` + +Expected: no whitespace errors and no transfer-logic changes. diff --git a/docs/superpowers/plans/2026-07-15-drag-transfer-mode.md b/docs/superpowers/plans/2026-07-15-drag-transfer-mode.md new file mode 100644 index 00000000..f844e0e6 --- /dev/null +++ b/docs/superpowers/plans/2026-07-15-drag-transfer-mode.md @@ -0,0 +1,242 @@ +# Drag Transfer Mode Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add an independent copy/cut drag mode to every TurboFile page and apply it safely to every internal and Windows drag path. + +**Architecture:** Store the page-scoped operation on `TurboFileEditorInput`, centralize drag-operation decisions in pure helpers, and snapshot the operation into drag data at drag start. Reuse the existing batch transfer `operation: 'copy' | 'move'` pipeline; for local cut-drag to Windows, reuse the existing CopyHook target-capture path before moving any source. + +**Tech Stack:** TypeScript, VS Code EditorInput/EditorPane APIs, Electron `webContents.startDrag`, Windows CopyHook helper, CSS, Mocha assertions. + +## Global Constraints + +- Default mode is `copy`; every new TurboFile page starts independently in `copy`. +- The mode applies to cross-pane, same-pane folder, Windows-to-pane, and pane-to-Windows drags. +- Ctrl+C, Ctrl+X, Ctrl+V and non-drag transfer commands keep their existing explicit semantics. +- Cancellation, target-capture failure, or transfer failure must never delete a source item that was not successfully moved. +- Do not persist the mode and do not create separate left/right modes. +- Preserve all pre-existing dirty working-tree changes; do not stage or commit overlapping implementation files. +- Run `npm run compile-check-ts-native` before any unit test command. +- The unit-test launcher currently cannot download Electron from GitHub (`ECONNRESET`/`ETIMEDOUT`); retry it after implementation but do not report tests as passing unless they actually execute. + +--- + +### Task 1: Page-Scoped Operation and Pure Drag Decisions + +**Files:** +- Modify: `src/vs/workbench/contrib/turbofile/common/turbofileDragDrop.ts` +- Modify: `src/vs/workbench/contrib/turbofile/common/turbofileEditorInput.ts` +- Modify: `src/vs/workbench/contrib/turbofile/test/common/turbofileDragDrop.test.ts` +- Modify: `src/vs/workbench/contrib/turbofile/test/common/turbofileEditorInput.test.ts` + +**Interfaces:** +- Produces: `TurboFileDragOperation = 'copy' | 'move'`. +- Produces: `resolveTurboFileDragOperation(value, fallback)`, `getTurboFileDropEffect(operation)`, `getNextTurboFileDragOperation(operation)`, and `shouldCaptureWindowsDropTarget(sourceKind, operation)`. +- Produces: `TurboFileEditorInput.getDragOperation()`, `setDragOperation(operation)`, and `toggleDragOperation()`. + +- [ ] **Step 1: Write failing pure-helper tests** + +Add one snapshot-style test asserting: + +```ts +assert.deepStrictEqual({ + resolvedCopy: resolveTurboFileDragOperation('copy', 'move'), + resolvedMove: resolveTurboFileDragOperation('move', 'copy'), + invalidFallback: resolveTurboFileDragOperation('link', 'copy'), + copyEffect: getTurboFileDropEffect('copy'), + moveEffect: getTurboFileDropEffect('move'), + nextCopy: getNextTurboFileDragOperation('copy'), + nextMove: getNextTurboFileDragOperation('move'), + localCopyCapture: shouldCaptureWindowsDropTarget('local', 'copy'), + localMoveCapture: shouldCaptureWindowsDropTarget('local', 'move'), + sshCopyCapture: shouldCaptureWindowsDropTarget('ssh', 'copy') +}, { + resolvedCopy: 'copy', resolvedMove: 'move', invalidFallback: 'copy', + copyEffect: 'copy', moveEffect: 'move', nextCopy: 'move', nextMove: 'copy', + localCopyCapture: false, localMoveCapture: true, sshCopyCapture: true +}); +``` + +- [ ] **Step 2: Write failing page-isolation test** + +Create two `TurboFileEditorInput` instances, toggle only the first, and assert the first is `move`, the second stays `copy`, and a second toggle restores `copy`. + +- [ ] **Step 3: Run compile check to confirm missing-interface failures** + +Run: `npm run compile-check-ts-native` + +Expected: non-zero exit with missing exported helper/method errors from the new tests. + +- [ ] **Step 4: Implement the minimal helpers and input state** + +Implement: + +```ts +export type TurboFileDragOperation = 'copy' | 'move'; + +export function resolveTurboFileDragOperation(value: string | undefined, fallback: TurboFileDragOperation): TurboFileDragOperation { + return value === 'copy' || value === 'move' ? value : fallback; +} + +export function getTurboFileDropEffect(operation: TurboFileDragOperation): 'copy' | 'move' { + return operation; +} + +export function getNextTurboFileDragOperation(operation: TurboFileDragOperation): TurboFileDragOperation { + return operation === 'copy' ? 'move' : 'copy'; +} + +export function shouldCaptureWindowsDropTarget(sourceKind: 'local' | 'ssh', operation: TurboFileDragOperation): boolean { + return sourceKind === 'ssh' || operation === 'move'; +} +``` + +Store `private dragOperation: TurboFileDragOperation = 'copy'` on `TurboFileEditorInput`; its setter returns early when unchanged, and its toggle uses `getNextTurboFileDragOperation`. + +- [ ] **Step 5: Run compile check** + +Run: `npm run compile-check-ts-native` + +Expected: exit 0. + +- [ ] **Step 6: Check only intended task files** + +Run: `git diff --check -- src/vs/workbench/contrib/turbofile/common/turbofileDragDrop.ts src/vs/workbench/contrib/turbofile/common/turbofileEditorInput.ts src/vs/workbench/contrib/turbofile/test/common/turbofileDragDrop.test.ts src/vs/workbench/contrib/turbofile/test/common/turbofileEditorInput.test.ts` + +Expected: exit 0. Do not stage because `turbofileEditorInput.ts` already contains user-owned dirty changes. + +### Task 2: Toolbar Toggle and In-App Drag Paths + +**Files:** +- Modify: `src/vs/workbench/contrib/turbofile/browser/turbofileEditor.ts` +- Modify: `src/vs/workbench/contrib/turbofile/browser/media/turbofile.css` +- Modify: `src/vs/workbench/contrib/turbofile/test/common/turbofileDragDrop.test.ts` + +**Interfaces:** +- Consumes: page operation and pure helpers from Task 1. +- Produces: synchronized `.turbofile-drag-operation-button` controls in both pane toolbars. +- Produces: `transferSelected(sourceSide, targetSide, initiatedAt, operation)` and folder-target copy/move routing. + +- [ ] **Step 1: Add failing source-wiring assertions** + +Read `turbofileEditor.ts` as text and assert it contains the mode button class, writes `application/x-turbofile-operation`, resolves that payload at drops, passes the operation to `transferSelected` and `transferExternalPaths`, and routes same-pane folder drops through a method that accepts the operation. + +- [ ] **Step 2: Run compile check to verify the new API references fail** + +Run: `npm run compile-check-ts-native` + +Expected: non-zero until the editor imports and methods exist. + +- [ ] **Step 3: Render the synchronized two-state toolbar button** + +Insert the button between sort and refresh in `renderPane`. The button reads `this.currentInput?.getDragOperation() ?? 'copy'`, renders `files` for copy and `scissors` for move, sets `aria-pressed`, localized title/label text, and calls `this.currentInput?.toggleDragOperation(); this.render();`. + +- [ ] **Step 4: Snapshot operation at drag start and consume it at every in-app drop** + +At row and image-card drag start, write the current operation into `application/x-turbofile-operation` and set `effectAllowed` accordingly. At page, list, image-grid, and external-window interceptors, resolve the operation once from drag data with the current page mode as fallback, set the matching `dropEffect`, and pass it into the deferred transfer call. + +- [ ] **Step 5: Make cross-pane and folder-target transfers operation-aware** + +Change `transferSelected` to accept `operation: TurboFileDragOperation = 'copy'`. Route all move operations and multi-item copies through `transferItemsBatch`; keep the existing optimized single-item copy path. Add an optional target-directory argument to `transferItemsBatch` so a same-pane folder drop can copy into that folder. In folder-row drop handling, call native `moveSelectedItemsIntoDirectory` for `move`, otherwise batch-copy the validated selection to `dropTargetPath`. + +- [ ] **Step 6: Style the destructive state without moving existing icons** + +Keep the existing 24x24 button geometry. Add `.turbofile-drag-operation-button.is-move` color, background, border/inset outline, hover, focus-visible, and high-contrast rules using VS Code theme variables. Do not change toolbar height, alignment, padding, or neighboring icon offsets. + +- [ ] **Step 7: Compile and inspect diff** + +Run: `npm run compile-check-ts-native` + +Expected: exit 0. + +Run: `git diff --check -- src/vs/workbench/contrib/turbofile/browser/turbofileEditor.ts src/vs/workbench/contrib/turbofile/browser/media/turbofile.css src/vs/workbench/contrib/turbofile/test/common/turbofileDragDrop.test.ts` + +Expected: exit 0. + +### Task 3: Reliable Cut-Drag from Pane to Windows + +**Files:** +- Modify: `src/vs/platform/turbofile/common/turbofile.ts` +- Modify: `src/vs/platform/turbofile/electron-main/turbofileSshMainService.ts` +- Modify: `src/vs/workbench/contrib/turbofile/browser/turbofileEditor.ts` +- Modify: `src/vs/workbench/contrib/turbofile/test/common/turbofileDragDrop.test.ts` + +**Interfaces:** +- Extends: `ITurboFileStartWindowsDragRequest` with `readonly operation?: 'copy' | 'move'`. +- Consumes: `shouldCaptureWindowsDropTarget(source.kind, operation)`. +- Guarantees: local-copy keeps direct native drag; local-move and all SSH drags capture a confirmed target directory first. + +- [ ] **Step 1: Add failing contract assertions** + +Assert the request interface includes `operation`, the editor passes the drag-start snapshot to `startWindowsFileDrag`, and the main service selects target capture for local move. + +- [ ] **Step 2: Run compile check to expose missing request support** + +Run: `npm run compile-check-ts-native` + +Expected: non-zero until the request and call sites are updated. + +- [ ] **Step 3: Extend the request without changing copy defaults** + +Add optional `operation`. In the main service resolve `request.operation ?? 'copy'`. If the source is local and operation is copy, keep the current `webContents.startDrag({ files })` path. Otherwise call a generalized target-capture method that uses the existing fake directory and CopyHook for both local move and SSH operations. + +- [ ] **Step 4: Execute transfer only after confirmed target** + +Pass the snapshotted operation through `startWindowsFileDragFromPane` and `transferItemsToWindowsDirectory`. On `canceled`, timeout, missing target, or `ok: false`, return without calling any transfer or deletion. For a confirmed local move, `transferBatch(... operation: 'move')` performs native local move; for SSH move, the existing batch pipeline removes only successfully moved sources. + +- [ ] **Step 5: Keep direct local-copy drag fast** + +Do not route local copy through fake target capture. Its behavior and native multi-file `startDrag` call remain byte-for-byte equivalent except for receiving the defaulted operation. + +- [ ] **Step 6: Compile and inspect the platform diff** + +Run: `npm run compile-check-ts-native` + +Expected: exit 0. + +Run: `git diff --check -- src/vs/platform/turbofile/common/turbofile.ts src/vs/platform/turbofile/electron-main/turbofileSshMainService.ts src/vs/workbench/contrib/turbofile/browser/turbofileEditor.ts` + +Expected: exit 0. + +### Task 4: Verification and Manual Test Build + +**Files:** +- Verify all files from Tasks 1-3. + +**Interfaces:** +- Consumes the complete feature. +- Produces fresh verification evidence and a precise list of any environment-blocked checks. + +- [ ] **Step 1: Run the mandatory compiler check** + +Run: `npm run compile-check-ts-native` + +Expected: exit 0. + +- [ ] **Step 2: Retry focused unit tests** + +Run: `scripts\test.bat --grep "TurboFileDragDrop|TurboFileEditorInput"` + +Expected: all matching tests pass. If Electron download still fails, record the exact network error and do not describe tests as executed. + +- [ ] **Step 3: Run diff safety checks** + +Run: `git diff --check` + +Expected: exit 0. + +Run: `git status --short` + +Expected: only known user-owned changes plus this feature's files; no generated temporary files. + +- [ ] **Step 4: Review requirement coverage** + +Check each path explicitly: cross-pane copy/move, same-pane folder copy/move, Windows-to-pane copy/move, local-pane-to-Windows copy/move, SSH-pane-to-Windows copy/move, per-page isolation, synchronized toolbar icons, Ctrl+C/X/V unchanged, cancellation/failed target capture no source deletion. + +- [ ] **Step 5: Prepare manual validation instructions** + +Tell the user to open two TurboFile tabs, switch only one to cut, and test the four drag directions plus canceling a Windows drop. Do not package unless the user requests it after validation. + +## Execution Choice + +Inline execution is selected because the user explicitly requested implementation on the current `codex/image-stream-loading` branch and did not request subagents. The worktree is intentionally not changed, and overlapping dirty files will not be staged. diff --git a/docs/superpowers/plans/2026-07-15-image-grid-fast-correct-sorting.md b/docs/superpowers/plans/2026-07-15-image-grid-fast-correct-sorting.md new file mode 100644 index 00000000..4d2d9c89 --- /dev/null +++ b/docs/superpowers/plans/2026-07-15-image-grid-fast-correct-sorting.md @@ -0,0 +1,246 @@ +# 图片网格快速且正确的排序实施计划 + +> 此计划中的图片头元数据预热已被 `2026-07-17-image-grid-instant-file-sort.md` 取代。 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 让图片网格的名称、类型、修改日期和原始像素面积排序全部正确,并使元数据就绪后的排序切换只进行同步内存排序。 + +**Architecture:** 把比较规则提取为纯函数模块;在主进程增加批量图片元数据接口,通过单个或少量批次读取文件属性和有限图片头;图片网格打开时主动预热并缓存结果,排序点击只使用缓存。缩略图加载不再驱动排序。 + +**Tech Stack:** TypeScript、VS Code service DI/main-process remote service、Node.js 文件与 SSH 流、Mocha 单元测试。 + +## Global Constraints + +- “大小”表示原始宽度 × 原始高度,不表示文件字节大小。 +- 元数据未知项无论递增或递减都排在已知项之后。 +- 禁止为了排序下载完整大图,禁止每张图片一次 SSH 连接或命令。 +- 排序后必须保持单选、多选、锚点和滚动位置。 +- 修改现有共享文件时保留工作区中用户已有改动;未经用户要求不提交实现文件。 +- 测试前必须运行 `npm run compile-check-ts-native`;不得运行 `npm run compile`。 + +--- + +### Task 1: 可测试的图片网格纯排序器 + +**Files:** +- Create: `src/vs/workbench/contrib/turbofile/common/turbofileImageGridSort.ts` +- Create: `src/vs/workbench/contrib/turbofile/test/common/turbofileImageGridSort.test.ts` + +**Interfaces:** +- Produces: `TurboFileImageGridSortKey`, `ITurboFileImageGridSortableItem`, `sortTurboFileImageGridItems(items, key, descending)`。 + +- [ ] **Step 1: 写失败测试** + +测试数据直接构造名称、修改时间、原始宽高和就绪标记,断言: + +```ts +assert.deepStrictEqual(names('date', true), ['new.jpg', 'old.jpg', 'unknown.jpg']); +assert.deepStrictEqual(names('size', false), ['small.jpg', 'large.jpg', 'unknown.jpg']); +assert.deepStrictEqual(names('size', true), ['large.jpg', 'small.jpg', 'unknown.jpg']); +assert.deepStrictEqual(names('name', false), ['image2.jpg', 'image10.jpg']); +assert.deepStrictEqual(names('type', false), ['a.jpg', 'b.png']); +``` + +- [ ] **Step 2: 编译并确认 RED** + +Run: + +```powershell +npm run compile-check-ts-native +``` + +Expected: FAIL,因为 `turbofileImageGridSort.ts` 尚不存在。 + +- [ ] **Step 3: 实现最小纯排序器** + +核心规则: + +```ts +export function sortTurboFileImageGridItems(items: readonly T[], key: TurboFileImageGridSortKey, descending: boolean): T[] { + return [...items].sort((left, right) => compareTurboFileImageGridItems(left, right, key, descending)); +} +``` + +日期和尺寸先比较 `modifiedKnown`/`dimensionsKnown`,已知项永远在前;两个已知项才按方向比较数值;未知项按名称稳定排序。 + +- [ ] **Step 4: 确认 GREEN** + +Run: + +```powershell +npm run compile-check-ts-native +node build/next/index.ts transpile +node test/unit/node/index.js --run src/vs/workbench/contrib/turbofile/test/common/turbofileImageGridSort.test.ts +``` + +Expected: 编译通过,排序测试全部通过。 + +--- + +### Task 2: 有限图片头尺寸解析器 + +**Files:** +- Create: `src/vs/platform/turbofile/common/turbofileImageMetadata.ts` +- Create: `src/vs/platform/turbofile/test/common/turbofileImageMetadata.test.ts` + +**Interfaces:** +- Produces: `ITurboFileImageDimensions` 和 `readTurboFileImageDimensions(name, bytes)`。 +- Consumed by: Task 3 的本地和远端批量元数据读取。 + +- [ ] **Step 1: 写失败测试** + +用小型合成头部覆盖 PNG、JPEG、GIF、BMP、WebP、ICO、TIFF、SVG 和 AVIF;另测截断、损坏数据返回 `undefined`。示例: + +```ts +assert.deepStrictEqual(readTurboFileImageDimensions('a.png', pngHeader(1920, 1080)), { width: 1920, height: 1080 }); +assert.deepStrictEqual(readTurboFileImageDimensions('a.jpg', jpegHeader(4032, 3024)), { width: 4032, height: 3024 }); +assert.strictEqual(readTurboFileImageDimensions('bad.png', new Uint8Array([1, 2, 3])), undefined); +``` + +- [ ] **Step 2: 编译并确认 RED** + +Run `npm run compile-check-ts-native`。 + +Expected: FAIL,因为图片头解析模块尚不存在。 + +- [ ] **Step 3: 实现格式解析** + +解析器只读取传入的有限字节,不访问文件系统;所有宽高必须为安全正整数。JPEG 扫描 SOF 标记,TIFF 读取首个 IFD 的 256/257 标签,AVIF 选择有效 `ispe` 中面积最大的尺寸,SVG 优先宽高并回退 `viewBox`。 + +- [ ] **Step 4: 确认 GREEN** + +Run: + +```powershell +npm run compile-check-ts-native +node build/next/index.ts transpile +node test/unit/node/index.js --run src/vs/platform/turbofile/test/common/turbofileImageMetadata.test.ts +``` + +Expected: 所有格式和损坏输入测试通过。 + +--- + +### Task 3: 主进程批量图片排序元数据接口 + +**Files:** +- Modify: `src/vs/platform/turbofile/common/turbofile.ts` +- Modify: `src/vs/platform/turbofile/electron-main/turbofileSshMainService.ts` +- Modify: `src/vs/platform/turbofile/test/common/turbofileSshMainServiceStatic.test.ts` +- Create: `src/vs/platform/turbofile/test/common/turbofileImageMetadataBatch.test.ts` + +**Interfaces:** +- Produces: `ITurboFileImageMetadataEntry { path, size, modified, width?, height? }`。 +- Produces: `ITurboFileSshService.readImageMetadataBatch(endpoint, paths)`。 +- Consumes: `readTurboFileImageDimensions`。 + +- [ ] **Step 1: 写失败测试** + +为二进制 NUL 分帧解析写真实测试,覆盖路径、大小、秒级修改时间、工具返回的宽高、头部长度和损坏尾帧;静态测试断言接口按批次读取有限头部,且方法体不调用逐图 `readImage`。 + +- [ ] **Step 2: 编译并确认 RED** + +Run `npm run compile-check-ts-native`。 + +Expected: FAIL,因为服务契约和批量实现尚不存在。 + +- [ ] **Step 3: 实现批量接口** + +- 本地:并发上限内执行 `stat`,每个文件只读取前 `262144` 字节。 +- POSIX SSH:把路径分成有大小限制的批次;每批只执行一个远端 shell 命令。命令优先用 `vipsheader` 或 ImageMagick `identify` 返回原始宽高;无法由工具识别时才返回有限头部。帧格式为 `path\0size\0mtime\0width\0height\0headerLength\0
`。 +- Windows SSH:每批只执行一个 PowerShell 命令,优先通过可用的系统图片元数据能力读取宽高,无法识别时返回包含 Base64 有限头部的压缩 JSON。 +- 每个批次共享已有 SSH 会话;单文件失败转换为缺失宽高的条目,不使整批失败。 + +- [ ] **Step 4: 确认 GREEN** + +Run: + +```powershell +npm run compile-check-ts-native +node build/next/index.ts transpile +node test/unit/node/index.js --run src/vs/platform/turbofile/test/common/turbofileImageMetadataBatch.test.ts +node test/unit/node/index.js --run src/vs/platform/turbofile/test/common/turbofileSshMainServiceStatic.test.ts +``` + +Expected: 编译及批量协议测试通过。 + +--- + +### Task 4: 图片网格主动预热和一次性排序 + +**Files:** +- Modify: `src/vs/workbench/contrib/turbofile/browser/turbofileEditor.ts` +- Modify: `src/vs/workbench/contrib/turbofile/test/common/turbofileImageGridPane.test.ts` + +**Interfaces:** +- Consumes: `sortTurboFileImageGridItems` 和 `readImageMetadataBatch`。 +- Produces: 网格状态中的元数据就绪状态、请求代次和预热 Promise。 + +- [ ] **Step 1: 写失败测试** + +静态/状态测试断言: + +```ts +assert.ok(editor.includes('void this.prepareImageGridSortMetadata(nextState)')); +assert.ok(editor.includes('this.sshService.readImageMetadataBatch(')); +assert.ok(!editor.includes('resortImageGridAfterSizeUpdate')); +assert.ok(editor.includes('sortTurboFileImageGridItems')); +``` + +纯状态测试验证排序前后根据路径恢复 `selectedIndex`、`selectedIndexes` 和锚点。 + +- [ ] **Step 2: 编译并确认 RED** + +Run `npm run compile-check-ts-native`。 + +Expected: FAIL,因为预热方法和新排序器接线尚不存在。 + +- [ ] **Step 3: 实现预热与接线** + +- `openImageGrid` 完成状态创建后立即启动 `prepareImageGridSortMetadata`。 +- 批量结果按路径一次性写回 `rawModified`、`rawSize`、`originalWidth`、`originalHeight` 和已知标记。 +- 请求代次不匹配或网格已关闭时丢弃结果。 +- 名称/类型立即同步排序;日期/大小未就绪时记录目标排序并显示“正在准备排序信息”,就绪后只调用一次 `resortImageGridState`。 +- 删除缩略图 `load`、预览 `load` 和单图读取完成后的逐张大小重排。 +- 重排前保存滚动位置,重排后保持同一图片路径、多选和锚点。 + +- [ ] **Step 4: 确认 GREEN** + +Run: + +```powershell +npm run compile-check-ts-native +node build/next/index.ts transpile +node test/unit/node/index.js --run src/vs/workbench/contrib/turbofile/test/common/turbofileImageGridSort.test.ts +node test/unit/node/index.js --run src/vs/workbench/contrib/turbofile/test/common/turbofileImageGridPane.test.ts +``` + +Expected: 排序和网格接线测试全部通过。 + +--- + +### Task 5: 回归和性能约束验证 + +**Files:** +- Verify only; no additional production scope. + +- [ ] **Step 1: 运行最终编译检查** + +Run `npm run compile-check-ts-native`。 + +Expected: PASS。 + +- [ ] **Step 2: 运行全部 TurboFile 图片和排序测试** + +转译后依次运行文件名匹配 `turbofile(FileSort|Image).*\.test\.ts` 的测试。 + +Expected: 全部 PASS。 + +- [ ] **Step 3: 检查性能不变量** + +使用源码断言确认:已就绪的排序点击路径只调用纯排序器;元数据接口按批次调用;不存在逐张图片加载后重排;不存在为排序读取完整文件的调用。 + +- [ ] **Step 4: 检查差异边界** + +Run `git diff --check` 仅针对本计划涉及文件,并确认未暂存工作区中用户已有修改。 diff --git a/docs/superpowers/plans/2026-07-15-non-single-file-unknown-size.md b/docs/superpowers/plans/2026-07-15-non-single-file-unknown-size.md new file mode 100644 index 00000000..e2ed1d99 --- /dev/null +++ b/docs/superpowers/plans/2026-07-15-non-single-file-unknown-size.md @@ -0,0 +1,185 @@ +# Non-Single-File Unknown Size Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ensure every directory or multi-item transfer starts without recursive size scans while preserving known size for exactly one ordinary file. + +**Architecture:** Keep the existing transfer protocols and progress event contract. Decide whether a total is known from item cardinality and source type, report real streamed bytes when available, and leave `totalBytes` at zero when no reliable byte stream exists; remove the local and same-SSH directory scan call sites rather than weakening the shared scan helpers used elsewhere. + +**Tech Stack:** TypeScript, VS Code IPC services, Node.js filesystem/streams, Mocha TDD tests. + +## Global Constraints + +- Only an operation containing exactly one ordinary file may expose a known total size. +- Any directory or any multi-item operation must keep `totalBytes = 0` before and during transfer. +- No source or target recursive size scan may be added before, during, or after tar or same-device transfer. +- Real bytes from an existing stream may still drive transferred size and speed. +- Do not change copy commands, tar buffers, concurrency, cancellation, conflict handling, or source-deletion safety. +- Work in the current dirty `codex/image-stream-loading` worktree; do not stage or commit unrelated files. + +--- + +### Task 1: Lock the no-scan same-device policy with regression tests + +**Files:** +- Modify: `src/vs/platform/turbofile/test/common/turbofileSameDeviceMergeStatic.test.ts` +- Verify: `src/vs/platform/turbofile/test/common/turbofileSshMainServiceStatic.test.ts` + +**Interfaces:** +- Consumes: `transferFile`, `trySameRemoteCopy`, and `execRemoteCopyWithProgress` source blocks. +- Produces: static regression assertions that reject recursive directory size and target-growth scans. + +- [x] **Step 1: Replace the old directory-growth expectation and add local-copy assertions** + +```ts +test('keeps non-single-file same-device totals unknown without recursive size scans', () => { + const service = fs.readFileSync(servicePath(), 'utf8'); + const transferFile = service.slice(service.indexOf('async transferFile'), service.indexOf('\n\tprivate async transferBetweenSshDevices')); + const sameRemoteCopy = methodBlock(service, 'trySameRemoteCopy'); + const remoteProgress = methodBlock(service, 'execRemoteCopyWithProgress'); + + assert.ok(!transferFile.includes('getLocalPathTransferSize(request.source.path')); + assert.ok(transferFile.includes("sourceType === 'directory' ? 0")); + assert.ok(sameRemoteCopy.includes("const expectedBytes = sourceType === 'directory' ? 0")); + assert.ok(/if \(sourceType === 'file' && bytes <= 0\)[\s\S]*getRemotePathSizeByCommand\(request\.source\)/.test(sameRemoteCopy)); + assert.ok(!sameRemoteCopy.includes('getRemotePathSizeByCommand(request.target)')); + assert.ok(!remoteProgress.includes('beginRemotePathGrowthTiming')); + assert.ok(remoteProgress.includes('beginRemoteFileGrowthTiming')); +}); +``` + +- [x] **Step 2: Run the prerequisite type check and compile the test output** + +Run: `npm run compile-check-ts-native` + +Expected: exit code 0. + +Run: `npm run gulp compile-client` + +Expected: compilation finishes with 0 errors. + +- [x] **Step 3: Run the focused test and verify RED** + +Run: `node_modules\.bin\mocha.cmd --ui tdd --timeout 10000 --exit out/vs/platform/turbofile/test/common/turbofileSameDeviceMergeStatic.test.js` + +Expected: FAIL because the current implementation still calls `getLocalPathTransferSize`, permits remote directory source/target scans, and starts directory-growth polling. + +--- + +### Task 2: Remove directory size scans while preserving single-file totals + +**Files:** +- Modify: `src/vs/platform/turbofile/electron-main/turbofileSshMainService.ts:799-821` +- Modify: `src/vs/platform/turbofile/electron-main/turbofileSshMainService.ts:4973-5015` +- Modify: `src/vs/platform/turbofile/electron-main/turbofileSshMainService.ts:6645-6661` +- Modify: `src/vs/platform/turbofile/electron-main/turbofileSshMainService.ts:11272-11277` + +**Interfaces:** +- Consumes: `ITurboFileTransferRequest.sourceType`, `expectedBytes`, shallow `fsp.stat`, and real `copyLocalPath` byte deltas. +- Produces: unknown totals for directories and known totals for a single ordinary file without recursive scans. + +- [x] **Step 1: Make local-to-local size resolution shallow and type-aware** + +Use the request source type when known and otherwise perform only one shallow `fsp.stat`: + +```ts +const sourceStat = request.sourceType ? undefined : await fsp.stat(request.source.path); +const sourceType = request.sourceType ?? (sourceStat?.isDirectory() ? 'directory' : 'file'); +const totalBytes = sourceType === 'directory' + ? 0 + : Math.max(0, request.expectedBytes ?? sourceStat?.size ?? 0); +``` + +When `totalBytes` is unknown, publish the real local stream delta without clamping to zero: + +```ts +const progressBytes = totalBytes > 0 ? Math.min(transferredBytes, totalBytes) : transferredBytes; +this.fireTransferProgress(request, progressBytes, totalBytes, 'transfer', 'Copying locally.'); +``` + +Complete with real `transferredBytes` but keep the event total unknown for a directory: + +```ts +const bytes = Math.max(totalBytes, transferredBytes); +this.fireTransferProgress(request, bytes, totalBytes, 'complete', 'Local copy completed.', true); +``` + +- [x] **Step 2: Remove same-SSH directory source and target scans** + +Keep the listing-provided total only for a file, and never repair an unknown directory total by scanning: + +```ts +const expectedBytes = sourceType === 'directory' ? 0 : Math.max(0, request.expectedBytes ?? 0); +let bytes = expectedBytes; +if (sourceType === 'file' && bytes <= 0) { + bytes = await this.getRemotePathSizeByCommand(request.source).catch(() => expectedBytes); +} +``` + +Remove the completion-time `getRemotePathSizeByCommand(request.target)` branch. + +- [x] **Step 3: Disable remote directory growth polling for native copy** + +Retain file-level monitoring only: + +```ts +if (bytes > 0 && sourceType !== 'directory' && request.target.connection) { + stopGrowthTiming = this.beginRemoteFileGrowthTiming(request.progressId, request.target.connection, request.target.path, 0, bytes, copiedBytes => { + this.fireTransferProgress(request, copiedBytes, bytes, 'transfer', 'Remote copy in progress.'); + }); +} +``` + +The unused `targetInitiallyExists` parameter and its directory-existence preflight should be removed from `execRemoteCopyWithProgress` and its caller because it existed only to control recursive directory polling. + +- [x] **Step 4: Remove the now-unused recursive local transfer-size wrapper** + +Delete `getLocalPathTransferSize`; retain `getLocalPathSize` and Windows size helpers because other explicit size features may still use them. + +- [x] **Step 5: Compile and verify GREEN** + +Run: `npm run compile-check-ts-native` + +Expected: exit code 0. + +Run: `npm run gulp compile-client` + +Expected: compilation finishes with 0 errors. + +Run: `node_modules\.bin\mocha.cmd --ui tdd --timeout 10000 --exit out/vs/platform/turbofile/test/common/turbofileSameDeviceMergeStatic.test.js out/vs/platform/turbofile/test/common/turbofileSshMainServiceStatic.test.js` + +Expected: all tests pass. + +--- + +### Task 3: Verify protocol-wide unknown-total behavior + +**Files:** +- Verify: `src/vs/platform/turbofile/test/common/turbofileTransferPlan.test.ts` +- Verify: `src/vs/platform/turbofile/test/common/turbofileTarProgress.test.ts` +- Verify: `src/vs/workbench/contrib/turbofile/test/common/turbofileTransferDisplay.test.ts` +- Verify: `src/vs/workbench/contrib/turbofile/test/common/turbofileDragDrop.test.ts` + +**Interfaces:** +- Consumes: progress events with real `transferredBytes` and unknown `totalBytes = 0`. +- Produces: evidence that tar byte reporting, batch routing, copy/cut behavior, and UI display remain intact. + +- [x] **Step 1: Run the full TurboFile transfer regression set** + +Run: `node_modules\.bin\mocha.cmd --ui tdd --timeout 10000 --exit out/vs/platform/turbofile/test/common/turbofileWindowsClipboard.test.js out/vs/platform/turbofile/test/common/turbofileSshMainServiceStatic.test.js out/vs/platform/turbofile/test/common/turbofileTimeout.test.js out/vs/platform/turbofile/test/common/turbofileTarProgress.test.js out/vs/platform/turbofile/test/common/turbofileTransferPlan.test.js out/vs/platform/turbofile/test/common/turbofileSameDeviceMergeStatic.test.js out/vs/workbench/contrib/turbofile/test/common/turbofileEditorInput.test.js out/vs/workbench/contrib/turbofile/test/common/turbofileDragDrop.test.js out/vs/workbench/contrib/turbofile/test/common/turbofileImageGridPane.test.js out/vs/workbench/contrib/turbofile/test/common/turbofileTransferDisplay.test.js` + +Expected: all selected tests pass with zero failures. + +- [x] **Step 2: Audit the final diff** + +Run: `git diff --check` + +Expected: exit code 0; CRLF conversion warnings are acceptable, whitespace errors are not. + +Run: `rg -n "getLocalPathTransferSize\(|getRemotePathSizeByCommand\(request\.(source|target)\)|beginRemotePathGrowthTiming" src/vs/platform/turbofile/electron-main/turbofileSshMainService.ts` + +Expected: no same-device directory transfer call sites remain; unrelated explicit size or cross-device monitoring call sites may remain. + +- [x] **Step 3: Leave source changes uncommitted for user testing** + +Do not stage or commit implementation files because the shared worktree already contains unrelated user changes. Report modified files and verification evidence; do not package until explicitly requested. diff --git a/docs/superpowers/plans/2026-07-15-transfer-reliability-and-pane-controls.md b/docs/superpowers/plans/2026-07-15-transfer-reliability-and-pane-controls.md new file mode 100644 index 00000000..5d0dea1a --- /dev/null +++ b/docs/superpowers/plans/2026-07-15-transfer-reliability-and-pane-controls.md @@ -0,0 +1,514 @@ +# TurboFile Transfer Reliability and Pane Controls Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make each pane's copy/cut control independent, make same-pane organization always move, remove the panel clipboard delay, show structured same-name conflicts, and prevent tar transfers from hanging or exposing partial targets. + +**Architecture:** Keep TurboFile's existing high-throughput tar byte path intact and add small, testable state/control helpers around it. Renderer state owns pane modes and trusted clipboard state; the main process owns structured conflicts, transactional staging, bounded post-EOF drain, process-tree cleanup, and protocol fallback. No recursive size scan, content verification, archive-to-disk step, or extra network copy is introduced. + +**Tech Stack:** TypeScript, VS Code workbench/Electron services, Node.js child processes and streams, Mocha/TDD, VS Code CSS theme variables. + +## Global Constraints + +- Work directly on `codex/image-stream-loading`; preserve unrelated dirty-worktree changes. +- New transfer pages default to `copy` independently for both `left` and `right` panes; state is not persisted. +- Same-pane drops into a folder or the parent entry always use `move`, regardless of the pane button. +- The pane button is a single icon-plus-label toggle: cyan `复制`, orange `剪切`, with no persistent selected background. +- Ctrl+X never deletes sources until every requested target has committed successfully; failure or cancellation never deletes sources. +- Tar's normal stdout-to-SSH-to-stdin stream, concurrency, buffer sizes, and archive bytes remain unchanged. +- Multi-item transfers do not perform recursive size scans and display transferred bytes, live speed, and elapsed time only. +- Existing target directories abort the complete batch before transfer; no merge, overwrite, skip, or auto-rename is added. +- Direct SSH-to-SSH remains direct-first and falls back only after a failed attempt. +- Run `npm run compile-check-ts-native` before any passing unit-test validation; never run `npm run compile`. +- Packaging is excluded from this implementation pass. + +--- + +### Task 1: Independent Pane Modes and Forced Same-Pane Move + +**Files:** +- Modify: `src/vs/workbench/contrib/turbofile/common/turbofileEditorInput.ts` +- Modify: `src/vs/workbench/contrib/turbofile/browser/turbofileEditor.ts` +- Modify: `src/vs/workbench/contrib/turbofile/browser/media/turbofile.css` +- Modify: `src/vs/workbench/contrib/turbofile/test/common/turbofileEditorInput.test.ts` +- Modify: `src/vs/workbench/contrib/turbofile/test/common/turbofileDragDrop.test.ts` + +**Interfaces:** +- Produces: `getDragOperation(side: 'left' | 'right'): TurboFileDragOperation` +- Produces: `setDragOperation(side: 'left' | 'right', operation: TurboFileDragOperation): void` +- Produces: `toggleDragOperation(side: 'left' | 'right'): TurboFileDragOperation` +- Consumes: existing `moveSelectedItemsIntoDirectory(...)` for same-pane folder and parent-entry drops. + +- [ ] **Step 1: Replace the page-level mode test with failing per-pane assertions** + +```ts +test('keeps left and right drag operations independent on each transfer page', () => { + const first = createInput(1); + const second = createInput(2); + + assert.deepStrictEqual([ + first.getDragOperation('left'), first.getDragOperation('right'), + second.getDragOperation('left'), second.getDragOperation('right') + ], ['copy', 'copy', 'copy', 'copy']); + assert.strictEqual(first.toggleDragOperation('left'), 'move'); + assert.deepStrictEqual([ + first.getDragOperation('left'), first.getDragOperation('right'), + second.getDragOperation('left') + ], ['move', 'copy', 'copy']); +}); +``` + +- [ ] **Step 2: Add failing renderer-source assertions for side-aware buttons and forced same-pane move** + +```ts +assert.deepStrictEqual({ + passesSideToButton: editor.includes('this.appendDragOperationButton(toolbar, side)'), + readsSourcePaneMode: editor.includes('this.getCurrentDragOperation(sourceSide)'), + forcesFolderMove: samePaneDrop.includes("'move'") && samePaneDrop.includes('moveSelectedItemsIntoDirectory'), + buttonHasCopyLabel: editor.includes("localize('turbofile.dragMode.copy.label', '复制')"), + buttonHasCutLabel: editor.includes("localize('turbofile.dragMode.move.label', '剪切')") +}, { + passesSideToButton: true, + readsSourcePaneMode: true, + forcesFolderMove: true, + buttonHasCopyLabel: true, + buttonHasCutLabel: true +}); +``` + +- [ ] **Step 3: Run the focused tests and confirm RED** + +Run after compiling the already-existing source baseline with the project's test harness: + +```powershell +scripts\test.bat --grep "TurboFileEditorInput|TurboFileDragDrop" +``` + +Expected: failure because `getDragOperation`, `toggleDragOperation`, and the renderer button are not side-aware. + +- [ ] **Step 4: Implement the two-slot editor-input state** + +```ts +private readonly dragOperations: Record<'left' | 'right', TurboFileDragOperation> = { + left: 'copy', + right: 'copy' +}; + +getDragOperation(side: 'left' | 'right'): TurboFileDragOperation { + return this.dragOperations[side]; +} + +setDragOperation(side: 'left' | 'right', operation: TurboFileDragOperation): void { + this.dragOperations[side] = operation; +} + +toggleDragOperation(side: 'left' | 'right'): TurboFileDragOperation { + return this.dragOperations[side] = getNextTurboFileDragOperation(this.dragOperations[side]); +} +``` + +- [ ] **Step 5: Thread `side` through drag snapshots and the toolbar** + +```ts +private getCurrentDragOperation(side: 'left' | 'right'): TurboFileDragOperation { + return this.currentInput?.getDragOperation(side) ?? 'copy'; +} + +private appendDragOperationButton(parent: HTMLElement, side: 'left' | 'right'): void { + const operation = this.getCurrentDragOperation(side); + const isMove = operation === 'move'; + const label = isMove + ? localize('turbofile.dragMode.move.label', '剪切') + : localize('turbofile.dragMode.copy.label', '复制'); + // Build icon + span label and toggle only this side. + this.currentInput?.toggleDragOperation(side); +} +``` + +For same-pane folder/parent drops, call `moveSelectedItemsIntoDirectory(...)` directly and do not pass the pane mode. Cross-pane and Windows-facing paths continue using the immutable operation stored at drag start. + +- [ ] **Step 6: Style the compact state switch without selected highlighting** + +```css +.turbofile-drag-operation-button { + display: inline-flex; + align-items: center; + gap: 4px; + min-width: 50px; + padding: 2px 7px; + border: 1px solid color-mix(in srgb, currentColor 65%, transparent); + border-radius: 4px; + background: transparent; +} + +.turbofile-drag-operation-button.is-copy { color: var(--vscode-charts-green); } +.turbofile-drag-operation-button.is-move { color: var(--vscode-charts-orange); } +.turbofile-drag-operation-button:active { background: transparent; } +``` + +Retain the existing standard toolbar hover rule and `:focus-visible` outline. + +- [ ] **Step 7: Type-check, run focused tests, and commit only task files** + +```powershell +npm run compile-check-ts-native +scripts\test.bat --grep "TurboFileEditorInput|TurboFileDragDrop" +git add src/vs/workbench/contrib/turbofile/common/turbofileEditorInput.ts src/vs/workbench/contrib/turbofile/browser/turbofileEditor.ts src/vs/workbench/contrib/turbofile/browser/media/turbofile.css src/vs/workbench/contrib/turbofile/test/common/turbofileEditorInput.test.ts src/vs/workbench/contrib/turbofile/test/common/turbofileDragDrop.test.ts +git commit -m "feat: make transfer modes independent per pane" +``` + +Expected: type-check exits 0 and both suites pass. + +--- + +### Task 2: Zero-Wait Trusted Internal Clipboard + +**Files:** +- Create: `src/vs/workbench/contrib/turbofile/common/turbofileClipboardTrust.ts` +- Create: `src/vs/workbench/contrib/turbofile/test/common/turbofileClipboardTrust.test.ts` +- Modify: `src/vs/workbench/contrib/turbofile/browser/turbofileEditor.ts` +- Modify: `src/vs/workbench/contrib/turbofile/test/common/turbofileImageGridPane.test.ts` + +**Interfaces:** +- Produces: `TurboFileClipboardTrust = 'trusted' | 'confirmation-required'` +- Produces: `resolveTurboFileClipboardReadMode(internalId, trust): 'internal-fast-path' | 'windows-confirmation'` +- Consumes: existing `resolveTurboFilePasteSource(...)`, `pasteInternalTransferClipboard(...)`, and Windows clipboard `clipboardId`. + +- [ ] **Step 1: Write failing pure-state tests** + +```ts +test('uses internal fast path only while the snapshot is trusted', () => { + assert.deepStrictEqual([ + resolveTurboFileClipboardReadMode('id-1', 'trusted'), + resolveTurboFileClipboardReadMode('id-1', 'confirmation-required'), + resolveTurboFileClipboardReadMode(undefined, 'trusted') + ], ['internal-fast-path', 'windows-confirmation', 'windows-confirmation']); +}); +``` + +- [ ] **Step 2: Write failing renderer-source assertions** + +```ts +assert.deepStrictEqual({ + fastPathBeforeWindowsRead: pasteBody.indexOf('pasteInternalTransferClipboard') < pasteBody.indexOf('readWindowsClipboardFiles'), + doesNotAwaitClipboardWrite: !pasteBody.includes('await internal?.windowsClipboardWrite'), + invalidatesOnBlur: editor.includes("addEventListener('blur'") || editor.includes('onDidChangeFocus'), + usesStablePasteKey: editor.includes('getWindowsClipboardPasteKey') +}, { + fastPathBeforeWindowsRead: true, + doesNotAwaitClipboardWrite: true, + invalidatesOnBlur: true, + usesStablePasteKey: true +}); +``` + +- [ ] **Step 3: Run focused tests and confirm RED** + +```powershell +scripts\test.bat --grep "TurboFileClipboardTrust|TurboFileImageGridPane" +``` + +Expected: missing helper module or assertions fail because `pasteIntoPane` reads Windows first. + +- [ ] **Step 4: Implement trust state and immediate paste dispatch** + +```ts +export type TurboFileClipboardTrust = 'trusted' | 'confirmation-required'; + +export function resolveTurboFileClipboardReadMode( + internalClipboardId: string | undefined, + trust: TurboFileClipboardTrust +): 'internal-fast-path' | 'windows-confirmation' { + return internalClipboardId && trust === 'trusted' ? 'internal-fast-path' : 'windows-confirmation'; +} +``` + +Set trust to `trusted` synchronously in `copySelectedToInternalClipboard`. In `pasteIntoPane`, call `pasteInternalTransferClipboard` immediately when trusted; only the confirmation branch calls `readWindowsClipboardFiles`. Mark trust `confirmation-required` when the editor window blurs, hides, or is disposed/recreated. Keep Windows export asynchronous and keep the existing `clipboardId` comparison in the confirmation branch. + +- [ ] **Step 5: Prevent duplicate keydown/paste dispatch** + +Reuse `getWindowsClipboardPasteKey(...)` for both keyboard and DOM paste events. Record the last dispatched key and timestamp, suppressing only the duplicate event for the same target/path/operation; do not suppress later user paste commands. + +- [ ] **Step 6: Type-check, test, and commit** + +```powershell +npm run compile-check-ts-native +scripts\test.bat --grep "TurboFileClipboardTrust|TurboFileImageGridPane|TurboFileWindowsClipboard" +git add src/vs/workbench/contrib/turbofile/common/turbofileClipboardTrust.ts src/vs/workbench/contrib/turbofile/test/common/turbofileClipboardTrust.test.ts src/vs/workbench/contrib/turbofile/browser/turbofileEditor.ts src/vs/workbench/contrib/turbofile/test/common/turbofileImageGridPane.test.ts +git commit -m "perf: use zero-wait panel clipboard transfers" +``` + +Expected: type-check exits 0; trusted panel paste never invokes a Windows clipboard read. + +--- + +### Task 3: Structured Same-Name Directory Conflicts + +**Files:** +- Modify: `src/vs/platform/turbofile/common/turbofile.ts` +- Create: `src/vs/platform/turbofile/common/turbofileTransferConflict.ts` +- Create: `src/vs/platform/turbofile/test/common/turbofileTransferConflict.test.ts` +- Modify: `src/vs/platform/turbofile/electron-main/turbofileSshMainService.ts` +- Modify: `src/vs/platform/turbofile/test/common/turbofileSshMainServiceStatic.test.ts` +- Modify: `src/vs/workbench/contrib/turbofile/browser/turbofileEditor.ts` + +**Interfaces:** +- Produces: `ITurboFileTransferConflict { readonly kind: 'target-directory-exists'; readonly paths: readonly string[] }` +- Adds optional `conflict?: ITurboFileTransferConflict` to `ITurboFileTransferResult` and `ITurboFileBatchTransferResult`. +- Produces: `formatTurboFileConflictDetails(paths, limit = 8): string`. + +- [ ] **Step 1: Write failing conflict-format tests** + +```ts +test('formats conflict paths and truncates long lists', () => { + assert.strictEqual(formatTurboFileConflictDetails(['A', 'B'], 8), 'A\nB'); + assert.strictEqual(formatTurboFileConflictDetails(['A', 'B', 'C'], 2), 'A\nB\n…以及另外 1 个同名文件夹'); +}); +``` + +- [ ] **Step 2: Add failing contract/static assertions** + +Assert that preflight returns `kind: 'target-directory-exists'` with all paths and the renderer calls the dialog service with localized title `目标存在同名文件夹`, message `为避免覆盖或混合文件,本次批量传输未开始。`, details, and one `确定` button. + +- [ ] **Step 3: Run focused tests and confirm RED** + +```powershell +scripts\test.bat --grep "TurboFileTransferConflict|TurboFileSshMainServiceStatic" +``` + +Expected: helper/contract is absent and main-process preflight still throws only English text. + +- [ ] **Step 4: Add structured conflict results at local and remote preflight boundaries** + +```ts +export interface ITurboFileTransferConflict { + readonly kind: 'target-directory-exists'; + readonly paths: readonly string[]; +} +``` + +Collect all existing directory targets before starting a batch. Return a failed result with `completed: 0`, all items failed, and `conflict`; do not enter any transfer protocol. Preserve the status/task failure message for diagnostics. + +- [ ] **Step 5: Show one localized informational dialog in the renderer** + +```ts +await this.dialogService.info( + localize('turbofile.conflict.title', '目标存在同名文件夹'), + localize('turbofile.conflict.message', '为避免覆盖或混合文件,本次批量传输未开始。'), + { detail: formatTurboFileConflictDetails(result.conflict.paths) } +); +``` + +Trigger the dialog only for the structured conflict kind. Network, permission, and protocol failures keep their existing error handling. + +- [ ] **Step 6: Type-check, test, and commit** + +```powershell +npm run compile-check-ts-native +scripts\test.bat --grep "TurboFileTransferConflict|TurboFileSshMainServiceStatic|TurboFileDragDrop" +git add src/vs/platform/turbofile/common/turbofile.ts src/vs/platform/turbofile/common/turbofileTransferConflict.ts src/vs/platform/turbofile/test/common/turbofileTransferConflict.test.ts src/vs/platform/turbofile/electron-main/turbofileSshMainService.ts src/vs/platform/turbofile/test/common/turbofileSshMainServiceStatic.test.ts src/vs/workbench/contrib/turbofile/browser/turbofileEditor.ts +git commit -m "feat: report same-name transfer conflicts clearly" +``` + +Expected: type-check exits 0 and all conflict-focused tests pass. + +--- + +### Task 4: Tar Lifecycle Supervisor and Process-Tree Cleanup + +**Files:** +- Create: `src/vs/platform/turbofile/common/turbofileTarLifecycle.ts` +- Create: `src/vs/platform/turbofile/test/common/turbofileTarLifecycle.test.ts` +- Modify: `src/vs/platform/turbofile/electron-main/turbofileSshMainService.ts` +- Modify: `src/vs/platform/turbofile/test/common/turbofileSshMainServiceStatic.test.ts` + +**Interfaces:** +- Produces: `TurboFileTarLifecycle` with `markStarted()`, `markActivity(now?)`, `markEof(now?)`, and `getDecision(now): 'continue' | 'startup-stall' | 'stream-stall' | 'drain-stall'`. +- Produces: `waitForLocalProcessClose(child, context, options)` with activity and bounded post-EOF drain. +- Reuses: existing `killLocalProcessTree(child)`. + +- [ ] **Step 1: Write failing lifecycle decision tests with a fake clock** + +```ts +test('never applies a fixed total timeout while stream activity continues', () => { + const lifecycle = new TurboFileTarLifecycle({ startupMs: 30_000, inactivityMs: 30_000, drainMs: 5_000 }, 0); + lifecycle.markStarted(0); + lifecycle.markActivity(29_000); + lifecycle.markActivity(58_000); + assert.strictEqual(lifecycle.getDecision(60_000), 'continue'); +}); + +test('reports drain stall only after eof grace expires', () => { + const lifecycle = new TurboFileTarLifecycle({ startupMs: 30_000, inactivityMs: 30_000, drainMs: 5_000 }, 0); + lifecycle.markStarted(0); + lifecycle.markActivity(1_000); + lifecycle.markEof(2_000); + assert.deepStrictEqual([lifecycle.getDecision(6_999), lifecycle.getDecision(7_001)], ['continue', 'drain-stall']); +}); +``` + +- [ ] **Step 2: Run focused lifecycle tests and confirm RED** + +```powershell +scripts\test.bat --grep "TurboFileTarLifecycle" +``` + +Expected: module/class is absent. + +- [ ] **Step 3: Implement the pure lifecycle state machine** + +```ts +export class TurboFileTarLifecycle { + private startedAt: number | undefined; + private lastActivityAt: number | undefined; + private eofAt: number | undefined; + + constructor(private readonly limits: TurboFileTarLifecycleLimits, private readonly createdAt = Date.now()) { } + markStarted(now = Date.now()): void { this.startedAt = now; } + markActivity(now = Date.now()): void { this.lastActivityAt = now; } + markEof(now = Date.now()): void { this.eofAt = now; } + getDecision(now = Date.now()): TurboFileTarLifecycleDecision { + if (this.eofAt !== undefined) { return now - this.eofAt > this.limits.drainMs ? 'drain-stall' : 'continue'; } + if (this.lastActivityAt !== undefined) { return now - this.lastActivityAt > this.limits.inactivityMs ? 'stream-stall' : 'continue'; } + return now - (this.startedAt ?? this.createdAt) > this.limits.startupMs ? 'startup-stall' : 'continue'; + } +} +``` + +- [ ] **Step 4: Integrate the supervisor into native and ssh2 tar paths** + +Mark activity from existing stdout/stderr/progress chunks. Mark EOF when the pipeline resolves. During drain, race process/channel close against the lifecycle drain deadline. On a stall, invoke `killLocalProcessTree` for every local child and close/destroy the ssh2 stream/client, then throw a typed `TurboFileTarStallError` identifying the phase. + +Do not add per-file callbacks, new transforms, fixed total-duration timeout, or different stream high-water marks. + +- [ ] **Step 5: Prewarm local SSH/tar dependencies without blocking startup** + +Schedule one background call that resolves the local SSH tool and `getLocalTarCharsetArgs()`. Catch and discard prewarm failure; the first real transfer retains lazy resolution and normal errors. + +- [ ] **Step 6: Make batch tar exceptions enter the existing fallback chain** + +Wrap each tar strategy attempt so that non-cancellation failures clear timing/progress attempt state and return `undefined` to the caller's next strategy. Cancellation must rethrow `TurboFileTransferCanceledError`. Ensure the order remains native tar, ssh2 tar, rsync when available, then concurrent SFTP/SCP. + +- [ ] **Step 7: Type-check, test, and commit** + +```powershell +npm run compile-check-ts-native +scripts\test.bat --grep "TurboFileTarLifecycle|TurboFileSshMainServiceStatic|TurboFileTarProgress" +git add src/vs/platform/turbofile/common/turbofileTarLifecycle.ts src/vs/platform/turbofile/test/common/turbofileTarLifecycle.test.ts src/vs/platform/turbofile/electron-main/turbofileSshMainService.ts src/vs/platform/turbofile/test/common/turbofileSshMainServiceStatic.test.ts +git commit -m "fix: bound tar pipeline shutdown and fallback safely" +``` + +Expected: type-check exits 0; lifecycle and static integration tests pass. + +--- + +### Task 5: Transactional Tar Targets and Move Safety + +**Files:** +- Create: `src/vs/platform/turbofile/common/turbofileTransferStaging.ts` +- Create: `src/vs/platform/turbofile/test/common/turbofileTransferStaging.test.ts` +- Modify: `src/vs/platform/turbofile/electron-main/turbofileSshMainService.ts` +- Modify: `src/vs/platform/turbofile/test/common/turbofileSshMainServiceStatic.test.ts` +- Modify: `src/vs/platform/turbofile/test/common/turbofileSameDeviceMergeStatic.test.ts` + +**Interfaces:** +- Produces: `createTurboFileStagingName(id: string): string` returning `.turbofile-transfer-.partial`. +- Produces: local/remote staging plans containing extraction root, final entries, cleanup command, and commit command. +- Consumes: Task 3's preflight conflicts and Task 4's stall/fallback signal. + +- [ ] **Step 1: Write failing staging-name and commit-plan tests** + +```ts +test('creates a hidden same-filesystem staging directory', () => { + assert.strictEqual(createTurboFileStagingName('abc-123'), '.turbofile-transfer-abc-123.partial'); +}); + +test('commits only top-level items after successful extraction', () => { + assert.deepStrictEqual(createTurboFileLocalStagingPlan('D:\\target', ['a', 'b'], 'id'), { + stagingDirectory: 'D:\\target\\.turbofile-transfer-id.partial', + entries: [ + { staged: 'D:\\target\\.turbofile-transfer-id.partial\\a', final: 'D:\\target\\a' }, + { staged: 'D:\\target\\.turbofile-transfer-id.partial\\b', final: 'D:\\target\\b' } + ] + }); +}); +``` + +- [ ] **Step 2: Run focused staging tests and confirm RED** + +```powershell +scripts\test.bat --grep "TurboFileTransferStaging" +``` + +Expected: staging helper is absent. + +- [ ] **Step 3: Implement deterministic staging plans** + +```ts +export function createTurboFileStagingName(id: string): string { + return `.turbofile-transfer-${id}.partial`; +} +``` + +Build local paths with Node `path.join`; build remote POSIX paths with the existing remote path helpers and shell quoting. Keep staging under the destination parent so each final rename/mv is same-filesystem. + +- [ ] **Step 4: Extract tar into staging and atomically commit top-level targets** + +For downloads, create the local staging root, point local `tar -C` to it, and rename every staged top-level entry to its final name only after both SSH and tar exit 0. For uploads/direct/relay, make the remote command extract into the hidden staging directory and run guarded `mv -- staged/name final/name` only after the pipeline exits 0. + +If any preflight conflict exists, do not create staging. If extraction, drain, or commit fails, remove only the current staging directory and surface failure/fallback. Never delete or mutate a pre-existing final target. + +- [ ] **Step 5: Keep source cleanup after final commit only** + +Place `cleanupBatchSourcesAfterTar(...)` after successful commit. Ensure every catch, cancellation, fallback, and partial-commit error returns before source deletion. Add static assertions that source cleanup follows the commit call and that cancel paths do not call it. + +- [ ] **Step 6: Type-check, run all transfer-focused tests, and commit** + +```powershell +npm run compile-check-ts-native +scripts\test.bat --grep "TurboFileTransferStaging|TurboFileTarLifecycle|TurboFileSshMainServiceStatic|TurboFileSameDeviceMergeStatic|TurboFileTransferPlan|TurboFileDragDrop|TurboFileClipboardTrust" +git add src/vs/platform/turbofile/common/turbofileTransferStaging.ts src/vs/platform/turbofile/test/common/turbofileTransferStaging.test.ts src/vs/platform/turbofile/electron-main/turbofileSshMainService.ts src/vs/platform/turbofile/test/common/turbofileSshMainServiceStatic.test.ts src/vs/platform/turbofile/test/common/turbofileSameDeviceMergeStatic.test.ts +git commit -m "fix: commit tar transfers transactionally" +``` + +Expected: type-check exits 0; all transfer-focused suites pass. + +--- + +### Task 6: Final Verification Without Packaging + +**Files:** +- Verify only; do not modify generated `out/` manually. + +**Interfaces:** +- Consumes all previous tasks. + +- [ ] **Step 1: Run mandatory TypeScript validation** + +```powershell +npm run compile-check-ts-native +``` + +Expected: exit code 0 with no TypeScript errors. + +- [ ] **Step 2: Run the complete relevant unit-test set** + +```powershell +scripts\test.bat --grep "TurboFile(EditorInput|DragDrop|ClipboardTrust|TransferConflict|TarLifecycle|TransferStaging|TarProgress|SshMainServiceStatic|SameDeviceMergeStatic|TransferPlan|WindowsClipboard|ImageGridPane)" +``` + +Expected: all selected tests pass with no failures. + +- [ ] **Step 3: Inspect the scoped diff and generated-file boundary** + +```powershell +git diff --check +git status --short +git diff -- src/vs/platform/turbofile src/vs/workbench/contrib/turbofile docs/superpowers/plans/2026-07-15-transfer-reliability-and-pane-controls.md +``` + +Expected: no whitespace errors; no unrelated source is staged or overwritten; `out/` remains generated state and is not hand-edited. + +- [ ] **Step 4: Report manual checks still required** + +List Windows UI checks for independent pane buttons, same-pane forced move, trusted Ctrl+C/X/V latency, conflict dialog, first tar transfer, repeated tar transfer, forced child-process hang, cancellation, and direct-to-relay fallback. Do not claim these interactive scenarios passed unless they were actually run. diff --git a/docs/superpowers/plans/2026-07-15-turbofile-document-tab-affinity.md b/docs/superpowers/plans/2026-07-15-turbofile-document-tab-affinity.md new file mode 100644 index 00000000..02b34ca6 --- /dev/null +++ b/docs/superpowers/plans/2026-07-15-turbofile-document-tab-affinity.md @@ -0,0 +1,359 @@ +# TurboFile Document Tab Affinity Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Remove numeric prefixes from TurboFile page/document tabs, color each opened document with its source page color, and keep every transfer page left of every document. + +**Architecture:** Add a generic transient label-class facility to `EditorInput`, then keep TurboFile-specific affinity and ordering logic in a focused browser helper. The tab renderer uses the label classes to suppress indices only for TurboFile-associated tabs, while the TurboFile editor and contribution assign colors and normalize ordering without replacing native file editor inputs. + +**Tech Stack:** TypeScript, VS Code workbench editor APIs, CSS theme variables, Mocha unit tests. + +## Global Constraints + +- Do not change document rendering, editing, preview, save, code navigation, or editor association behavior. +- The same document reopened from another transfer page uses the most recent page color. +- Association is in-memory only and does not alter files or user settings. +- Transfer pages remain in the same editor group and always precede documents. +- Preserve all unrelated dirty-worktree changes. +- Run `npm run compile-check-ts-native` before any tests; never run `npm run compile`. + +--- + +### Task 1: Transient Editor Label Classes + +**Files:** +- Modify: `src/vs/workbench/common/editor/editorInput.ts` +- Test: `src/vs/workbench/contrib/turbofile/test/common/turbofileDocumentTabAffinity.test.ts` + +**Interfaces:** +- Produces: `EditorInput.setLabelExtraClasses(classes: readonly string[]): void` +- Produces: existing `EditorInput.getLabelExtraClasses(): string[]` returns a defensive copy of transient classes unless overridden. + +- [ ] **Step 1: Write the failing event and defensive-copy test** + +```ts +class TestDocumentEditorInput extends EditorInput { + override readonly typeId = 'test.document'; + + constructor(override readonly resource = URI.file('document.txt')) { + super(); + } +} + +test('updates transient editor label classes and fires only for changes', () => { + const input = disposables.add(new TestDocumentEditorInput()); + let changes = 0; + disposables.add(input.onDidChangeLabel(() => changes++)); + input.setLabelExtraClasses(['turbofile-document-tab', 'turbofile-tab-color-2']); + input.setLabelExtraClasses(['turbofile-document-tab', 'turbofile-tab-color-2']); + const classes = input.getLabelExtraClasses(); + classes.length = 0; + assert.deepStrictEqual([input.getLabelExtraClasses(), changes], [[ + 'turbofile-document-tab', + 'turbofile-tab-color-2' + ], 1]); +}); +``` + +- [ ] **Step 2: Run the mandatory type-check and observe the missing method** + +Run: `npm run compile-check-ts-native` + +Expected: FAIL because `setLabelExtraClasses` does not exist. + +- [ ] **Step 3: Implement the minimal transient class API** + +```ts +private labelExtraClasses: string[] = []; + +getLabelExtraClasses(): string[] { + return [...this.labelExtraClasses]; +} + +setLabelExtraClasses(classes: readonly string[]): void { + if (equals(this.labelExtraClasses, classes)) { + return; + } + this.labelExtraClasses = [...classes]; + this._onDidChangeLabel.fire(); +} +``` + +Import the existing array equality helper from `vs/base/common/arrays`. + +- [ ] **Step 4: Re-run type-check** + +Run: `npm run compile-check-ts-native` + +Expected: PASS. + +### Task 2: Page-to-Document Color Affinity + +**Files:** +- Create: `src/vs/workbench/contrib/turbofile/browser/turbofileDocumentTabAffinity.ts` +- Modify: `src/vs/workbench/contrib/turbofile/common/turbofileEditorInput.ts` +- Modify: `src/vs/workbench/contrib/turbofile/browser/media/turbofile.css` +- Modify: `src/vs/workbench/contrib/turbofile/browser/turbofileEditor.ts` +- Test: `src/vs/workbench/contrib/turbofile/test/common/turbofileDocumentTabAffinity.test.ts` + +**Interfaces:** +- Produces: `TurboFileEditorInput.getTabColorSlot(): number` +- Produces: `applyTurboFileDocumentTabAffinity(document: EditorInput, source: TurboFileEditorInput): void` +- Consumes: `EditorInput.setLabelExtraClasses(classes)` from Task 1. + +- [ ] **Step 1: Add failing affinity tests** + +```ts +test('applies the source page color to a document and replaces previous affinity', () => { + const document = disposables.add(new TestDocumentEditorInput()); + const first = createTransferInput(2); + const second = createTransferInput(7); + applyTurboFileDocumentTabAffinity(document, first); + const firstClasses = document.getLabelExtraClasses(); + applyTurboFileDocumentTabAffinity(document, second); + assert.deepStrictEqual([firstClasses, document.getLabelExtraClasses()], [[ + 'turbofile-document-tab', 'turbofile-tab-color-1' + ], [ + 'turbofile-document-tab', 'turbofile-tab-color-0' + ]]); +}); +``` + +- [ ] **Step 2: Type-check and confirm the missing helper failure** + +Run: `npm run compile-check-ts-native` + +Expected: FAIL because the affinity helper and color-slot accessor do not exist. + +- [ ] **Step 3: Implement color-slot and affinity helper** + +```ts +export const TURBOFILE_TAB_COLOR_COUNT = 6; + +getTabColorSlot(): number { + return (this.ordinal - 1) % TURBOFILE_TAB_COLOR_COUNT; +} + +override getLabelExtraClasses(): string[] { + return ['turbofile-transfer-tab', `turbofile-tab-color-${this.getTabColorSlot()}`]; +} +``` + +```ts +export function applyTurboFileDocumentTabAffinity(document: EditorInput, source: TurboFileEditorInput): void { + document.setLabelExtraClasses([ + 'turbofile-document-tab', + `turbofile-tab-color-${source.getTabColorSlot()}` + ]); +} +``` + +- [ ] **Step 4: Apply affinity before opening a resolved native document** + +In `openNativeDocument`, immediately after `resolveTextEditor`: + +```ts +const sourceInput = this.currentInput; +if (!sourceInput) { + return; +} +const fileEditor = await this.textEditorService.resolveTextEditor(editorInput); +applyTurboFileDocumentTabAffinity(fileEditor, sourceInput); +``` + +Do not create a replacement editor input. + +- [ ] **Step 5: Generalize the six CSS selectors** + +```css +.tabs-container .tab .monaco-icon-label.turbofile-tab-color-0::before { + color: var(--vscode-charts-blue) !important; +} + +.tabs-container .tab .monaco-icon-label.turbofile-tab-color-1::before { + color: var(--vscode-charts-green) !important; +} + +.tabs-container .tab .monaco-icon-label.turbofile-tab-color-2::before { + color: var(--vscode-charts-orange) !important; +} + +.tabs-container .tab .monaco-icon-label.turbofile-tab-color-3::before { + color: var(--vscode-charts-purple) !important; +} + +.tabs-container .tab .monaco-icon-label.turbofile-tab-color-4::before { + color: var(--vscode-charts-red) !important; +} + +.tabs-container .tab .monaco-icon-label.turbofile-tab-color-5::before { + color: var(--vscode-charts-cyan) !important; +} +``` + +Apply the existing inactive/hover opacity behavior to both `.turbofile-transfer-tab` and `.turbofile-document-tab`. + +- [ ] **Step 6: Re-run type-check** + +Run: `npm run compile-check-ts-native` + +Expected: PASS. + +### Task 3: Remove Numeric Prefixes from Associated Tabs + +**Files:** +- Modify: `src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts` +- Test: `src/vs/workbench/contrib/turbofile/test/common/turbofileTabPresentation.test.ts` + +**Interfaces:** +- Consumes: `turbofile-transfer-tab` and `turbofile-document-tab` label classes. +- Produces: tab index prefix suppression scoped only to associated TurboFile tabs. + +- [ ] **Step 1: Add a failing static presentation assertion** + +```ts +test('suppresses numeric prefixes only for TurboFile-associated tabs', () => { + const tabsSource = readFileSync(join(repoRoot, 'src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts'), 'utf8'); + assert.ok(tabsSource.includes("labelExtraClasses.includes('turbofile-transfer-tab')")); + assert.ok(tabsSource.includes("labelExtraClasses.includes('turbofile-document-tab')")); +}); +``` + +- [ ] **Step 2: Type-check first** + +Run: `npm run compile-check-ts-native` + +Expected: PASS; the new source assertion remains unrun until compilation succeeds. + +- [ ] **Step 3: Reuse label classes while rendering the tab** + +```ts +const labelExtraClasses = editor.getLabelExtraClasses(); +const suppressTabIndex = labelExtraClasses.includes('turbofile-transfer-tab') || + labelExtraClasses.includes('turbofile-document-tab'); +namePrefix = options.showTabIndex && !suppressTabIndex ? `${this.toEditorIndex(tabIndex) + 1}: ` : undefined; +``` + +Pass the same `labelExtraClasses` variable to `extraClasses` so the getter is evaluated once. + +- [ ] **Step 4: Re-run type-check** + +Run: `npm run compile-check-ts-native` + +Expected: PASS. + +### Task 4: Stable Left/Right Tab Partition + +**Files:** +- Modify: `src/vs/workbench/contrib/turbofile/browser/turbofileDocumentTabAffinity.ts` +- Modify: `src/vs/workbench/contrib/turbofile/browser/turbofileEditor.ts` +- Modify: `src/vs/workbench/contrib/turbofile/browser/turbofile.contribution.ts` +- Test: `src/vs/workbench/contrib/turbofile/test/common/turbofileDocumentTabAffinity.test.ts` +- Test: `src/vs/workbench/contrib/turbofile/test/common/turbofileTabPresentation.test.ts` + +**Interfaces:** +- Produces: `getTurboFileTransferTargetIndex(editors: readonly EditorInput[]): number` +- Produces: `getTurboFileDocumentTargetIndex(editors: readonly EditorInput[]): number` +- Produces: `ensureTurboFileTransferTabsLeft(group: IEditorGroup): void` + +- [ ] **Step 1: Add failing pure ordering tests** + +```ts +test('computes stable transfer and document insertion indexes', () => { + const page1 = createTransferInput(1); + const page2 = createTransferInput(2); + const doc1 = disposables.add(new TestDocumentEditorInput()); + const doc2 = disposables.add(new TestDocumentEditorInput(URI.file('two.txt'))); + assert.deepStrictEqual([ + getTurboFileTransferTargetIndex([page1, doc1, doc2]), + getTurboFileDocumentTargetIndex([page1, page2, doc1]) + ], [1, 3]); +}); +``` + +- [ ] **Step 2: Type-check and confirm missing helper failures** + +Run: `npm run compile-check-ts-native` + +Expected: FAIL because the ordering helpers do not exist. + +- [ ] **Step 3: Implement insertion and normalization helpers** + +```ts +export function getTurboFileTransferTargetIndex(editors: readonly EditorInput[]): number { + const firstDocument = editors.findIndex(editor => !(editor instanceof TurboFileEditorInput)); + return firstDocument >= 0 ? firstDocument : editors.length; +} + +export function getTurboFileDocumentTargetIndex(editors: readonly EditorInput[]): number { + return editors.length; +} + +export function ensureTurboFileTransferTabsLeft(group: IEditorGroup): void { + const desired = [ + ...group.editors.filter(editor => editor instanceof TurboFileEditorInput), + ...group.editors.filter(editor => !(editor instanceof TurboFileEditorInput)) + ]; + for (let index = 0; index < desired.length; index++) { + if (group.editors[index] !== desired[index]) { + group.moveEditor(desired[index], group, { index }); + } + } +} +``` + +- [ ] **Step 4: Open documents at the right edge and normalize afterward** + +Replace the local document-index helpers in `turbofileEditor.ts` with: + +```ts +index: getTurboFileDocumentTargetIndex(this.group?.editors ?? []) +``` + +Then call: + +```ts +if (pane?.group) { + ensureTurboFileTransferTabsLeft(pane.group); +} +``` + +- [ ] **Step 5: Insert new transfer pages before the first document** + +For both startup and action paths: + +```ts +const editors = editorService.activeEditorPane?.group.editors ?? []; +await editorService.openEditor(input, { + pinned: true, + index: getTurboFileTransferTargetIndex(editors) +}); +``` + +- [ ] **Step 6: Normalize user-driven tab moves without recursion** + +Inject `IEditorGroupsService`, keep a `Set` guard, and on `editorService.onDidEditorsChange` normalize only `EDITOR_OPEN` and `EDITOR_MOVE` events for the changed group. Remove the guard in `finally`. + +- [ ] **Step 7: Run mandatory type-check** + +Run: `npm run compile-check-ts-native` + +Expected: PASS with no TypeScript errors. + +- [ ] **Step 8: Run focused tests** + +Run: `scripts\test.bat --grep "TurboFile.*Tab"` + +Expected: PASS for editor-input, rename, presentation, and document-affinity tests. + +- [ ] **Step 9: Run the layer check** + +Run: `npm run valid-layers-check` + +Expected: PASS. + +- [ ] **Step 10: Inspect the final scoped diff** + +Run: `git diff -- src/vs/workbench/common/editor/editorInput.ts src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts src/vs/workbench/contrib/turbofile/browser/turbofileDocumentTabAffinity.ts src/vs/workbench/contrib/turbofile/browser/turbofileEditor.ts src/vs/workbench/contrib/turbofile/browser/turbofile.contribution.ts src/vs/workbench/contrib/turbofile/browser/media/turbofile.css src/vs/workbench/contrib/turbofile/common/turbofileEditorInput.ts src/vs/workbench/contrib/turbofile/test/common/turbofileDocumentTabAffinity.test.ts src/vs/workbench/contrib/turbofile/test/common/turbofileTabPresentation.test.ts` + +Expected: only the approved tab-affinity behavior plus pre-existing changes in already-dirty TurboFile files. diff --git a/docs/superpowers/plans/2026-07-16-immediate-transfer-start-and-move-refresh.md b/docs/superpowers/plans/2026-07-16-immediate-transfer-start-and-move-refresh.md new file mode 100644 index 00000000..178e7c84 --- /dev/null +++ b/docs/superpowers/plans/2026-07-16-immediate-transfer-start-and-move-refresh.md @@ -0,0 +1,483 @@ +# Immediate Transfer Start and Move Refresh Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Remove every recursive total-size discovery step from folder and multi-item transfer startup, preserve live transferred bytes/speed/time, and refresh both affected directories after every successful move. + +**Architecture:** The renderer owns the selection-shape policy: exactly one ordinary file tracks a total; every other selection sends `expectedBytes: 0`. The main process starts the existing 1.148.0 data command without a preliminary walk or remote `du`, and reports real byte deltas from the existing copy/stream/growth mechanisms. Move refresh is centralized behind a small pure refresh plan and is invoked only after copy-then-delete cleanup or native move completion. + +**Tech Stack:** TypeScript, VS Code workbench/editor code, Node.js filesystem/SSH transfer service, Mocha TDD tests, CSS. + +## Global Constraints + +- Exactly one ordinary file may display `transferred / total`; a folder or any multi-item selection displays transferred bytes only. +- Unknown-total transfers retain live speed and elapsed time. +- Drag-and-drop, Ctrl+C/V, Ctrl+X/V, same-machine, same-SSH, cross-SSH, upload, and download use the same immediate-start rule. +- Failed or canceled transfers never delete source items. +- Successful moves refresh source and target only after source cleanup or native rename/move has completed. +- Preserve the restored 1.148.0 transfer protocol order and data path; do not add staging, buffering, throttling, archive wrappers, or progress relays. +- Keep the copy/cut badge at 24 x 24 with a 24 x 18 inner label and retain all existing colors. +- Run `npm run compile-check-ts-native` before every unit-test command; never run `npm run compile`. +- The worktree already contains user-owned changes in the files below. Do not discard them, and do not stage or commit implementation files automatically. + +--- + +### Task 1: Lock the single-file total policy and remove renderer size jobs + +**Files:** +- Modify: `src/vs/workbench/contrib/turbofile/test/common/turbofileTransferDisplay.test.ts` +- Modify: `src/vs/workbench/contrib/turbofile/test/common/turbofileDragDrop.test.ts` +- Modify: `src/vs/workbench/contrib/turbofile/browser/turbofileEditor.ts` +- Verify: `src/vs/workbench/contrib/turbofile/common/turbofileTransferDisplay.ts` + +**Interfaces:** +- Consumes: `createTurboFileTransferSizePolicy(items)` and `resolveTurboFileVisibleTotalBytes(options)`. +- Produces: `task.trackTotalBytes`, `task.totalBytes`, and batch request totals that are known only for one regular file. + +- [x] **Step 1: Add failing unit coverage for selection-shape totals** + +Update the import and add these tests: + +```ts +import { chooseDisplayTransferSpeed, createTurboFileTransferSizePolicy, getVisibleTransferStartedAt, resolveTurboFileVisibleTotalBytes, shouldStartTransferTiming } from '../../common/turbofileTransferDisplay.js'; + +test('tracks a total only for exactly one ordinary file', () => { + assert.deepStrictEqual(createTurboFileTransferSizePolicy([{ directory: false, rawSize: 4096 }]), { + trackTotalBytes: true, + totalBytes: 4096 + }); + assert.deepStrictEqual(createTurboFileTransferSizePolicy([{ directory: true, rawSize: 0 }]), { trackTotalBytes: false }); + assert.deepStrictEqual(createTurboFileTransferSizePolicy([ + { directory: false, rawSize: 10 }, + { directory: false, rawSize: 20 } + ]), { trackTotalBytes: false }); +}); + +test('never exposes a protocol-reported total for an unknown-total selection', () => { + assert.strictEqual(resolveTurboFileVisibleTotalBytes({ + trackTotalBytes: false, + listedTotalBytes: 0, + reportedTotalBytes: 8192 + }), undefined); +}); +``` + +- [x] **Step 2: Add a failing renderer regression assertion** + +Extend `TurboFileDragDrop` with: + +```ts +test('starts folder and multi-item transfers without renderer size discovery', () => { + const editor = fs.readFileSync(path.join(process.cwd(), 'src/vs/workbench/contrib/turbofile/browser/turbofileEditor.ts'), 'utf8'); + const batchStart = editor.indexOf('private async transferItemsBatch'); + const batchEnd = editor.indexOf('private insertOptimisticPaneItem', batchStart); + const batch = editor.slice(batchStart, batchEnd); + + assert.ok(batchStart >= 0); + assert.ok(!batch.includes('fillBatchDirectorySizesInBackground')); + assert.ok(!batch.includes('calculatePathSize')); + assert.ok(batch.includes('task.trackTotalBytes ? Math.max(0, item.rawSize) : 0')); +}); +``` + +- [x] **Step 3: Type-check, emit test JavaScript, and verify RED** + +Run: + +```powershell +npm run compile-check-ts-native +npm run gulp compile-client +scripts\test.bat --grep "TurboFileTransferDisplay|TurboFileDragDrop" +``` + +Expected: type-check succeeds; tests fail because the renderer still starts `fillBatchDirectorySizesInBackground` and batch items still expose known file totals for multi-item operations. + +- [x] **Step 4: Apply the selection policy when transfer tasks are primed** + +Import `createTurboFileTransferSizePolicy` and extend `primeTransferTaskStrategy` before strategy selection: + +```ts +const sizePolicy = createTurboFileTransferSizePolicy(sourceItems); +task.trackTotalBytes = sizePolicy.trackTotalBytes; +task.totalBytes = sizePolicy.totalBytes; +``` + +Call `primeTransferTaskStrategy` for the single internal clipboard path as well as the existing drag/copy paths. In both batch request builders, derive values from the task policy: + +```ts +const expectedBytes = task.trackTotalBytes ? Math.max(0, task.totalBytes ?? 0) : 0; + +expectedBytes: task.trackTotalBytes ? Math.max(0, item.rawSize) : 0 +``` + +- [x] **Step 5: Remove background recursive total discovery** + +Delete the call to `fillBatchDirectorySizesInBackground` and delete the entire helper. Do not replace it with a delayed or fire-and-forget scan. + +- [x] **Step 6: Verify GREEN** + +Run the three commands from Step 3 again. + +Expected: all `TurboFileTransferDisplay` and `TurboFileDragDrop` tests pass. + +--- + +### Task 2: Start local transfers without a first recursive traversal + +**Files:** +- Modify: `src/vs/platform/turbofile/test/common/turbofileSshMainServiceStatic.test.ts` +- Modify: `src/vs/platform/turbofile/electron-main/turbofileSshMainService.ts` + +**Interfaces:** +- Consumes: `ITurboFileTransferRequest.sourceType`, `expectedBytes`, `copyLocalPath` byte deltas, and `fireTransferProgress`. +- Produces: immediate local command/copy dispatch with real transferred-byte events and unknown totals for directory/multi-item work. + +- [x] **Step 1: Add failing static regression tests for local startup** + +Add a small method extractor and test: + +```ts +function methodBlock(source: string, name: string, nextName: string): string { + const start = source.indexOf(name); + return source.slice(start, source.indexOf(nextName, start)); +} + +test('starts local folder and batch copies without recursive size discovery', () => { + const service = fs.readFileSync(servicePath(), 'utf8'); + const transferFile = methodBlock(service, 'async transferFile', 'async cancelTransfer'); + const localBatch = methodBlock(service, 'private async runLocalNativeBatchTransfer', 'private async runSameRemoteNativeBatchTransfer'); + + assert.ok(!transferFile.includes('getLocalPathTransferSize(request.source.path')); + assert.ok(!localBatch.includes('getLocalBatchTransferSize')); + assert.ok(!service.includes('private async getLocalBatchTransferSize')); + assert.ok(localBatch.includes('totalBytes > 0 ? Math.min(transferredBytes, totalBytes) : transferredBytes')); +}); +``` + +- [x] **Step 2: Type-check, emit, and verify RED** + +Run: + +```powershell +npm run compile-check-ts-native +npm run gulp compile-client +scripts\test.bat --grep "TurboFileSshMainServiceStatic" +``` + +Expected: FAIL because `transferFile` and `runLocalNativeBatchTransfer` still await recursive size helpers. + +- [x] **Step 3: Make single local transfer size resolution shallow** + +Replace the recursive helper call with one shallow stat only when the request type is unavailable: + +```ts +const sourceStat = request.sourceType ? undefined : await fsp.stat(request.source.path); +const sourceType = request.sourceType ?? (sourceStat?.isDirectory() ? 'directory' : 'file'); +const totalBytes = sourceType === 'directory' + ? 0 + : Math.max(0, request.expectedBytes ?? sourceStat?.size ?? 0); +``` + +Report real deltas for unknown totals: + +```ts +const progressBytes = totalBytes > 0 ? Math.min(transferredBytes, totalBytes) : transferredBytes; +this.fireTransferProgress(request, progressBytes, totalBytes, 'transfer', 'Copying locally.'); +``` + +Complete with `bytes = Math.max(totalBytes, transferredBytes)` while leaving the progress event total at `totalBytes` rather than changing it to `bytes`. + +- [x] **Step 4: Remove local batch pre-scan and preserve byte deltas** + +Use the renderer-provided value directly: + +```ts +const totalBytes = Math.max(0, expectedBytes); +``` + +In every local batch progress event use: + +```ts +const progressBytes = totalBytes > 0 ? Math.min(transferredBytes, totalBytes) : transferredBytes; +``` + +Return the actual transferred result: + +```ts +const resultBytes = operation === 'copy' ? transferredBytes : totalBytes; +``` + +Delete `getLocalBatchTransferSize` and delete `getLocalPathTransferSize` if it has no remaining callers. Retain explicit user-invoked size helpers used outside transfer startup. + +- [x] **Step 5: Verify GREEN** + +Run the commands from Step 2 again. + +Expected: `TurboFileSshMainServiceStatic` passes. + +--- + +### Task 3: Remove protocol startup totals without changing the data path + +**Files:** +- Modify: `src/vs/platform/turbofile/test/common/turbofileSshMainServiceStatic.test.ts` +- Modify: `src/vs/platform/turbofile/electron-main/turbofileSshMainService.ts` +- Verify: `src/vs/platform/turbofile/test/common/turbofileTransfer148Baseline.test.ts` + +**Interfaces:** +- Consumes: listing-provided `expectedBytes`, archive delta callbacks, rsync progress, and destination-growth callbacks that begin only after command setup. +- Produces: TAR/SCP/SFTP/same-remote strategies that never wait for a recursive source total before dispatch. + +- [x] **Step 1: Add failing protocol-wide startup assertions** + +Add: + +```ts +test('does not discover directory totals before protocol dispatch', () => { + const service = fs.readFileSync(servicePath(), 'utf8'); + const batchTar = methodBlock(service, 'private async tryBatchTarTransfer', 'private createRemoteBatchTarPlan'); + const sameRemote = methodBlock(service, 'private async trySameRemoteCopy', 'private async tryDirectTarTransfer'); + const directTar = methodBlock(service, 'private async tryDirectTarTransfer', 'private async tryDirectTarTransferRoute'); + const directScp = methodBlock(service, 'private async tryDirectScpTransfer', 'private async tryDirectScpTransferRoute'); + const directSftp = methodBlock(service, 'private async tryDirectSftpTransferRoute', 'private async tryTunneledRsyncRelayTransfer'); + const tarRelay = methodBlock(service, 'private async tryTarRelayTransfer', 'private async tryStreamRelayTransfer'); + + assert.ok(!batchTar.includes('getLocalBatchTarBytes')); + assert.ok(!batchTar.includes('getRemoteBatchSourceContentSize')); + assert.ok(!sameRemote.includes("sourceType === 'directory') {\n\t\t\tbytes =")); + assert.ok(!directTar.includes('getRemotePathContentSizeByCommand(request.source)')); + assert.ok(!directScp.includes('|| await this.getRemotePathSizeByCommand(request.source)')); + assert.ok(!directSftp.includes('if (bytes === 0)')); + assert.ok(!tarRelay.includes('getRemotePathContentSizeByCommand(request.source)')); +}); +``` + +- [x] **Step 2: Type-check, emit, and verify RED** + +Run: + +```powershell +npm run compile-check-ts-native +npm run gulp compile-client +scripts\test.bat --grep "TurboFileSshMainServiceStatic|TurboFileTransfer148Baseline" +``` + +Expected: FAIL on the new startup assertions; the 1.148.0 baseline tests remain green. + +- [x] **Step 3: Remove TAR batch size promises and pre-dispatch source scans** + +For local TAR upload/download, remove `sizePromise`, `getLocalBatchTarBytes`, and all awaits of that promise. Use the already-counted archive bytes at completion: + +```ts +const resultBytes = Math.max(bytes, archiveBytes); +``` + +For SSH-to-SSH batch TAR, pass `Math.max(0, expectedBytes)` directly into direct/relay strategies. In direct and relay route callbacks retain a `transferredBytes` variable updated by destination-growth events and return `Math.max(expectedBytes, transferredBytes)` without a final target-total scan solely for progress reporting. + +- [x] **Step 4: Make single-directory protocol branches type-aware** + +In same-remote copy, direct SCP, direct SFTP, direct TAR, TAR relay, SCP relay, and SFTP relay: + +```ts +let bytes = Math.max(0, request.expectedBytes ?? 0); +if (sourceType === 'file' && bytes <= 0) { + bytes = await this.getRemotePathSizeByCommand(request.source).catch(() => 0); +} +``` + +Directory branches keep `bytes === 0` and proceed. Remove guards that reject a directory strategy solely because `bytes <= 0`. Keep connection setup, target-parent creation, cancellation, command exit handling, and known-file size verification unchanged. + +Capture existing post-start stream or destination-growth callbacks into `transferredBytes` and use that value in the result for an unknown-total directory. Do not add a new stream transform, relay, wrapper, or polling loop. + +- [x] **Step 5: Verify GREEN and restored baseline** + +Run the commands from Step 2 again. + +Expected: all selected tests pass, including both 1.148.0 baseline assertions. + +--- + +### Task 4: Enforce delete-before-refresh for every successful move + +**Files:** +- Restore/modify: `src/vs/workbench/contrib/turbofile/common/turbofileTransferRefresh.ts` +- Restore/modify: `src/vs/workbench/contrib/turbofile/test/common/turbofileTransferRefresh.test.ts` +- Modify: `src/vs/workbench/contrib/turbofile/test/common/turbofileDragDrop.test.ts` +- Modify: `src/vs/workbench/contrib/turbofile/browser/turbofileEditor.ts` + +**Interfaces:** +- Produces: `createTurboFilePostTransferRefreshPlan(operation, sourceSide, targetSide)` returning deduplicated pane sides. +- Consumes: `refreshPane(side, { force: true })`, `deleteInternalClipboardSources`, batch result status, and native move completion. + +- [x] **Step 1: Restore the pure refresh plan and add behavior tests** + +Use this focused helper: + +```ts +export type TurboFilePaneSide = 'left' | 'right'; + +export function createTurboFilePostTransferRefreshPlan( + operation: 'copy' | 'move', + sourceSide: TurboFilePaneSide, + targetSide: TurboFilePaneSide +): readonly TurboFilePaneSide[] { + if (operation === 'copy') { + return [targetSide]; + } + return sourceSide === targetSide ? [targetSide] : [sourceSide, targetSide]; +} +``` + +Test copy, cross-pane move, and same-pane deduplication with `assert.deepStrictEqual`. + +- [x] **Step 2: Add a failing ordering regression test** + +Extend `TurboFileDragDrop`: + +```ts +test('deletes cut sources before refreshing source and target panes', () => { + const editor = fs.readFileSync(path.join(process.cwd(), 'src/vs/workbench/contrib/turbofile/browser/turbofileEditor.ts'), 'utf8'); + const start = editor.indexOf('private async pasteInternalTransferClipboard'); + const block = editor.slice(start, editor.indexOf('private isSameTransferTarget', start)); + const deleteIndex = block.indexOf('await this.deleteInternalClipboardSources'); + const refreshIndex = block.lastIndexOf('await this.refreshTransferPanes'); + + assert.ok(deleteIndex >= 0); + assert.ok(refreshIndex > deleteIndex); +}); +``` + +- [x] **Step 3: Type-check, emit, and verify RED** + +Run: + +```powershell +npm run compile-check-ts-native +npm run gulp compile-client +scripts\test.bat --grep "TurboFileTransferRefresh|TurboFileDragDrop" +``` + +Expected: FAIL because the current clipboard fallback refreshes before deleting source items and has no centralized helper. + +- [x] **Step 4: Centralize pane refresh and correct ordering** + +Add: + +```ts +private async refreshTransferPanes(operation: 'copy' | 'move', sourceSide: 'left' | 'right', targetSide: 'left' | 'right'): Promise { + const sides = createTurboFilePostTransferRefreshPlan(operation, sourceSide, targetSide); + await Promise.all(sides.map(side => this.refreshPane(side, { force: true }))); +} +``` + +Remove the refresh from `deleteInternalClipboardSources`. In `pasteInternalTransferClipboard`, perform successful copy, then source cleanup for move, then `refreshTransferPanes`. If transfer or cleanup fails, do not report success; refresh the target for a possible partial copy and refresh the source after a cleanup failure. + +Replace scattered successful batch/native cross-pane refresh blocks with `refreshTransferPanes`. Keep same-pane folder moves using their existing source/target directory-path invalidation because both paths can belong to one pane. + +For external Windows moves, wait for native move/copy-then-delete completion, then refresh the target pane and every visible local pane matching a source parent. Ensure batch external move calls this only after `transferItemsBatch` returns success. + +- [x] **Step 5: Verify GREEN** + +Run the commands from Step 3 again. + +Expected: all refresh and drag/drop tests pass. + +--- + +### Task 5: Increase and lighten the copy/cut label + +**Files:** +- Modify: `src/vs/workbench/contrib/turbofile/test/common/turbofileDragDrop.test.ts` +- Modify: `src/vs/workbench/contrib/turbofile/browser/media/turbofile.css` + +**Interfaces:** +- Consumes: `.turbofile-drag-operation-icon`. +- Produces: a 10 px, weight-400 label with unchanged geometry and colors. + +- [x] **Step 1: Add a failing CSS regression assertion** + +Add to the existing mode-button test object: + +```ts +largerThinLabel: /\.turbofile-drag-operation-icon\s*\{[\s\S]*font-size:\s*10px;[\s\S]*font-weight:\s*400;/.test(css) +``` + +Expect `largerThinLabel: true`. + +- [x] **Step 2: Type-check, emit, and verify RED** + +Run: + +```powershell +npm run compile-check-ts-native +npm run gulp compile-client +scripts\test.bat --grep "TurboFileDragDrop" +``` + +Expected: FAIL because current values are `9px` and `600`. + +- [x] **Step 3: Change only typography** + +```css +.turbofile-drag-operation-icon { + font-size: 10px; + font-weight: 400; +} +``` + +Leave width, height, border, color selectors, hover, active, focus, and focus-visible rules unchanged. + +- [x] **Step 4: Verify GREEN** + +Run the commands from Step 2 again. + +Expected: `TurboFileDragDrop` passes. + +--- + +### Task 6: Full validation and dirty-worktree audit + +**Files:** +- Verify all modified files above. +- Verify: `src/vs/platform/turbofile/test/common/turbofileTransferPlan.test.ts` +- Verify: `src/vs/platform/turbofile/test/common/turbofileRemoteNativeBatchPlan.test.ts` +- Verify: `src/vs/workbench/contrib/turbofile/test/common/turbofileEditorInput.test.ts` + +**Interfaces:** +- Consumes: all prior tasks. +- Produces: type, unit, baseline, and diff evidence ready for user testing. + +- [x] **Step 1: Run mandatory type and layer validation** + +```powershell +npm run compile-check-ts-native +npm run valid-layers-check +``` + +Expected: both exit code 0. + +- [x] **Step 2: Emit current sources and run the complete TurboFile suite** + +```powershell +npm run gulp compile-client +scripts\test.bat --grep TurboFile +``` + +Expected: zero failures, including `TurboFileTransfer148Baseline`, `TurboFileSshMainServiceStatic`, `TurboFileTransferDisplay`, `TurboFileTransferRefresh`, and `TurboFileDragDrop`. + +- [x] **Step 3: Audit prohibited regressions and whitespace** + +```powershell +git diff --check +rg -n "fillBatchDirectorySizesInBackground|getLocalBatchTransferSize|buildTarProgressPipeCommand|TURBOFILE_TAR_PROGRESS_MARKER|createTarStreamWatchdog|waitForTarDrain|\.partial|dd bs=" src/vs/platform/turbofile src/vs/workbench/contrib/turbofile +``` + +Expected: no size-startup helpers and no post-1.148 TAR relay/staging/watchdog markers. Existing unrelated `.partial` or `dd bs=` hits outside transfer code must be reviewed rather than deleted blindly. + +- [x] **Step 4: Review the final scoped diff without committing user-owned work** + +```powershell +git diff -- src/vs/platform/turbofile/electron-main/turbofileSshMainService.ts src/vs/platform/turbofile/test/common/turbofileSshMainServiceStatic.test.ts src/vs/workbench/contrib/turbofile/browser/turbofileEditor.ts src/vs/workbench/contrib/turbofile/browser/media/turbofile.css src/vs/workbench/contrib/turbofile/common/turbofileTransferDisplay.ts src/vs/workbench/contrib/turbofile/common/turbofileTransferRefresh.ts src/vs/workbench/contrib/turbofile/test/common/turbofileTransferDisplay.test.ts src/vs/workbench/contrib/turbofile/test/common/turbofileTransferRefresh.test.ts src/vs/workbench/contrib/turbofile/test/common/turbofileDragDrop.test.ts +``` + +Expected: only the approved startup, progress, refresh, and typography behavior plus already-present user changes. Leave implementation uncommitted and do not package unless the user separately requests it. diff --git a/docs/superpowers/plans/2026-07-16-pane-copy-cut-mode-on-148.md b/docs/superpowers/plans/2026-07-16-pane-copy-cut-mode-on-148.md new file mode 100644 index 00000000..7be87b35 --- /dev/null +++ b/docs/superpowers/plans/2026-07-16-pane-copy-cut-mode-on-148.md @@ -0,0 +1,356 @@ +# Pane Copy/Cut Mode on v1.148 Baseline Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Restore independent left/right copy-cut drag modes while preserving the current v1.148 transfer protocol pipelines and package the result as version 1.151.0. + +**Architecture:** Keep operation state in each `TurboFileEditorInput`, keep operation parsing in the pure drag-drop helper, and snapshot the source or target pane mode at each drag boundary. Reuse the existing `operation: 'copy' | 'move'` batch execution and source-cleanup paths; only the Windows drop-target capture contract is extended, without changing tar/scp/rsync/SFTP data pipelines. + +**Tech Stack:** TypeScript, DOM/CSS, Electron Windows drag bridge, Mocha, VS Code gulp/Inno Setup packaging. + +## Global Constraints + +- Work directly on `codex/image-stream-loading` and preserve unrelated dirty-worktree changes. +- Each page owns independent left/right state; both sides default to `copy` and the state is not persisted. +- Left copy uses the same blue as the left sort icon; right copy uses the same green as the right sort icon; cut is red on both sides. +- Same-pane drops into a folder or `..` always move and never consult or change the mode button. +- Ctrl+C/X/V keep their explicit keyboard semantics and never consult the mode button. +- A move deletes sources only after the complete target operation succeeds; failure or cancellation never deletes sources. +- Do not restore or modify post-v1.148 tar staging, `dd` progress relay, watchdog, drain timeout, or protocol buffers. +- Run `npm run compile-check-ts-native` before passing tests; never run `npm run compile`. + +--- + +### Task 1: Pure Operation Helpers and Per-Pane State + +**Files:** +- Modify: `src/vs/workbench/contrib/turbofile/common/turbofileDragDrop.ts` +- Modify: `src/vs/workbench/contrib/turbofile/common/turbofileEditorInput.ts` +- Modify: `src/vs/workbench/contrib/turbofile/test/common/turbofileEditorInput.test.ts` +- Modify: `src/vs/workbench/contrib/turbofile/test/common/turbofileDragDrop.test.ts` + +**Interfaces:** +- Produces: `TurboFileDragOperation = 'copy' | 'move'` +- Produces: `resolveTurboFileDragOperation(value, fallback)`, `getTurboFileDropEffect(operation)`, and `getNextTurboFileDragOperation(operation)`. +- Produces: `TurboFileEditorInput.getDragOperation(side)`, `setDragOperation(side, operation)`, and `toggleDragOperation(side)`. + +- [ ] **Step 1: Add failing state and helper tests** + +```ts +test('keeps copy and cut modes independent by pane and page', () => { + const first = createInput(1); + const second = createInput(2); + assert.deepStrictEqual([ + first.getDragOperation('left'), first.getDragOperation('right'), + second.getDragOperation('left'), second.getDragOperation('right') + ], ['copy', 'copy', 'copy', 'copy']); + assert.strictEqual(first.toggleDragOperation('left'), 'move'); + assert.deepStrictEqual([ + first.getDragOperation('left'), first.getDragOperation('right'), second.getDragOperation('left') + ], ['move', 'copy', 'copy']); +}); + +test('resolves drag operation and drop effect safely', () => { + assert.deepStrictEqual([ + resolveTurboFileDragOperation('move', 'copy'), + resolveTurboFileDragOperation('invalid', 'copy'), + getNextTurboFileDragOperation('copy'), + getTurboFileDropEffect('move') + ], ['move', 'copy', 'move', 'move']); +}); +``` + +- [ ] **Step 2: Compile the test output and run the focused tests to verify RED** + +Run: `npm run gulp compile-client` + +Run: `node_modules\.bin\mocha.cmd --ui tdd --timeout 10000 --exit out\vs\workbench\contrib\turbofile\test\common\turbofileEditorInput.test.js out\vs\workbench\contrib\turbofile\test\common\turbofileDragDrop.test.js` + +Expected: failure because the helper exports and editor-input methods do not exist. + +- [ ] **Step 3: Implement the pure helpers** + +```ts +export type TurboFileDragOperation = 'copy' | 'move'; + +export function resolveTurboFileDragOperation(value: string | undefined, fallback: TurboFileDragOperation): TurboFileDragOperation { + return value === 'copy' || value === 'move' ? value : fallback; +} + +export function getTurboFileDropEffect(operation: TurboFileDragOperation): 'copy' | 'move' { + return operation; +} + +export function getNextTurboFileDragOperation(operation: TurboFileDragOperation): TurboFileDragOperation { + return operation === 'copy' ? 'move' : 'copy'; +} +``` + +- [ ] **Step 4: Implement two-slot page state** + +```ts +private readonly dragOperations: Record<'left' | 'right', TurboFileDragOperation> = { + left: 'copy', + right: 'copy' +}; + +getDragOperation(side: 'left' | 'right'): TurboFileDragOperation { + return this.dragOperations[side]; +} + +setDragOperation(side: 'left' | 'right', operation: TurboFileDragOperation): void { + this.dragOperations[side] = operation; +} + +toggleDragOperation(side: 'left' | 'right'): TurboFileDragOperation { + return this.dragOperations[side] = getNextTurboFileDragOperation(this.dragOperations[side]); +} +``` + +- [ ] **Step 5: Run the mandatory type-check and focused tests to verify GREEN** + +Run: `npm run compile-check-ts-native` + +Run: `npm run gulp compile-client` + +Run the Mocha command from Step 2. + +Expected: both suites pass. + +--- + +### Task 2: Compact Colored Button and In-App Drag Routing + +**Files:** +- Modify: `src/vs/workbench/contrib/turbofile/browser/turbofileEditor.ts` +- Modify: `src/vs/workbench/contrib/turbofile/browser/media/turbofile.css` +- Modify: `src/vs/workbench/contrib/turbofile/test/common/turbofileDragDrop.test.ts` + +**Interfaces:** +- Consumes: Task 1 operation helpers and `TurboFileEditorInput` state. +- Produces: `appendDragOperationButton(parent, side)` and `getCurrentDragOperation(side)`. +- Produces: operation-aware `transferSelected(sourceSide, targetSide, initiatedAt, operation)`. + +- [ ] **Step 1: Add failing renderer and CSS assertions** + +```ts +assert.deepStrictEqual({ + button: editor.includes('this.appendDragOperationButton(toolbar, side)'), + snapshot: editor.includes("setData('application/x-turbofile-operation', operation)"), + internalDrop: editor.includes('this.transferSelected(sourceSide, targetSide, initiatedAt, operation)'), + externalDrop: editor.includes('this.transferExternalPaths(targetSide, externalPaths, operation, initiatedAt)'), + leftBlue: css.includes('.turbofile-pane[data-turbofile-side="left"] .turbofile-drag-operation-button.is-copy'), + rightGreen: css.includes('.turbofile-pane[data-turbofile-side="right"] .turbofile-drag-operation-button.is-copy'), + cutRed: css.includes('.turbofile-drag-operation-button.is-move') && css.includes('turbofile-text-danger') +}, { + button: true, + snapshot: true, + internalDrop: true, + externalDrop: true, + leftBlue: true, + rightGreen: true, + cutRed: true +}); +``` + +- [ ] **Step 2: Recompile and run `TurboFileDragDrop` to verify RED** + +Run: `npm run gulp compile-client` + +Run: `node_modules\.bin\mocha.cmd --ui tdd --timeout 10000 --exit out\vs\workbench\contrib\turbofile\test\common\turbofileDragDrop.test.js` + +Expected: the new source assertions fail because the button and routing are missing. + +- [ ] **Step 3: Render the independent compact mode button after sort** + +```ts +private appendDragOperationButton(parent: HTMLElement, side: 'left' | 'right'): void { + const operation = this.getCurrentDragOperation(side); + const isMove = operation === 'move'; + const label = isMove + ? localize('turbofile.dragMode.move.label', '剪切') + : localize('turbofile.dragMode.copy.label', '复制'); + const button = $('button.turbofile-toolbar-button.turbofile-drag-operation-button') as HTMLButtonElement; + button.classList.add(isMove ? 'is-move' : 'is-copy'); + button.title = label; + button.setAttribute('aria-label', label); + button.appendChild($('span.turbofile-drag-operation-icon', undefined, label)); + button.addEventListener('click', () => { + this.currentInput?.toggleDragOperation(side); + this.render(); + }); + parent.appendChild(button); +} +``` + +- [ ] **Step 4: Snapshot and route the correct operation** + +At row/image drag start, write the source pane mode to `application/x-turbofile-operation` while retaining `effectAllowed = 'copyMove'`. At internal page/list/image drops, resolve that payload once and pass it to `transferSelected`. At Windows-to-pane drops, use the target pane mode. Set `dropEffect` from the same resolved operation. + +Same-pane folder and parent-row handlers continue calling `moveSelectedItemsIntoDirectory(...)` directly and never read the operation payload. + +- [ ] **Step 5: Reuse batch move safety for cross-pane cut** + +Change the method signature to: + +```ts +private async transferSelected( + sourceSide: 'left' | 'right', + targetSide: 'left' | 'right', + initiatedAt?: number, + operation: TurboFileDragOperation = 'copy' +): Promise +``` + +Route `operation === 'move'` or multi-item copy through `transferItemsBatch(..., operation, initiatedAt)`. Preserve the existing optimized single-item path only for copy. + +- [ ] **Step 6: Add compact side-aware CSS without persistent highlight** + +```css +.turbofile-toolbar-button.turbofile-drag-operation-button { + width: 24px; + min-width: 24px; + height: 24px; + padding: 0; + border: 0; + background: transparent; + box-shadow: none; +} + +.turbofile-drag-operation-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 18px; + box-sizing: border-box; + border: 1px solid currentColor; + border-radius: 2px; + font-size: 9px; + font-weight: 600; + line-height: 1; +} + +.turbofile-pane[data-turbofile-side="left"] .turbofile-drag-operation-button.is-copy { color: #3b82f6; } +.turbofile-pane[data-turbofile-side="right"] .turbofile-drag-operation-button.is-copy { color: #2fae7b; } +.turbofile-drag-operation-button.is-move { color: #ef6670; } +``` + +Explicitly keep hover/active/focus backgrounds transparent so switching does not create a large selected rectangle. + +- [ ] **Step 7: Type-check, rebuild and run the focused suites** + +Run: `npm run compile-check-ts-native` + +Run: `npm run gulp compile-client` + +Run both focused suites from Task 1. + +Expected: all focused tests pass. + +--- + +### Task 3: Safe Pane-to-Windows Cut + +**Files:** +- Modify: `src/vs/platform/turbofile/common/turbofile.ts` +- Modify: `src/vs/platform/turbofile/electron-main/turbofileSshMainService.ts` +- Modify: `src/vs/workbench/contrib/turbofile/browser/turbofileEditor.ts` +- Modify: `src/vs/platform/turbofile/test/common/turbofileSshMainServiceStatic.test.ts` +- Modify: `src/vs/workbench/contrib/turbofile/test/common/turbofileDragDrop.test.ts` + +**Interfaces:** +- Extends: `ITurboFileStartWindowsDragRequest` with `readonly operation?: TurboFileDragOperation` represented at the platform boundary as `'copy' | 'move'`. +- Guarantees: local copy retains direct native `webContents.startDrag`; local move and SSH copy/move capture a target directory before executing the transfer. + +- [ ] **Step 1: Add failing contract assertions** + +Assert that the request interface contains `operation?: 'copy' | 'move'`, the renderer passes the snapshotted operation to `startWindowsFileDrag`, and the main service routes local move through the existing fake-directory/CopyHook target capture rather than direct native copy. + +- [ ] **Step 2: Recompile and run focused tests to verify RED** + +Run: `npm run gulp compile-client` + +Run the focused renderer tests plus `out\vs\platform\turbofile\test\common\turbofileSshMainServiceStatic.test.js`. + +Expected: source assertions fail because the request lacks an operation. + +- [ ] **Step 3: Extend only the Windows drag bridge contract** + +```ts +export interface ITurboFileStartWindowsDragRequest { + readonly source: ITurboFileTransferEndpoint; + readonly items: readonly ITurboFileWindowsDragItem[]; + readonly operation?: 'copy' | 'move'; + readonly progressId?: string; +} +``` + +Resolve `const operation = request.operation ?? 'copy'`. Keep local copy on the current direct native path. Route local move and all SSH drags through the existing target-capture method. Do not alter transfer protocol builders or stream settings. + +- [ ] **Step 4: Execute a move only after a confirmed target** + +Pass the source-pane snapshot to `startWindowsFileDragFromPane` and `transferItemsToWindowsDirectory`. For move, use `transferBatch({ ..., operation: 'move' })`, including a single item, so the existing batch result controls source cleanup. On canceled, timeout, missing target or `ok: false`, return before transfer and deletion. + +- [ ] **Step 5: Type-check and run focused tests to verify GREEN** + +Run: `npm run compile-check-ts-native` + +Run: `npm run gulp compile-client` + +Run the three focused suites from Step 2. + +Expected: all pass, and the v1.148 transfer-pipeline baseline test remains green. + +--- + +### Task 4: Full Verification and 1.151.0 Packaging + +**Files:** +- Verify all files from Tasks 1-3. +- Produce: `.build/win32-x64/TurboFileSetup-win32-x64-1.151.0.exe` +- Produce: `.build/win32-x64/TurboFileSetup-win32-x64-1.151.0.exe.sha256` + +**Interfaces:** +- Consumes: the complete feature. +- Produces: fresh compile/test/build evidence and a versioned installer. + +- [ ] **Step 1: Run mandatory type-check** + +Run: `npm run compile-check-ts-native` + +Expected: exit 0. + +- [ ] **Step 2: Rebuild and run every TurboFile unit test** + +Run: `npm run gulp compile-client` + +Run Mocha over every `*.test.js` under both TurboFile test output directories using absolute paths. + +Expected: 0 failures. + +- [ ] **Step 3: Verify transfer-baseline and diff safety** + +Run `git diff --check` and search for `buildTarProgressPipeCommand`, `TURBOFILE_TAR_PROGRESS_MARKER`, `createTarStreamWatchdog`, `waitForTarDrain`, `.partial`, and `dd bs=` in the transfer service/editor. + +Expected: no whitespace errors and no forbidden post-v1.148 pipeline marker. + +- [ ] **Step 4: Build Windows x64 product and installer** + +Run: `npx gulp vscode-win32-x64` + +Run: `npx gulp vscode-win32-x64-inno-updater` + +Run: `npx gulp vscode-win32-x64-user-setup` + +Expected: all three tasks exit 0. + +- [ ] **Step 5: Version, copy and hash the installer** + +Copy `.build/win32-x64/user-setup/极速传Setup.exe` to `.build/win32-x64/TurboFileSetup-win32-x64-1.151.0.exe`, generate a SHA-256 sidecar, and verify the first two bytes are `4D 5A` and packaged `product.json` reports `1.151.0`. + +Expected: sidecar hash equals `Get-FileHash`, PE header and version match. + +## Execution Choice + +Inline execution is selected because the user explicitly requested implementation in the current branch and did not authorize subagent delegation. The current normal repository and dirty worktree are preserved. diff --git a/docs/superpowers/plans/2026-07-16-per-device-recycle-bin.md b/docs/superpowers/plans/2026-07-16-per-device-recycle-bin.md new file mode 100644 index 00000000..a60c1e64 --- /dev/null +++ b/docs/superpowers/plans/2026-07-16-per-device-recycle-bin.md @@ -0,0 +1,487 @@ +# Per-Device Recycle Bin Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a Windows-style, per-device recycle bin so every explicit TurboFile delete is a same-filesystem native move, while transfer cleanup and cut/move semantics continue using permanent deletion. + +**Architecture:** Add dedicated recycle-bin contracts and pure path/metadata helpers in the platform layer, then implement local Windows, remote Windows, and POSIX SSH operations in the existing main-process SSH service. The workbench keeps recycle-bin mode independently in each pane and renders metadata-only rows; all explicit user delete entry points call `movePathsToTrash`, while existing `deletePath` and `deletePaths` remain unchanged for internal cleanup and permanent deletion. + +**Tech Stack:** TypeScript, VS Code dependency injection and IPC proxying, Node.js filesystem APIs, SSH2, PowerShell, POSIX shell utilities, Mocha unit tests, CSS/Codicons. + +## Global Constraints + +- Work directly on branch `codex/image-stream-loading` and preserve all unrelated dirty-worktree changes. +- Every device has an independent recycle bin; only the pane switched to recycle-bin mode changes. +- Each filesystem, drive, or share uses a same-filesystem hidden recycle-bin root. +- Explicit user deletion always enters the recycle bin; no ordinary-view permanent-delete bypass exists. +- Cut, Ctrl+X/V, drag move, transfer cleanup, rollback, and temporary-file cleanup remain permanent operations. +- Recycle-bin data never expires automatically; permanent delete and empty are available only inside the recycle-bin view. +- Restore conflicts offer “Keep Both” or cancel and never overwrite. +- Directory size is never scanned; metadata uses only already-known file size. +- Delete and restore never invoke SCP, SFTP transfer, TAR, rsync, or cross-volume copy. +- Failure to create or use a same-filesystem trash root leaves the source untouched. +- Run `npm run compile-check-ts-native` before every test or validation command and never run `npm run compile`. + +--- + +## File Structure + +- Create `src/vs/platform/turbofile/common/turbofileTrash.ts`: pure metadata validation, device identity, restore-name generation, root/path safety, and in-memory sorting helpers. +- Create `src/vs/platform/turbofile/test/common/turbofileTrash.test.ts`: unit tests for those helpers. +- Modify `src/vs/platform/turbofile/common/turbofile.ts`: public recycle-bin request/result contracts and service methods. +- Modify `src/vs/platform/turbofile/electron-main/turbofileSshMainService.ts`: local and SSH recycle-bin orchestration, atomic metadata, crash recovery, listing, restore, permanent delete, and empty. +- Create `src/vs/workbench/contrib/turbofile/common/turbofileTrashView.ts`: pure pane-mode and row mapping helpers. +- Create `src/vs/workbench/contrib/turbofile/test/common/turbofileTrashView.test.ts`: pane independence, ordering, selection, and display tests. +- Modify `src/vs/workbench/contrib/turbofile/browser/turbofileEditor.ts`: toolbar icon, independent pane state, recycle-bin list/actions, explicit delete routing, conflict prompts, and refresh behavior. +- Modify `src/vs/workbench/contrib/turbofile/browser/media/turbofile.css`: empty/full recycle-bin icons and recycle-bin row layout. +- Modify `src/vs/platform/turbofile/test/common/turbofileSshMainServiceStatic.test.ts`: static guarantees that move-to-trash does not use transfer protocols or permanent-delete fallbacks. +- Modify `src/vs/workbench/contrib/turbofile/test/common/turbofileImageGridPane.test.ts`: all image delete entry points route to the recycle-bin API. + +### Task 1: Recycle-Bin Domain Model and Pure Helpers + +**Files:** +- Create: `src/vs/platform/turbofile/common/turbofileTrash.ts` +- Create: `src/vs/platform/turbofile/test/common/turbofileTrash.test.ts` +- Modify: `src/vs/platform/turbofile/common/turbofile.ts` + +**Interfaces:** +- Produces: `ITurboFileTrashItem`, `ITurboFileTrashDevice`, `ITurboFileMoveToTrashRequest`, `ITurboFileTrashItemsRequest`, `ITurboFileRestoreTrashRequest`, `ITurboFileTrashOperationResult`, `ITurboFileTrashListing`, `ITurboFileTrashState`. +- Produces: `getTurboFileTrashDeviceIdentity`, `createTurboFileTrashItemId`, `createTurboFileKeepBothName`, `sortTurboFileTrashItems`, `isTurboFileTrashMetadata`, and `isTurboFileProtectedTrashSource`. + +- [ ] **Step 1: Write failing pure-helper tests** + +```ts +suite('TurboFileTrash', () => { + test('keeps SSH identities stable and separates users', () => { + assert.deepStrictEqual( + [ + getTurboFileTrashDeviceIdentity({ kind: 'ssh', host: 'NAS', port: 22, username: 'alice' }), + getTurboFileTrashDeviceIdentity({ kind: 'ssh', host: 'nas', port: 22, username: 'bob' }) + ], + ['ssh:nas:22:alice', 'ssh:nas:22:bob'] + ); + }); + + test('creates deterministic keep-both names without changing extensions', () => { + assert.deepStrictEqual( + [ + createTurboFileKeepBothName('photo.jpg', 1), + createTurboFileKeepBothName('archive.tar.gz', 2), + createTurboFileKeepBothName('folder', 3) + ], + ['photo (1).jpg', 'archive.tar (2).gz', 'folder (3)'] + ); + }); + + test('sorts newest deleted item first without reading payloads', () => { + assert.deepStrictEqual( + sortTurboFileTrashItems([ + { id: 'old', deletedAt: 1 }, + { id: 'new', deletedAt: 2 } + ] as ITurboFileTrashItem[]).map(item => item.id), + ['new', 'old'] + ); + }); +}); +``` + +- [ ] **Step 2: Type-check before running the new tests** + +Run: `npm run compile-check-ts-native` + +Expected: FAIL because the recycle-bin helpers and contracts do not exist. + +- [ ] **Step 3: Implement the contracts and pure helpers** + +```ts +export interface ITurboFileTrashItem { + readonly id: string; + readonly originalName: string; + readonly originalPath: string; + readonly deletedAt: number; + readonly type: TurboFileFileType; + readonly knownSize?: number; + readonly volumeId: string; + readonly trashRoot: string; + readonly payloadPath: string; + readonly deviceIdentity: string; + readonly damaged?: boolean; + readonly damageMessage?: string; +} + +export function createTurboFileKeepBothName(name: string, ordinal: number): string { + const extensionIndex = name.lastIndexOf('.'); + if (extensionIndex <= 0) { + return `${name} (${ordinal})`; + } + return `${name.slice(0, extensionIndex)} (${ordinal})${name.slice(extensionIndex)}`; +} +``` + +Implement strict metadata validation, normalized SSH identity, cryptographically unique item IDs, protected-root checks, and deletion-time descending sort. Add the six dedicated methods to `ITurboFileSshService`; do not change the existing permanent-delete signatures. + +- [ ] **Step 4: Type-check and run helper tests** + +Run: + +```powershell +npm run compile-check-ts-native +npm run gulp compile-client +npm run compile-check-ts-native +scripts\test.bat --grep TurboFileTrash +``` + +Expected: compilation succeeds and all `TurboFileTrash` tests pass. + +### Task 2: Local Windows Recycle-Bin Transactions + +**Files:** +- Modify: `src/vs/platform/turbofile/electron-main/turbofileSshMainService.ts` +- Modify: `src/vs/platform/turbofile/test/common/turbofileSshMainServiceStatic.test.ts` + +**Interfaces:** +- Consumes: Task 1 contracts and metadata helpers. +- Produces: local implementations of all six recycle-bin service methods. + +- [ ] **Step 1: Add failing service/static tests** + +```ts +test('keeps user delete separate from permanent delete', () => { + const source = readService(); + assert.ok(source.includes('async movePathsToTrash(')); + assert.ok(source.includes('await fsp.rename(source.path, payloadPath)')); + assert.ok(!extractMethod(source, 'movePathsToTrash').includes('fsp.rm(')); + assert.ok(!extractMethod(source, 'movePathsToTrash').includes('transferBatch(')); +}); +``` + +Add tests for system-drive root selection, non-system drive root selection, pending metadata before rename, metadata finalization after rename, root protection, and partial batch results. + +- [ ] **Step 2: Type-check before tests** + +Run: `npm run compile-check-ts-native` + +Expected: PASS for existing code or FAIL only for newly referenced unimplemented methods. + +- [ ] **Step 3: Implement local trash-root resolution and transactions** + +For a local Windows source: + +```ts +const pendingPath = path.join(infoDirectory, `${item.id}.pending.json`); +const completePath = path.join(infoDirectory, `${item.id}.json`); +await fsp.mkdir(filesDirectory, { recursive: true }); +await fsp.mkdir(infoDirectory, { recursive: true }); +await fsp.writeFile(pendingPath, JSON.stringify(item), { encoding: 'utf8', flag: 'wx' }); +await fsp.rename(source.path, payloadPath); +await fsp.rename(pendingPath, completePath); +``` + +Use `%LOCALAPPDATA%\TurboFile\RecycleBin\` for the current user profile drive and `:\.turbofile-trash-\turbofile` for other drives. Set hidden attributes best-effort only after directory creation; failure to write metadata or rename must return a failed item and leave source data intact. + +- [ ] **Step 4: Implement local list/recovery/restore/delete/empty** + +List only `info/*.json` and reconcile pending entries. Restore creates missing parent directories, detects conflicts, and only uses `fsp.rename`. Permanent delete calls the existing `deletePaths` implementation on payload endpoints, then removes metadata only after payload success. Empty groups items by trash root and returns partial failures. + +- [ ] **Step 5: Verify local service behavior** + +Run: + +```powershell +npm run compile-check-ts-native +npm run gulp compile-client +npm run compile-check-ts-native +scripts\test.bat --grep "TurboFileTrash|TurboFileSshMainService" +``` + +Expected: all focused tests pass. + +### Task 3: POSIX SSH and Remote Windows Recycle Bins + +**Files:** +- Modify: `src/vs/platform/turbofile/electron-main/turbofileSshMainService.ts` +- Modify: `src/vs/platform/turbofile/test/common/turbofileSshMainServiceStatic.test.ts` + +**Interfaces:** +- Consumes: Task 1 contracts and Task 2 orchestration. +- Produces: same-filesystem native move implementations for POSIX and remote Windows. + +- [ ] **Step 1: Add failing command-safety tests** + +```ts +test('remote trash commands never invoke transfer protocols or rm during move-to-trash', () => { + const method = extractMethod(readService(), 'movePathsToTrash'); + for (const forbidden of ['scp ', 'sftp', 'tar ', 'rsync', 'rm -', 'Remove-Item']) { + assert.ok(!method.includes(forbidden), forbidden); + } +}); +``` + +Add coverage for NUL-delimited batch input, `findmnt` with `df -P` fallback, POSIX UID isolation, remote PowerShell drive roots, and one SSH round trip per batch stage rather than per selected file. + +- [ ] **Step 2: Type-check before tests** + +Run: `npm run compile-check-ts-native` + +Expected: compilation succeeds before executing tests. + +- [ ] **Step 3: Implement POSIX root discovery and native move** + +Resolve the source mount with `findmnt -T -- -no TARGET` and fall back to `df -P`. Use `~/.local/share/Trash/turbofile` when source and home share a filesystem; otherwise use a validated `.Trash//turbofile` or `/.Trash-/turbofile`. Write pending metadata before `mv -- source payload`, then atomically rename metadata. Batch command input must be NUL-delimited and return one JSON result per item. + +- [ ] **Step 4: Implement remote Windows root discovery and native move** + +Use one remote PowerShell script with JSON input to resolve the drive, remote `%LOCALAPPDATA%`, username-derived key, create hidden roots, write pending JSON, call `[System.IO.File]::Move` or `[System.IO.Directory]::Move`, and finalize metadata. Never use `Move-Item` in a mode that silently falls back to copy across providers. + +- [ ] **Step 5: Implement remote list/recovery/restore/delete/empty** + +Enumerate Windows drives or POSIX mount points, inspect only the known metadata directories, merge records, and retain inaccessible-volume diagnostics. Restore and permanent delete operate against the recorded `trashRoot`, `payloadPath`, and `volumeId`, not a recomputed path. + +- [ ] **Step 6: Verify remote implementations** + +Run: + +```powershell +npm run compile-check-ts-native +npm run gulp compile-client +npm run compile-check-ts-native +scripts\test.bat --grep "TurboFileTrash|TurboFileSshMainService" +``` + +Expected: all focused tests pass; static checks find no transfer protocol in recycle-bin move/restore paths. + +### Task 4: Independent Pane Recycle-Bin State and Row Mapping + +**Files:** +- Create: `src/vs/workbench/contrib/turbofile/common/turbofileTrashView.ts` +- Create: `src/vs/workbench/contrib/turbofile/test/common/turbofileTrashView.test.ts` +- Modify: `src/vs/workbench/contrib/turbofile/browser/turbofileEditor.ts` + +**Interfaces:** +- Consumes: `ITurboFileTrashItem` and `ITurboFileTrashListing`. +- Produces: `TurboFileTrashPaneMode`, `enterTurboFileTrashView`, `leaveTurboFileTrashView`, `mapTurboFileTrashItemsToRows`. + +- [ ] **Step 1: Write failing pane-independence tests** + +```ts +test('entering the left recycle bin leaves the right pane untouched', () => { + const panes = createPaneFixture(); + const next = enterTurboFileTrashView(panes, 'left'); + assert.deepStrictEqual(next, { + left: { mode: 'trash', returnPath: 'C:\\left' }, + right: panes.right + }); +}); +``` + +Add tests for returning to the prior path, device-change exit, deletion-time ordering, no parent row, metadata-only size display, and independent selections. + +- [ ] **Step 2: Type-check before tests** + +Run: `npm run compile-check-ts-native` + +Expected: FAIL because view helpers do not exist. + +- [ ] **Step 3: Implement pure pane-mode helpers** + +```ts +export interface TurboFileTrashPaneMode { + readonly active: boolean; + readonly returnPath?: string; +} + +export function enterTurboFileTrashView(path: string): TurboFileTrashPaneMode { + return { active: true, returnPath: path }; +} + +export function leaveTurboFileTrashView(): TurboFileTrashPaneMode { + return { active: false }; +} +``` + +Map recycle metadata to dedicated UI rows without pretending payload paths are ordinary browsable paths. + +- [ ] **Step 4: Extend each `TurboFilePaneState` independently** + +Add `trashMode`, `trashItems`, `trashLoading`, and `trashError`. Switching devices clears only that side’s trash mode. Directory refresh and trash refresh use separate request sequence checks so a response from one mode cannot overwrite the other. + +- [ ] **Step 5: Verify pane helper behavior** + +Run: + +```powershell +npm run compile-check-ts-native +npm run gulp compile-client +npm run compile-check-ts-native +scripts\test.bat --grep TurboFileTrashView +``` + +Expected: all pane-state tests pass. + +### Task 5: Toolbar, Recycle-Bin View, and Actions + +**Files:** +- Modify: `src/vs/workbench/contrib/turbofile/browser/turbofileEditor.ts` +- Modify: `src/vs/workbench/contrib/turbofile/browser/media/turbofile.css` +- Modify: `src/vs/workbench/contrib/turbofile/test/common/turbofileImageGridPane.test.ts` + +**Interfaces:** +- Consumes: Task 3 service methods and Task 4 pane state. +- Produces: empty/full icon, view toggle, restore, permanent delete, empty, refresh, context menu, and return behavior. + +- [ ] **Step 1: Add failing UI/static tests** + +Assert that the recycle-bin toolbar button is appended immediately after the delete button, left/right state fields are separate, recycle mode has no parent row or drag-out handler, and permanent delete/empty actions are only rendered when `trashMode.active`. + +- [ ] **Step 2: Type-check before tests** + +Run: `npm run compile-check-ts-native` + +Expected: compilation succeeds before running tests. + +- [ ] **Step 3: Add the toolbar icon and view toggle** + +Append the recycle-bin button immediately after: + +```ts +this.appendToolbarButton(toolbar, Codicon.trash, localize('turbofile.deleteSelected', "Delete Selected"), ...); +``` + +Use compact code-native empty/full icon classes matching existing toolbar dimensions. Clicking it calls `toggleTrashView(side)` and only refreshes/render the selected pane state. + +- [ ] **Step 4: Render metadata-only recycle rows and toolbar** + +Render title ` - Recycle Bin`, columns for original name/path/deleted time/type/known size, and actions Back, Restore, Permanently Delete, Empty Recycle Bin, Refresh. Disable rename, directory navigation, paste, transfer drag, image-grid opening, and terminal actions while in recycle mode. + +- [ ] **Step 5: Implement restore conflict flow** + +Call restore with `conflictPolicy: 'cancel'`. If conflicts are returned, show a modal prompt with “Keep Both” and “Cancel”; retry only conflicted items with `conflictPolicy: 'keepBoth'`. Refresh recycle metadata and any visible pane showing a restored parent path. + +- [ ] **Step 6: Implement permanent delete and empty confirmations** + +Use stronger localized confirmation text. Show per-item success/failure counts, preserve failed records, refresh only the active recycle pane, and update empty/full icon state cache. + +- [ ] **Step 7: Verify UI behavior** + +Run: + +```powershell +npm run compile-check-ts-native +npm run gulp compile-client +npm run compile-check-ts-native +scripts\test.bat --grep "TurboFileTrashView|TurboFileImageGridPane" +``` + +Expected: focused UI tests pass. + +### Task 6: Route Every Explicit Delete to Trash + +**Files:** +- Modify: `src/vs/workbench/contrib/turbofile/browser/turbofileEditor.ts` +- Modify: `src/vs/workbench/contrib/turbofile/test/common/turbofileImageGridPane.test.ts` +- Modify: `src/vs/platform/turbofile/test/common/turbofileSshMainServiceStatic.test.ts` + +**Interfaces:** +- Consumes: `movePathsToTrash`. +- Produces: one workbench helper `moveUserSelectedItemsToTrash` shared by file rows, context menu, keyboard Delete, image grid, and image preview. + +- [ ] **Step 1: Add failing routing tests** + +```ts +test('explicit delete entry points use movePathsToTrash', () => { + const source = readEditor(); + for (const methodName of ['deleteSelected', 'deleteSelectedImageGridItems', 'deleteImageGridPreview']) { + assert.ok(extractMethod(source, methodName).includes('movePathsToTrash')); + } +}); +``` + +Also assert that transfer cleanup methods still contain `deletePath`/`deletePaths` and do not call `movePathsToTrash`. + +- [ ] **Step 2: Type-check before tests** + +Run: `npm run compile-check-ts-native` + +Expected: compilation succeeds before running tests. + +- [ ] **Step 3: Replace explicit-delete calls only** + +Build one batch request containing endpoint, item type, and already-known size. Change confirmation copy to “Move these items to the Recycle Bin?”. On success refresh the source directory and recycle state; on partial failure keep failed selections and show exact messages. + +- [ ] **Step 4: Preserve permanent cleanup semantics** + +Audit every remaining `deletePath` and `deletePaths` call. Keep calls used by Ctrl+X/V completion, drag move completion, remote-to-local move cleanup, rollback, temporary resources, and recycle-bin permanent delete. Add comments only where intent is otherwise ambiguous. + +- [ ] **Step 5: Verify delete routing** + +Run: + +```powershell +npm run compile-check-ts-native +npm run gulp compile-client +npm run compile-check-ts-native +scripts\test.bat --grep "TurboFileTrash|TurboFileImageGridPane|TurboFileSshMainService" +``` + +Expected: all explicit-delete routing tests pass and transfer cleanup remains permanent. + +### Task 7: Full Validation and Manual Acceptance + +**Files:** +- Modify only files required to fix validation failures. + +**Interfaces:** +- Consumes all prior tasks. +- Produces a verified feature ready for user packaging/testing. + +- [ ] **Step 1: Run final type check** + +Run: `npm run compile-check-ts-native` + +Expected: exit code 0. + +- [ ] **Step 2: Compile the client and re-run type check** + +Run: + +```powershell +npm run gulp compile-client +npm run compile-check-ts-native +``` + +Expected: both commands exit 0. + +- [ ] **Step 3: Run all TurboFile tests** + +Run: + +```powershell +npm run compile-check-ts-native +scripts\test.bat --grep TurboFile +``` + +Expected: all TurboFile tests pass with no regressions. + +- [ ] **Step 4: Validate architecture and whitespace** + +Run: + +```powershell +npm run compile-check-ts-native +npm run valid-layers-check +git diff --check +``` + +Expected: all commands exit 0. + +- [ ] **Step 5: Manual desktop acceptance** + +Verify local C drive, local data drive, POSIX home filesystem, separate POSIX mount/NAS share, and remote Windows: + +1. Explicit Delete immediately moves files and large directories without a size scan. +2. Recycle-bin icon appears directly after Delete and changes empty/full state. +3. Left and right recycle-bin modes are independent. +4. Restore recreates missing parents; conflict offers only Keep Both or Cancel. +5. Permanent Delete and Empty exist only in recycle-bin mode. +6. Ctrl+X/V and drag move do not create recycle-bin entries. +7. Disconnect, permission failure, and unavailable mount never permanently remove the source. diff --git a/docs/superpowers/plans/2026-07-16-single-file-transfer-total.md b/docs/superpowers/plans/2026-07-16-single-file-transfer-total.md new file mode 100644 index 00000000..d228f2ec --- /dev/null +++ b/docs/superpowers/plans/2026-07-16-single-file-transfer-total.md @@ -0,0 +1,426 @@ +# Single-File Transfer Total Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Show `transferred / total` only for exactly one ordinary file, while every multi-item or directory transfer starts immediately and shows only real transferred bytes. + +**Architecture:** Add a pure transfer-size policy that is decided once from selection cardinality and item type. Persist that policy on the renderer task so protocol changes cannot invent a total, and make live aggregation treat total bytes as optional rather than deriving them from transferred bytes. Keep all transfer commands, tar streams, buffers, concurrency, retries, cancellation, and source-deletion behavior unchanged. + +**Tech Stack:** TypeScript, VS Code renderer and Electron-main transfer services, Mocha TDD tests, Gulp Windows packaging, Inno Setup. + +## Global Constraints + +- Exactly one ordinary file uses the file-list `rawSize` and displays `transferred / total` for every protocol. +- Multiple files, one directory, multiple directories, or mixed selections do not perform recursive size calculation and display only transferred bytes with no trailing slash. +- Multi-item and directory tasks must not infer a total from `rawSize`, progress bytes, completion bytes, or target size. +- Protocol selection and fallback must not change the size policy selected at task creation. +- Do not add file-system calls, SSH commands, polling, buffering, or work on the transfer hot path. +- Do not change transfer speed, tar piping, compression, concurrency, conflict checks, cancellation, or cut safety. +- Work in the current dirty `codex/image-stream-loading` worktree and stage only files named by each task. + +--- + +### Task 1: Define the selection-based size policy with failing tests + +**Files:** +- Modify: `src/vs/workbench/contrib/turbofile/common/turbofileTransferDisplay.ts` +- Modify: `src/vs/workbench/contrib/turbofile/test/common/turbofileTransferDisplay.test.ts` + +**Interfaces:** +- Produces: `createTurboFileTransferSizePolicy(items)` returning `{ trackTotalBytes: boolean; totalBytes?: number }`. +- Produces: `resolveTurboFileVisibleTotalBytes(options)` returning `number | undefined` without using transferred bytes. +- Consumes: only already-loaded `{ directory, rawSize }` values; no I/O. + +- [ ] **Step 1: Add failing policy tests** + +Extend the test import and add these assertions: + +```ts +import { chooseDisplayTransferSpeed, createTurboFileTransferSizePolicy, getVisibleTransferStartedAt, resolveTurboFileVisibleTotalBytes, shouldStartTransferTiming } from '../../common/turbofileTransferDisplay.js'; + +test('tracks total size only for exactly one ordinary file', () => { + assert.deepStrictEqual(createTurboFileTransferSizePolicy([{ directory: false, rawSize: 8192 }]), { + trackTotalBytes: true, + totalBytes: 8192 + }); + assert.deepStrictEqual(createTurboFileTransferSizePolicy([{ directory: true, rawSize: 8192 }]), { + trackTotalBytes: false + }); + assert.deepStrictEqual(createTurboFileTransferSizePolicy([ + { directory: false, rawSize: 4096 }, + { directory: false, rawSize: 4096 } + ]), { trackTotalBytes: false }); +}); + +test('never infers a total for multi-item or directory progress', () => { + assert.strictEqual(resolveTurboFileVisibleTotalBytes({ + trackTotalBytes: false, + listedTotalBytes: undefined, + reportedTotalBytes: 1024 * 1024 + }), undefined); + assert.strictEqual(resolveTurboFileVisibleTotalBytes({ + trackTotalBytes: true, + listedTotalBytes: 1024, + reportedTotalBytes: 2048 + }), 2048); +}); +``` + +- [ ] **Step 2: Type-check and compile the new test** + +Run: `npm run compile-check-ts-native` + +Expected: exit code 0. + +Run: `npm run gulp compile-client` + +Expected: compilation completes with 0 errors. + +- [ ] **Step 3: Run the focused test and verify RED** + +Run: `node_modules\.bin\mocha.cmd --ui tdd --timeout 10000 --exit out/vs/workbench/contrib/turbofile/test/common/turbofileTransferDisplay.test.js` + +Expected: FAIL because `createTurboFileTransferSizePolicy` and `resolveTurboFileVisibleTotalBytes` are not implemented. + +- [ ] **Step 4: Implement the minimal pure policy** + +Add these interfaces and functions to `turbofileTransferDisplay.ts`: + +```ts +export interface ITurboFileTransferSizeItem { + readonly directory: boolean; + readonly rawSize: number; +} + +export interface ITurboFileTransferSizePolicy { + readonly trackTotalBytes: boolean; + readonly totalBytes?: number; +} + +export function createTurboFileTransferSizePolicy(items: readonly ITurboFileTransferSizeItem[]): ITurboFileTransferSizePolicy { + if (items.length !== 1 || items[0].directory) { + return { trackTotalBytes: false }; + } + return { trackTotalBytes: true, totalBytes: Math.max(0, items[0].rawSize) }; +} + +export function resolveTurboFileVisibleTotalBytes(options: { + readonly trackTotalBytes: boolean; + readonly listedTotalBytes: number | undefined; + readonly reportedTotalBytes: number | undefined; +}): number | undefined { + if (!options.trackTotalBytes) { + return undefined; + } + return Math.max(0, options.listedTotalBytes ?? 0, options.reportedTotalBytes ?? 0); +} +``` + +- [ ] **Step 5: Recompile and verify GREEN** + +Run the type-check, `compile-client`, and focused Mocha command from Steps 2-3. + +Expected: all commands exit 0 and the focused suite has zero failures. + +- [ ] **Step 6: Commit the policy and tests** + +```powershell +git add -- src/vs/workbench/contrib/turbofile/common/turbofileTransferDisplay.ts src/vs/workbench/contrib/turbofile/test/common/turbofileTransferDisplay.test.ts +git commit -m "fix: define single-file transfer total policy" +``` + +--- + +### Task 2: Persist the policy on transfer tasks and remove directory pre-sizing + +**Files:** +- Modify: `src/vs/workbench/contrib/turbofile/browser/turbofileEditor.ts` +- Modify: `src/vs/platform/turbofile/test/common/turbofileSshMainServiceStatic.test.ts` +- Modify: `src/vs/workbench/contrib/turbofile/test/common/turbofileTransferDisplay.test.ts` + +**Interfaces:** +- Consumes: `createTurboFileTransferSizePolicy` from Task 1. +- Produces: `TurboFileTask.trackTotalBytes: boolean` and optional `totalBytes` on task and transfer items. +- Produces: `setTransferTaskItems(task, items)` as the only initializer for transfer item size policy. + +- [ ] **Step 1: Add failing static integration assertions** + +Add a source regression test that extracts `turbofileEditor.ts` and asserts: + +```ts +assert.ok(editor.includes('private setTransferTaskItems')); +assert.ok(editor.includes('createTurboFileTransferSizePolicy(items)')); +assert.ok(!editor.includes('task.totalBytes = 0;')); +assert.ok(!editor.includes('ensureTransferItemSize(')); +assert.ok(!editor.includes('Math.max(item.totalBytes, item.rawSize, item.transferredBytes, 0)')); +assert.ok(!editor.includes('transferItem.totalBytes = Math.max(result.bytes')); +``` + +Update the existing batch assertions in `turbofileSshMainServiceStatic.test.ts` so renderer batch requests omit `expectedBytes` instead of assigning `0`: + +```ts +assert.ok(!windowsBatchBlock.includes('task.totalBytes = 0;')); +assert.ok(!windowsBatchBlock.includes('expectedBytes: 0')); +assert.ok(!batchBlock.includes('task.totalBytes = 0;')); +assert.ok(!batchBlock.includes('expectedBytes: 0')); +``` + +- [ ] **Step 2: Compile and verify RED** + +Run `npm run compile-check-ts-native`, then `npm run gulp compile-client`, then: + +`node_modules\.bin\mocha.cmd --ui tdd --timeout 10000 --exit out/vs/workbench/contrib/turbofile/test/common/turbofileTransferDisplay.test.js out/vs/platform/turbofile/test/common/turbofileSshMainServiceStatic.test.js` + +Expected: FAIL because task initialization still assigns totals per item, batch tasks still use zero sentinels, and directory sizing code still exists. + +- [ ] **Step 3: Make total bytes optional and add one policy initializer** + +Change task models: + +```ts +interface TurboFileTaskTransferItem { + // existing fields + totalBytes?: number; +} + +interface TurboFileTask { + // existing fields + trackTotalBytes: boolean; + totalBytes?: number; +} +``` + +Initialize `trackTotalBytes: false` in `beginTask`, then add: + +```ts +private setTransferTaskItems(task: TurboFileTask, items: readonly TurboFileFileItem[]): void { + const policy = createTurboFileTransferSizePolicy(items); + task.trackTotalBytes = policy.trackTotalBytes; + task.totalBytes = policy.totalBytes; + task.transferItems = items.map(item => this.createTransferTaskItem(item, policy.trackTotalBytes ? policy.totalBytes : undefined)); +} +``` + +Change `createTransferTaskItem` to accept `totalBytes: number | undefined`, retain `rawSize` only as item metadata, and set the optional total from the parameter. Replace every direct `task.transferItems = items.map(...)` assignment with `this.setTransferTaskItems(task, items)`. + +- [ ] **Step 4: Delete transfer-time directory size calculation** + +Delete `ensureTransferItemSize` and all its call sites. Remove the renderer batch request fields `expectedBytes: 0` at both the item and request levels. For `transferItem`, keep expected bytes limited to the single-file policy: + +```ts +const expectedBytes = options.task?.trackTotalBytes + ? Math.max(activeItem?.totalBytes ?? sourceItem.rawSize, 0) + : 0; +``` + +This code reads existing memory only and adds no `stat`, SSH command, or scan. + +- [ ] **Step 5: Prevent completion paths from inventing totals** + +Remove every assignment shaped like: + +```ts +transferItem.totalBytes = Math.max(result.bytes, transferItem.totalBytes); +``` + +In `finishTask`, always store `options.bytes` as `transferredBytes`, but update `task.totalBytes` only when `task.trackTotalBytes` is true: + +```ts +if (options.bytes !== undefined) { + task.transferredBytes = options.bytes; + if (task.trackTotalBytes) { + task.totalBytes = Math.max(task.totalBytes ?? 0, options.bytes); + } +} +``` + +Do not populate an optional item total for multi-item or directory tasks. + +- [ ] **Step 6: Recompile and verify GREEN** + +Run the prerequisite type-check, `compile-client`, and the two-suite Mocha command from Step 2. + +Expected: all commands exit 0 and both suites have zero failures. + +- [ ] **Step 7: Commit renderer policy integration** + +```powershell +git add -- src/vs/workbench/contrib/turbofile/browser/turbofileEditor.ts src/vs/platform/turbofile/test/common/turbofileSshMainServiceStatic.test.ts src/vs/workbench/contrib/turbofile/test/common/turbofileTransferDisplay.test.ts +git commit -m "fix: skip totals for directory and batch transfers" +``` + +--- + +### Task 3: Make live aggregation preserve unknown totals + +**Files:** +- Modify: `src/vs/workbench/contrib/turbofile/browser/turbofileEditor.ts` +- Modify: `src/vs/workbench/contrib/turbofile/test/common/turbofileTransferDisplay.test.ts` + +**Interfaces:** +- Consumes: `resolveTurboFileVisibleTotalBytes` and `task.trackTotalBytes`. +- Produces: live aggregate `totalBytes?: number`; undefined means no denominator. + +- [ ] **Step 1: Add a failing regression assertion for the original tar symptom** + +Add a source assertion that the live-item total delegates to the policy and never uses transferred bytes: + +```ts +const totalStart = editor.indexOf('private getLiveTransferItemTotal'); +const totalEnd = editor.indexOf('\n\tprivate ', totalStart + 1); +const totalBlock = editor.slice(totalStart, totalEnd); +assert.ok(totalBlock.includes('resolveTurboFileVisibleTotalBytes')); +assert.ok(!totalBlock.includes('item.transferredBytes')); +``` + +Also assert the status bar has separate known and unknown formats: + +```ts +assert.ok(taskbarBlock.includes('liveSummary.totalBytes !== undefined')); +assert.ok(taskbarBlock.includes('`${formatSize(liveSummary.transferredBytes)} / ${formatSize(liveSummary.totalBytes)}`')); +assert.ok(taskbarBlock.includes('formatSize(liveSummary.transferredBytes)')); +``` + +- [ ] **Step 2: Compile and verify RED** + +Run the type-check, `compile-client`, and focused `turbofileTransferDisplay.test.js` Mocha command. + +Expected: FAIL because the current aggregate promotes `transferredBytes` to `totalBytes`. + +- [ ] **Step 3: Resolve totals strictly from the persisted policy** + +Change `getLiveTransferItemTotal` to return `number | undefined`: + +```ts +private getLiveTransferItemTotal(item: TurboFileTaskTransferItem, task: TurboFileTask): number | undefined { + return resolveTurboFileVisibleTotalBytes({ + trackTotalBytes: task.trackTotalBytes, + listedTotalBytes: item.totalBytes ?? (task.trackTotalBytes ? item.rawSize : undefined), + reportedTotalBytes: item.totalBytes + }); +} +``` + +Change `getLiveTransferAggregate` so any running task with `trackTotalBytes === false` makes the aggregate total undefined. Continue summing `transferredBytes` and speed regardless of total availability. Calculate remaining time only for a defined positive total. + +- [ ] **Step 4: Ignore protocol totals when the selection policy forbids them** + +In `updateTransferProgress`, resolve the reported total through the task policy: + +```ts +const totalBytes = resolveTurboFileVisibleTotalBytes({ + trackTotalBytes: task.trackTotalBytes, + listedTotalBytes: activeItem?.totalBytes ?? task.totalBytes, + reportedTotalBytes: event.totalBytes +}); +const transferredBytes = Math.min(event.transferredBytes, totalBytes !== undefined && totalBytes > 0 ? totalBytes : event.transferredBytes); +``` + +Use optional-total checks for progress percentage, remaining time, completion rendering, and `task.totalBytes` assignment. A tar event containing `transferredBytes = 1.2 GB` and `totalBytes = 0` must leave the task total undefined when the source selection is a directory or batch. + +- [ ] **Step 5: Render the exact requested formats** + +Use: + +```ts +const transferSizeText = liveSummary.totalBytes !== undefined + ? `${formatSize(liveSummary.transferredBytes)} / ${formatSize(liveSummary.totalBytes)}` + : liveSummary.transferredBytes > 0 ? formatSize(liveSummary.transferredBytes) : '准备传输'; +``` + +Single-file example: `1.2 GB / 8.0 GB`. + +Directory or batch example: `1.2 GB` with no trailing slash. + +- [ ] **Step 6: Recompile and verify GREEN** + +Run the type-check, `compile-client`, and focused display suite. + +Expected: all commands exit 0 and the focused suite has zero failures. + +- [ ] **Step 7: Commit aggregation fix** + +```powershell +git add -- src/vs/workbench/contrib/turbofile/browser/turbofileEditor.ts src/vs/workbench/contrib/turbofile/test/common/turbofileTransferDisplay.test.ts +git commit -m "fix: preserve unknown transfer totals in progress UI" +``` + +--- + +### Task 4: Verify all transfer paths and package version 1.150.0 + +**Files:** +- Verify: `src/vs/platform/turbofile/electron-main/turbofileSshMainService.ts` +- Verify: `src/vs/workbench/contrib/turbofile/browser/turbofileEditor.ts` +- Generate: `.build/win32-x64/TurboFileSetup-win32-x64-1.150.0.exe` +- Generate: `.build/win32-x64/TurboFileSetup-win32-x64-1.150.0.exe.sha256` + +**Interfaces:** +- Consumes: completed size policy and renderer aggregation. +- Produces: validated Windows x64 installer and SHA-256 sidecar. + +- [ ] **Step 1: Run fresh TypeScript validation** + +Run: `npm run compile-check-ts-native` + +Expected: exit code 0 and zero TypeScript errors. + +- [ ] **Step 2: Run focused and full TurboFile regressions** + +Run: `npm run gulp compile-client` + +Expected: compilation completes with 0 errors. + +Run: `scripts\test.bat --grep TurboFile` + +Expected: all TurboFile tests pass with zero failures. + +- [ ] **Step 3: Audit that no pre-scan or total inference remains** + +Run: + +```powershell +rg -n "ensureTransferItemSize|task\.totalBytes = 0|transferItem\.totalBytes = Math\.max\(result\.bytes|Math\.max\(item\.totalBytes, item\.rawSize, item\.transferredBytes" src/vs/workbench/contrib/turbofile/browser/turbofileEditor.ts +``` + +Expected: no matches. + +Run: `git diff --check` + +Expected: exit code 0; line-ending conversion warnings are acceptable, whitespace errors are not. + +- [ ] **Step 4: Build the Windows x64 product and installer** + +Run in order: + +```powershell +npx gulp vscode-win32-x64 +npx gulp vscode-win32-x64-inno-updater +npx gulp vscode-win32-x64-user-setup +``` + +Expected: all three commands exit 0; Inno Setup reports a successful compile at `.build/win32-x64/user-setup/极速传Setup.exe`. + +- [ ] **Step 5: Publish the local artifact name and checksum** + +```powershell +$source='.build\win32-x64\user-setup\极速传Setup.exe' +$dest='.build\win32-x64\TurboFileSetup-win32-x64-1.150.0.exe' +Copy-Item -LiteralPath $source -Destination $dest -Force +$hash=(Get-FileHash -Algorithm SHA256 -LiteralPath $dest).Hash.ToLowerInvariant() +Set-Content -LiteralPath "$dest.sha256" -Value "$hash TurboFileSetup-win32-x64-1.150.0.exe" -Encoding ascii -NoNewline +``` + +- [ ] **Step 6: Verify the final package** + +Confirm source and product `package.json` versions equal `1.150.0`, the installer begins with the `MZ` header, the installer is non-empty, and the sidecar hash equals a fresh `Get-FileHash` result. + +Expected: version checks match, PE header is valid, and checksum comparison is true. + +--- + +## Plan Self-Review + +- Spec coverage: single ordinary file, directory, multiple files, multiple directories, mixed selection, protocol fallback, transferred-byte display, speed preservation, completion behavior, and packaging are each covered by a task. +- Placeholder scan: no placeholder text, deferred error handling, or unnamed implementation steps remain. +- Type consistency: `trackTotalBytes`, optional `totalBytes`, `createTurboFileTransferSizePolicy`, and `resolveTurboFileVisibleTotalBytes` use the same names and signatures across all tasks. diff --git a/docs/superpowers/plans/2026-07-16-stable-directory-sort.md b/docs/superpowers/plans/2026-07-16-stable-directory-sort.md new file mode 100644 index 00000000..b8ed6a0b --- /dev/null +++ b/docs/superpowers/plans/2026-07-16-stable-directory-sort.md @@ -0,0 +1,186 @@ +# Stable Directory Sort Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make every file-pane directory first appear in its final selected sort order without visible lazy-metadata re-sorting. + +**Architecture:** Restore complete one-round-trip remote listings for foreground navigation while retaining names-only bounded background prefetch. Add a pure display-readiness predicate shared by cache, snapshot, navigation, and sort-change paths, and prevent visible-row enrichment from changing order for attribute-dependent sorts. + +**Tech Stack:** TypeScript, VS Code workbench renderer, Electron main SSH service contract, Mocha TDD tests. + +## Global Constraints + +- Name, type, modified-time, and size sorting must be correct on the first visible frame. +- Ascending and descending directions must both be correct. +- Do not add loading text, skeletons, animation, transitions, or other waiting effects. +- Foreground remote metadata must use one full-list command, not per-entry SSH stat requests. +- Idle neighbor prefetch remains names-only and bounded. +- Parent-entry and directory-first grouping remain unchanged. +- Preserve unrelated dirty changes in the current `codex/image-stream-loading` branch. + +--- + +### Task 1: Lock all sort modes and display readiness with tests + +**Files:** +- Modify: `src/vs/workbench/contrib/turbofile/common/turbofileFileSort.ts` +- Modify: `src/vs/workbench/contrib/turbofile/test/common/turbofileFileSort.test.ts` + +**Interfaces:** +- Produces: `turboFileSortNeedsAttributes(key: TurboFileSortKey): boolean`. +- Produces: `isTurboFileListingReadyForSort(items, key): boolean`. + +- [ ] **Step 1: Add complete sort characterization and failing readiness tests** + +Extend `TurboFileSortableItem` with `readonly attrsPending?: boolean`, import the two planned helpers in the test, and assert: + +```ts +assert.deepStrictEqual([ + names('name', 'descending'), + names('modified', 'descending'), + names('size', 'ascending'), + names('type', 'descending') +], [ + ['..', 'docs', 'zeta.txt', 'beta.txt', 'alpha.png'], + ['..', 'docs', 'beta.txt', 'zeta.txt', 'alpha.png'], + ['..', 'docs', 'zeta.txt', 'beta.txt', 'alpha.png'], + ['..', 'docs', 'beta.txt', 'zeta.txt', 'alpha.png'] +]); + +assert.strictEqual(turboFileSortNeedsAttributes('modified'), true); +assert.strictEqual(turboFileSortNeedsAttributes('size'), true); +assert.strictEqual(turboFileSortNeedsAttributes('name'), false); +assert.strictEqual(isTurboFileListingReadyForSort([item('pending', { attrsPending: true })], 'modified'), false); +assert.strictEqual(isTurboFileListingReadyForSort([item('pending', { attrsPending: true })], 'name'), true); +``` + +- [ ] **Step 2: Run the mandatory type-check and compile client output** + +Run: `npm run compile-check-ts-native` + +Expected: FAIL only because the planned helper exports do not exist yet. + +- [ ] **Step 3: Implement the minimal pure helpers** + +Add: + +```ts +export function turboFileSortNeedsAttributes(key: TurboFileSortKey): boolean { + return key === 'modified' || key === 'size'; +} + +export function isTurboFileListingReadyForSort(items: readonly TurboFileSortableItem[], key: TurboFileSortKey): boolean { + return !turboFileSortNeedsAttributes(key) || !items.some(item => !item.parent && item.attrsPending); +} +``` + +- [ ] **Step 4: Compile and run the focused sort suite** + +Run `npm run compile-check-ts-native`, `npm run gulp compile-client`, then: + +`node_modules\.bin\mocha.cmd --ui tdd --timeout 10000 --exit out/vs/workbench/contrib/turbofile/test/common/turbofileFileSort.test.js` + +Expected: all commands exit 0 and all sort tests pass. + +--- + +### Task 2: Prevent partial foreground listings and enrichment re-sorts + +**Files:** +- Modify: `src/vs/workbench/contrib/turbofile/browser/turbofileEditor.ts` +- Modify: `src/vs/workbench/contrib/turbofile/test/common/turbofileDirectoryNavigation.test.ts` + +**Interfaces:** +- Consumes: `turboFileSortNeedsAttributes` and `isTurboFileListingReadyForSort` from Task 1. +- Produces: `paneListingSatisfiesSort(side, items)` as the renderer's single display gate. + +- [ ] **Step 1: Add failing source regression tests** + +Add assertions that: + +```ts +const load = methodBlock(source, 'private async loadPaneDirectory', 'private async recoverMissingPanePath'); +assert.ok(load.includes("const loadsAttributes = needsAttributes || (isRemote && purpose === 'foreground');")); +assert.ok(load.includes('? await this.sshService.listRemoteDirectory(')); +assert.ok(load.includes(': await this.sshService.listRemoteDirectoryNames(')); + +const enrich = methodBlock(source, 'private async enrichVisibleRows', 'private scheduleVisibleRowEnrichmentRetry'); +assert.ok(enrich.includes('if (this.paneSortNeedsAttributes(side))')); +assert.ok(!enrich.includes('state.items = this.sortPaneItems(side, state.items);')); + +const setDevice = methodBlock(source, 'private setPaneDevice', 'private capturePaneSelectionForCurrentPage'); +assert.ok(setDevice.includes('this.paneListingSatisfiesSort(side, snapshot.items)')); +assert.ok(setDevice.includes('this.paneDirectoryEntrySatisfiesSort(side, cached)')); +``` + +Also assert direct hover/commit prefetch passes `this.paneSortNeedsAttributes(side)`, while `prefetchNeighborDirectories` retains the default names-only call. + +- [ ] **Step 2: Compile and verify RED** + +Run the mandatory type-check, compile client output, and focused `TurboFileDirectoryNavigation` suite. + +Expected: the new source regression test fails because foreground remote loading and restoration are not yet centrally gated. + +- [ ] **Step 3: Add the central readiness gate** + +Import the two helpers and implement: + +```ts +private paneListingSatisfiesSort(side: 'left' | 'right', items: readonly TurboFileFileItem[]): boolean { + return isTurboFileListingReadyForSort(items, this.paneStates[side].sortKey); +} + +private paneDirectoryEntrySatisfiesSort(side: 'left' | 'right', entry: TurboFileDirectoryCacheEntry): boolean { + return this.paneListingSatisfiesSort(side, entry.items); +} +``` + +Use the gate before applying cached or snapshotted items in `navigatePaneDirectory`, `loadPaneDirectory`, and `setPaneDevice`. + +- [ ] **Step 4: Restore full remote foreground loading** + +Inside `loadPaneDirectory`, compute: + +```ts +const isRemote = device.id !== LOCAL_WINDOWS_DEVICE_ID; +const loadsAttributes = needsAttributes || (isRemote && purpose === 'foreground'); +``` + +Use `loadsAttributes` for cache/in-flight reuse, the remote full-vs-names request, the local full-vs-names request, and `attrsPending` marking. This makes every remote foreground load complete while keeping background neighbor prefetch names-only. + +- [ ] **Step 5: Make direct prefetch sort-aware** + +Pass `this.paneSortNeedsAttributes(side)` through `prefetchPaneDirectoryNow` and prioritized hover prefetch. Leave `prefetchNeighborDirectories` calls at the default `false` so idle neighbor work stays lightweight. + +- [ ] **Step 6: Block lazy re-sorting for attribute sorts** + +Return before visible-row enrichment when `paneSortNeedsAttributes(side)` is true. Remove the modified/size branch that assigns sorted items inside `enrichVisibleRows`; name/type enrichment may update displayed metadata in place but cannot change order. + +- [ ] **Step 7: Compile and verify GREEN** + +Run `npm run compile-check-ts-native`, `npm run gulp compile-client`, and the focused sort and directory-navigation suites. + +Expected: all commands exit 0 with zero failures. + +--- + +### Task 3: Verify regression coverage and performance invariants + +**Files:** +- Verify: `src/vs/workbench/contrib/turbofile/browser/turbofileEditor.ts` +- Verify: `src/vs/platform/turbofile/electron-main/turbofileSshMainService.ts` + +- [ ] **Step 1: Run all TurboFile tests** + +Run: `scripts\test.bat --grep TurboFile` + +Expected: all TurboFile tests pass with zero failures. + +- [ ] **Step 2: Audit no per-entry foreground SSH stat loop was introduced** + +Confirm foreground remote loading calls `listRemoteDirectory`, whose fast command returns name, type, size, and modified time in one command. Confirm no new loop calls `statRemoteEntries` for every item before first render. + +- [ ] **Step 3: Audit UI and diff constraints** + +Run `git diff --check` and inspect the targeted diffs. Confirm no loading text, animation, CSS, transfer, image-grid, or toolbar-order changes were introduced. + diff --git a/docs/superpowers/plans/2026-07-16-transfer-148-pane-isolation-and-151-package.md b/docs/superpowers/plans/2026-07-16-transfer-148-pane-isolation-and-151-package.md new file mode 100644 index 00000000..3ad1864f --- /dev/null +++ b/docs/superpowers/plans/2026-07-16-transfer-148-pane-isolation-and-151-package.md @@ -0,0 +1,350 @@ +# 1.148.0 Transfer Restoration, Pane Isolation, and 1.151.0 Package Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Restore every transfer-related source path to the `v1.148.0` baseline, keep current non-transfer capabilities, isolate left/right pane image DOM, restore same-pane move drops, and produce a verified Windows x64 1.151.0 installer. + +**Architecture:** Treat `v1.148.0` (`6b65264f`) as the immutable transfer reference. Restore transfer-only modules exactly and transplant tag implementations at symbol boundaries inside mixed files, then add two narrowly scoped UI fixes on top: retained pane-image DOM islands and an internal drag effect that permits same-pane moves. Compile and test after each boundary so baseline incompatibilities are found before packaging. + +**Tech Stack:** TypeScript, VS Code workbench DOM, Electron main-process SSH/tar services, Mocha TDD tests, Gulp Windows packaging, Inno Setup. + +## Global Constraints + +- Work directly on `codex/image-stream-loading`; do not create another worktree. +- Preserve all current non-transfer document, image, sorting, tab, terminal, installation, packaging, and update behavior. +- Restore transfer behavior exactly from tag `v1.148.0`, including removal of transfer behavior introduced after that tag. +- Same-pane drops onto a folder or `..` are always moves and do not depend on the pane copy/cut mode. +- Left and right pane image surfaces must retain independent DOM, decoded images, scroll state, and listeners. +- Run `npm run compile-check-ts-native` before every unit-test command; never run `npm run compile`. +- Do not package until typecheck, focused tests, TurboFile full tests, and `git diff --check` pass. +- Final package version is exactly `1.151.0`. + +--- + +### Task 1: Lock the 1.148.0 transfer baseline with failing contracts + +**Files:** +- Create: `src/vs/platform/turbofile/test/common/turbofileTransfer148Baseline.test.ts` +- Modify: `src/vs/workbench/contrib/turbofile/test/common/turbofileDragDrop.test.ts` +- Modify: `src/vs/workbench/contrib/turbofile/test/common/turbofileImageGridPane.test.ts` + +**Interfaces:** +- Consumes: tag `v1.148.0` and current source text. +- Produces: regression contracts that fail while post-1.148 transfer paths, incompatible drag effects, and cross-pane image rebuilds remain. + +- [ ] **Step 1: Add the transfer-baseline contract** + +Create a static source contract that asserts the current transfer service uses the 1.148 direct `tar | ssh` and `ssh | tar` command shapes and rejects post-baseline transfer-only symbols: + +```ts +suite('TurboFileTransfer148Baseline', () => { + test('uses the 1.148.0 direct tar pipelines without a progress relay', () => { + assert.ok(source.includes('tar -cf - -C')); + assert.ok(!source.includes('buildTarProgressPipeCommand')); + assert.ok(!source.includes('TURBOFILE_TAR_PROGRESS_MARKER')); + }); + + test('does not retain post-1.148 staging and tar watchdog behavior', () => { + assert.ok(!source.includes('createTarStreamWatchdog')); + assert.ok(!source.includes('waitForTarDrain')); + assert.ok(!source.includes('.turbofile-transfer-${randomUUID()}.partial')); + }); +}); +``` + +- [ ] **Step 2: Add same-pane drag and image-island contracts** + +Add assertions that the drag source permits an internal move while folder rows still force `move`, and that pane image state owns a retained root/disposable pair reused by `renderImageGridPage`. + +- [ ] **Step 3: Verify RED** + +Run: + +```powershell +npm run compile-check-ts-native +npm run gulp compile-client +node_modules\.bin\mocha.cmd --ui tdd --timeout 10000 --exit out/vs/platform/turbofile/test/common/turbofileTransfer148Baseline.test.js out/vs/workbench/contrib/turbofile/test/common/turbofileDragDrop.test.js out/vs/workbench/contrib/turbofile/test/common/turbofileImageGridPane.test.js +``` + +Expected: the new baseline, drag-effect, and retained-image-root assertions fail for the current implementation. + +--- + +### Task 2: Restore transfer contracts, planning, and dedicated helpers + +**Files:** +- Modify: `src/vs/platform/turbofile/common/turbofile.ts` +- Restore: `src/vs/platform/turbofile/common/turbofileTransferPlan.ts` +- Restore: `src/vs/platform/turbofile/common/turbofileRemoteNativeBatchPlan.ts` +- Restore: `src/vs/platform/turbofile/common/turbofileTimeout.ts` +- Delete: `src/vs/platform/turbofile/common/turbofileTransferConflictPolicy.ts` +- Delete: `src/vs/platform/turbofile/common/turbofileTarProgress.ts` +- Delete: `src/vs/platform/turbofile/common/turbofileWindowsClipboard.ts` +- Delete: `src/vs/workbench/contrib/turbofile/common/turbofileTransferRefresh.ts` +- Restore/Delete corresponding transfer-only tests to match `v1.148.0`. + +**Interfaces:** +- Consumes: exact files and exported transfer types at `v1.148.0`. +- Produces: the 1.148 request/result, plan, route, timeout, batch, clipboard, and drag contracts required by renderer and main service restoration. + +- [ ] **Step 1: Restore transfer-only tracked files from the tag** + +Use the exact `v1.148.0:` contents for the transfer plan, native batch plan, timeout module, and their baseline tests. Remove post-tag files whose sole purpose is post-1.148 transfer behavior. + +- [ ] **Step 2: Restore only transfer declarations in `turbofile.ts`** + +Replace transfer request/result, batch transfer, progress, clipboard, Windows drag, strategy, and route declarations with their tag definitions. Preserve current non-transfer directory, image, device, and document interfaces. + +- [ ] **Step 3: Remove imports of deleted transfer helpers** + +Update direct importers so no source references conflict policy, tar progress parsing, Windows clipboard identity helpers, or transfer refresh helpers. + +- [ ] **Step 4: Typecheck the restored contract layer** + +Run: `npm run compile-check-ts-native` + +Expected: remaining errors identify only main-service or renderer call sites that still use post-baseline transfer contracts; no errors originate inside the restored common transfer files. + +--- + +### Task 3: Restore Electron-main transfer execution to 1.148.0 + +**Files:** +- Modify: `src/vs/platform/turbofile/electron-main/turbofileSshMainService.ts` +- Restore: `src/vs/platform/turbofile/test/common/turbofileSshMainServiceStatic.test.ts` +- Restore: transfer-related sections in `src/vs/platform/turbofile/test/common/turbofileTransferPlan.test.ts` + +**Interfaces:** +- Consumes: Task 2's 1.148 transfer contracts. +- Produces: exact 1.148 local/SSH, SSH/local, SSH/SSH, single, batch, tar, scp, rsync, sftp, cancellation, route, progress, and completion execution. + +- [ ] **Step 1: Restore transfer service fields and public methods** + +Transplant the tag implementations of `transfer`, `transferBatch`, cancellation, progress emission, clipboard transfer, Windows file drag, and transfer cache methods. Retain current directory listing/session generation, image metadata, thumbnail, device, archive, terminal, and document-support methods. + +- [ ] **Step 2: Restore protocol implementations** + +Transplant every 1.148 transfer implementation and direct helper it calls: local copy, native remote copy/move, scp, rsync, sftp, local tar upload/download, SSH direct tar, SSH relay tar, and batch variants. + +- [ ] **Step 3: Restore exact command builders and process pipelines** + +Restore the tag command strings, `pipeline(...)` topology, `PassThrough` behavior, buffering, process close ordering, parallel chunking, zstd behavior, route choice, and cleanup. Remove `dd` progress relay, tar lifecycle watchdog, drain timeout, staging commit, post-tag conflict preflight, and unknown-total logic. + +- [ ] **Step 4: Preserve non-transfer service additions** + +Verify that remote directory retry/generation, SFTP session generation, image metadata batching, archive extraction, preview streaming, terminal support, and device operations still match the pre-restoration current source. + +- [ ] **Step 5: Typecheck before tests** + +Run: `npm run compile-check-ts-native` + +Expected: zero main-service errors; any remaining errors are renderer transfer call sites handled by Task 4. + +--- + +### Task 4: Restore renderer transfer orchestration without reverting unrelated UI + +**Files:** +- Modify: `src/vs/workbench/contrib/turbofile/browser/turbofileEditor.ts` +- Modify: `src/vs/workbench/contrib/turbofile/common/turbofileEditorInput.ts` +- Restore: `src/vs/workbench/contrib/turbofile/common/turbofileDragDrop.ts` +- Restore: `src/vs/workbench/contrib/turbofile/common/turbofileTransferDisplay.ts` +- Modify: `src/vs/workbench/contrib/turbofile/browser/media/turbofile.css` +- Restore/delete transfer-only renderer tests to match tag behavior. + +**Interfaces:** +- Consumes: Tasks 2-3's exact 1.148 transfer API. +- Produces: the 1.148 transfer initiation, progress, retry, cancel, clipboard, drag, task aggregation, and status display behavior inside the current editor UI shell. + +- [ ] **Step 1: Restore renderer transfer state and execution methods** + +At symbol boundaries, transplant tag implementations for transfer task creation/update, `transferSelected`, batch transfer, `transferItem`, `transferExternalPaths`, clipboard copy/paste, Windows drag handoff, cancel/retry, progress aggregation, route display, and post-transfer refresh. Preserve current file sorting, directory loading, image grid, document opening, tab, terminal, and settings implementations. + +- [ ] **Step 2: Restore transfer-mode state and UI to baseline** + +Remove post-1.148 per-side drag-operation persistence, controls, styles, and execution branches when absent from the tag. Keep toolbar layout and unrelated visual changes intact. + +- [ ] **Step 3: Restore baseline transfer display helpers and tests** + +Replace transfer display behavior with the tag version and remove tests that only assert post-tag total-size, staging, conflict, or completion behavior. + +- [ ] **Step 4: Typecheck before tests** + +Run: `npm run compile-check-ts-native` + +Expected: exit 0 with no TypeScript errors. + +- [ ] **Step 5: Run baseline transfer tests** + +Run `npm run gulp compile-client`, then the transfer-plan, main-service static, transfer-display, clipboard, Windows drag, and drag-drop suites. + +Expected: all retained 1.148 baseline tests and the new baseline source contract pass. + +--- + +### Task 5: Restore reliable same-pane folder and parent moves + +**Files:** +- Modify: `src/vs/workbench/contrib/turbofile/browser/turbofileEditor.ts` +- Modify: `src/vs/workbench/contrib/turbofile/common/turbofileDragDrop.ts` only if a pure resolver is required by the baseline structure. +- Test: `src/vs/workbench/contrib/turbofile/test/common/turbofileDragDrop.test.ts` + +**Interfaces:** +- Consumes: restored 1.148 drag behavior. +- Produces: an internal drag effect compatible with both cross-pane baseline operations and forced same-pane move targets. + +- [ ] **Step 1: Confirm the drag regression remains RED after baseline restoration** + +Run typecheck, compile client, and `turbofileDragDrop.test.js`. + +Expected: the default-mode same-pane move contract fails if the restored source still restricts `effectAllowed` to copy. + +- [ ] **Step 2: Permit internal same-pane moves at drag start** + +Set the internal browser drag's allowed effects so a folder row can legally select `dropEffect = 'move'`. Keep external Windows drag and cross-pane behavior on their restored 1.148 paths. + +- [ ] **Step 3: Keep same-pane targets fixed to move** + +Ensure folder and `..` handlers validate current-page source, reject no-op/self/descendant targets, consume the event before page/window handlers, snapshot the selection, and invoke native rename/move without consulting pane copy/cut mode. + +- [ ] **Step 4: Verify GREEN** + +Run typecheck, compile client, and the drag-drop suite. + +Expected: default and cut modes both permit same-pane folder/parent moves; invalid targets remain rejected. + +--- + +### Task 6: Retain independent pane-image DOM islands + +**Files:** +- Modify: `src/vs/workbench/contrib/turbofile/browser/turbofileEditor.ts` +- Test: `src/vs/workbench/contrib/turbofile/test/common/turbofileImageGridPane.test.ts` + +**Interfaces:** +- Consumes: `TurboFileImageGridState` and current pane-mode image renderer. +- Produces: per-state retained root/disposable ownership and explicit invalidation only for same-side image mutations. + +- [ ] **Step 1: Confirm the cross-pane image test is RED** + +Run typecheck, compile client, and `turbofileImageGridPane.test.js`. + +Expected: source lacks a retained pane-image root and independent disposable store. + +- [ ] **Step 2: Add retained DOM ownership** + +Extend pane image state with an optional retained root and `DisposableStore`. When `renderImageGridPage` receives an unchanged pane-mode state with a retained root, return the same root; register sort/scale window listeners in the state-owned store rather than the global render store. + +- [ ] **Step 3: Invalidate only the owning image side** + +Dispose and clear the retained root when that state changes columns, scale, sort, directory, or closes. Do not invalidate it for opposite-pane navigation, selection, device, preview, task, or transfer updates. + +- [ ] **Step 4: Release resources safely** + +Update `releaseImageGridState` and editor disposal to dispose the state-owned listener store exactly once while preserving existing Blob URL, observer, thumbnail queue, preview render, and scroll cleanup. + +- [ ] **Step 5: Verify GREEN** + +Run typecheck, compile client, and image-grid pane/preview/sort suites. + +Expected: the same pane image root survives opposite-side actions; same-side controls and cleanup still work. + +--- + +### Task 7: Set version 1.151.0 and run complete verification + +**Files:** +- Modify: `package.json` +- Modify: `package-lock.json` +- Verify: all modified TurboFile source and tests. + +**Interfaces:** +- Consumes: completed restoration and UI fixes. +- Produces: a type-safe, regression-tested 1.151.0 source tree. + +- [ ] **Step 1: Update version metadata** + +Change the root package version and both package-lock version fields from `1.150.0` to `1.151.0`. Do not modify dependency versions. + +- [ ] **Step 2: Run fresh type and layer validation** + +```powershell +npm run compile-check-ts-native +npm run valid-layers-check +``` + +Expected: both commands exit 0. + +- [ ] **Step 3: Compile and run focused tests** + +```powershell +npm run gulp compile-client +node_modules\.bin\mocha.cmd --ui tdd --timeout 10000 --exit out/vs/platform/turbofile/test/common/turbofileTransfer148Baseline.test.js out/vs/workbench/contrib/turbofile/test/common/turbofileDragDrop.test.js out/vs/workbench/contrib/turbofile/test/common/turbofileImageGridPane.test.js +``` + +Expected: all focused tests pass. + +- [ ] **Step 4: Run all TurboFile regressions and audit the diff** + +```powershell +scripts\test.bat --grep TurboFile +git diff --check +``` + +Expected: all tests pass and no whitespace errors are reported. + +- [ ] **Step 5: Audit transfer equivalence and non-transfer preservation** + +Compare every restored transfer symbol against `v1.148.0`, and compare current non-transfer directory/image/document symbols against the pre-restoration working tree. Every remaining transfer difference must be a mechanical compile adapter with no data-path effect. + +--- + +### Task 8: Build and verify the Windows x64 1.151.0 installer + +**Files:** +- Generate: `.build/win32-x64/TurboFileSetup-win32-x64-1.151.0.exe` +- Generate: `.build/win32-x64/TurboFileSetup-win32-x64-1.151.0.exe.sha256` + +**Interfaces:** +- Consumes: verified 1.151.0 source. +- Produces: installable Windows x64 setup and matching checksum sidecar. + +- [ ] **Step 1: Build product and installer in established order** + +```powershell +npx gulp vscode-win32-x64 +npx gulp vscode-win32-x64-inno-updater +npx gulp vscode-win32-x64-user-setup +``` + +Expected: all commands exit 0 and Inno Setup creates `.build/win32-x64/user-setup/极速传Setup.exe`. + +- [ ] **Step 2: Publish the versioned artifact and checksum** + +```powershell +$source='.build\win32-x64\user-setup\极速传Setup.exe' +$dest='.build\win32-x64\TurboFileSetup-win32-x64-1.151.0.exe' +Copy-Item -LiteralPath $source -Destination $dest -Force +$hash=(Get-FileHash -Algorithm SHA256 -LiteralPath $dest).Hash.ToLowerInvariant() +Set-Content -LiteralPath "$dest.sha256" -Value "$hash TurboFileSetup-win32-x64-1.151.0.exe" -Encoding ascii -NoNewline +``` + +Do not alter source files during artifact naming. + +Final paths: + +```text +.build/win32-x64/TurboFileSetup-win32-x64-1.151.0.exe +.build/win32-x64/TurboFileSetup-win32-x64-1.151.0.exe.sha256 +``` + +- [ ] **Step 3: Verify the package** + +Confirm source and packaged `package.json` versions are `1.151.0`, installer size is greater than zero, first two bytes are `MZ`, and the sidecar equals a fresh SHA-256 hash. + +## Plan Self-Review + +- Spec coverage: strict 1.148 transfer restoration, non-transfer preservation, same-pane move, pane-image isolation, versioning, and packaging each have an independent test/verification task. +- Placeholder scan: implementation steps name exact tag, files, symbols, commands, expected failures, and expected success conditions. +- Type consistency: restored contracts flow from common types to main service to renderer before UI-specific fixes and packaging. +- Scope consistency: no task restores whole mixed-responsibility files; image/document/sort/tab/package capabilities are explicitly preserved. diff --git a/docs/superpowers/plans/2026-07-17-image-grid-instant-file-sort.md b/docs/superpowers/plans/2026-07-17-image-grid-instant-file-sort.md new file mode 100644 index 00000000..b4a96c99 --- /dev/null +++ b/docs/superpowers/plans/2026-07-17-image-grid-instant-file-sort.md @@ -0,0 +1,60 @@ +# 图片网格即时文件排序实施计划 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 让图片网格四种排序复用主文件列表元数据并在点击后立即、正确、局部生效。 + +**Architecture:** 纯排序器改用文件字节大小和目录修改时间;编辑器移除图片排序元数据预扫描,把排序后的 UI 更新限制在当前图片网格的排序控件和虚拟切片。选择、滚动和已加载图片缓存按图片对象重新映射。 + +**Tech Stack:** TypeScript、VS Code DOM 工具、Mocha 单元测试。 + +## Global Constraints + +- 不修改传输协议或传输代码。 +- 不扫描图片内容或图片头来完成排序。 +- 不在排序切换时调用编辑器级 `render()`。 +- 保留当前工作区中的用户改动,本次不创建 Git 提交。 + +--- + +### Task 1: 文件元数据纯排序器 + +**Files:** +- Modify: `src/vs/workbench/contrib/turbofile/common/turbofileImageGridSort.ts` +- Modify: `src/vs/workbench/contrib/turbofile/test/common/turbofileImageGridSort.test.ts` + +**Interfaces:** +- Produces: `ITurboFileImageGridSortableItem.rawSize`, `sizeKnown` and `sortTurboFileImageGridItems(...)`. + +- [x] **Step 1: Write failing tests** asserting byte-size sorting, both directions, unknown-last behavior, date, type and natural name ordering. +- [x] **Step 2: Run** `scripts\test.bat --grep TurboFileImageGridSort` and confirm the byte-size assertion fails against pixel-area behavior. +- [x] **Step 3: Replace** pixel-area fields and comparison with `rawSize` and `sizeKnown` while retaining deterministic name fallback. +- [x] **Step 4: Run** the focused sort test and confirm all assertions pass. + +### Task 2: 局部即时排序交互 + +**Files:** +- Modify: `src/vs/workbench/contrib/turbofile/browser/turbofileEditor.ts` +- Modify: `src/vs/workbench/contrib/turbofile/test/common/turbofileImageGridPane.test.ts` + +**Interfaces:** +- Consumes: `sortTurboFileImageGridItems(...)` from Task 1. +- Produces: `updateImageGridAfterSort(state)` for sort-control and virtual-slice refresh. + +- [x] **Step 1: Write failing structural assertions** requiring no `prepareImageGridSortMetadata`, no `readImageMetadataBatch`, no full `render()` in `applyImageGridSort`, and a local sort refresh method. +- [x] **Step 2: Run** `scripts\test.bat --grep TurboFileImageGridPane` and confirm the new assertions fail for the existing metadata scan/full render path. +- [x] **Step 3: Remove** image sort metadata status, promise, retry and prewarm fields and methods. +- [x] **Step 4: Map** `rawSize`, `rawModified`, `attrsPending` into the pure sorter and remap selection/loading indexes after sorting. +- [x] **Step 5: Implement** `updateImageGridAfterSort(state)` to preserve scroll position, update the sort control and repaint only the virtual grid slice. +- [x] **Step 6: Run** focused image-grid tests and confirm they pass. + +### Task 3: 完整验证 + +**Files:** +- Verify only. + +- [x] **Step 1: Run** `npm run compile-check-ts-native`; expect zero TypeScript errors. +- [x] **Step 2: Run** `scripts\test.bat --grep TurboFileImageGridSort`; expect all tests pass. +- [x] **Step 3: Run** `scripts\test.bat --grep TurboFileImageGridPane`; expect all tests pass. +- [x] **Step 4: Run** `scripts\test.bat --grep TurboFileFileSort`; expect all tests pass. +- [x] **Step 5: Run** `npm run gulp compile-client`; expect compilation with zero errors. diff --git a/docs/superpowers/plans/2026-07-17-large-batch-tar-path-list.md b/docs/superpowers/plans/2026-07-17-large-batch-tar-path-list.md new file mode 100644 index 00000000..cb179b70 --- /dev/null +++ b/docs/superpowers/plans/2026-07-17-large-batch-tar-path-list.md @@ -0,0 +1,78 @@ +# Large Batch Tar Path List Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Remove shell argument-length limits from large tar batches without changing the archive data path or small-batch performance. + +**Architecture:** Select inline arguments by UTF-8 command byte length. When unsafe, send NUL-delimited names through SSH stdin to `tar --null -T -`, and use the same manifest pattern for target preparation and post-move cleanup. + +**Tech Stack:** TypeScript, Node.js child processes and streams, ssh2 channels, GNU tar, Mocha. + +## Global Constraints + +- Keep small-batch transfer commands and tar data streaming unchanged. +- Never delete move sources before the complete target transfer succeeds. +- Preserve unrelated dirty-worktree changes and do not commit automatically. +- Run `npm run compile-check-ts-native` before unit tests; never run `npm run compile`. + +--- + +### Task 1: Define and test adaptive tar source plans + +**Files:** +- Modify: `src/vs/platform/turbofile/common/turbofileBatchTarPlan.ts` +- Test: `src/vs/platform/turbofile/test/common/turbofileBatchTarPlan.test.ts` + +**Interfaces:** +- Produces: `createRemoteBatchTarSourcePlan(parentPath, names)` returning `{ command, input? }` and `isTurboFileBatchCommandSafe(command)`. +- Consumes: Existing `createTarNullListBuffer(entries)`. + +- [ ] Write failing tests proving three names stay inline, 6172 names use `tar --null -T -` with a compact command, and 100000 special-character names survive NUL encoding. +- [ ] Compile the client test output and run `TurboFileBatchTarPlan` to confirm the new tests fail for missing exports. +- [ ] Implement UTF-8 byte-length selection with a conservative 32 KiB safe command limit. +- [ ] Re-run the focused tests and confirm they pass. + +### Task 2: Feed manifests into SSH-to-Windows tar downloads + +**Files:** +- Modify: `src/vs/platform/turbofile/electron-main/turbofileSshMainService.ts` +- Test: `src/vs/platform/turbofile/test/common/turbofileSshMainServiceStatic.test.ts` + +**Interfaces:** +- Consumes: `createRemoteBatchTarSourcePlan`. +- Produces: Native OpenSSH and ssh2 tar-download paths that write optional manifest input while reading archive stdout. + +- [ ] Add failing static assertions for optional stdin on `runNativeLocalTarDownload` and ssh2 source streams. +- [ ] Extend native download and remote exec options with optional `Uint8Array` input while retaining the no-input fast path. +- [ ] Use the adaptive source plan in `runRemoteBatchTarDownload` for both native and compatibility transports. +- [ ] Bound local target cleanup with existing batch worker concurrency. + +### Task 3: Harden SSH-to-SSH tar and move cleanup + +**Files:** +- Modify: `src/vs/platform/turbofile/electron-main/turbofileSshMainService.ts` +- Test: `src/vs/platform/turbofile/test/common/turbofileSshMainServiceStatic.test.ts` + +**Interfaces:** +- Consumes: adaptive tar source plans, `createTarNullListBuffer`, and compact target-prepare command. +- Produces: constant-length large-batch direct/relay commands and stdin-driven POSIX source deletion. + +- [ ] Add failing assertions that oversized direct and relay batches prepare targets separately and send manifests through stdin. +- [ ] Preserve current inline direct/relay commands when their UTF-8 byte length is safe. +- [ ] For oversized commands, run target preparation from a NUL list, then execute the same tar data pipeline with a stdin-driven source tar. +- [ ] Replace oversized POSIX `rm -rf path...` cleanup with `xargs -0 -r rm -rf --` input. + +### Task 4: Verify build and stress invariants + +**Files:** +- Verify: `src/vs/platform/turbofile/common/turbofileBatchTarPlan.ts` +- Verify: `src/vs/platform/turbofile/electron-main/turbofileSshMainService.ts` + +**Interfaces:** +- Consumes: Tasks 1-3. +- Produces: compiled `out/` runtime ready for manual NAS verification. + +- [ ] Run `npm run compile-check-ts-native` and require exit code 0. +- [ ] Run focused tar-plan and SSH-main-service tests with zero failures. +- [ ] Run `npm run gulp compile-client` and require zero compile errors. +- [ ] Run `git diff --check` on touched source and test files. diff --git a/docs/superpowers/plans/2026-07-17-persistent-taskbar-with-terminal.md b/docs/superpowers/plans/2026-07-17-persistent-taskbar-with-terminal.md new file mode 100644 index 00000000..f5f11f45 --- /dev/null +++ b/docs/superpowers/plans/2026-07-17-persistent-taskbar-with-terminal.md @@ -0,0 +1,67 @@ +# Persistent Taskbar With Terminal Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Keep the TurboFile task log bar visible at the bottom while native VS Code terminals open in the panel above it. + +**Architecture:** Retain the existing terminal panel lifecycle and fixed taskbar CSS. Remove terminal visibility from the taskbar rendering decision so the transfer page always owns one taskbar element. + +**Tech Stack:** TypeScript, VS Code workbench DOM APIs, Mocha TDD tests, CSS. + +## Global Constraints + +- Do not change terminal creation, SSH PTY, transfer protocol, task statistics, or taskbar visual styling. +- Preserve all unrelated dirty-worktree changes. +- Run `npm run compile-check-ts-native` before unit tests; do not run `npm run compile`. + +--- + +### Task 1: Lock the persistent taskbar behavior with a regression test + +**Files:** +- Modify: `src/vs/workbench/contrib/turbofile/test/common/turbofileTerminalPanel.test.ts` + +**Interfaces:** +- Consumes: `TurboFileEditor.renderTransferPage()` and `TurboFileEditor.refreshTaskbar()` source structure. +- Produces: A regression assertion that the taskbar is rendered independently of terminal/task state. + +- [x] **Step 1: Replace the conditional-taskbar assertions with persistent-taskbar assertions** + +Assert that `renderTransferPage()` appends `renderTaskbar()` unconditionally, `refreshTaskbar()` creates or replaces the taskbar without a removal branch, and `shouldRenderTaskbar()` no longer exists. + +- [x] **Step 2: Run the focused test and verify it fails** + +Run: `npm run test-node -- --grep "TurboFileTerminalPanel"` + +Expected: FAIL because the current implementation still contains `shouldRenderTaskbar()` and conditional removal. + +### Task 2: Make the taskbar independent from terminal visibility + +**Files:** +- Modify: `src/vs/workbench/contrib/turbofile/browser/turbofileEditor.ts` + +**Interfaces:** +- Consumes: `renderTaskbar(): HTMLElement` and existing terminal panel visibility detection. +- Produces: A transfer page that always contains one `.turbofile-taskbar`. + +- [x] **Step 1: Implement the minimal rendering change** + +Append `this.renderTaskbar()` directly in `renderTransferPage()`. Remove `shouldRenderTaskbar()` and the `current?.remove()` branch from `refreshTaskbar()`. Keep `isTerminalPanelVisibleForCurrentPage()` for the existing `is-terminal-focused` layout class. + +- [x] **Step 2: Run TypeScript validation** + +Run: `npm run compile-check-ts-native` + +Expected: exit code 0. + +- [x] **Step 3: Run the focused test** + +Run: `npm run test-node -- --grep "TurboFileTerminalPanel"` + +Expected: all `TurboFileTerminalPanel` tests pass. + +- [x] **Step 4: Compile the client for manual verification** + +Run: `npm run gulp compile-client` + +Expected: exit code 0 and updated client output under `out/`. diff --git a/docs/superpowers/plans/2026-07-17-recycle-bin-file-operations-and-sorting.md b/docs/superpowers/plans/2026-07-17-recycle-bin-file-operations-and-sorting.md new file mode 100644 index 00000000..349b4a51 --- /dev/null +++ b/docs/superpowers/plans/2026-07-17-recycle-bin-file-operations-and-sorting.md @@ -0,0 +1,91 @@ +# Recycle Bin File Operations and Sorting Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make recycle-bin rows readable, sortable, operable like normal files, fully Chinese, and visible in task progress/logs. + +**Architecture:** Extend the existing per-pane recycle-bin mode with an optional, root-confined directory browsing state. Keep top-level trash metadata operations separate from ordinary payload-child operations, while reusing the existing file browser, sorting, editor, transfer, and task systems. + +**Tech Stack:** TypeScript, VS Code workbench DOM/CSS, Electron IPC trash service, Mocha unit tests, Gulp client compilation. + +## Global Constraints + +- Preserve all unrelated dirty-worktree changes and do not commit or stage them. +- Keep native batch deletion; do not add recursive size scans or per-item SSH round trips. +- Root trash metadata must remain valid after every supported operation. +- All user-visible recycle-bin strings and hover labels must be Chinese and localized. +- Run `npm run compile-check-ts-native` before tests or client compilation. + +--- + +### Task 1: Recycle-bin view state and sorting input + +**Files:** +- Modify: `src/vs/workbench/contrib/turbofile/common/turbofileTrashView.ts` +- Modify: `src/vs/workbench/contrib/turbofile/test/common/turbofileTrashView.test.ts` + +**Interfaces:** +- Produces: root/browse-mode helpers, root confinement checks, and trash-row sortable fields consumed by the editor. + +- [ ] **Step 1: Write failing tests** for entering a trash directory, returning to the trash root, rejecting paths above the payload root, and mapping `deletedAt`, `knownSize`, type and name to deterministic sort fields. +- [ ] **Step 2: Run** `npm run compile-check-ts-native` and the focused `TurboFileTrashView` tests; confirm the new expectations fail. +- [ ] **Step 3: Implement** immutable helpers in `turbofileTrashView.ts`, keeping Windows comparisons case-insensitive and POSIX comparisons separator-aware. +- [ ] **Step 4: Re-run** typecheck and the focused tests; expect PASS. + +### Task 2: Readable two-line root rows and Chinese controls + +**Files:** +- Modify: `src/vs/workbench/contrib/turbofile/browser/turbofileEditor.ts` +- Modify: `src/vs/workbench/contrib/turbofile/browser/media/turbofile.css` +- Test: `src/vs/workbench/contrib/turbofile/test/common/turbofileTrashView.test.ts` + +**Interfaces:** +- Consumes: root/browse-mode helpers from Task 1. +- Produces: normal 30px rows with filename, size, and deletion time; original paths remain available through the context menu. + +- [ ] **Step 1: Add failing assertions** for the normal 30px trash-root row height and Chinese toolbar/title defaults. +- [ ] **Step 2: Reuse** the normal single-line file-row layout at the trash root and keep its sorting icon on the neutral toolbar foreground. +- [ ] **Step 3: Replace** recycle-bin English defaults with “回收站”“返回”“还原”“永久删除”“清空回收站”“刷新”“打开当前设备回收站”, including `aria-label`. +- [ ] **Step 4: Run** typecheck and focused tests; expect PASS. + +### Task 3: Sorting and safe file operations + +**Files:** +- Modify: `src/vs/workbench/contrib/turbofile/browser/turbofileEditor.ts` +- Modify: `src/vs/workbench/contrib/turbofile/common/turbofileTrashView.ts` +- Test: `src/vs/workbench/contrib/turbofile/test/common/turbofileTrashView.test.ts` + +**Interfaces:** +- Consumes: existing `sortTurboFileItems`, editor preview/document open functions, internal clipboard, and directory listing cache. +- Produces: immediate recycle-bin sorting and root-confined directory navigation. + +- [ ] **Step 1: Add failing tests** proving all four sort keys receive the correct trash metadata and root confinement is maintained. +- [ ] **Step 2: Show** the existing pane sort control in recycle-bin mode and sort mapped rows before first render. +- [ ] **Step 3: Route** root-file double-click to existing preview/editor logic and root-directory double-click to the confined browsing state. +- [ ] **Step 4: Reuse** ordinary directory actions inside a trash payload; route root delete to permanent deletion and prevent raw root rename/move from invalidating metadata. +- [ ] **Step 5: Run** typecheck and focused tests; expect PASS. + +### Task 4: Permanent-delete and empty-trash task progress/logs + +**Files:** +- Modify: `src/vs/workbench/contrib/turbofile/browser/turbofileEditor.ts` +- Test: `src/vs/workbench/contrib/turbofile/test/common/turbofileTrashView.test.ts` + +**Interfaces:** +- Consumes: `beginTask`, `updateTask`, `finishTask`, native batch trash service calls. +- Produces: `delete` tasks for permanent deletion, payload-child deletion, restore, and empty recycle bin. + +- [ ] **Step 1: Add failing static assertions** that both permanent-delete and empty-trash flows begin, update, and finish a delete task. +- [ ] **Step 2: Create** each task before the native operation, update it during refresh, and finish with Chinese success/failure detail while preserving service batching. +- [ ] **Step 3: Catch** thrown errors, finish tasks as errors, and refresh the view without masking failures. +- [ ] **Step 4: Run** typecheck and focused tests; expect PASS. + +### Task 5: Full verification and compilation + +**Files:** +- Verify only; generated `out/` files may change through the approved client compiler. + +- [ ] **Step 1: Run** `npm run compile-check-ts-native`; expect exit code 0. +- [ ] **Step 2: Run** the focused TurboFile trash/file-sort test command; expect all tests PASS. +- [ ] **Step 3: Run** `npm run gulp compile-client`; expect exit code 0. +- [ ] **Step 4: Launch** `scripts\\code.bat` with an isolated user-data directory and confirm the workbench process becomes responsive; then close only the isolated smoke-test process. diff --git a/docs/superpowers/plans/2026-07-17-ssh-device-form-focus-and-password-only.md b/docs/superpowers/plans/2026-07-17-ssh-device-form-focus-and-password-only.md new file mode 100644 index 00000000..18711f07 --- /dev/null +++ b/docs/superpowers/plans/2026-07-17-ssh-device-form-focus-and-password-only.md @@ -0,0 +1,111 @@ +# SSH Device Form Focus and Password-Only Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Remove private-key controls from the SSH device form, keep all fields editable during background renders, and rebuild the Windows x64 `1.154.0` installer. + +**Architecture:** Keep the already-mounted device overlay as a direct child of the editor container while rebuilding only the page shell beneath it. Render SSH authentication as a hidden password mode plus the visible password field, while retaining backend private-key types for storage compatibility. + +**Tech Stack:** TypeScript, DOM APIs, VS Code Electron workbench, Mocha static regression tests, Gulp, Inno Setup. + +## Global Constraints + +- Preserve all unrelated dirty working-tree changes. +- Do not alter transfer protocols or SSH backend private-key contracts. +- Run `npm run compile-check-ts-native` before unit tests. +- Never run `npm run compile`. +- The final installer and packaged application version must both be exactly `1.154.0`. + +--- + +### Task 1: Add Device Form Regression Tests + +**Files:** +- Create: `src/vs/workbench/contrib/turbofile/test/common/turbofileDeviceForm.test.ts` + +**Interfaces:** +- Consumes: `TurboFileEditorPane.render()` and `TurboFileEditorPane.renderDeviceForm()` source contracts. +- Produces: regression assertions for password-only authentication and retained overlay rendering. + +- [ ] **Step 1: Write failing source-contract tests** + +Read `turbofileEditor.ts`, isolate the `render()` and `renderDeviceForm()` method bodies, then assert that the SSH branch contains `appendHiddenInput(form, 'authType', 'password')`, excludes the private-key option and fields, and that `render()` retains an existing direct-child device overlay while creating a new form only when no retained overlay exists. + +- [ ] **Step 2: Run compilation before tests** + +Run: `npm run compile-check-ts-native` + +Expected: exit code 0. + +- [ ] **Step 3: Run the focused test and verify RED** + +Run: `scripts\\test.bat --grep TurboFileDeviceForm` + +Expected: assertions fail because the current form still renders private-key controls and `render()` clears the active overlay. + +### Task 2: Implement Password-Only Form and Retained Overlay + +**Files:** +- Modify: `src/vs/workbench/contrib/turbofile/browser/turbofileEditor.ts` + +**Interfaces:** +- Consumes: `isDeviceFormVisible`, `.turbofile-device-overlay`, `renderDeviceForm()`. +- Produces: a continuously attached device overlay and `authType=password` form submissions. + +- [ ] **Step 1: Preserve the mounted overlay in `render()`** + +Before clearing the container, query the direct-child device overlay when the form is visible. Remove every other child while leaving that overlay attached. Insert the newly rendered shell before it, and call `renderDeviceForm()` only when no retained overlay exists. + +- [ ] **Step 2: Remove private-key controls** + +Replace the SSH authentication select with `appendHiddenInput(form, 'authType', 'password')`; keep the password input; remove `privateKeyPath` and `passphrase` input creation. + +- [ ] **Step 3: Make first focus window-safe** + +Replace the global timer with `getWindow(form).requestAnimationFrame`, verify the form is connected, then focus and select the name input. Retained overlays must not trigger this path again. + +- [ ] **Step 4: Run compilation and focused test for GREEN** + +Run: `npm run compile-check-ts-native` + +Run: `scripts\\test.bat --grep TurboFileDeviceForm` + +Expected: compilation succeeds and all focused tests pass. + +### Task 3: Validate and Repackage 1.154.0 + +**Files:** +- Regenerate: `.build/win32-x64/TurboFileSetup-win32-x64-1.154.0.exe` +- Regenerate: `.build/win32-x64/TurboFileSetup-win32-x64-1.154.0.exe.sha256` + +**Interfaces:** +- Consumes: verified `1.154.0` source tree. +- Produces: installable Windows x64 setup and matching SHA-256 sidecar. + +- [ ] **Step 1: Run full source validation** + +Run: `npm run compile-check-ts-native` + +Run: `scripts\\test.bat --grep TurboFileDeviceForm` + +Run: `npm run gulp compile-client` + +Expected: every command exits 0. + +- [ ] **Step 2: Build application, updater, and installer** + +Run: `npx gulp vscode-win32-x64` + +Run: `npx gulp vscode-win32-x64-inno-updater` + +Run: `npx gulp vscode-win32-x64-user-setup` + +Expected: Inno Setup successfully creates `.build/win32-x64/user-setup/极速传Setup.exe`. + +- [ ] **Step 3: Replace the versioned artifact and checksum** + +Copy the new setup executable to `.build/win32-x64/TurboFileSetup-win32-x64-1.154.0.exe`, calculate SHA-256, and replace the sidecar with ` TurboFileSetup-win32-x64-1.154.0.exe`. + +- [ ] **Step 4: Verify the final package** + +Confirm source, lockfile, packaged `resources/app/package.json`, installer file version, and installer product version are all `1.154.0`; confirm `MZ` header, non-zero size, fresh timestamp, and sidecar equality. diff --git a/docs/superpowers/plans/2026-07-17-turbofile-company-value-slide.md b/docs/superpowers/plans/2026-07-17-turbofile-company-value-slide.md new file mode 100644 index 00000000..d218bb6c --- /dev/null +++ b/docs/superpowers/plans/2026-07-17-turbofile-company-value-slide.md @@ -0,0 +1,187 @@ +# TurboFile Company Value Slide Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 在极速传领导汇报中增加一页“对公司的直接价值”,并在 Markdown 中记录 AI 算法、售后/应用和数据标注三类岗位的使用价值。 + +**Architecture:** 以当前“实测数据版” PPT 为源文件,用 `python-pptx` 新建一张白底三栏页面,将其插入到现有第 8 页和现场演示页之间,并把原第 9 页页码更新为 10。Markdown 使用独立章节保留更完整的岗位场景、收益和实测证据。 + +**Tech Stack:** Python 3、python-pptx、PowerPoint COM、Markdown、PowerShell + +## Global Constraints + +- PPT 继续使用白底、深灰文字、单一蓝色强调的极简风格。 +- 新页使用三栏并列布局,不使用深色大卡片、阴影或彩色圆形。 +- 只使用已提供的 234 秒、24.6 秒、12 分钟和 23.7 秒等实测数据。 +- Markdown 只记录软件优势、业务价值、实验数据和演示清单,不增加演讲稿。 +- 不生成 PDF,不保留 PPT 生成脚本。 + +--- + +### Task 1: 在 Markdown 中新增公司业务价值章节 + +**Files:** +- Modify: `docs/presentations/极速传软件优势与演示清单.md` + +**Interfaces:** +- Consumes: 现有“极速传的核心优势”和“WinSCP 传输实验记录”章节。 +- Produces: 介于两者之间的“对公司的业务价值”章节。 + +- [ ] **Step 1: 写入三类岗位的业务价值** + +使用 `apply_patch` 在 `## 四、WinSCP 传输实验记录` 之前插入以下结构,并把后续章节序号顺延一位: + +```markdown +## 四、对公司的业务价值 + +### AI 算法工程师 + +- 直接在服务器上网格浏览模型训练图片,不需要先整批下载。 +- 左右对比原始图片、处理结果和不同数据集。 +- 更快排查数据质量、样本差异和模型效果问题。 + +### 售后与应用工程师 + +- 快速传输 X86 设备和 AI 芯片采集的现场图片。 +- 减少先导入 Windows,再发送回公司的等待时间。 +- 2.32 GB 混合文件实测从 234 秒缩短到 24.6 秒,耗时提升约 9.5 倍。 + +### 数据标注人员 + +- 使用图片缩放能力查看大米、小麦等微小物料的细节特征。 +- 大批量数据可快速上传至 NAS、62 和 64 等服务器。 +- 62 → 64 设备传输实测从 12 分钟缩短到 23.7 秒。 + +**公司整体价值:** 缩短“采图—传输—分析—标注—反馈”的完整业务周期。 +``` + +- [ ] **Step 2: 检查 Markdown 结构和数据** + +Run: + +```powershell +rg -n "对公司的业务价值|AI 算法工程师|售后与应用工程师|数据标注人员|234 秒|24\.6 秒|12 分钟|23\.7 秒" "docs/presentations/极速传软件优势与演示清单.md" +``` + +Expected: 三类岗位、两组实测数据和新章节标题均能被检索到。 + +### Task 2: 新建并插入公司价值页 + +**Files:** +- Read: `docs/presentations/极速传领导汇报-实测数据版.pptx` +- Create: `docs/presentations/极速传领导汇报-公司价值版.pptx` + +**Interfaces:** +- Consumes: 现有 9 页、16:9 的实测数据版 PPT。 +- Produces: 10 页 PPT,新第 9 页为公司价值页,第 10 页为现场演示页。 + +- [ ] **Step 1: 用 python-pptx 创建三栏页面** + +通过一次性 Python 命令加载源 PPT,使用空白版式新建页面,并写入: + +```python +columns = [ + ( + "AI 算法工程师", + "服务器上的训练图片可以直接查看和分析", + ["网格浏览训练数据集", + "左右对比原图与处理结果", + "快速排查数据与模型效果"], + ), + ( + "售后与应用工程师", + "现场密集图片可以更快传回公司", + ["X86 设备与 AI 芯片图片快速导出", + "减少 Windows 中转和上传等待", + "2.32 GB:234s → 24.6s,提升 9.5 倍"], + ), + ( + "数据标注人员", + "微小物料看得更清楚,海量数据传得更快", + ["缩放查看大米、小麦等细节特征", + "快速上传至 NAS、62 和 64 服务器", + "62→64:12 分钟 → 23.7s"], + ), +] +``` + +统一使用 `Microsoft YaHei`,标题 27 pt,岗位标题 16 pt,核心价值 13 pt,说明文字 11 pt。三栏使用细灰线分割,底部浅蓝色总结条写入: + +```text +缩短“采图—传输—分析—标注—反馈”的完整业务周期 +``` + +- [ ] **Step 2: 插入新第 9 页并更新页码** + +将新建页的 slide ID 从列表末尾移动到原第 9 页之前: + +```python +sld_id_list = prs.slides._sldIdLst +new_id = sld_id_list[-1] +sld_id_list.remove(new_id) +sld_id_list.insert(8, new_id) +``` + +将原现场演示页右上角页码从 `09` 改为 `10`,并把新页右上角页码设为 `09`。保存为 `极速传领导汇报-公司价值版.pptx`。 + +- [ ] **Step 3: 检查 PPT 结构** + +Run: + +```python +from pptx import Presentation +prs = Presentation(r"docs/presentations/极速传领导汇报-公司价值版.pptx") +assert len(prs.slides) == 10 +assert "极速传对公司的直接价值" in prs.slides[8].shapes[0].text +assert "现场演示" in " ".join( + shape.text for shape in prs.slides[9].shapes if hasattr(shape, "text") +) +``` + +Expected: 所有断言通过。 + +### Task 3: 打开、渲染并检查最终交付物 + +**Files:** +- Verify: `docs/presentations/极速传领导汇报-公司价值版.pptx` +- Verify: `docs/presentations/极速传软件优势与演示清单.md` + +**Interfaces:** +- Consumes: Task 1 和 Task 2 的最终文件。 +- Produces: 可正常打开、无文字溢出的 10 页 PPT 和完整 Markdown。 + +- [ ] **Step 1: 用 PowerPoint COM 打开 PPT** + +Run: + +```powershell +$ppt = (Resolve-Path -LiteralPath 'docs\presentations\极速传领导汇报-公司价值版.pptx').Path +$app = New-Object -ComObject PowerPoint.Application +$pres = $app.Presentations.Open($ppt, $true, $true, $false) +$pres.Slides.Count +$pres.Close() +$app.Quit() +``` + +Expected: 输出 `10`,不出现修复或文件损坏提示。 + +- [ ] **Step 2: 渲染新第 9 页** + +使用 PowerPoint `Slide.Export` 把第 9 页导出为 1600×900 PNG,并用图像检查确认: + +- 标题、三栏和底部总结条全部可见。 +- 无文字溢出、文字重叠或中文问号乱码。 +- 无深色大面积背景和彩色装饰。 + +- [ ] **Step 3: 最终文件检查** + +Run: + +```powershell +Get-Item -LiteralPath 'docs\presentations\极速传领导汇报-公司价值版.pptx' +Get-Item -LiteralPath 'docs\presentations\极速传软件优势与演示清单.md' +Test-Path -LiteralPath 'docs\presentations\极速传领导汇报.pdf' +Test-Path -LiteralPath 'scripts\generate-turbofile-leadership-presentation.py' +``` + +Expected: PPT 和 Markdown 存在,PDF 和生成脚本均不存在。 diff --git a/docs/superpowers/plans/2026-07-17-turbofile-leadership-presentation.md b/docs/superpowers/plans/2026-07-17-turbofile-leadership-presentation.md new file mode 100644 index 00000000..4a7bd446 --- /dev/null +++ b/docs/superpowers/plans/2026-07-17-turbofile-leadership-presentation.md @@ -0,0 +1,254 @@ +# 极速传领导汇报材料 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 生成一套可编辑的 9 页极速传领导汇报 PPT,以及与页序一致的 Markdown 讲稿、现场演示清单和实验记录模板。 + +**Architecture:** 使用 Python 和 `python-pptx` 生成可编辑的 PowerPoint 元素,避免将文字和图表栅格化。PPT 采用演示驱动结构,速度数据区域显示明确的“待填入实测数据”,不虚构实验结果;Markdown 保存完整讲稿、演示动作和测试记录字段。 + +**Tech Stack:** Python 3、python-pptx、Pillow、PowerPoint Open XML、Markdown + +## Global Constraints + +- 输出目录固定为 `docs/presentations/`。 +- PPT 文件固定为 `docs/presentations/极速传领导汇报.pptx`。 +- Markdown 文件固定为 `docs/presentations/极速传领导汇报讲稿与演示清单.md`。 +- PPT 共 9 页,适配 10–15 分钟汇报。 +- 不写入虚构的 WinSCP 或极速传耗时、平均速度和峰值速度。 +- 回收站必须标记为“规划中”,不能表述为当前已完成功能。 +- 每页只保留一个主要结论,详细说明放入 Markdown。 +- 生成器不得覆盖或修改用户现有源码、未提交修改和实验数据。 + +--- + +### Task 1: 建立可重复执行的 PPT 生成器 + +**Files:** +- Create: `scripts/generate-turbofile-leadership-presentation.py` +- Create: `docs/presentations/极速传领导汇报.pptx` + +**Interfaces:** +- Consumes: `docs/superpowers/specs/2026-07-17-turbofile-leadership-presentation-design.md` +- Produces: `main() -> None`,生成固定路径的 PowerPoint 文件 + +- [ ] **Step 1: 检查运行环境** + +Run: + +```powershell +python -c "import pptx, PIL; print('pptx and Pillow available')" +``` + +Expected: + +```text +pptx and Pillow available +``` + +如果导入失败,只安装缺失的 Python 包: + +```powershell +python -m pip install python-pptx Pillow +``` + +- [ ] **Step 2: 创建生成器** + +生成器必须: + +- 使用 16:9 宽屏尺寸。 +- 设置中文字体为 `Microsoft YaHei`,数字和英文可使用 `Aptos`。 +- 使用深蓝、亮蓝、青绿色和白色作为主色。 +- 提供封面、章节标题、优势卡片、数据表、流程箭头、结论标签等复用函数。 +- 使用 PowerPoint 原生文本框、形状、表格和图表。 +- 生成设计文档规定的 9 页内容。 +- 在第 5 页插入 WinSCP 对比数据表和零值占位柱状图。 +- 在第 9 页把“回收站”标为“规划中”。 +- 在文件属性中将标题设置为“极速传领导汇报”。 + +- [ ] **Step 3: 执行生成器** + +Run: + +```powershell +python scripts/generate-turbofile-leadership-presentation.py +``` + +Expected: + +```text +Created: D:\Work\Code\TurboFile-Windows\docs\presentations\极速传领导汇报.pptx +Slides: 9 +``` + +- [ ] **Step 4: 验证 PowerPoint Open XML** + +Run: + +```powershell +python -c "from pptx import Presentation; p=Presentation(r'docs/presentations/极速传领导汇报.pptx'); print(len(p.slides), p.slide_width, p.slide_height)" +``` + +Expected: + +- 第一项为 `9` +- 宽高比约为 `1.7778` + +### Task 2: 编写逐页讲稿和现场演示清单 + +**Files:** +- Create: `docs/presentations/极速传领导汇报讲稿与演示清单.md` + +**Interfaces:** +- Consumes: PPT 的 9 页标题和顺序 +- Produces: 可直接照读或精简使用的逐页讲稿、演示动作和实验数据模板 + +- [ ] **Step 1: 编写汇报总览** + +文档开头必须写明: + +- 建议总时长为 10–15 分钟。 +- PPT 讲解约 6–8 分钟。 +- 软件现场演示约 5–7 分钟。 +- 发生网络或设备异常时使用提前准备的截图或录像。 + +- [ ] **Step 2: 编写九页逐页讲稿** + +每页包含: + +- 本页目标。 +- 建议讲解时间。 +- 30–60 秒口语化讲稿。 +- 是否切换软件。 +- 要展示的具体操作。 +- 演示结束后如何切回 PPT。 + +- [ ] **Step 3: 编写传输实验模板** + +模板必须分别记录: + +- 单个大文件。 +- 大量小图片。 +- SSH A 到 SSH B。 + +每组必须包含: + +- 数据大小。 +- 文件数量。 +- 网络带宽。 +- WinSCP 协议、耗时、平均速度和峰值速度。 +- 极速传协议、路线、耗时、平均速度和峰值速度。 +- 首次出现进度所需时间。 +- 提升倍数和节省时间。 + +- [ ] **Step 4: 编写演示前检查表** + +检查表至少包含: + +- 所有 SSH 设备能够连接。 +- 左右图片目录已经准备。 +- 测试数据已经复制到来源目录。 +- 删除测试使用可安全删除的副本。 +- WinSCP 会话和目标目录已经配置。 +- 远程终端命令提示符正常。 +- Markdown、代码和日志示例文件存在。 +- Windows 资源管理器目标目录已打开。 +- 关闭无关通知。 +- 准备网络异常时的备用截图或录像。 + +### Task 3: 视觉和内容验证 + +**Files:** +- Verify: `docs/presentations/极速传领导汇报.pptx` +- Verify: `docs/presentations/极速传领导汇报讲稿与演示清单.md` + +**Interfaces:** +- Consumes: Task 1 和 Task 2 的产物 +- Produces: 验证结果,不修改其他项目文件 + +- [ ] **Step 1: 检查幻灯片标题** + +Run: + +```powershell +python -c "from pptx import Presentation; p=Presentation(r'docs/presentations/极速传领导汇报.pptx'); print([next((s.text for s in slide.shapes if hasattr(s, 'text') and s.text.strip()), '') for slide in p.slides])" +``` + +Expected: + +- 输出 9 个非空页面标题。 +- 第一页包含“极速传”。 +- 第五页包含“高速传输”。 +- 第九页包含“总结”。 + +- [ ] **Step 2: 检查禁止性表述** + +Run: + +```powershell +rg -n "已经实现回收站|全球最快|绝对最快|所有软件中唯一|100%领先" docs/presentations scripts/generate-turbofile-leadership-presentation.py +``` + +Expected: + +- 无匹配结果。 + +- [ ] **Step 3: 检查数据占位** + +Run: + +```powershell +rg -n "待填入实测数据|待填写" docs/presentations/极速传领导汇报讲稿与演示清单.md scripts/generate-turbofile-leadership-presentation.py +``` + +Expected: + +- 第 5 页生成内容和 Markdown 实验模板均包含明确的数据占位说明。 + +- [ ] **Step 4: 检查 Markdown 完整性** + +Run: + +```powershell +rg -n "^## 第 [1-9] 页|^## 传输实验记录模板|^## 演示前检查表" docs/presentations/极速传领导汇报讲稿与演示清单.md +``` + +Expected: + +- 九个页面章节全部存在。 +- 实验模板存在。 +- 演示前检查表存在。 + +### Task 4: 最终交付 + +**Files:** +- Deliver: `docs/presentations/极速传领导汇报.pptx` +- Deliver: `docs/presentations/极速传领导汇报讲稿与演示清单.md` +- Deliver: `scripts/generate-turbofile-leadership-presentation.py` + +**Interfaces:** +- Consumes: 所有验证结果 +- Produces: 可点击的本地文件链接和数据替换说明 + +- [ ] **Step 1: 检查最终文件** + +Run: + +```powershell +Get-Item 'docs/presentations/极速传领导汇报.pptx','docs/presentations/极速传领导汇报讲稿与演示清单.md','scripts/generate-turbofile-leadership-presentation.py' | Select-Object Name,Length,LastWriteTime +``` + +Expected: + +- 三个文件均存在。 +- PPT 文件大小大于 10 KB。 +- Markdown 和生成器文件大小均大于 1 KB。 + +- [ ] **Step 2: 输出交付说明** + +最终说明必须包括: + +- PPT 文件链接。 +- Markdown 文件链接。 +- 生成器文件链接。 +- 第 5 页实验数据需要替换。 +- 建议先在 PowerPoint 中检查字体和投影比例。 diff --git a/docs/superpowers/plans/2026-07-17-windows-recycle-bin-icon-and-delete-dialog.md b/docs/superpowers/plans/2026-07-17-windows-recycle-bin-icon-and-delete-dialog.md new file mode 100644 index 00000000..01eaa1ea --- /dev/null +++ b/docs/superpowers/plans/2026-07-17-windows-recycle-bin-icon-and-delete-dialog.md @@ -0,0 +1,148 @@ +# Windows Recycle Bin Icon and Delete Dialog Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Package Windows-native empty/full Recycle Bin artwork and restore the previous Chinese danger confirmation for ordinary delete operations. + +**Architecture:** A focused browser helper owns the two bundled PNG data URIs and maps the existing `trashHasItems` boolean to one icon. `TurboFileEditor` renders the selected icon without changing state probes or recycle-bin data flow, and ordinary delete entry points restore their previous dialog copy while continuing to call `moveUserItemsToTrash`. + +**Tech Stack:** TypeScript, VS Code workbench DOM/CSS, Mocha unit tests, Windows Shell stock icon API. + +## Global Constraints + +- Run `npm run compile-check-ts-native` before other validation commands. +- Never run `npm run compile`; use `npm run gulp compile-client` when compiled output is required. +- Preserve the current dirty worktree and do not commit or stage unrelated user changes. +- Ordinary deletion must continue moving items to the per-device Recycle Bin. +- Do not add filesystem scans, SSH calls, or startup-time icon extraction. + +--- + +### Task 1: Windows-native Recycle Bin icon helper + +**Files:** +- Create: `src/vs/workbench/contrib/turbofile/browser/turbofileRecycleBinIcons.ts` +- Create: `src/vs/workbench/contrib/turbofile/test/common/turbofileRecycleBinIcons.test.ts` + +**Interfaces:** +- Produces: `getTurboFileRecycleBinIcon(hasItems: boolean): string` returning a bundled PNG data URI. +- Consumes: the existing `trashHasItems` boolean; no service dependency. + +- [ ] **Step 1: Write the failing icon test** + +Test that both states return distinct `data:image/png;base64,` values, decode to the PNG signature, and have 32×32 source dimensions. + +- [ ] **Step 2: Run the focused test and verify RED** + +Run: `npm run compile-check-ts-native` followed by `scripts\test.bat --grep TurboFileRecycleBinIcons`. + +Expected: the compile or test fails because the helper module does not exist. + +- [ ] **Step 3: Extract and add the minimal icon helper** + +Use `SHGetStockIconInfo` with stock IDs 31 and 32, encode the resulting 32×32 PNG files as data URIs, and implement: + +```ts +export function getTurboFileRecycleBinIcon(hasItems: boolean): string { + return hasItems ? TURBOFILE_RECYCLE_BIN_FULL_ICON : TURBOFILE_RECYCLE_BIN_EMPTY_ICON; +} +``` + +- [ ] **Step 4: Run the focused test and verify GREEN** + +Run the same compile and focused-test commands. Expected: PASS. + +### Task 2: Render the native icon without a status badge + +**Files:** +- Modify: `src/vs/workbench/contrib/turbofile/browser/turbofileEditor.ts` +- Modify: `src/vs/workbench/contrib/turbofile/browser/media/turbofile.css` +- Modify: `src/vs/workbench/contrib/turbofile/test/common/turbofileImageGridPane.test.ts` + +**Interfaces:** +- Consumes: `getTurboFileRecycleBinIcon(state.trashHasItems)` from Task 1. +- Produces: an `` inside the unchanged toolbar button. + +- [ ] **Step 1: Add a failing rendering regression test** + +Assert that `appendRecycleBinButton` uses the icon helper and an image element, and that the CSS no longer defines the `.is-full ...::after` orange badge. + +- [ ] **Step 2: Run the focused test and verify RED** + +Run: `scripts\test.bat --grep TurboFileImageGridPane`. + +Expected: FAIL because the current button still renders `Codicon.trash` and the badge CSS remains. + +- [ ] **Step 3: Implement the minimal DOM and CSS change** + +Import the helper, set the image `src`, set `alt` to an empty string because the button already owns the accessible label, size it to 20×20, and remove the orange badge rules. + +- [ ] **Step 4: Run the focused test and verify GREEN** + +Run the same focused test. Expected: PASS. + +### Task 3: Restore the previous ordinary-delete confirmation + +**Files:** +- Modify: `src/vs/workbench/contrib/turbofile/browser/turbofileEditor.ts` +- Modify: `src/vs/workbench/contrib/turbofile/test/common/turbofileImageGridPane.test.ts` + +**Interfaces:** +- Ordinary file-list delete: `showDialogConfirm('删除确认', message, undefined, true)`. +- Image-grid and preview delete: `showDialogConfirm('删除图片', message, undefined, true)`. +- Data mutation remains `moveUserItemsToTrash(device, items)`. + +- [ ] **Step 1: Add failing dialog regression assertions** + +Assert that the file-list, image-grid and preview delete method slices contain the previous Chinese titles, danger flag, and `moveUserItemsToTrash`, and do not contain `moveToRecycleBinTitle`. + +- [ ] **Step 2: Run the focused test and verify RED** + +Run: `scripts\test.bat --grep TurboFileImageGridPane`. + +Expected: FAIL because the current methods render the English recycle-bin dialog. + +- [ ] **Step 3: Restore only the dialog presentation** + +Replace the three ordinary-delete dialog calls with the previous Chinese title/message/danger arguments. Do not change any subsequent recycle-bin service calls. + +- [ ] **Step 4: Run the focused test and verify GREEN** + +Run the same focused test. Expected: PASS. + +### Task 4: Full verification and desktop UI check + +**Files:** +- Generated: `out/vs/workbench/contrib/turbofile/**` + +**Interfaces:** +- Consumes: all prior tasks. +- Produces: verified compiled application output. + +- [ ] **Step 1: Run type checking** + +Run: `npm run compile-check-ts-native`. + +Expected: exit code 0. + +- [ ] **Step 2: Run all TurboFile tests** + +Run: `scripts\test.bat --grep TurboFile`. + +Expected: all TurboFile tests pass. + +- [ ] **Step 3: Compile the client** + +Run: `npm run gulp compile-client`. + +Expected: compilation completes with zero errors. + +- [ ] **Step 4: Verify the desktop behavior** + +Launch the desktop application and confirm: empty/full Windows icons switch independently by pane, no orange badge appears, normal delete shows the old Chinese red confirmation, cancel performs no mutation, and confirm moves the selected item into the current device Recycle Bin. + +## Plan Self-Review + +- Spec coverage: icon extraction, offline packaging, state switching, old dialog restoration and unchanged trash data flow are each covered. +- Placeholder scan: no TBD/TODO or unspecified implementation steps remain. +- Type consistency: the helper accepts the existing boolean state and returns the string required by `HTMLImageElement.src`. diff --git a/docs/superpowers/plans/2026-07-18-extreme-batch-stability.md b/docs/superpowers/plans/2026-07-18-extreme-batch-stability.md new file mode 100644 index 00000000..c9005006 --- /dev/null +++ b/docs/superpowers/plans/2026-07-18-extreme-batch-stability.md @@ -0,0 +1,91 @@ +# Extreme Batch Stability Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make ten-thousand-item transfers and deletes responsive and reliable without reducing the throughput of a running transfer. + +**Architecture:** A cancellable multi-device scheduler prevents heavy writes from competing for the same device. Each running operation keeps streaming through tar/native commands, while path preparation, progress aggregation, and recycle-bin metadata are converted from per-item work to bounded or NUL-delimited batches. + +**Tech Stack:** TypeScript, Electron main process, VS Code workbench renderer, Node child processes, OpenSSH, GNU tar/xargs, Mocha. + +## Global Constraints + +- Same-device heavy writes execute one at a time; operations on disjoint devices may run concurrently. +- Do not add recursive size scans or per-file network transfers. +- Preserve copy/cut semantics: source cleanup happens only after target success. +- Keep small-batch fast paths and live byte/speed/elapsed-time reporting. +- Path transport must support at least 100,000 Unicode/special-character names without command-line concatenation. +- Do not package or change the product version in this task. + +--- + +### Task 1: Cancellable multi-device operation scheduler + +**Files:** +- Create: `src/vs/platform/turbofile/common/turbofileOperationQueue.ts` +- Create: `src/vs/platform/turbofile/test/common/turbofileOperationQueue.test.ts` +- Modify: `src/vs/platform/turbofile/electron-main/turbofileSshMainService.ts` + +**Interfaces:** +- Produces `TurboFileOperationQueue.run(keys, operation, options)` and `cancel(id)`. +- Service wrappers consume stable local/SSH device keys and return existing transfer/file-operation result types. + +- [ ] Write tests proving same-key serialization, disjoint-key concurrency, sorted multi-key acquisition, FIFO fairness, rejection recovery, and queued cancellation. +- [ ] Run `npm run compile-check-ts-native`; expect missing module/type failures. +- [ ] Implement the queue with no timers or polling and integrate it around public heavy write operations; internal fallback calls use unscheduled core methods to avoid re-entrant deadlock. +- [ ] Re-run type checking, compile the focused test, and run its Mocha file with all cases passing. + +### Task 2: Fast, bounded tar preparation and O(1) progress + +**Files:** +- Modify: `src/vs/platform/turbofile/common/turbofileBatchTarPlan.ts` +- Modify: `src/vs/platform/turbofile/test/common/turbofileBatchTarPlan.test.ts` +- Modify: `src/vs/platform/turbofile/electron-main/turbofileSshMainService.ts` +- Modify: `src/vs/platform/turbofile/test/common/turbofileSshMainServiceStatic.test.ts` + +**Interfaces:** +- Tar plans expose whether selected top-level items contain directories. +- Remote target preparation consumes full target paths through `createTarNullListBuffer`. +- Aggregate progress consumes scalar totals rather than reducing full arrays. + +- [ ] Add failing tests for 10883 plain-file plans, a compact NUL target-cleanup command, four-stream maximum, and absence of array reductions/forced per-item progress. +- [ ] Run type checking/focused tests and verify the expected assertions fail. +- [ ] Bypass local `lstat` collection for plain-file plans, cap tar stream concurrency at 4, and replace the remote cleanup loop with `xargs -0 rm -rf --`. +- [ ] Maintain aggregate byte totals by delta and throttle item-completion progress; keep final completion forced. +- [ ] Re-run focused tests and type checking. + +### Task 3: Batch recycle-bin metadata and bounded local deletion + +**Files:** +- Modify: `src/vs/platform/turbofile/electron-main/turbofileSshMainService.ts` +- Modify: `src/vs/platform/turbofile/test/common/turbofileSshMainServiceStatic.test.ts` +- Modify: `src/vs/workbench/contrib/turbofile/browser/turbofileEditor.ts` +- Modify: `src/vs/workbench/contrib/turbofile/test/common/turbofileRecycleBinInteraction.test.ts` + +**Interfaces:** +- Renderer constructs all SSH trash endpoints from one resolved connection. +- POSIX trash batch input contains `id/source/payload/pending/final/metadata` NUL fields. +- Local operations use existing `mapLimit` with 16 file workers or 4 when directories are present. + +- [ ] Add failing static/interaction tests proving one credential resolution, no per-item SFTP metadata loops, atomic pending/move/commit command ordering, and bounded local cleanup. +- [ ] Run focused tests and verify failures reflect the current per-item implementation. +- [ ] Reuse one connection in renderer; execute POSIX metadata+move+commit in one stdin batch; bound local trash and permanent-delete work. +- [ ] Convert Windows SSH multi-delete to a fixed command reading stdin. +- [ ] Re-run focused tests and type checking. + +### Task 4: Renderer large-task hot-path and final regression + +**Files:** +- Modify: `src/vs/workbench/contrib/turbofile/browser/turbofileEditor.ts` +- Modify: `src/vs/workbench/contrib/turbofile/test/common/turbofileTransferDisplay.test.ts` + +**Interfaces:** +- Batch task state stores `currentName` from progress events. +- Taskbar title uses summary state for multi-item tasks without flattening all transfer items. + +- [ ] Add a failing test/static assertion for constant-time batch status-title selection. +- [ ] Implement summary-name rendering and avoid full-page render when starting a large batch. +- [ ] Run focused renderer tests. +- [ ] Run `npm run compile-check-ts-native`. +- [ ] Run all compiled TurboFile Mocha tests and record any unrelated pre-existing failures separately. +- [ ] Run `npm run gulp compile-client` and inspect the scoped diff for size scans, per-file network loops, or changed tar data paths. diff --git a/docs/superpowers/plans/2026-07-21-same-directory-copy-name.md b/docs/superpowers/plans/2026-07-21-same-directory-copy-name.md new file mode 100644 index 00000000..7f4645f7 --- /dev/null +++ b/docs/superpowers/plans/2026-07-21-same-directory-copy-name.md @@ -0,0 +1,55 @@ +# Same-Directory Copy Naming Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 同一设备同一目录复制粘贴时创建 Windows 风格的“副本”名称。 + +**Architecture:** 用独立纯函数分配不冲突的目标名称;编辑器仅在内部剪贴板同目录复制入口调用该函数,然后完全复用现有传输执行链。底层传输协议、跨目录冲突和移动行为不变。 + +**Tech Stack:** TypeScript、VS Code Mocha 单元测试、TurboFile 现有传输服务。 + +## Global Constraints + +- 只修改同一设备同一目录的内部复制粘贴。 +- 不修改剪切、移动、拖拽、跨目录、跨设备或传输协议行为。 +- 文件夹和多文件不进行大小扫描。 + +--- + +### Task 1: Windows 风格副本名称分配器 + +**Files:** +- Create: `src/vs/workbench/contrib/turbofile/common/turbofileCopyName.ts` +- Test: `src/vs/workbench/contrib/turbofile/test/common/turbofileCopyName.test.ts` + +**Interfaces:** +- Produces: `createTurboFileCopyName(name, directory, occupiedNames, caseSensitive): string` and `createTurboFileCopyNames(items, occupiedNames, caseSensitive): string[]` + +- [x] **Step 1: Write the failing tests** covering files, folders, numbering and case sensitivity. +- [x] **Step 2: Run the focused test and verify it fails because the module is missing.** +- [x] **Step 3: Implement the minimal pure naming function.** +- [x] **Step 4: Run the focused test and verify it passes.** + +### Task 2: Internal same-directory paste integration + +**Files:** +- Modify: `src/vs/workbench/contrib/turbofile/browser/turbofileEditor.ts` +- Test: `src/vs/workbench/contrib/turbofile/test/common/turbofileCopyName.test.ts` + +**Interfaces:** +- Consumes: `createCopyName(...)` +- Produces: renamed target items passed into the existing single or batch transfer path. + +- [x] **Step 1: Add a failing static integration test** proving only same-directory copy invokes the allocator before transfer. +- [x] **Step 2: Run the test and verify the expected failure.** +- [x] **Step 3: Add the minimal editor helper and call it from internal paste.** +- [x] **Step 4: Run focused tests and verify they pass.** + +### Task 3: Verification + +**Files:** +- Verify only; no production changes planned. + +- [x] **Step 1: Run `npm run compile-check-ts-native`.** +- [x] **Step 2: Run the focused TurboFile tests.** +- [x] **Step 3: Inspect the final diff and confirm unrelated dirty files were untouched.** diff --git a/docs/superpowers/specs/2026-07-10-same-device-directory-merge-design.md b/docs/superpowers/specs/2026-07-10-same-device-directory-merge-design.md new file mode 100644 index 00000000..a23a70c7 --- /dev/null +++ b/docs/superpowers/specs/2026-07-10-same-device-directory-merge-design.md @@ -0,0 +1,44 @@ +# Same-Device Directory Merge Design + +## Goal + +Allow an ordinary folder copy between the left and right panes when both panes point to the same device and the exact target folder already exists. The copy merges directory contents and overwrites same-name files without deleting target-only files. + +## Behavior Matrix + +| Operation | Endpoints | Existing same-name directory | Behavior | +| --- | --- | --- | --- | +| Copy | Same local device | Yes | Merge; overwrite same-name files | +| Copy | Same SSH connection | Yes | Merge; overwrite same-name files | +| Move / cut | Same device | Yes | Reject | +| Copy or move | Different devices, non-rsync | Yes | Reject | +| Copy or move | Any rsync-only plan | Yes | Keep incremental rsync behavior | +| Any operation | Source directory conflicts with a target file | Yes | Reject | + +"Same SSH connection" means identical host, port, and username. A matching display name or path alone is not sufficient. + +## Design + +The transfer service will make the conflict decision at its public single-item and batch entry points. A same-device directory merge exception is permitted only when the operation is copy and the route is the native same-device route. Cross-device protocol fallbacks do not inherit this exception. + +For same-device copies, the native copy implementation will copy the source directory contents into the exact target directory. It must not copy the source directory itself into the existing target, which would create an incorrect `folder/folder` nesting. Existing target-only files remain untouched, while same-name files are replaced. + +The lower-level tar, SCP, and SFTP target guards remain in place. They continue to reject existing target directories for cross-device non-rsync transfers, even if an entry-point check is bypassed. + +## Error Handling + +- A same-name target file is never treated as a mergeable directory. +- A move into an existing same-name directory is rejected before any source deletion. +- A failure during copy leaves the source untouched. +- Existing target contents are never recursively deleted as preparation for a copy. + +## Tests + +- Same-device single-directory copy permits an existing target directory. +- Same-device batch copy permits existing target directories. +- Same-device move still rejects existing target directories. +- Cross-device tar, SCP, and SFTP transfers still reject existing target directories. +- Explicit rsync remains exempt from the rejection. +- Native POSIX same-device copy merges contents into the exact target and does not create `folder/folder` nesting. +- Existing target-only files survive; same-name files are overwritten. +- TypeScript compilation and the relevant TurboFile unit tests pass. diff --git a/docs/superpowers/specs/2026-07-10-ssh-post-transfer-session-recovery-design.md b/docs/superpowers/specs/2026-07-10-ssh-post-transfer-session-recovery-design.md new file mode 100644 index 00000000..804cd270 --- /dev/null +++ b/docs/superpowers/specs/2026-07-10-ssh-post-transfer-session-recovery-design.md @@ -0,0 +1,97 @@ +# SSH Post-Transfer Directory Recovery Design + +## Problem + +After a multi-item transfer, TurboFile refreshes both panes with forced directory reads. A forced read forgets an existing prefetch request without stopping it, so an old prefetch and the new foreground refresh can overlap on the same shared SSH/SFTP directory session. + +When either request fails, the current cleanup path disposes the session by connection key alone. A late failure from the old session can therefore remove and close a newer replacement session. The replacement request then fails with `No response from server`. A manual refresh succeeds because the overlapping requests have finished by that point. + +## Goals + +- Never let an old request close a newer SSH/SFTP directory session. +- Recover transient directory-read failures without exposing an avoidable offline state. +- Reduce unnecessary post-transfer directory refreshes and channel pressure. +- Prevent stale prefetch results from replacing a fresh post-transfer listing. +- Preserve immediate errors for authentication, permission, missing-path, and invalid-directory failures. +- Keep transfer protocols, transfer data paths, and UI layout unchanged. + +## Non-Goals + +- Reworking transfer cancellation in this change. +- Changing SSH credentials, device storage, or transfer protocol selection. +- Replacing the directory cache architecture. +- Hiding persistent connection errors. + +## Root Cause + +The directory session map is keyed by SSH identity and stores a shared session promise. Cleanup currently calls `disposeDirectorySftpSession(key)`. With overlapping requests, this sequence is possible: + +1. Requests A and B use session generation 1. +2. A detects a transient disconnect and disposes generation 1. +3. A creates generation 2 and stores it under the same key. +4. B later handles the generation 1 failure and disposes by key. +5. The key now points to generation 2, so B closes the healthy replacement. + +Multi-item transfer completion makes the race much more likely because the renderer force-refreshes source and target panes concurrently while prefetch requests may still be running. + +## Design + +### Generation-Safe Directory Sessions + +Each directory SSH/SFTP session receives a monotonically increasing generation number. The service records the current generation for each connection key. + +All cleanup paths carry the generation they intend to dispose. A cleanup operation removes the map entry, clears its idle timer, and closes resources only when the expected generation is still current. Close/error callbacks from an old client use the same conditional removal rule. + +Idle timers also capture the generation active when the timer is created. A delayed timer from an old generation cannot close a replacement session. + +### Transient Read Recovery + +Directory reads keep using the shared session fast path. Retryable errors use up to three attempts: + +- Attempt 1 uses the current session. +- A retry disposes only the failed generation. +- Retries wait for a short bounded backoff before creating or reusing the replacement session. +- Each retry checks that the error is classified as transient. + +Authentication, permission, missing-path, and not-a-directory errors are returned immediately. A prefetch failure remains background-only and does not mark a visible pane offline. + +### Post-Transfer Refresh Policy + +After an ordinary copy, only the target pane is refreshed because the source directory did not change. + +After a move, both source and target panes are refreshed. If both panes use the same SSH identity, the reads run sequentially. Different devices may still refresh concurrently. + +The policy is expressed as a small pure helper so copy/move and same-device behavior can be unit tested independently. + +### Stale Prefetch Protection + +For each pane directory cache key, an in-flight request is applied only if it is still the current tracked request for that key. A force refresh invalidates the older tracked request. The old request may finish, but it cannot overwrite the newer cache entry. + +The main-process directory listing cache uses the same invalidation intent: a listing started before a cache-clear event cannot repopulate the cleared entry after a transfer. + +## Error Handling + +- Transient socket/SFTP errors: generation-safe reconnect and bounded retry. +- Authentication failure: no retry; report the server error. +- Permission failure: no retry; report the path and permission error. +- Missing path/not a directory: no retry; preserve existing path recovery behavior. +- Prefetch failure: discard silently and leave the visible pane unchanged. +- Exhausted transient retries: display the final connection error once. + +## Tests + +- An old session generation cannot remove or close a newer generation. +- An old idle timer cannot dispose a replacement session. +- Retryable directory errors receive bounded retries; path and permission errors do not. +- Copy completion refreshes only the target pane. +- Move completion refreshes both panes. +- Same-device move refreshes sequentially; different-device move may refresh concurrently. +- A stale prefetch completion cannot overwrite a force-refresh cache entry. +- Existing directory navigation, transfer plan, and SSH main-service regression tests continue to pass, except for any documented pre-existing unrelated failure. + +## Verification + +- Run `npm run compile-check-ts-native -- --pretty false`. +- Run `npm run gulp compile-client`. +- Run the focused TurboFile directory session, retry, inflight, navigation, and SSH service tests. +- Exercise repeated multi-item transfers followed by target-pane refreshes without observing `No response from server`. diff --git a/docs/superpowers/specs/2026-07-14-drag-transfer-mode-design.md b/docs/superpowers/specs/2026-07-14-drag-transfer-mode-design.md new file mode 100644 index 00000000..30d4c5dd --- /dev/null +++ b/docs/superpowers/specs/2026-07-14-drag-transfer-mode-design.md @@ -0,0 +1,95 @@ +# 拖拽复制/剪切模式设计 + +## 目标 + +在极速传文件面板工具栏新增复制/剪切两态图标按钮,使用户可以明确控制所有拖拽传输是复制还是剪切,同时保证失败、取消或部分失败时不会错误删除未成功传输的源文件。 + +## 已确认约束 + +- 默认模式为“复制”。 +- 点击复制图标切换到“剪切”,再次点击切回“复制”。 +- 模式对全部拖拽路径生效:左右面板互传、同侧拖入文件夹、Windows 拖入面板、面板拖到 Windows。 +- 模式只对当前传输页面生效;每个标签页状态独立。 +- 模式不持久化。新建页面和重新打开应用时仍从“复制”开始。 +- Ctrl+C、Ctrl+X、Ctrl+V 保持其显式复制/剪切语义,不受拖拽模式影响。 +- 失败或取消时不删除源文件;批量传输只允许清理已确认成功的源项。 + +## 方案比较 + +### 方案 A:两态图标按钮(采用) + +在左右面板工具栏的排序与刷新控件附近显示当前模式。复制模式使用现有复制图标,剪切模式使用剪刀图标;点击直接切换。左右两个按钮绑定当前页面的同一状态,因此始终同步。 + +优点是占用空间最小、一键切换、当前状态始终可见,并与现有纯图标工具栏一致。缺点是需要通过标题和辅助功能名称解释状态与点击后的结果。 + +### 方案 B:下拉菜单 + +显示“复制模式/剪切模式”菜单。语义更明确,但需要两次操作,占用更多宽度,也不符合截图中紧凑图标工具栏的布局。 + +### 方案 C:Ctrl/Shift 修饰拖拽 + +不增加固定状态,仅在拖拽时按键切换。它难以保证 Windows 内外拖拽链路一致,也无法满足用户要求的可见选项。 + +## 页面状态与界面 + +`TurboFileEditorInput` 保存当前页面的临时拖拽模式,类型为 `'copy' | 'move'`,默认 `'copy'`。编辑器切换标签页时从对应输入读取状态,关闭页面后状态随输入销毁,不写入全局存储。 + +每侧文件面板工具栏增加一个模式按钮,放在排序按钮与刷新按钮之间: + +- 复制:显示复制图标,标题和 `aria-label` 为“拖拽模式:复制;点击切换为剪切”。 +- 剪切:显示剪刀图标,增加活动样式,标题和 `aria-label` 为“拖拽模式:剪切;点击切换为复制”。 +- 点击任意一侧按钮更新当前 `TurboFileEditorInput` 并重新渲染,两侧按钮同步。 +- 剪切状态使用警示色/高对比边框,避免用户忘记当前处于破坏性模式。 + +模式按钮仅改变拖拽行为,不改变工具栏其他按钮、快捷键、粘贴、同步或任务重试的语义。 + +## 拖拽行为 + +### 面板之间 + +拖拽开始时将当前页面模式写入 `application/x-turbofile-operation`,并设置匹配的 `effectAllowed`/`dropEffect`。跨面板放下时把该操作传入统一传输方法:复制使用现有复制流程;剪切使用现有批量 `move` 流程或“成功复制后清理源”的流程。 + +### 同侧拖入文件夹 + +复制模式把选中项复制到目标文件夹,保留源项;剪切模式沿用当前同设备原生移动逻辑。源路径与目标路径相同、把目录放入自身或子目录等非法目标继续被拒绝。 + +### Windows 拖入面板 + +捕获外部路径后读取当前页面模式。复制模式保持现状;剪切模式调用现有 `transferExternalPaths(..., 'move')`。本地到远端时先完成并验证目标写入,再删除对应本地源;本地到本地优先使用原生移动。 + +### 面板拖到 Windows + +- 远端源:继续通过现有 Windows 目标目录捕获流程取得真实目标,再以当前模式执行批量传输。 +- 本地源复制:保留现有 Electron 原生拖出路径,避免降低常用复制性能。 +- 本地源剪切:不能依赖 Electron `startDrag()` 的返回值推断是否成功放下。改走已存在的目标目录捕获流程,确认目标后调用本地 `move`,从而保证取消拖拽时不删除源文件。 + +## 安全与错误处理 + +- 只有传输层明确返回成功的项目才能进入源清理步骤。 +- 取消、目标捕获超时、冲突被拒绝、网络断开、校验失败和传输失败均保留源文件。 +- 批量操作发生部分失败时,失败项保留源文件;成功项按传输层的逐项结果完成移动。任务栏明确显示完成数与失败数。 +- 同设备移动优先使用重命名/原生移动,避免“复制后删除”带来的额外时间与风险。 +- 拖拽开始时固定本次操作模式,拖拽过程中切换页面或按钮不会改变已开始拖拽的语义。 + +## 代码边界 + +- `src/vs/workbench/contrib/turbofile/common/turbofileEditorInput.ts`:页面级模式状态及切换接口。 +- `src/vs/workbench/contrib/turbofile/common/turbofileDragDrop.ts`:纯函数形式的模式、拖拽效果和目标行为决策,供单元测试复用。 +- `src/vs/workbench/contrib/turbofile/browser/turbofileEditor.ts`:工具栏按钮、DOM 拖放事件和传输操作透传。 +- `src/vs/workbench/contrib/turbofile/browser/media/turbofile.css`:按钮普通态、剪切活动态和高对比主题样式。 +- `src/vs/platform/turbofile/common/turbofile.ts` 与 `src/vs/platform/turbofile/electron-main/turbofileSshMainService.ts`:仅在本地剪切拖出需要时扩展 Windows 目标捕获请求,不改变现有复制接口语义。 +- `src/vs/workbench/contrib/turbofile/test/common/turbofileDragDrop.test.ts` 与相关平台测试:覆盖模式隔离、全部路径映射和失败保护。 + +## 验证 + +1. 先新增失败测试,覆盖默认复制、页面间隔离、按钮切换、四类拖拽的操作映射和拖拽开始后模式固定。 +2. 覆盖取消/失败不清理源、部分成功只处理成功项、本地剪切拖出必须先捕获目标。 +3. 按项目要求先运行 `npm run compile-check-ts-native`,无类型错误后再运行 TurboFile 对应单元测试。 +4. 启动桌面应用,分别验证两个以上标签页的独立状态、左右按钮同步、深浅色和高对比主题、四类拖拽的复制/剪切结果以及取消拖出。 + +## 非目标 + +- 不持久化拖拽模式。 +- 不允许为左右面板分别设置不同模式。 +- 不改变 Ctrl+C/X/V、工具栏传输按钮或同步功能。 +- 不新增删除确认弹窗;安全性由“确认成功后才移动/清理”保证。 diff --git a/docs/superpowers/specs/2026-07-14-turbofile-codebase-slimming-design.md b/docs/superpowers/specs/2026-07-14-turbofile-codebase-slimming-design.md new file mode 100644 index 00000000..fe4efc2b --- /dev/null +++ b/docs/superpowers/specs/2026-07-14-turbofile-codebase-slimming-design.md @@ -0,0 +1,83 @@ +# 极速传代码库轻量化设计 + +## 目标 + +在不影响极速传、完整文档工作流、VS Code 默认代码语言能力,以及现有安装/打包/更新/发布能力的前提下,删除其余 VS Code 功能的入口、发布内容、依赖和源码,显著降低安装包与安装后占用。 + +当前基线为:Windows x64 构建目录约 788.8 MiB,安装包约 163.9 MiB;已安装目录还存在一个约 587.6 MiB 的历史更新目录。主要可裁剪项包括约 130 MiB source map、93.1 MiB 内置扩展、82.6 MiB Sessions 产物和 50.7 MiB Copilot 运行依赖。 + +## 不可删除能力 + +### 极速传 + +- 保留双栏本地/远程文件浏览、设备管理、SSH/SFTP/SCP/rsync/tar 传输、冲突处理、任务日志、图片页、设置和主题。 +- 保留同侧、跨侧及外部文件传输,Windows 拖拽、剪贴板复制/粘贴/剪切和系统资源管理器互操作。 +- 保留集成终端、极速传 SSH 终端、标签页、标题重命名、图标样式及现有更新入口。 +- 保留 `ssh2`、字符编码、xterm、node-pty、Windows 拖拽扩展和 rsync/OpenSSH 运行资源。 + +### 文档与代码 + +- 完整保留 VS Code 的文档相关源码,不对编辑器、文本模型、文件服务、工作区、Explorer、Search、保存/自动保存、编码、备份、撤销恢复、查找替换、格式化、折叠、代码动作、Diff/Merge、历史和导航子系统做源码裁剪。 +- 保留 Markdown、图片、媒体预览、Webview、自定义编辑器和 Notebook 等文档渲染能力。 +- 保留主题、语法高亮、Snippet、文件图标,以及 VS Code 默认随附的全部语言和文档扩展源码。 +- 恢复原版扩展激活链路,使 VS Code 默认支持的跳转到定义、查找引用、补全、悬停和语言服务重新可用;不承诺增加 VS Code 默认未提供的第三方语言能力。 + +### 安装、打包和发布 + +- 完整保留 Gulp 编译/压缩/打包任务、Windows x64 产物生成、Inno Setup、`inno_updater`、版本和校验清单、GitHub Release 与现有发布脚本。 +- 保留全新安装、覆盖安装、后台更新、卸载、快捷方式、开始菜单、PATH、URL 协议、文件关联和资源管理器右键菜单。 +- 允许调整发布有效载荷白名单、压缩参数和更新垃圾回收逻辑,但不得删除或替换上述能力。 + +## 删除范围 + +- 删除 Copilot、Chat、AI Agent、Sessions、MCP、Browser View 和 Playwright 工作台能力及其专用依赖。 +- 删除 Git、SCM、GitHub 集成,Debug、Testing,VS Code Tasks,Remote Development、Tunnel 和端口转发。 +- 删除账户、在线认证、Settings Sync、Profiles、Speech、Telemetry、Surveys、Issue Reporter、Process Explorer、企业策略导出、快捷键导出和 Emergency Alert。 +- 删除上述能力对应的工作台贡献入口、服务注册、专用内置扩展、生产依赖、发布资源和已经证明不可达的源码。 +- 从发布包移除 source map、测试资源、开发专用文件和非简体中文/英文回退 Electron 语言包;许可证和第三方声明继续保留。 +- 修复更新后历史版本目录未被回收的问题,但只在确认当前版本成功启动且旧目录不再被进程占用后执行清理。 + +## 架构与实施策略 + +采用“功能白名单、先停止打包、后删除源码”的分阶段方案: + +1. 建立机器可读的 TurboFile 产品能力白名单,分别描述必须保留的工作台贡献、内置扩展、生产依赖和发布资源。 +2. 先恢复原生语言扩展运行链路,同时限制扩展集合为 VS Code 默认语言/文档扩展及终端相关扩展;扩展市场、推荐、在线安装和无关扩展不进入产品。 +3. 将桌面工作台入口改为显式导入保留模块;先移除无关副作用导入并通过编译、单测和桌面回归验证依赖闭包。 +4. 修改打包流程,使本地构建与 CI 均剥离 source map,并按白名单打包扩展、Node 依赖、资源和语言包;安装/更新任务名称与输出格式保持兼容。 +5. 在连续验证通过后,删除已无任何生产或测试引用的无关源码目录和依赖声明。禁止仅凭目录名称批量删除共享基础模块。 +6. 为更新垃圾回收增加可测试的旧版本识别和安全删除规则,避免删除当前版本、正在使用的版本或用户数据。 + +每个阶段都必须能独立构建和启动;任何文档或极速传回归都会阻止进入下一阶段。源码删除只发生在功能入口和打包内容已经稳定移除之后。 + +## 验证与验收 + +### 自动验证 + +- 先运行 `npm run compile-check-ts-native`,通过后再运行 TurboFile、编辑器、语言服务、更新和 Windows 安装器相关测试。 +- 为能力白名单增加静态测试:必需入口必须存在,禁止入口不得重新引入,文档/语言扩展必须完整,非文档扩展不得进入发布目录。 +- 为扩展激活增加回归测试,验证打开默认支持语言时会启动对应语言功能,且不启动 Copilot、Git、调试或远程开发扩展。 +- 为更新垃圾回收增加测试,覆盖当前版本保护、旧版本清理、文件占用、清理失败重试和异常退出。 +- 对最终构建产物生成体积清单和禁止文件扫描,确认不存在 source map、Sessions、Copilot、Playwright 和被删除扩展。 + +### 桌面回归 + +- 验证极速传所有页面、设备认证、各传输协议、同侧/跨侧/外部拖拽、Ctrl+C/V/X、任务、图片、终端和多标签页。 +- 验证普通文本和代码的打开、编辑、保存、自动保存、编码、撤销恢复、查找替换、格式化、折叠、Diff/Merge、Markdown/图片/媒体预览、Webview、自定义编辑器和 Notebook。 +- 对 VS Code 默认具备语言服务的文件验证 Ctrl+点击、F12、查找引用、补全和悬停;对默认只提供语法高亮的语言保持原版行为。 +- 验证简体中文和英文回退、深浅主题、冷启动、重复启动和异常恢复。 +- 验证全新安装、覆盖安装、后台更新、失败回滚、卸载和重新安装,并确认更新完成后只保留当前有效版本目录。 + +### 体积验收 + +- 每阶段记录未压缩应用、安装包、安装后单版本目录和更新后总目录体积,与 788.8 MiB/163.9 MiB 基线对比。 +- 不设置以牺牲功能为代价的硬性目标;在全部回归通过的前提下删除所有已确认无关内容。 +- Electron/Chromium 必需二进制、极速传传输资源、文档系统和安装发布链路不因体积目标被强行裁剪。 + +## 风险控制 + +- 现有工作区包含未提交的极速传标签页和剪贴板改动;轻量化必须保留这些改动,不回退、不覆盖,并在验证时纳入回归范围。 +- VS Code 模块通过副作用导入注册服务,删除入口前必须验证依赖注入服务仍有实现;缺少实现时恢复最小必需入口,而不是用空桩掩盖错误。 +- 文档能力边界优先于体积:无法证明与文档无关的共享服务默认保留。 +- 第三方许可证、版权声明和发布合规文件不作为“无运行依赖”资源删除。 + diff --git a/docs/superpowers/specs/2026-07-15-batch-stream-progress-direct-first-design.md b/docs/superpowers/specs/2026-07-15-batch-stream-progress-direct-first-design.md new file mode 100644 index 00000000..b1df3aa9 --- /dev/null +++ b/docs/superpowers/specs/2026-07-15-batch-stream-progress-direct-first-design.md @@ -0,0 +1,59 @@ +# TurboFile Batch Stream Progress and Direct-First Routing Design + +**Date:** 2026-07-15 +**Status:** Approved in chat + +## Goal + +Start multi-item and directory tar transfers immediately without walking the source tree to calculate a logical total. While the transfer is running, show only the bytes that have actually passed through the transfer, the current speed, and elapsed time. Preserve the existing single regular-file SCP behavior, including its known total size. + +For SSH-to-SSH transfers, optimistically try both direct server-to-server directions before using a relay. A cached or recently failed route may affect ordering, but it must not classify the devices as relay-only. + +## Progress Policy + +- A single regular file keeps its existing SCP size and percentage behavior. +- A transfer containing multiple selected items starts with `expectedBytes = 0`, including when every selected item is a regular file with a known listing size. +- A directory transferred through tar also starts with an unknown total because recursively calculating it has the same metadata cost as walking all contained files. +- Unknown-total transfers show transferred bytes, current speed, and elapsed time. They do not show a denominator, percentage-derived ETA, or a misleading partial total. +- Local-to-SSH and SSH-to-local tar transfers count archive bytes in the existing stream callback. +- SSH-to-SSH relay tar transfers add a pass-through counter to the existing Node.js pipeline. +- SSH-to-SSH direct tar transfers must not poll the target with recursive `find`/`du`. Direct progress uses lightweight output from the direct stream when available; otherwise the UI remains indeterminate until completion rather than scanning the directory tree. +- Completion uses the successful exit status of both tar endpoints. A failed or canceled move never deletes source items. + +Archive bytes are intentionally used instead of logical file sizes. Tar headers can make this value slightly larger than the sum of file contents, but it accurately represents transferred stream data and has effectively no additional filesystem traversal cost. + +## Direct-First Routing + +- Every automatic SSH-to-SSH transfer plan is ordered as direct, then relay for the same protocol. +- The direct route resolver always returns `source-push` and `target-pull` candidates when both endpoints have SSH connection details. +- A previously successful route is tried first. +- A route in failure cooldown is moved later in the direct ordering, not removed. This prevents a stale classification from skipping direct transfer entirely. +- If both direct directions fail or stall within the existing bounded timeout, the current relay attempt runs automatically. +- An explicit relay strategy hint remains respected. + +## Code Changes + +1. Remove UI background directory-size calculation from batch transfer entry points and force batch request/item expected sizes to zero. +2. Make the main-process batch total policy authoritative so callers cannot accidentally re-enable a multi-item pre-scan. +3. Remove batch tar source-size promises, blocking size scans, and recursive target-growth polling. +4. Count archive bytes in upload, download, and relay pipelines and report progress with `totalBytes = 0`. +5. Preserve single regular-file SCP expected-size handling. +6. Change direct-route cooldown filtering into stable prioritization so both direct directions remain candidates before relay. +7. Keep existing cancellation and move cleanup ordering unchanged. + +## Testing + +- Unit/static tests prove multi-item requests do not pass known totals or call background size calculation. +- Tests prove batch tar paths do not call source/target recursive size helpers before or during transfer. +- Tests prove relay tar counts stream bytes with an unknown total. +- Transfer-plan tests retain direct-before-relay ordering. +- Route-resolution tests prove cooldown cannot eliminate all direct candidates. +- Existing single-file SCP plan tests continue to pass unchanged. +- Run `npm run compile-check-ts-native` before the targeted TurboFile unit tests, then run the relevant test suites. + +## Non-Goals + +- No optional pre-scan toggle. +- No per-file logical byte accounting inside tar. +- No change to single-file SCP behavior. +- No change to same-device native copy or move behavior. diff --git a/docs/superpowers/specs/2026-07-15-drag-mode-compact-icon-design.md b/docs/superpowers/specs/2026-07-15-drag-mode-compact-icon-design.md new file mode 100644 index 00000000..4c123982 --- /dev/null +++ b/docs/superpowers/specs/2026-07-15-drag-mode-compact-icon-design.md @@ -0,0 +1,38 @@ +# 复制/剪切模式紧凑图标设计 + +## 目标 + +将当前工具栏中横向排列的“独立图标+文字+外层长方形边框”改成与其他工具栏图标相同大小的紧凑控件。文字直接显示在图标框内部,不再占用额外横向空间。 + +## 视觉结构 + +- 外层按钮固定为标准工具栏尺寸 `24×24px`,与刷新、搜索等按钮一致。 +- 外层按钮在默认、悬停、按下和聚焦状态下均不显示边框、轮廓、背景或选中高亮。 +- 按钮内部只有一个 `24×18px` 的小方框图标。 +- 小方框内部以 `9px`、半粗字重居中显示完整中文“复制”或“剪切”,保持清晰且不触碰边线。 +- 删除现有独立文件图标、剪刀图标以及图标右侧的文字。 +- 控件放在排序按钮左侧,工具栏顺序固定为“复制/剪切、排序、刷新、搜索……”;它是最晚新增的操作,但在视觉顺序上先于排序。 + +## 颜色 + +- 复制模式:青绿色,使用清晰的主题色并保证深色、浅色主题下可见。 +- 剪切模式:亮红色,避免现有剪切状态过暗。 +- 颜色同时应用于内部小方框边线和文字。 +- 点击切换时只改变文字与颜色,不改变控件尺寸和位置。 +- 高对比度主题允许使用系统对比色,以保证可访问性。 + +## 行为 + +- 默认仍为复制模式。 +- 点击后在复制和剪切之间切换,左右面板继续保持各自独立状态。 +- 不改变拖拽复制、拖拽剪切、同侧移动以及失败时不删除源文件等传输逻辑。 +- 鼠标或键盘切换后都不显示外层焦点框;按钮继续保留 `title` 和 `aria-label`,不改变键盘可操作性。 + +## 验证 + +- 检查按钮外层尺寸与相邻工具栏图标一致。 +- 检查复制和剪切文字完整位于内部小方框内。 +- 检查默认、悬停、按下、聚焦及切换后均不存在独立文件/剪刀图标、外部长方形边框、背景或阴影。 +- 检查复制为青绿色、剪切为亮红色,并在深浅主题下清晰可见。 +- 检查左右面板模式仍独立,点击切换不造成工具栏位移。 +- 检查左右工具栏中复制/剪切按钮都位于排序按钮左侧。 diff --git a/docs/superpowers/specs/2026-07-15-image-grid-fast-correct-sorting-design.md b/docs/superpowers/specs/2026-07-15-image-grid-fast-correct-sorting-design.md new file mode 100644 index 00000000..bf9275aa --- /dev/null +++ b/docs/superpowers/specs/2026-07-15-image-grid-fast-correct-sorting-design.md @@ -0,0 +1,78 @@ +# 图片网格快速且正确的排序设计 + +> 此设计中的“像素面积排序与图片头预热”已被 `2026-07-17-image-grid-instant-file-sort-design.md` 取代;当前实现复用主文件列表的文件字节大小和修改日期,以保证排序即时生效。 + +## 目标 + +修复图片网格的日期和大小排序,使四种排序(名称、类型、修改日期、大小)在递增和递减方向下都稳定、正确,并让用户切换排序方式时立即完成排序。 + +## 已确认的产品语义 + +- “大小”继续表示图片原始像素面积(原始宽度 × 原始高度),不改成文件字节大小。 +- 名称和类型排序继续使用自然数字排序并忽略大小写。 +- 修改日期使用文件真实修改时间。 +- 元数据缺失或读取失败的图片固定排在有完整元数据的图片之后,不因升降序跑到最前面;失败项之间按名称稳定排序。 +- 排序后保留当前选中图片、多选集合、选择锚点和当前滚动位置。 + +## 根因 + +1. 目录默认使用名称优先的快速列表,只为可见文件延迟补充大小和修改时间。图片网格直接复用这些条目,因此未显示过的图片可能仍以修改时间 `0` 参与日期排序。 +2. 图片大小比较器依赖运行时解码得到的宽高。虚拟网格只加载可见缩略图,而且远程缩略图可能已经缩小,因此未显示图片的面积为 `0`,已显示图片也可能使用缩略图尺寸而不是原始尺寸。 +3. 现有测试主要检查源码中是否存在排序分支,没有直接验证真实比较器输出和未知元数据规则。 + +## 设计 + +### 可测试的纯排序模块 + +新增独立的图片网格排序模块,包含排序键、方向、最小图片排序记录和纯比较函数。比较器只读取已准备好的内存字段:名称、扩展名、修改时间、原始宽度、原始高度及各字段是否已解析。 + +比较规则: + +- 名称:自然数字排序。 +- 类型:小写扩展名,自然排序,名称兜底。 +- 日期:真实修改时间,名称兜底。 +- 大小:原始宽高的安全像素面积,名称兜底。 +- 日期或大小未知时,无论递增还是递减都放在已知值之后。 + +### 元数据预热 + +图片网格创建后立即开始后台预热,而不是等用户点击排序: + +1. 修改时间和文件字节数复用目录完整属性批量接口,一次请求补齐当前目录中的全部图片,禁止逐文件 SSH `stat`。 +2. 原始宽高通过新的批量图片元数据接口取得。接口按设备执行少量批次请求,优先使用远端可用的图片元数据工具;无工具时读取有限图片头并在主进程解析,不传输完整图片。 +3. 元数据以图片路径写回当前网格状态,并设置独立的日期/尺寸就绪标记。 +4. 使用请求代次标识丢弃目录切换、网格关闭或新请求产生后的旧结果。 + +### 快速切换 + +- 名称和类型排序始终同步完成。 +- 日期和大小在元数据已预热后只执行一次内存排序和一次网格更新,目标是点击后一个渲染帧内完成比较,常规目录不产生新的 SSH 往返。 +- 如果用户在首次预热完成前点击日期或大小,立即提升对应元数据任务优先级;网格保持稳定并显示简短的“正在准备排序信息”,元数据到齐后只重排一次,不随单张图片加载反复跳动。 +- 不在每张缩略图加载时触发大小重排;缩略图宽高只用于显示布局,不再作为排序数据源。 + +## 性能约束 + +- 切换已就绪的排序键不得发起文件或网络读取。 +- 元数据获取必须批量化且有并发上限,不得出现每张图片一次 SSH 往返。 +- 不读取完整大图来取得排序宽高;只允许远端元数据工具或有限头部读取。 +- 排序复杂度为 `O(n log n)`,渲染只执行一次。 + +## 错误处理 + +- 单张图片元数据失败不阻塞整个网格。 +- 失败项标记为未知并稳定排在已知项之后。 +- 整批瞬时失败可以按现有图片读取重试策略有限重试;过期请求结果不得写回新目录。 +- 不因排序元数据失败破坏缩略图、预览、删除或拖拽功能。 + +## 测试 + +- 纯排序单元测试覆盖四个键、两个方向、数字文件名、大小写扩展名、相同主键名称兜底、未知日期、未知宽高和超大尺寸。 +- 状态测试覆盖排序后单选、多选和锚点按路径保持。 +- 静态/集成测试覆盖打开图片网格即启动预热、日期和大小切换不逐文件请求、尺寸更新不再逐张重排、过期请求被丢弃。 +- 运行 TypeScript 编译检查以及全部 TurboFile 图片相关测试。 + +## 非目标 + +- 不改变“大小”为文件字节大小。 +- 不改变图片网格布局、缩略图质量或预览渲染。 +- 不为排序下载完整原图。 diff --git a/docs/superpowers/specs/2026-07-15-non-single-file-unknown-size-design.md b/docs/superpowers/specs/2026-07-15-non-single-file-unknown-size-design.md new file mode 100644 index 00000000..f2a5a7b5 --- /dev/null +++ b/docs/superpowers/specs/2026-07-15-non-single-file-unknown-size-design.md @@ -0,0 +1,47 @@ +# 非单文件传输禁止大小扫描设计 + +## 目标 + +只有一次传输恰好包含一个普通文件时才保留总大小;多文件或包含任何文件夹的传输都立即开始,不在传输前、传输中或传输完成后递归扫描来源或目标以计算总大小。该规则同时适用于本机同设备、同一 SSH 设备、跨设备、复制、剪切、批量传输以及 SCP、SFTP、rsync、tar 等所有协议。 + +## 统一判定规则 + +- 仅当一次传输恰好包含一个普通文件时,使用目录列表已经取得的 `rawSize` 作为总大小;不为此增加额外 `stat`、扫描或网络请求。 +- 一次选择多个项目时,即使全部是普通文件,也不设置总大小。 +- 一次传输包含任何目录时,即使只选择一个目录,也不设置总大小。 +- 未知总大小不使用 `0` 作为占位值;任务和传输项中的 `totalBytes` 直接保持未设置。 +- 协议不能改变该规则;SCP、SFTP、rsync、tar、同设备原生复制和中转路径都继承传输开始时确定的大小策略,协议回退也不能重新计算或补填总大小。 + +## 进度显示 + +- 单个普通文件在所有协议下均显示“已传输大小 / 总大小”、实时速度和时间信息,例如 `1.2 GB / 8.0 GB`。 +- 多文件或文件夹路径有真实字节流时(例如 tar 管道、本机逐文件复制),只累计并显示已传输字节、实时速度和已用时间,例如 `1.2 GB`;已传输大小后面没有 `/`、没有分母,也不显示百分比或剩余时间。 +- 没有真实字节反馈的同 SSH 设备原生 `cp`/`mv` 保持最快原生命令,不通过轮询目录大小制造进度;只显示能够可靠取得的项目进度和已用时间。 +- 剪切通过同设备 `rename`/`mv` 完成时没有实际传输字节,字节数保持为零是正确结果。 +- 完成后也不扫描目标目录补算总大小,不把已传输字节、结果字节或目标大小回填成总大小。 + +## 实现边界 + +- 渲染进程在任务创建时确定大小策略:单个普通文件保存已有总大小,多文件或文件夹完全不设置总大小。 +- 主进程在本机单目录复制和同 SSH 单目录复制路径中跳过递归大小获取。 +- 同 SSH 原生目录复制不启动目标目录增长扫描;单个普通文件仍可沿用现有文件级进度。 +- 进度聚合只累计真实已传输字节,不允许使用 `rawSize`、`transferredBytes` 或完成结果推导缺失的总大小。 +- 完成事件保留真实累计字节;没有真实字节来源时保持零,但该值只表示已传输字节,不代表总大小。 +- 不改变复制命令、tar 数据通路、缓冲区、并发度、冲突处理、取消处理或剪切成功后删除来源的安全规则。 + +## 错误与安全 + +- 失败或取消时绝不因为无法计算大小而删除来源。 +- 剪切仅在目标操作成功后执行来源删除;同设备原生移动继续使用现有原子移动语义。 +- 大小未知不影响同名冲突预检、事务式临时目录提交或 tar 失败回退。 + +## 测试 + +- 单个普通文件在 SCP、SFTP、rsync、tar、同机复制等所有策略下仍保留目录列表已有的总大小,且不增加额外 I/O。 +- 单个目录、多个普通文件、多个目录和混合选择的 `totalBytes` 均保持未设置。 +- 本机单目录复制不调用递归本地大小函数。 +- 同 SSH 单目录复制不调用远端递归大小命令,也不在完成后补扫目标。 +- 同 SSH 原生目录复制在未知总大小时不启动目录增长扫描。 +- tar 与批量回退路径继续报告真实流字节,但不创建或回填总大小;状态栏只显示已传输大小、实时速度和已用时间,且已传输大小后面没有 `/`。 +- 协议回退后仍保持原始大小策略:单文件保留总大小,多文件或文件夹不出现总大小。 +- 运行 TypeScript 原生检查、客户端编译及 TurboFile 定向测试。 diff --git a/docs/superpowers/specs/2026-07-15-tar-resilience-pane-modes-conflict-alert-design.md b/docs/superpowers/specs/2026-07-15-tar-resilience-pane-modes-conflict-alert-design.md new file mode 100644 index 00000000..76b88a97 --- /dev/null +++ b/docs/superpowers/specs/2026-07-15-tar-resilience-pane-modes-conflict-alert-design.md @@ -0,0 +1,236 @@ +# Tar 传输稳定性、左右栏独立模式、快捷键响应与同名冲突提醒设计 + +## 背景与根因 + +本次改动包含三个相互关联但边界清晰的问题: + +1. 当前 `TurboFileEditorInput` 仅保存一份页面级 `dragOperation`,左右栏工具栏读取并切换同一状态,因此两栏必然同步。 +2. 本机参与的 tar 传输使用“远端 tar / 本地 tar → SSH 管道 → 目标 tar”的高速流式路径。数据流结束后,代码继续无限等待 `ssh.exe`、`tar.exe` 或 ssh2 channel 的关闭事件;只要某一层没有正常收尾,任务就会永久停在接近完成的位置。当前 tar 还直接写最终目录,异常后可能留下半成品,使自动重试和协议回退不安全。 +3. 批量传输已经会在目标存在同名目录时整批拒绝,但冲突只显示在状态栏。用户容易把这种安全拒绝误认为传输协议故障。 +4. 面板内 Ctrl+C/X 已经同步创建内部剪贴板,但 Ctrl+V 会先等待 Windows 剪贴板写入,再启动 PowerShell STA 读取系统剪贴板,最后才决定使用内部数据。实测一次 Windows 剪贴板探测冷启动约 2.3 秒、热启动仍约 0.3 秒;面板内粘贴被不必要地串行阻塞,而拖拽直接使用内存 snapshot,因此响应差异明显。 + +对用户提供的 64 服务器目录做了只读验证:2835 个文件、内容约 4.87 MB、tar 流约 6.84 MB,远端 tar 在 0.06 秒内正常结束。因此该案例的瓶颈不在远端打包,而在 Windows 本机 SSH/tar 管道的生命周期收尾。 + +## 目标 + +- 每个传输页内,左栏和右栏分别保存复制/剪切模式;新页面两栏均默认复制。 +- tar 正常传输的数据路径、并发度、压缩策略和流缓冲保持不变,不增加递归大小扫描、内容校验、二次复制或额外网络流量。 +- 任意 tar/SSH 子进程或 ssh2 channel 都不能无限等待。 +- tar 异常、卡住或取消时不向最终目录暴露半成品,并能安全使用下一种协议重试。 +- 剪切只在目标完整提交成功后删除源文件;任何失败、取消或回退失败均保留源文件。 +- 同名目录继续保持“整批不开始、不覆盖、不合并”,但在界面弹窗明确说明冲突路径和安全行为。 +- 当前页面左右栏之间的 Ctrl+C/X/V 使用同步内存快路径,按键后立即进入与拖拽相同的传输调用链,不等待 Windows 剪贴板桥接。 +- 保留与 Windows Explorer 之间复制、剪切和粘贴文件的能力;外部剪贴板探测只在确实需要区分外部来源时执行。 + +## 非目标 + +- 不新增覆盖、合并、跳过或自动改名策略。 +- 不改变 Ctrl+C、Ctrl+X、Ctrl+V 的明确语义。 +- 不持久化左右栏模式;关闭标签或重启应用后恢复默认复制。 +- 不恢复目录大小预扫描,也不为 tar 增加百分比或 ETA。 +- 不通过降低并发、禁用 tar 或默认改用慢协议规避问题。 +- 不让“面板内快路径”覆盖用户在应用外复制的新内容;窗口离开前台后必须重新确认系统剪贴板来源。 + +## 方案比较 + +### 方案 A:只增加超时 + +实现最少,但超时后最终目录可能已经存在半成品,无法安全自动回退;只把“无限卡住”变成“失败”,不能满足稳定性要求。 + +### 方案 B:禁用 tar + +能绕开 tar 管道问题,但多目录、多文件传输性能明显下降,不满足速度要求。 + +### 方案 C:控制面监管 + 同盘临时目标 + 异常回退(采用) + +保持正常 tar 流完全不变,只在控制面增加生命周期监管。tar 先写目标同一文件系统中的隐藏临时路径,成功后用原子重命名提交;失败时清理临时目标并切换下一条路径。正常传输只新增少量内存状态、定时器和一次同盘 rename,不增加数据读写量或网络字节。 + +## 左右栏独立复制/剪切模式 + +`TurboFileEditorInput` 将单一 `dragOperation` 改为: + +```ts +{ + left: 'copy' | 'move'; + right: 'copy' | 'move'; +} +``` + +- `getDragOperation(side)`、`setDragOperation(side, operation)`、`toggleDragOperation(side)` 都必须显式接收栏位。 +- `appendDragOperationButton` 接收 `side`,点击只更新对应栏位。 +- 拖拽开始时读取来源栏模式并写入 drag snapshot;拖拽过程中切换按钮不会改变已经开始的操作。 +- 同一栏内部把项目拖入该栏中的文件夹,或拖到“上一级”入口,固定执行移动,不读取也不改变该栏的复制/剪切模式。该行为属于当前设备内的目录整理,优先使用原生 rename/move;失败或取消时保留来源。 +- Windows 拖入面板以目标栏模式为准;面板内部或拖出 Windows 以来源栏模式为准。 +- 上一条中的“面板内部”仅指跨栏拖拽;同栏目录整理始终按移动处理。 +- 每个 `TurboFileEditorInput` 仍然相互隔离。 + +### 模式按钮外观 + +左右栏各显示一个紧凑的圆角描边切换按钮,不再只显示单个无文字图标: + +- 复制状态:复制图标 + `复制`,使用青绿色的图标、文字和边框。 +- 剪切状态:剪刀图标 + `剪切`,使用橙色的图标、文字和边框。 +- 单个按钮点击后直接替换图标、文字、颜色和辅助功能名称,不同时显示两个操作。 +- 按钮背景保持普通工具栏背景,不使用持续高亮、选中底色、发光或按下后的粘滞状态;剪切状态也不再通过 `is-move` 高亮强调。 +- 鼠标悬停沿用普通工具栏 hover;键盘焦点仍显示标准 focus outline,避免移除必要的无障碍反馈。 +- 使用 VS Code 主题颜色变量和半透明边框适配深色、浅色及高对比度主题,不写死只适合当前截图背景的颜色。 + +## Tar 正常路径与预热 + +应用主进程在空闲阶段后台预热本地 tar 传输依赖:定位 SSH 工具、执行一次 `tar.exe --version` 并缓存 UTF-8 charset 参数。预热失败不阻止启动,正式传输仍可按现有懒加载路径重试。 + +正常传输仍保持: + +```text +tar stdout → SSH stdin/stdout → tar stdin +``` + +不增加哈希、递归扫描、压缩包落盘或数据复制。现有进度流继续统计已经过管道的 archive bytes。 + +## 管道生命周期监管 + +新增可测试的 tar pipeline supervisor,统一覆盖: + +- 本地 tar 上传和远程 tar 下载的原生 `ssh.exe` 路径; +- 原生路径失败后的 ssh2 stream 路径; +- 批量与单目录路径; +- SSH 到 SSH 的 direct/relay tar channel 收尾。 + +监管区分三个阶段: + +1. **启动阶段**:工具解析、SSH 建连和首个数据块使用宽松的启动边界,不把正常首次握手误判为卡住。 +2. **流式阶段**:只要 stdout/stderr 或 archive bytes 持续活动,就不限制总传输时长;传输文件再大也不会因为固定总时长被中止。 +3. **收尾阶段**:数据管道 EOF 后,相关 SSH/tar 进程或 channel 必须在短暂宽限期内退出。未退出则判定为收尾卡住。 + +监管只复用现有数据和进程事件更新时间戳,不在每个文件上增加操作。检测到卡住时: + +- 终止该尝试的完整本地进程树或关闭 ssh2 channel; +- 等待资源释放,避免残留 `ssh.exe`/`tar.exe`; +- 抛出可识别的 tar stall 错误,进入安全回退; +- 用户主动取消始终优先,返回取消而不是自动重试。 + +## 事务型临时目标 + +tar 传输不再直接写最终目录。 + +### 本地目标 + +在最终目标的同一父目录或目标根目录内创建 `.turbofile-transfer-.partial`。tar 解包到临时目录;两端进程都以退出码 0 完成后,将临时目录中的顶层项目原子重命名到最终名称。 + +### 远程目标 + +在目标所在文件系统内创建隐藏临时目录,tar 解包到临时目录;成功后使用远端 `mv` 提交。提交前继续执行现有同名目录检查。 + +### 失败处理 + +- tar 失败、卡住或取消:只清理本次临时目录。 +- 原子提交失败:回滚尚未提交的项目并报告错误,不删除源文件。 +- 正常成功路径不做递归完成扫描;tar/SSH 退出码和成功的原子提交构成完成条件。 + +临时目录与最终目录位于同一文件系统,提交是目录项 rename,不复制文件内容。 + +## 自动回退 + +本机参与的 tar 尝试顺序为: + +```text +原生 ssh.exe + tar.exe +→ ssh2 stream + tar.exe +→ rsync(可用时) +→ 并发 SFTP/SCP +``` + +SSH 到 SSH 保持 direct 优先,并在 direct tar 异常时进入 relay 或计划中的下一协议。只有异常尝试才回退;正常 tar 不额外启动任何协议。 + +每次回退前必须完成:终止旧进程/channel、清理临时目标、清除该尝试的进度状态。界面显示正在切换可靠传输路径,不能继续伪装成原 tar 任务卡住。 + +剪切源清理位于最终成功提交之后。tar、rsync 或 SFTP 任意一条最终成功均可进入源清理;失败和取消永远不进入源清理。 + +## 同名目录冲突弹窗 + +保持当前安全策略:批量中任意目标目录已存在,整个批次在传输前终止。 + +主进程返回结构化冲突信息,而不是让界面依赖英文错误字符串。界面收到冲突后弹出信息框: + +- 标题:`目标存在同名文件夹` +- 正文:`为避免覆盖或混合文件,本次批量传输未开始。` +- 详情:列出冲突目标路径;数量较多时显示前若干项和剩余数量。 +- 按钮:`确定` + +状态栏和任务记录仍保留失败信息。弹窗不提供覆盖、合并或跳过操作,源文件保持不变。 + +## Ctrl+C/X/V 面板内零等待快路径 + +### 当前阻塞链 + +当前 `pasteIntoPane()` 的顺序为: + +```text +等待 internal.windowsClipboardWrite +→ 启动 PowerShell 读取 Windows 剪贴板 +→ 比较 clipboardId / sequenceNumber +→ 调用 pasteInternalTransferClipboard +``` + +这条链路对“刚在当前页面左栏复制、马上到右栏粘贴”的场景没有必要。内部 clipboard 已经包含来源设备、栏位、操作类型和完整项目 snapshot,可以与拖拽使用相同传输入口。 + +### 快路径规则 + +- Ctrl+C/X 在键盘事件中同步完成选择 snapshot 和 `internalTransferClipboard` 更新,立即显示“已复制/已剪切”的界面反馈。 +- Windows 文件剪贴板写入继续异步执行,用于随后粘贴到 Explorer;它不能阻塞面板内操作。 +- 记录内部 clipboard 的可信状态。用户自 Ctrl+C/X 后一直留在当前应用窗口内时,Ctrl+V 直接调用 `pasteInternalTransferClipboard(targetSide)`,不等待写入 Promise,也不读取 Windows 剪贴板。 +- 应用窗口失焦、隐藏或检测到外部剪贴板变更后,将内部 clipboard 标记为“需要确认”。下一次 Ctrl+V 才读取 Windows 剪贴板:若 `clipboardId` 仍匹配则恢复内部快路径;否则使用新的 Windows 文件列表。 +- 同一页面内左右栏粘贴保持现有 copy/move 语义和成功后刷新策略。Ctrl+X 仍然只在目标传输完整成功后清理来源。 +- 对一次按键生成稳定 paste key,继续阻止 keydown 与 DOM paste 事件造成重复传输。 + +### Windows 剪贴板慢路径 + +Windows Explorer → 面板或面板 → Explorer 仍使用现有带 `Preferred DropEffect` 和自定义 `clipboardId` 的可靠桥接。主进程在应用空闲时预热 PowerShell/WinForms 剪贴板依赖,降低第一次外部粘贴的冷启动;预热失败时保留现有按需启动和重试作为兼容回退。 + +该优化不改变传输协议或数据吞吐,只移除传输开始前不必要的串行等待。 + +## 测试设计 + +### 左右栏模式 + +- 新页面左右栏均为复制。 +- 切换左栏不改变右栏,切换右栏不改变左栏。 +- 不同标签页继续相互隔离。 +- 拖拽 snapshot 使用来源栏模式;Windows 拖入使用目标栏模式。 +- 同栏拖入文件夹或“上一级”在复制模式和剪切模式下都固定解析为移动,且不改变按钮状态。 +- 每栏按钮显示对应的图标和中文文字;复制为青绿色、剪切为橙色。 +- 点击切换后不存在持续高亮或选中背景,深色、浅色和高对比度主题下文字与边框均清晰。 + +### Tar 监管 + +- 数据持续活动时不会触发固定总超时。 +- EOF 后进程正常退出立即完成,不等待宽限期。 +- EOF 后任一进程不退出会终止整个进程组并产生 stall 错误。 +- 无数据卡住会终止尝试;用户取消不会触发自动回退。 +- 原生路径卡住后进入 ssh2;ssh2 失败后进入 rsync/SFTP。 +- 每次异常均清理临时目标,不留下最终半成品或孤儿进程。 +- 正常成功仅执行同盘 rename,不执行递归大小扫描或内容二次复制。 +- 剪切只有在提交成功后清理源文件。 + +### 冲突提醒 + +- 单个和批量同名目录返回结构化冲突路径。 +- 任意冲突阻止整个批次启动。 +- 界面弹窗显示冲突路径和“未覆盖、未开始”的说明。 +- 非冲突传输不弹窗;普通网络或权限错误仍走原错误提示。 + +### 快捷键响应 + +- 内部 Windows 剪贴板写入 Promise 保持 pending 时,当前页面 Ctrl+V 仍立即调用内部传输。 +- 内部快路径不调用 `readWindowsClipboardFiles()`。 +- Ctrl+C/X 同步更新 snapshot 和操作类型,异步桥接失败不丢失内部 clipboard。 +- 窗口失焦后复制了 Explorer 文件时,下一次 Ctrl+V 不使用过期内部内容。 +- Windows clipboardId 仍匹配时,慢路径确认后恢复内部粘贴。 +- keydown 与 paste 事件不会为同一次按键启动两份任务。 + +## 验证 + +1. 运行 `npm run compile-check-ts-native`。 +2. 运行相关 TurboFile 单元测试,覆盖 drag mode、tar supervisor、事务目标、回退、冲突信息、快捷键快路径和剪切保护。 +3. 使用完整 `npx gulp vscode-win32-x64` 生成新的发布 Bundle,确认新代码进入实际安装包。 +4. 在 Windows 上验证首次和连续 tar 传输、主动取消、断网/杀死子进程、同名冲突弹窗、左右栏独立模式,以及当前页面和 Explorer 两类 Ctrl+C/X/V。 diff --git a/docs/superpowers/specs/2026-07-15-turbofile-document-tab-affinity-design.md b/docs/superpowers/specs/2026-07-15-turbofile-document-tab-affinity-design.md new file mode 100644 index 00000000..9d249a0a --- /dev/null +++ b/docs/superpowers/specs/2026-07-15-turbofile-document-tab-affinity-design.md @@ -0,0 +1,46 @@ +# 极速传页面与文档标签关联设计 + +## 目标 + +- 极速传页面标签和文档标签均不显示数字序号。 +- 从某个极速传页面打开的文档,其标签图标颜色与来源页面一致。 +- 同一个文档再次从其他页面打开时,颜色更新为最近一次来源页面的颜色。 +- 同一编辑器标签栏内,所有极速传页面始终位于左侧,所有文档始终位于右侧。 +- 不改变文档渲染、编辑、预览、代码跳转和默认编辑器行为。 + +## 标签名称 + +数字序号来自 VS Code 的 `workbench.editor.showTabIndex` 标签前缀,而不是编辑器标题本身。极速传主编辑器组中的标签将忽略该前缀;其他普通 VS Code 窗口或编辑器组仍遵循原有设置。 + +极速传页面继续显示自定义标题;未设置时显示“新标签页”。文档继续显示原始文件名,不添加页面编号、颜色名称或其他文字前缀。 + +## 颜色关联 + +极速传页面继续使用现有六色循环色板,并将稳定颜色槽暴露给其打开的文档。打开文档时,把来源页面的颜色槽以临时标签展示信息关联到实际 `EditorInput`,触发标签刷新。 + +文档标签只继承图标颜色,不改变文档内容、文件类型图标、文件名或编辑器选择。同一文档再次由其他页面打开时,覆盖为最近来源页面的颜色。关联仅存在于当前应用生命周期,不写入文件或用户配置。 + +## 标签顺序 + +标签栏按两类稳定分区: + +1. 左侧为所有 `TurboFileEditorInput` 页面,保留页面之间的相对顺序。 +2. 右侧为所有普通文档,保留文档之间的相对顺序。 + +新建极速传页面时插入到第一个文档之前;从页面打开文档时插入到最后一个文档之后。打开、复用或移动标签后进行轻量校正,防止页面落到文档右侧。校正只移动标签对象,不重新打开文档。 + +## 边界与兼容性 + +- 同一个文档已打开时复用现有编辑器,并刷新为最近来源页面的颜色。 +- 六个以上页面继续循环色板,页面和其文档始终使用同一颜色槽。 +- 关闭来源页面不会关闭已打开文档;文档保留最后关联的颜色直到关闭。 +- 不拆分左右编辑区域,不压缩极速传界面和文档编辑区。 +- 不改变用户手动设置的标签宽度、关闭按钮、固定标签和文档编辑器关联。 + +## 验证 + +- 验证开启 `workbench.editor.showTabIndex` 时,极速传主标签栏仍无数字序号。 +- 验证多个页面分别打开文档后,页面和文档图标颜色一一对应。 +- 验证同一文档从另一页面再次打开后切换到新页面颜色。 +- 验证先打开文档再新增页面,以及复用已打开文档时,页面始终全部在左侧。 +- 验证文档编辑、预览、保存、代码跳转和关闭行为保持不变。 diff --git a/docs/superpowers/specs/2026-07-16-immediate-transfer-start-and-move-refresh-design.md b/docs/superpowers/specs/2026-07-16-immediate-transfer-start-and-move-refresh-design.md new file mode 100644 index 00000000..adbd22cb --- /dev/null +++ b/docs/superpowers/specs/2026-07-16-immediate-transfer-start-and-move-refresh-design.md @@ -0,0 +1,119 @@ +# Immediate Transfer Start and Move Refresh Design + +## Goal + +Make every TurboFile transfer begin as soon as its entry point is triggered, without recursively calculating folder or multi-item totals first, while preserving live transferred bytes, speed, elapsed time, cancellation safety, and transfer throughput. Make every successful move refresh both source and target directories only after source cleanup has completed. Improve the copy/cut badge legibility without changing its footprint or colors. + +## Confirmed Behavior + +- A single regular file may use its already-known file size and displays `transferred / total`. +- A single folder, multiple files, multiple folders, and mixed selections have an unknown total and display transferred bytes only. +- Unknown-total transfers still display live speed and elapsed time. +- Drag-and-drop, Ctrl+C/V, Ctrl+X/V, same-machine, same-SSH, cross-SSH, upload, and download entry points follow the same immediate-start rule. +- Successful move operations refresh the source directory and target directory after the source has been removed. +- Failed or canceled transfers never delete the source. +- The copy/cut badge remains 24 px wide with the existing left-copy blue, right-copy green, and cut red colors. + +## Root Causes + +### Startup delay + +The current transfer path contains multiple forms of size discovery before or alongside actual transfer work: + +- Local native batch copy awaits `getLocalBatchTransferSize`, which recursively walks selected directories before entering the copy loop. +- The renderer starts `fillBatchDirectorySizesInBackground`, which sends a size request for every selected directory and competes with the active transfer for disk and SSH resources. +- Several TAR, SFTP, SCP, same-remote, and relay branches query source directory content size before starting the data command when `expectedBytes` is unknown. + +These scans contradict the unknown-total contract and account for the visible preparation delay on first transfer. + +### Stale source directory after cut + +The per-item internal clipboard fallback refreshes source and target panes before deleting the source. The subsequent deletion can therefore leave a just-refreshed, stale source listing visible. Move completion and refresh logic is also spread across several entry points, which makes ordering inconsistent. + +## Design + +### 1. Total-size policy + +Derive total-size knowledge from the selection shape, not from the transfer protocol: + +- Exactly one regular file: use the file size already present in the directory entry or the protocol's non-recursive file metadata. +- Any directory or any selection with more than one item: set the total to unknown (`0` in the existing transfer API) and never discover a recursive total solely for progress display. + +No directory size calculation may be started in the renderer, main process, or protocol strategy before transfer. Existing post-start byte reporting remains available: + +- Local copy callbacks count bytes actually copied. +- TAR upload/download stream callbacks count archive bytes as they flow. +- Rsync/SCP/SFTP/native direct strategies keep their existing non-blocking progress events or destination-growth sampling after the command has started. + +Post-transfer checks required to determine success may remain, but they must not be used to delay command launch or retroactively introduce a progress total for unknown-total transfers. + +### 2. Immediate command dispatch + +Each entry point performs only the minimum work required to construct a valid command: + +1. Capture the user action time and create the task immediately. +2. Validate source and target paths and resolve endpoint/connection information. +3. Select the transfer strategy. +4. Start the copy/move/protocol command. + +Recursive local walks, remote `du`/content-size commands, and per-folder background size jobs are excluded from this startup sequence. Necessary SSH connection establishment, target-parent creation, conflict handling, and source-type checks are retained because the transfer command cannot safely run without them. + +For local native batch copy, the existing recursive copy routine itself reports byte deltas while copying; it must not perform a separate first traversal. + +### 3. Move completion and refresh ordering + +All move paths follow the same state machine: + +1. Transfer/copy or native rename completes successfully. +2. If the strategy is copy-then-delete, delete all source items. +3. If any source deletion fails, report an error and do not report the move as successful. +4. Invalidate source and target directory caches. +5. Force-refresh every visible pane showing the affected source or target directory. +6. Mark the task successful only after the refresh request has been issued and completed. + +Native same-device rename/move skips the copy-then-delete step because the native command already removes the old path, but it still performs cache invalidation and both-directory refresh after the command succeeds. + +On cancellation or transfer failure, source deletion is skipped. The target may be refreshed to expose or clean up a partial result according to the existing protocol behavior, but the source listing remains authoritative and intact. + +External Windows moves refresh every visible local pane whose current path matches the moved items' source parent, plus the target pane. Cross-device and cross-SSH moves refresh the originating pane and destination pane after main-process cleanup has returned. + +### 4. Copy/cut badge typography + +Keep the existing 24 x 24 button and 24 x 18 inner badge geometry. Change only the label typography: + +- `font-size: 10px` (from 9 px) +- `font-weight: 400` (from 600) + +Hover, active, and focus states remain free of the large outer highlight. Existing colors and border geometry remain unchanged. + +## Error Handling and Safety + +- A move never deletes its source until the transfer result is successful. +- Cancellation is checked before cleanup and prevents cleanup. +- Cleanup failures remain visible as failures; they are never converted into successful moves. +- Removing total-size discovery must not remove protocol success checks or overwrite/conflict protection. +- No new buffering, staging file, throttling, archive wrapper, or progress relay is introduced, so the transfer data path and throughput remain unchanged. + +## Test Strategy + +Add regression tests before production edits: + +- Local native batch transfer does not call or await recursive batch-size discovery before copying. +- Renderer batch transfer does not start background folder-size calculation. +- TAR and other directory/multi-item strategies do not query a recursive source total before command dispatch. +- Single regular-file transfers retain a known total. +- Unknown-total progress preserves transferred bytes, speed, and elapsed time without displaying a total. +- Copy-then-delete moves delete the source before refreshing either pane. +- Successful native and cross-device moves refresh both affected directories. +- Failed and canceled moves do not delete the source. +- Copy/cut badge typography is 10 px and normal weight, while dimensions and colors are unchanged. + +Run `npm run compile-check-ts-native` first, then the targeted TurboFile unit tests, then the complete TurboFile unit suite required by the repository. + +## Non-Goals + +- Changing protocol order or replacing the restored 1.148.0 transfer data path. +- Adding directory totals after a transfer has started. +- Introducing fake progress percentages for unknown totals. +- Changing copy/cut button position, colors, dimensions, or per-pane independence. +- Packaging a new installer unless separately requested. diff --git a/docs/superpowers/specs/2026-07-16-pane-copy-cut-mode-on-148-design.md b/docs/superpowers/specs/2026-07-16-pane-copy-cut-mode-on-148-design.md new file mode 100644 index 00000000..bd99d638 --- /dev/null +++ b/docs/superpowers/specs/2026-07-16-pane-copy-cut-mode-on-148-design.md @@ -0,0 +1,51 @@ +# 基于 v1.148 传输基线恢复左右栏复制/剪切模式 + +## 目标 + +在保持当前 `v1.148.0` 传输协议与数据管道不变的前提下,恢复每个 TurboFile 页面左右栏各自独立的复制/剪切模式。控件必须清楚显示当前模式,并通过与栏位一致的颜色帮助用户辨认操作范围。 + +## 状态模型 + +- 每个 `TurboFileEditorInput` 保存左栏和右栏两个独立状态,取值为 `copy` 或 `move`。 +- 新页面左右栏均默认 `copy`;状态只存在于当前标签生命周期,不持久化。 +- 切换一栏不得改变另一栏,也不得改变其他 TurboFile 页面的状态。 +- 完成传输后保持当前模式,不自动恢复为复制。 + +## 控件与颜色 + +- 在左右文件栏工具栏中恢复标准工具栏尺寸的紧凑模式按钮,位置保持在排序按钮附近的既有位置。 +- 按钮内部的小方框直接显示完整中文“复制”或“剪切”,不额外显示独立图标或外层长方形边框。 +- 左栏复制模式使用与左栏排序按钮相同的蓝色。 +- 右栏复制模式使用与右栏排序按钮相同的绿色。 +- 左右栏剪切模式均使用清晰的红色,并一直保持红色,直到再次切换回复制。 +- 点击、悬停和键盘聚焦不得产生持续选中高亮或额外大边框;保留 `title`、`aria-label` 和键盘可操作性。 + +## 拖拽行为 + +- 内部跨栏拖拽在拖拽开始时快照来源栏模式,避免拖拽过程中切换按钮改变本次操作。 +- 从 TurboFile 栏拖到外部时使用来源栏模式。 +- 从 Windows 外部拖入 TurboFile 栏时使用目标栏模式。 +- 同一栏内拖到文件夹或“上一级”始终执行原生移动,不读取复制/剪切按钮状态,也不改变按钮状态。 +- Ctrl+C、Ctrl+X 和 Ctrl+V 保持快捷键自身的复制、剪切和粘贴语义,不受栏位模式按钮影响。 + +## 安全边界 + +- `copy` 只复制数据,不删除来源。 +- `move` 必须先完成目标传输;只有整次目标操作成功后才允许清理来源。 +- 失败、取消、部分成功、目标冲突或来源清理前出现异常时绝不删除来源。 +- 本功能只恢复模式状态、UI、拖拽操作选择和成功后的安全清理,不恢复 `v1.148.0` 之后的 tar staging、`dd` 进度中继、watchdog、drain 超时或其他协议管道改造。 + +## 实现边界 + +- 在公共拖拽辅助模块中提供 `copy`/`move` 类型、切换和 drop effect 解析,保持逻辑可单测。 +- 在 `TurboFileEditorInput` 中保存每页、每栏状态。 +- 在 `TurboFileEditor` 中渲染按钮,并在内部跨栏、外部拖入和向外拖出入口快照正确栏位的模式。 +- 复用当前 `v1.148.0` 批量传输与成功后来源清理能力,不修改主进程 tar/scp/rsync/SFTP 数据管道。 + +## 验证 + +- 单元测试覆盖默认状态、左右独立、页面独立、切换后保持、拖拽操作快照和同栏固定移动。 +- 静态 UI 测试覆盖左蓝、右绿、剪切红、紧凑文字框以及无持续高亮。 +- 安全测试覆盖失败或取消不删除来源,成功后才清理来源。 +- 运行 `npm run compile-check-ts-native`,然后运行相关及完整 TurboFile 测试。 +- 重新构建并校验版本为 `1.151.0` 的 Windows x64 安装包。 diff --git a/docs/superpowers/specs/2026-07-16-per-device-recycle-bin-design.md b/docs/superpowers/specs/2026-07-16-per-device-recycle-bin-design.md new file mode 100644 index 00000000..fc532dd9 --- /dev/null +++ b/docs/superpowers/specs/2026-07-16-per-device-recycle-bin-design.md @@ -0,0 +1,366 @@ +# 极速传分设备回收站设计 + +## 目标 + +为本机 Windows 和所有通过极速传添加的 SSH/NAS 设备增加类似 Windows 的回收站。用户在普通文件面板、图片网格或图片预览中明确执行“删除”时,文件不再永久删除,而是通过来源设备的原生移动命令进入同一磁盘或共享中的隐藏回收站。删除大文件夹不得复制数据、计算目录大小或经过 SCP、SFTP、TAR、rsync。 + +回收站按设备独立展示。左右文件面板可分别进入当前侧设备的回收站,互不影响。回收站内容不自动过期,只能由用户在回收站视图中永久删除或清空。 + +## 已确认产品规则 + +- 支持本机 Windows,以及所有通过极速传添加的 SSH、Linux、NAS 和远程 Windows 设备。 +- 每个设备拥有独立回收站;只有当前侧切换到该设备后才能查看。 +- 每个磁盘、文件系统挂载点或 NAS 共享拥有自己的隐藏回收站,当前设备界面聚合显示所有可访问分卷的内容。 +- 删除使用设备原生 Move/rename,同文件系统内不复制文件内容。 +- 无法建立同文件系统回收站时删除失败;不跨盘复制,也不永久删除来源。 +- 不自动清理。永久删除和清空回收站只能从回收站视图发起。 +- 普通文件视图不提供 Shift+Delete 或其他绕过回收站的入口。 +- 恢复时如果原位置存在同名内容,弹窗只允许“保留两者”或“取消”,绝不覆盖。 +- 只有用户明确点击界面删除或右键删除的操作进入回收站。 +- 剪切、Ctrl+X/V、拖拽移动、跨设备移动完成后的来源清理、失败回滚和内部临时数据清理不进入回收站。 +- 回收站不计算文件夹大小;文件大小只使用删除前目录列表已经知道的值。 + +## 方案选择 + +采用“极速传自建分卷回收站”。 + +该方案以用户提出的原生 Move 方法为核心,再增加分卷定位、唯一名称、恢复元数据、崩溃恢复和统一界面。没有采用各操作系统原生回收站,是因为 Windows Shell、Freedesktop Trash、远程 Windows 和不同 NAS 厂商的行为差异过大,无法保证相同的恢复、批量操作和错误处理。没有采用每设备单一集中目录,因为跨磁盘移动可能失败或退化为完整数据复制。 + +## 系统边界 + +### 用户删除 + +下列入口调用回收站 API: + +- 左右文件面板工具栏“删除选中项”。 +- 文件行右键菜单“删除”。 +- 图片网格删除。 +- 单张图片预览删除。 + +### 永久删除 + +下列内部路径继续调用永久删除 API: + +- 回收站中的“永久删除”。 +- “清空回收站”。 +- 剪切或跨设备移动完成后的来源清理。 +- 传输失败回滚和无效目标清理。 +- 极速传自身临时文件和安装更新清理。 + +底层必须明确分离: + +```text +movePathsToTrash() 用户主动删除 +deletePaths() 内部清理与永久删除 +``` + +现有 `deletePath`/`deletePaths` 不得被全局改造成回收站操作,否则会破坏剪切和传输语义。 + +## 分卷回收站解析 + +### 本机 Windows + +- 来源位于当前 Windows 用户主目录所在系统盘时,使用: + +```text +%LOCALAPPDATA%\TurboFile\RecycleBin\<卷标识> +``` + +- 来源位于其他盘符时,使用: + +```text +<盘符>:\.turbofile-trash-<用户标识>\turbofile +``` + +- 目录设置隐藏属性。 +- 如果数据盘根目录不允许创建回收站,则本次删除失败并提示权限原因。 +- 不回退到系统盘,不复制来源内容。 + +### Linux、SSH 和 NAS + +- 来源与用户主目录位于同一文件系统时,使用: + +```text +~/.local/share/Trash +``` + +- 来源位于其他挂载点或共享时,通过 `df`/`stat` 解析真实挂载点,使用: + +```text +<挂载点>/.Trash-/turbofile +``` + +- 如果挂载点已有符合权限要求的 `.Trash/`,可以复用标准分卷回收站位置,但极速传数据仍放在自己的命名空间中。 +- 如果挂载点不允许创建回收站,则删除失败,不回退到其他文件系统。 + +### 远程 Windows + +- 使用远程 PowerShell 解析盘符、用户标识和用户数据目录。 +- 系统盘与其他盘的选择规则和本机 Windows 一致。 +- 移动使用远程原生 `Move-Item` 或等价 MoveFile 操作。 + +### 设备身份 + +SSH 回收站按以下稳定身份区分: + +```text +host + port + username +``` + +删除并重新添加相同服务器、端口和用户名后,仍可看到原回收站。相同主机的不同 SSH 用户相互隔离。本机 Windows 使用当前 Windows 用户身份。 + +### 分卷发现与聚合 + +打开当前设备回收站时只枚举文件系统根,不递归扫描普通目录: + +- 本机和远程 Windows 使用文件系统驱动器枚举取得盘符列表。 +- POSIX SSH 优先使用 `findmnt` 枚举挂载点,不可用时回退到 `df -P`。 +- 对每个可访问分卷只检查约定的回收站元数据目录。 +- 当前会话曾经成功使用过的回收站根加入设备级缓存,避免重复探测。 +- 分卷离线或无权限时记录为不可访问,不阻塞其他分卷列表。 + +聚合结果仍携带真实 `volumeId` 和回收站根路径,因此恢复、永久删除和清空操作始终回到正确分卷,不存在跨盘 Move。 + +## 存储格式 + +每个分卷回收站使用独立的文件和信息目录: + +```text +.turbofile-trash/ +├─ files/ +│ └─ <唯一ID> +├─ info/ +│ ├─ <唯一ID>.pending.json +│ └─ <唯一ID>.json +└─ version.json +``` + +真实文件或完整文件夹原样移动到 `files/<唯一ID>`。不压缩、不打包、不重新编码,也不修改文件内部结构。唯一 ID 防止来自不同原始目录的同名文件互相覆盖。 + +完成元数据至少包含: + +```json +{ + "id": "唯一ID", + "originalName": "主体检测", + "originalPath": "/home/ubuntu/data/主体检测", + "deletedAt": "2026-07-16T16:30:00.000Z", + "type": "directory", + "knownSize": 0, + "volumeId": "文件系统或卷标识", + "deviceIdentity": "稳定设备身份" +} +``` + +文件夹 `knownSize` 保持未知,不递归统计。符号链接只移动链接本身,不遍历链接目标。 + +## 删除事务 + +单项删除按以下顺序执行: + +1. 验证来源存在,不是磁盘根目录、SSH 根目录、挂载点根目录或极速传回收站自身。 +2. 解析来源所在文件系统和对应回收站根。 +3. 确认回收站元数据目录可写。 +4. 创建唯一 ID 并写入 `.pending.json`。 +5. 使用同文件系统原生 Move 把来源移动到 `files/`。 +6. 将 pending 元数据原子改名为 `.json`。 +7. 清理来源目录缓存,更新回收站计数和图标状态。 + +任一步失败都不得调用永久删除。元数据无法写入时不移动来源。Move 失败时保留来源和可诊断的 pending 信息。 + +批量删除使用一个本机批处理或尽可能少的 SSH 往返。每一项拥有独立结果:成功项进入回收站,失败项留在原目录。任务日志显示成功数、失败数和失败原因。 + +## 崩溃恢复 + +打开回收站时检查 pending 元数据: + +- pending 存在、来源仍在、回收站 payload 不存在:删除无效 pending,来源不变。 +- pending 存在、来源不存在、payload 存在:将 pending 恢复为正式元数据,项目可正常还原。 +- pending 与 payload 都不存在:清理无效 pending。 +- 正式元数据存在但 payload 缺失:显示损坏记录并允许清理元数据,不伪装成可恢复项目。 + +这样即使程序在 Move 成功后、元数据确认前退出,也能保留原始路径。 + +## 恢复流程 + +1. 用户在当前设备回收站选择一个或多个项目并点击“还原”。 +2. 读取正式元数据,确认 payload 存在。 +3. 原始父目录不存在时自动创建。 +4. 原位置无同名内容时,使用同文件系统原生 Move 恢复。 +5. 原位置存在同名内容时,弹窗提供: + - “保留两者”:生成 `名称 (1)`、`名称 (2)` 等安全名称。 + - “取消”:项目继续留在回收站。 +6. 恢复成功后删除对应元数据并刷新原目录与回收站。 + +恢复失败时 payload 和元数据均保留。绝不覆盖原位置已有内容。 + +## 永久删除和清空 + +- 永久删除只能在回收站视图操作。 +- 单项或批量永久删除必须二次确认。 +- 清空回收站对当前设备所有可访问分卷生效,使用更高风险提示。 +- 永久删除继续复用现有高性能删除实现: + - 本机并行删除。 + - Linux/SSH 批量 `rm`。 + - 远程 Windows 批量 `Remove-Item`。 +- payload 删除成功后才删除元数据。 +- 分卷离线或删除失败时保留对应项目,清空结果按分卷和项目报告,不误报全部成功。 + +## 界面设计 + +### 普通视图 + +在左右两侧工具栏红色删除按钮右边增加 Windows 风格回收站图标: + +```text +排序 复制/剪切 刷新 搜索 删除 回收站 复制路径 图片网格…… +``` + +- 空回收站显示空图标。 +- 当前设备任一分卷存在项目时显示装有文件的图标。 +- 图标使用用户提供的 Windows 回收站视觉参考制作空/满两态小尺寸资源,保持与其他工具栏图标相同的占位和对齐。 +- 提示文字为“打开当前设备回收站”。 +- 普通删除确认改为“确定将这些项目移到回收站吗?”。 + +### 回收站视图 + +点击图标只切换当前侧文件区域,另一侧不刷新、不重渲染、不改变目录。 + +标题显示: + +```text +<设备名称> - 回收站 +``` + +列表字段: + +- 原始名称 +- 原始位置 +- 删除时间 +- 文件或文件夹类型 +- 已知文件大小 + +回收站工具栏: + +```text +返回 还原 永久删除 清空回收站 刷新 +``` + +- 再次点击回收站图标或点击“返回”回到进入回收站前的目录。 +- 支持单选、多选和全选。 +- 右键菜单包含“还原”“永久删除”“复制原始路径”。 +- 不显示上一级目录。 +- 不允许把回收站项目拖到普通目录绕过恢复冲突规则。 +- 切换设备时当前侧自动退出回收站并进入新设备普通视图。 + +回收站模式和进入前目录按页面、按左右侧临时保存,不持久化到应用重启后。 + +## 性能约束 + +- 删除文件夹不得遍历内部内容。 +- 删除和恢复不得调用 SCP、SFTP、TAR 或 rsync。 +- 不计算回收站项目的文件夹大小。 +- 同文件系统操作只能使用 Move/rename。 +- 不允许把跨文件系统复制包装成“删除到回收站”。 +- 批量删除不得逐项建立 SSH 连接或逐项执行一个 SSH 往返。 +- 回收站列表只读取元数据,不读取 payload 内容。 +- 回收站排序在内存中完成,默认按删除时间倒序。 +- 图标空/满状态使用缓存计数或轻量元数据目录检查,不扫描 payload。 + +## 错误处理 + +- 权限不足:提示无法创建或写入当前分卷回收站,来源保留。 +- SSH 断开:本次未确认完成的项目保留或由 pending 恢复,其他项目结果独立。 +- 分卷离线:显示部分回收站不可访问,已访问分卷仍可操作。 +- 元数据损坏:隔离损坏记录,不自动永久删除 payload。 +- 路径含中文、空格、换行、引号或特殊符号:全部使用参数化、NUL 分隔输入或严格平台转义。 +- 磁盘空间不足:元数据必须在 Move 前成功写入;失败时来源不动。 +- 取消操作:尚未 Move 的项目不处理;已经成功 Move 的项目保留在回收站,不回滚成永久删除。 + +## API 和组件边界 + +在 TurboFile 服务合同中增加专用回收站接口,避免复用永久删除接口: + +```text +movePathsToTrash(request) +listTrashItems(device) +restoreTrashItems(request) +permanentlyDeleteTrashItems(request) +emptyTrash(device) +getTrashState(device) +``` + +实现拆分为: + +- 回收站根解析器:只负责根据端点找到同文件系统回收站。 +- 元数据存储器:只负责 pending、完成、列出和修复元数据。 +- 原生移动执行器:只负责本机/远程平台 Move。 +- 回收站应用服务:编排删除、恢复、永久删除、清空和部分失败。 +- 工作台回收站视图状态:只负责左右侧界面、选择和操作。 + +每个组件使用明确数据结构通信,传输协议选择器不感知回收站实现。 + +## 测试计划 + +### 单元测试 + +- Windows 系统盘、数据盘和用户标识解析。 +- Linux 主目录文件系统、独立挂载点和 NAS 共享解析。 +- 同服务器不同用户隔离与重新添加设备后身份稳定。 +- 唯一 ID、同名文件、特殊路径和元数据序列化。 +- pending 状态四种恢复组合。 +- 根目录、挂载点根目录、回收站自身和符号链接保护。 +- 恢复名称 `名称 (1)`、`名称 (2)` 的确定性。 + +### 服务测试 + +- 本机、POSIX SSH 和远程 Windows 删除命令只使用 Move。 +- 删除目录不调用目录大小计算。 +- 删除/恢复不进入传输协议计划。 +- 批量操作使用一次或少量远程命令。 +- 部分失败保持逐项结果。 +- 永久删除复用当前删除路径。 +- 剪切、移动和内部清理继续调用永久删除,不进入回收站。 + +### 界面测试 + +- 图标位于删除按钮右侧。 +- 空/满图标状态正确。 +- 左右侧回收站状态独立。 +- 一个面板切换不会重渲染另一面板。 +- 返回恢复进入前目录。 +- 切换设备退出回收站。 +- 工具栏、右键菜单、多选、全选和确认框作用域正确。 +- 文件面板、图片网格和图片预览删除均调用回收站。 + +### 性能与手工验收 + +- Windows C 盘和其他数据盘。 +- Linux 用户主目录和独立挂载点。 +- NAS 共享。 +- 远程 Windows SSH。 +- 中文、空格、特殊符号和超长路径。 +- 单文件、几十 GB 文件夹、数千个独立文件和混合选择。 +- 权限不足、SSH 断开、挂载点离线和程序异常关闭。 +- 大文件夹删除不读取其内部条目,操作启动时间接近同设备普通移动。 + +完成后运行: + +```powershell +npm run compile-check-ts-native +npm run valid-layers-check +npm run gulp compile-client +scripts\test.bat --grep TurboFile +git diff --check +``` + +通过后再按用户指定版本生成 Windows x64 安装包。 + +## 非目标 + +- 不与 Windows 系统回收站、桌面回收站或 NAS 厂商回收站互通。 +- 不实现自动过期、容量配额或自动清理。 +- 不实现普通视图 Shift+Delete。 +- 不实现跨设备恢复或跨设备拖出回收站。 +- 不计算回收站文件夹总大小。 +- 不将剪切和移动当作删除。 diff --git a/docs/superpowers/specs/2026-07-16-stable-directory-sort-design.md b/docs/superpowers/specs/2026-07-16-stable-directory-sort-design.md new file mode 100644 index 00000000..c4b745aa --- /dev/null +++ b/docs/superpowers/specs/2026-07-16-stable-directory-sort-design.md @@ -0,0 +1,75 @@ +# Stable Directory Sort Design + +## Goal + +Every directory must first appear in the final order selected for its pane. No visible list may be rendered in a temporary order and then move after size or modified-time metadata arrives. + +## Required Behavior + +- Name, type, modified-time, and size sorting must all be correct on the first visible frame of a newly opened directory. +- Ascending and descending directions must both be respected before the directory becomes visible. +- The existing parent-entry and directory-first grouping rules remain unchanged. +- Double-click navigation, parent navigation, path entry, device switching, page restoration, cached navigation, refresh, and sort changes use the same display-readiness rule. +- No loading text, skeleton, animation, transition, or other waiting effect is added. +- A visible directory list must never be re-sorted because lazy metadata enrichment completed. + +## Root Cause + +Commit `f78b3e7b` introduced names-and-type-only directory listings plus lazy metadata enrichment for visible rows. Commit `2d4622df` removed the resulting jump by loading complete remote attributes through one `find` round trip before rendering. Commit `af0ac035` later restored the names-only foreground path to protect SSH sessions used by background prefetch and image work. + +The current conditional foreground path is not a complete invariant. Attribute-incomplete cache entries, device/page restoration, sort-change races, and an already-running visible-row enrichment can still expose or re-sort partial data. + +## Design + +### Foreground remote navigation + +Foreground remote directory loads always use `listRemoteDirectory`, which returns name, type, size, and modified time in one remote `find` command. The renderer converts and sorts the complete entries in memory before one render. It does not issue one SSH stat command per file. + +This restores the stable behavior from `2d4622df` without restoring unlimited full-metadata background prefetch. + +### Background prefetch + +Idle neighbor prefetch remains names-only and bounded, preserving the later SSH session-stability work. A names-only prefetch result may accelerate name or type views, but it never satisfies a foreground modified-time or size view. + +For the directory the user directly points to or presses, prefetch uses the pane's current sort requirement. Modified-time and size sorts request the complete listing early; name and type sorts keep the lightweight request. + +### Central display-readiness rule + +Introduce one renderer predicate that answers whether a cached or snapshotted listing can be displayed for the current pane sort: + +- name or type: names and types are sufficient; +- modified time or size: every non-parent entry must have complete attributes. + +All navigation and restoration paths use this predicate before assigning visible pane items. An attribute-incomplete listing is never rendered under an attribute-dependent sort. + +### Race prevention + +When a full foreground listing is loading for modified-time or size sorting, existing lazy visible-row enrichment must not mutate or re-sort the pane. Stale enrichment results are ignored using the existing request/path identity checks plus the foreground-loading state. + +Changing from name/type to modified-time/size performs one full foreground read and one final replacement. It does not progressively sort the currently visible subset. + +## Performance + +- Remote foreground metadata uses one `find` process and one SSH round trip, matching the historically fast stable implementation. +- No per-entry SSH request is added. +- Directory sorting remains an in-memory `Array.sort` operation. +- Background neighbor prefetch stays names-only and bounded, preventing SSH `MaxSessions` pressure. +- Existing caches and in-flight deduplication remain in use, but cache reuse is gated by display readiness. + +## Failure Handling + +If a complete foreground listing fails, no partial new-directory order is displayed. Existing error handling reports the failure and leaves no partially sorted result that can later jump. + +Stale requests from a previous path or sort cannot replace the current pane. + +## Verification + +- Pure sort tests cover name, type, modified time, and size in both directions, including ties, parent entry, and directory-first grouping. +- Static navigation tests verify foreground remote loads use complete listings and background neighbor prefetch remains lightweight. +- Renderer regression tests verify every cache/restoration path rejects attribute-incomplete data for modified-time and size sorts. +- A race regression test verifies visible-row enrichment cannot re-sort an attribute-dependent foreground load. +- Mandatory TypeScript compilation runs before focused and complete TurboFile tests. + +## Scope + +This change does not alter transfer protocols, image-grid sorting, toolbar order, file-row styling, or packaging configuration. diff --git a/docs/superpowers/specs/2026-07-16-transfer-148-and-pane-isolation-design.md b/docs/superpowers/specs/2026-07-16-transfer-148-and-pane-isolation-design.md new file mode 100644 index 00000000..3cac1b30 --- /dev/null +++ b/docs/superpowers/specs/2026-07-16-transfer-148-and-pane-isolation-design.md @@ -0,0 +1,100 @@ +# 1.148.0 传输基线与双侧交互隔离设计 + +## 目标 + +在保留当前文档、图片浏览、图片排序、目录排序、标签页和打包能力的前提下完成三项工作: + +1. 左右面板完全隔离;一侧以面板模式展开图片后,另一侧进入目录、选择文件、打开图片或刷新时,不销毁或重新创建已展开侧的图片 DOM。 +2. 恢复同一面板内把文件或文件夹拖到其他文件夹或“..”上级目录的移动能力;该行为固定为移动,不受复制/剪切模式影响。 +3. 所有传输相关实现严格恢复到 `v1.148.0`,包括后来新增的传输功能、安全策略、进度计算和路由行为;非传输功能保持当前实现。 + +## 范围定义 + +“传输相关实现”包括下列行为及其直接依赖: + +- 单项与批量传输计划,以及 local/SSH、SSH/local、SSH/SSH 的协议和路由选择。 +- tar、scp、rsync、sftp、本机复制、同设备远端复制和中转传输的数据路径。 +- 直连判定、路由缓存、失败降级、并发、缓冲、压缩和命令生成。 +- 传输取消、进度采样、总大小计算、速度与耗时事件、完成判定和错误映射。 +- Ctrl+C/V/X、拖拽复制/剪切、外部 Windows 拖入拖出以及面板间传输的执行路径。 +- 与上述路径直接绑定的接口、状态、控件和测试。 + +严格恢复意味着:如果某项传输行为在 `v1.148.0` 不存在,当前实现不再保留。包括但不限于后来新增的 tar `dd` 进度中转、tar 看门狗与 drain 超时、临时 staging 后提交、目录冲突保护、未知总大小策略、增强剪切确认和新的 Windows 剪贴板桥接。 + +以下内容不属于回退范围: + +- 文档打开、渲染、编辑和语言能力。 +- 图片网格、单图预览、图片元数据与图片排序,除本规格明确要求的跨侧 DOM 隔离。 +- 普通文件列表排序、目录导航优化、标签页标题/颜色和页面排列。 +- 设备管理、主题、终端、任务日志、安装、打包和更新功能中与传输数据路径无关的代码。 + +## 恢复策略 + +不能整文件执行 `git checkout v1.148.0 -- `,因为核心文件同时包含大量当前必须保留的非传输功能。恢复采用符号级移植: + +1. 以标签 `v1.148.0`(提交 `6b65264f`)为唯一传输基线。 +2. 对传输专用文件恢复整文件;对混合职责文件只恢复传输接口、字段、方法、命令构造器和调用分支。 +3. 删除仅为 1.148.0 之后传输行为服务、且在基线中不存在的专用模块。 +4. 只做让 1.148.0 传输代码与当前非传输代码共同编译所必需的机械适配;不得改变基线的协议顺序、数据管道、缓冲、并发或完成语义。 +5. 通过逐符号差异审计确认当前传输路径与标签一致,而不是以“行为相似”代替恢复。 + +## 图片面板隔离 + +当前 `render()` 会清空整个编辑器容器并重建左右面板。因此另一侧的任何全局渲染都会销毁已展开图片侧的网格节点、图片元素、滚动容器和画布,再重新挂载。 + +修复采用保留式图片 DOM 岛: + +- 每个面板图片状态拥有独立的根节点和独立的 DOM disposable store。 +- 全局渲染遇到仍然有效的面板图片状态时复用原节点,不重新调用完整图片网格构建。 +- 只有该图片面板自身的列数、缩放、排序、目录或关闭操作可以使自己的 DOM 岛失效。 +- 另一侧的目录、选择、预览、设备和任务状态变化不得使该 DOM 岛失效。 +- 状态释放时必须同步释放节点级监听器、Blob URL、缩略图任务和观察器,避免泄漏。 + +这一方案保留当前全局渲染结构,同时保证图片、滚动位置、已解码资源和画布不因另一侧操作而重新创建。 + +## 同侧拖拽移动 + +当前拖拽源根据面板复制/剪切模式把 `DataTransfer.effectAllowed` 设置为 `copy` 或 `move`,而同侧文件夹目标固定设置 `dropEffect = move`。默认复制模式下二者不兼容,Chromium 可以拒绝 drop。 + +修复规则: + +- 内部文件行拖拽的 `effectAllowed` 必须允许同侧移动和跨侧基线行为,不得因面板默认复制模式禁止 `move`。 +- 拖到同侧普通文件夹或“..”时固定执行同设备 rename/move。 +- 不能把文件夹移动到自身或子目录,不能移动到原父目录,保持现有防护。 +- drop 失败或用户取消时不删除源项目。 +- 同侧目标优先处理事件,页面级和窗口级拖拽拦截器不得抢占。 + +## 数据与错误处理 + +- 传输的数据流、错误、取消、回退和完成语义完全采用 1.148.0;不混入当前版本的后续保护逻辑。 +- 图片 DOM 复用失败时只重建对应图片侧,不允许重建另一侧。 +- 同侧移动逐项报告失败;未成功 rename 的项目保留在源位置,并刷新源目录和目标目录。 + +## 测试 + +### 图片隔离 + +- 一侧面板图片根节点在另一侧目录进入、刷新、选择、打开文件和打开图片后保持同一对象引用。 +- 图片侧自己的排序、缩放或关闭仍能正确更新或释放节点。 +- 左右两侧同时展开图片时,各自状态、监听器、滚动和缩略图队列互不影响。 + +### 同侧拖拽 + +- 默认复制模式下拖到同侧文件夹仍声明并执行 move。 +- 剪切模式下行为相同。 +- “..”和普通文件夹均可接收;自身、子目录和原父目录不能接收。 +- 同侧 drop 不进入跨侧复制或窗口外拖拽路径。 + +### 1.148.0 传输基线 + +- 对传输专用文件和混合文件中的传输符号执行标签差异审计。 +- 恢复并运行 1.148.0 传输测试;移除只验证后续传输行为的测试。 +- 覆盖 local/SSH、SSH/local、SSH/SSH、单项、批量、tar/scp/rsync/sftp、直连和中转计划。 +- 运行 `npm run compile-check-ts-native`、对应专项测试、`scripts\\test.bat --grep TurboFile` 和客户端编译。 + +## 完成标准 + +- 另一侧操作不会改变已展开图片侧的根 DOM 引用,也不会重新请求已加载图片。 +- 默认复制模式下同侧拖放文件或文件夹可以移动到文件夹和上级目录。 +- 当前源码中的传输实现与 `v1.148.0` 基线一致,所有差异均能证明属于非传输适配。 +- 非传输的图片、文档、排序、标签页、终端、安装和打包功能继续通过现有回归测试。 diff --git a/docs/superpowers/specs/2026-07-17-image-grid-instant-file-sort-design.md b/docs/superpowers/specs/2026-07-17-image-grid-instant-file-sort-design.md new file mode 100644 index 00000000..5fb06120 --- /dev/null +++ b/docs/superpowers/specs/2026-07-17-image-grid-instant-file-sort-design.md @@ -0,0 +1,31 @@ +# 图片网格即时文件排序设计 + +## 目标 + +图片网格复用主文件列表的排序语义和现有目录条目元数据。名称、修改日期、类型、大小以及升降序切换只执行内存排序,并立即更新当前图片网格,不等待图片头扫描、不重建双栏页面、不重新加载已有缩略图。 + +## 已确认规则 + +- 名称使用自然、大小写不敏感排序。 +- 修改日期使用目录条目的 `rawModified`。 +- 类型使用文件扩展名,扩展名相同时使用名称稳定兜底。 +- 大小使用文件字节大小 `rawSize`,不再表示原始像素面积。 +- 未知大小或日期排在已知值之后;升降序都保持未知值在末尾。 +- 排序键或方向变化后,菜单状态、标签和当前可见网格切片同步更新。 +- 保留选中图片、选择锚点、滚动位置、缩略图和预览缓存。 + +## 根因 + +原实现打开图片网格后调用 `readImageMetadataBatch`,为目录中每张图片读取文件属性和图片头。修改日期和像素面积排序被绑定到这次批量扫描,数千张图片时会长时间处于“正在准备排序信息”。类型排序虽是同步比较,但仍通过 `render()` 重建整个双栏页面并清空加载索引,反馈不及时;当目录内扩展名相同时,正确结果又会因名称兜底而看起来没有变化。 + +## 设计 + +`turbofileImageGridSort.ts` 的可排序记录改为名称、修改日期、文件字节大小及对应的已知标记。比较规则与文件列表一致,但图片网格不含目录优先规则。 + +创建图片网格时直接从 `TurboFileFileItem` 建立排序记录,不启动图片元数据预热。切换排序时先记录按图片对象标识的选择和加载状态,完成数组内存排序后重新映射索引,再调用局部更新方法刷新排序控件与虚拟网格切片。局部更新不调用编辑器级 `render()`,不销毁另一侧面板,也不丢弃已有图片 URL。 + +## 验证 + +- 纯排序测试覆盖四种键、两种方向、相同值和未知值。 +- 结构测试确保不再调用排序元数据扫描,排序变更走局部刷新且不调用全页面 `render()`。 +- 运行 TypeScript 类型检查、图片网格排序和图片网格面板测试、客户端编译。 diff --git a/docs/superpowers/specs/2026-07-17-large-batch-tar-path-list-design.md b/docs/superpowers/specs/2026-07-17-large-batch-tar-path-list-design.md new file mode 100644 index 00000000..dee0ac24 --- /dev/null +++ b/docs/superpowers/specs/2026-07-17-large-batch-tar-path-list-design.md @@ -0,0 +1,44 @@ +# 大批量 tar 路径清单稳定性设计 + +## 问题 + +SSH 到 Windows、SSH 到 SSH 的批量 tar 路径会把全部文件名拼入远程 shell 命令。6172 个长文件名会超过 Linux `execve` 的参数长度限制并报 `/bin/bash: Argument list too long`。tar 失败后回退路径可能显示为 `scp-download`,掩盖真实根因。远端剪切成功后的批量源清理也存在相同风险。 + +## 目标 + +- 大批量文件或目录不再受 shell 参数长度限制。 +- tar 数据通道、缓冲、压缩、并发和总大小策略保持不变。 +- 小批量继续使用当前内联命令,不增加准备时间。 +- 剪切仅在目标完整成功后清理源文件,清理失败必须报告失败,不能把剪切伪装成复制成功。 + +## 方案 + +使用 UTF-8 实际字节长度判断命令是否接近安全阈值。安全范围内保持当前命令;超出后将文件名编码为 NUL 分隔清单,通过 SSH 标准输入发送,远端 tar 使用 `--null -T -` 读取。 + +覆盖范围: + +- SSH → Windows:本地 OpenSSH 快速路径和 ssh2 兼容路径都向远端 tar 写入同一份 NUL 清单。 +- SSH → SSH 直连:目标清理由独立的标准输入清单命令完成,tar 数据仍在两台设备之间直传;源 tar 从当前执行通道的标准输入读取清单。 +- SSH → SSH 中转:复用现有两条 SSH 连接,目标先读取清单完成准备,随后 source tar → relay → target tar 保持原流式管道。 +- Windows → SSH:保留已经使用临时 NUL 清单文件的实现。 +- 远端剪切清理:超长路径集合使用 `xargs -0 rm -rf --`,仅在传输成功后执行。 +- Windows 本地目标准备:删除同名目标时使用有界并发,避免数千个 `rm` Promise 同时冲击磁盘和句柄上限。 + +## 速度保证 + +小批量代码路径不变。大批量只把控制信息从命令参数移动到标准输入,文件数据仍是单个连续 tar 流,不增加逐文件 SSH 握手,也不扫描文件总大小。SSH → SSH 的超大批量会多一个目标准备命令,但数据仍直连,不经过本机中转。 + +## 错误与取消 + +- 写入清单、tar 创建、tar 解包或目标准备任一失败都返回原始错误。 +- 取消会同时终止 SSH/tar 子进程或通道。 +- 剪切源清理发生在完整解包之后;清理失败返回明确失败并保留未删除的源文件。 +- tar 不可用时保留现有受控回退,不再因参数过长进入回退。 + +## 验证 + +- 6172 个截图同型长文件名必须选择标准输入清单,生成命令保持固定小于 1 KiB。 +- 100000 个包含中文、空格、引号的名称必须无丢失地生成 NUL 清单。 +- 小批量仍选择内联命令。 +- 远端批量删除的超长路径不进入 shell 参数。 +- TypeScript 检查、相关单元测试和客户端编译全部通过。 diff --git a/docs/superpowers/specs/2026-07-17-persistent-taskbar-with-terminal-design.md b/docs/superpowers/specs/2026-07-17-persistent-taskbar-with-terminal-design.md new file mode 100644 index 00000000..27c51245 --- /dev/null +++ b/docs/superpowers/specs/2026-07-17-persistent-taskbar-with-terminal-design.md @@ -0,0 +1,24 @@ +# 终端与任务日志固定分离设计 + +## 目标 + +打开或新建本地/SSH 终端时,底部蓝色任务日志条始终固定显示;VS Code 原生终端面板显示在日志条上方,二者互不替换、互不隐藏。 + +## 设计 + +- 保留现有 `TerminalLocation.Panel`、终端与传输页绑定、焦点恢复和面板显隐记忆逻辑。 +- 传输页始终渲染 `.turbofile-taskbar`,不再根据终端面板可见状态或任务运行状态删除日志条。 +- 保留现有固定定位以及终端内容底部 `24px` 预留规则,避免终端最后一行被日志条遮挡。 +- 日志条内容、点击进入任务日志、传输进度、成功/失败统计保持不变。 + +## 边界 + +- 不修改终端创建、SSH 连接、终端关闭、终端与页面绑定逻辑。 +- 不修改传输协议、传输任务状态或日志数据来源。 +- 不把任务日志迁移为 VS Code Panel 标签;它仍固定在当前窗口最底部。 + +## 验证 + +- 静态回归测试确认传输页无条件渲染并刷新日志条。 +- 验证终端可见状态仍只用于终端布局类,不再控制日志条存在性。 +- 运行 TypeScript 检查、TurboFile 终端面板单元测试和客户端编译。 diff --git a/docs/superpowers/specs/2026-07-17-recycle-bin-file-operations-and-sorting-design.md b/docs/superpowers/specs/2026-07-17-recycle-bin-file-operations-and-sorting-design.md new file mode 100644 index 00000000..3fbc8762 --- /dev/null +++ b/docs/superpowers/specs/2026-07-17-recycle-bin-file-operations-and-sorting-design.md @@ -0,0 +1,37 @@ +# 回收站文件操作、显示、任务日志与排序设计 + +## 目标 + +让回收站列表使用与普通目录一致的单行显示;让永久删除和清空回收站进入现有任务系统并显示进度、耗时和日志;恢复回收站项目的打开、编辑、预览、复制、目录浏览和排序能力;将回收站标题、路径、工具栏及右键菜单的可见文案和悬停提示统一为中文。 + +## 已确认规则 + +- 回收站根列表支持名称、删除时间、类型、大小和升降序排序,切换后立即完成,不重新扫描目录。 +- 文件可以双击打开;文档继续使用现有编辑器,图片继续使用现有图片预览。 +- 回收站中的目录可以进入,目录内部沿用普通目录的浏览、打开、编辑、重命名、新建、复制、剪切、粘贴、压缩和解压能力。 +- 回收站根项目的永久删除只能走回收站永久删除 API;目录内部的删除走高性能原生批量永久删除,不再次进入回收站。 +- 回收站根项目复制时读取 payload;剪切或拖出根项目不得破坏恢复元数据,仍使用“还原”语义。 +- 清空和永久删除复用已有原生命令,不增加逐项网络往返。 +- 左右面板状态继续完全独立。 + +## 界面 + +回收站根项目与普通目录使用相同的 30px 单行布局,只显示图标、名称、大小和删除时间,不额外显示原始路径。原始路径仍保留在“复制原始路径”操作中。回收站排序图标使用与返回、还原等按钮相同的普通前景色,不使用左右面板的强调色。 + +回收站工具栏显示:排序、返回、还原、永久删除、清空回收站、刷新、单栏显示。全部按钮的 `title` 和 `aria-label` 使用中文。标题显示“<设备> - 回收站”,路径栏显示“<设备> 回收站”。进入某个已删除目录后,路径栏显示当前回收站目录位置,并提供返回回收站根列表的入口。 + +## 操作语义 + +回收站根列表中的文件直接使用 payload 路径打开和编辑,因此保存会更新回收站中的真实文件,之后还原得到修改后的内容。根目录双击后进入受限于该 payload 根的目录浏览状态,不能通过“上一级”逃出该回收站项目。 + +在目录内部,文件和子目录属于同一个已删除根项目,普通编辑和结构操作不会破坏根项目的元数据。根项目自身不允许原地重命名或被原始文件操作直接移动;这些操作必须使用还原或永久删除,以保持 `payloadPath` 与元数据一致。 + +## 任务与日志 + +永久删除选中项和清空回收站均创建 `delete` 类型任务:开始时立即显示“正在永久删除”或“正在清空回收站”,原生命令完成后进入刷新阶段,最后写入成功数量、失败数量、耗时和错误信息。服务层仍保持单次批量命令,不为了显示进度改为逐项删除。 + +## 测试 + +- 单元测试覆盖回收站根/目录浏览状态、根路径边界、根行高度和排序输入映射。 +- 静态 UI 测试覆盖中文标题、中文工具提示、排序按钮、任务创建与完成调用、回收站行专用类。 +- 先运行 `npm run compile-check-ts-native`,再运行回收站专项单元测试,最后运行 `npm run gulp compile-client`。 diff --git a/docs/superpowers/specs/2026-07-17-ssh-device-form-focus-and-password-only-design.md b/docs/superpowers/specs/2026-07-17-ssh-device-form-focus-and-password-only-design.md new file mode 100644 index 00000000..3477b5cd --- /dev/null +++ b/docs/superpowers/specs/2026-07-17-ssh-device-form-focus-and-password-only-design.md @@ -0,0 +1,38 @@ +# SSH 设备表单精简与稳定输入设计 + +## 目标 + +将“添加/编辑 SSH 设备”表单改为仅支持密码认证,移除私钥认证选项、私钥路径和私钥口令两个模块;同时彻底解决表单偶发无法输入、输入内容被重置和焦点丢失的问题。 + +## 根因 + +`TurboFileEditorPane.render()` 会清空整个编辑器容器并重建所有 DOM。传输启动计时器、后台设备加载、状态刷新等异步路径都可能在设备表单打开期间调用 `render()`。当前表单因此会被销毁并重新创建,浏览器随之丢失活动输入框、光标、选区和未保存的输入值。原有的事件冒泡隔离和 `focus()` 只能处理点击后的焦点,无法阻止 DOM 被销毁,所以问题表现为偶发且在后台活动较多时更容易出现。 + +## 设计 + +### 密码认证表单 + +- SSH 表单不再渲染“认证方式”下拉框,而是提交隐藏的 `authType=password`。 +- 删除“私钥路径”和“私钥口令”输入模块。 +- 保留密码输入框、设备信息、传输模式、默认目录和检测功能。 +- 底层数据类型和 SSH 服务暂不删除私钥字段,避免破坏已有配置文件的兼容读取;用户在界面保存设备后统一使用密码认证。 + +### 表单 DOM 保留 + +- `render()` 开始时,如果设备表单已经打开,则识别当前 `.turbofile-device-overlay`。 +- 重绘底层页面时只移除容器中的其他直接子节点,不移除覆盖层本身。 +- 新页面壳插入覆盖层之前,覆盖层从始至终保持连接在文档中,因此活动输入框、中文输入法组合状态、光标选区和当前输入值都不会丢失。 +- 首次创建表单时使用表单所属窗口的 `requestAnimationFrame` 聚焦设备名称输入框;仅首次创建聚焦,后台重绘不重复抢焦点。 + +## 测试 + +- 添加静态回归测试,验证 SSH 表单仅提交密码认证且不再渲染三个私钥相关控件。 +- 添加静态回归测试,验证 `render()` 保留已存在的设备表单覆盖层且不会在后台重绘时重新调用 `renderDeviceForm()`。 +- 运行 `npm run compile-check-ts-native` 后运行定向 TurboFile 单元测试。 +- 运行客户端构建,再按既有 Windows x64 流程重新生成并核验 `1.154.0` 安装包。 + +## 非目标 + +- 不删除底层私钥数据结构或 SSH 服务能力。 +- 不迁移、清理或覆盖用户已有设备配置。 +- 不修改传输协议、设备检测逻辑或其他页面。 diff --git a/docs/superpowers/specs/2026-07-17-turbofile-company-value-slide-design.md b/docs/superpowers/specs/2026-07-17-turbofile-company-value-slide-design.md new file mode 100644 index 00000000..e747ed86 --- /dev/null +++ b/docs/superpowers/specs/2026-07-17-turbofile-company-value-slide-design.md @@ -0,0 +1,73 @@ +# 极速传“对公司的直接价值”页面设计 + +## 目标 + +在现有领导汇报 PPT 中增加一页,把极速传的功能优势转换为对公司和三类岗位的直接业务价值。页面要服务于领导汇报,信息必须简洁、具体、可由现场演示和实验数据支撑。 + +## 页面位置 + +新页插入在现有第 8 页“超大目录快速打开,批量操作在远程完成”之后,“建议用 8 分钟完成现场演示”之前。 + +调整后的叙事顺序为: + +1. 说明现有工具的问题。 +2. 展示极速传的核心功能和实测性能。 +3. 总结这些能力对公司的直接价值。 +4. 进入软件现场演示。 + +## 视觉设计 + +- 沿用现有极简版 PPT 的白色背景、深灰文字和单一蓝色强调。 +- 使用三栏并列布局,分别对应 AI 算法工程师、售后/应用工程师和数据标注人员。 +- 三栏之间使用细灰色分割线,不使用深色大卡片、阴影、彩色圆形或复杂装饰。 +- 每栏保留一个岗位标题、一句核心价值和三条具体说明。 +- 页面底部使用一条浅蓝色总结条,强调对公司整体业务周期的价值。 + +## 页面文案 + +### 标题 + +`极速传对公司的直接价值` + +副标题:`让采图、传输、分析、标注和反馈形成更高效的完整流程` + +### AI 算法工程师 + +核心价值:`服务器上的训练图片可以直接查看和分析` + +- 网格浏览模型训练数据集,不需要先整批下载。 +- 左右对比原始图片、处理结果和不同数据集。 +- 更快排查数据质量、样本差异和模型效果问题。 + +### 售后与应用工程师 + +核心价值:`现场密集图片可以更快传回公司` + +- 快速传输 X86 设备和 AI 芯片采集的现场图片。 +- 减少先导入 Windows,再发送回公司的等待时间。 +- 2.32 GB 混合文件实测从 234 秒缩短到 24.6 秒,耗时提升约 9.5 倍。 + +### 数据标注人员 + +核心价值:`微小物料看得更清楚,海量数据传得更快` + +- 使用图片缩放能力查看大米、小麦等微小物料的细节特征。 +- 大批量标注数据可快速上传至 NAS、62 和 64 等服务器。 +- 62 → 64 设备传输实测从 12 分钟缩短到 23.7 秒,减少几十 GB 数据分发的等待。 + +### 页面总结 + +`缩短“采图—传输—分析—标注—反馈”的完整业务周期` + +## Markdown 更新 + +在“极速传的核心优势”与“WinSCP 传输实验记录”之间新增“对公司的业务价值”章节,按三类岗位记录使用场景、直接收益和可用于证明的实测数据。该章节不写演讲稿。 + +## 验收标准 + +1. PPT 由 9 页增加为 10 页,新页位于现场演示页之前。 +2. 新页保持白底极简视觉风格,与现有 PPT 一致。 +3. 三类岗位的价值可在一页内完整读取,文字不溢出、不重叠。 +4. 页面只使用已提供的实测数据,不虚构新的速度或耗时。 +5. Markdown 包含完整的三类岗位价值说明,不增加演讲稿。 +6. 更新后的 PPT 可被 PowerPoint 正常打开,并通过新增页的 PNG 渲染检查。 diff --git a/docs/superpowers/specs/2026-07-17-turbofile-leadership-presentation-design.md b/docs/superpowers/specs/2026-07-17-turbofile-leadership-presentation-design.md new file mode 100644 index 00000000..7c266cd5 --- /dev/null +++ b/docs/superpowers/specs/2026-07-17-turbofile-leadership-presentation-design.md @@ -0,0 +1,220 @@ +# 极速传领导汇报 PPT 设计 + +## 汇报目标 + +为 2026 年 7 月 18 日的领导汇报制作一套简洁的演示型 PPT,并配套一份 Markdown 讲稿与现场演示清单。 + +汇报采用“边讲 PPT、边操作软件”的方式,预计时长 10–15 分钟。PPT 不展开复杂的代码实现,而是突出极速传解决的业务问题、用户能够直接感知的优势,以及相对 WinSCP 等传统工具的效率提升。 + +## 核心原则 + +- 每页只讲一个主要结论。 +- PPT 文字保持简洁,详细说明放入 Markdown 讲稿。 +- 所有速度结论使用实测数据,不虚构实验结果。 +- PPT 为 WinSCP 对比实验预留可直接替换的数据表和图表。 +- 功能优势与现场演示一一对应。 +- “最快”“唯一”等绝对化表述只有在实验数据或竞品证据充分时才使用。 + +## PPT 结构 + +### 第 1 页:封面 + +标题: + +> 极速传 + +副标题: + +> 面向海量图片和工业数据的高速远程文件工作台 + +页面只保留产品名称、副标题、汇报人和日期。 + +### 第 2 页:传统远程文件工具的痛点 + +突出三个问题: + +- 海量远程图片无法直接、高效浏览。 +- 目录和大量小文件使用传统逐文件传输时等待时间长。 +- 多服务器文件、文档和终端操作分散在不同窗口。 + +本页不进行现场操作,用于建立后续功能价值。 + +### 第 3 页:极速传核心优势总览 + +展示六项核心能力: + +1. 远程图片资产管理与左右对比。 +2. tar/rsync 智能高速传输。 +3. SSH 设备 A 到设备 B 直接传输。 +4. VS Code 原生文档与代码渲染。 +5. 多工作区与独立设备终端。 +6. 远程原生命令批量文件操作。 + +本页结束后切换到极速传软件开始现场演示。 + +### 第 4 页:图片能力不只是网格预览 + +展示内容: + +- SSH 图片目录直接网格展开。 +- 左右两侧同时显示不同设备或目录的图片。 +- 缩略图按可见区域批量加载。 +- 原图预览、缩放、像素检查和 RGB 直方图。 +- 图片复制、下载、删除和拖拽传输。 + +现场演示: + +1. 左侧打开一个 SSH 图片目录。 +2. 右侧打开另一台设备或另一个图片目录。 +3. 两侧分别切换到图片网格。 +4. 打开单张图片,展示缩放、RGB/HSV 像素检查和直方图。 + +### 第 5 页:高速传输与服务器直连 + +展示内容: + +- 单文件使用 SCP 快速路径。 +- 目录和多文件优先使用 tar 流。 +- 重复或增量数据使用 rsync。 +- 目录无需完成递归大小扫描即可开始传输。 +- SSH A 到 SSH B 优先直连,避免数据经过 Windows 落盘。 +- 直连失败时自动切换为同协议中继。 + +页面包含 WinSCP 对比实验表: + +| 项目 | WinSCP | 极速传 | +| --- | ---: | ---: | +| 文件总大小 | 待填写 | 待填写 | +| 文件数量 | 待填写 | 待填写 | +| 总耗时 | 待填写 | 待填写 | +| 平均速度 | 待填写 | 待填写 | +| 峰值速度 | 待填写 | 待填写 | + +页面包含一个可替换的柱状图,默认仅显示“待填入实测数据”。 + +现场演示: + +1. 使用同一批测试数据执行极速传传输。 +2. 展示实际协议、实时速度和任务进度。 +3. 如时间允许,现场执行 WinSCP 对照;否则展示提前录制的数据。 +4. 演示两台 SSH 设备之间直接拖拽传输。 + +### 第 6 页:超大目录与远程原生操作 + +展示内容: + +- 文件列表虚拟化。 +- 首屏优先加载名称和类型。 +- 可见行再补充大小和修改日期。 +- SSH 会话复用、目录缓存和相邻目录预取。 +- 删除、移动、复制、压缩和解压尽量在远端执行。 +- 批量删除使用远程批处理命令,避免逐文件往返。 +- 回收站作为近期规划能力标注,不写成已完成功能。 + +现场演示: + +1. 打开包含大量文件的远程目录。 +2. 展示目录进入、滚动和切换速度。 +3. 选择一批测试文件执行删除。 +4. 如有准备,展示同一批文件在 WinSCP 中删除的耗时。 + +### 第 7 页:多工作区、文档和终端 + +展示内容: + +- 一个标签页就是一个独立传输工作区。 +- 每个工作区保存左右设备、目录、排序、任务和终端。 +- 可以直接打开远程 Markdown、代码、日志和配置文件。 +- 使用 VS Code 原生编辑器和语言渲染能力。 +- 不同工作区可以分别绑定不同 SSH 设备的独立终端。 + +现场演示: + +1. 创建两个传输标签页。 +2. 为两个标签页选择不同 SSH 设备和目录。 +3. 分别打开对应设备的终端。 +4. 切换标签页,展示终端与工作区相互独立。 +5. 双击打开远程 Markdown、代码或配置文件并展示渲染与编辑。 + +### 第 8 页:Windows 资源管理器级交互与开箱即用 + +展示内容: + +- 从 Windows 资源管理器拖入文件。 +- 从极速传拖出本地或远程文件。 +- 支持 Windows 文件剪贴板复制、剪切和粘贴。 +- 支持桌面真实路径和多磁盘快捷切换。 +- 内置 rsync、OpenSSH 和所需运行库。 +- 自动检测远端传输能力。 +- 经用户确认后可以安装远端 rsync/sshpass。 + +现场演示: + +1. 从资源管理器拖入一个文件。 +2. 将极速传中的文件拖回资源管理器。 +3. 展示 Windows 磁盘和桌面快捷切换。 +4. 打开设备配置,展示协议选择和能力检测。 + +### 第 9 页:总结与下一步 + +总结六项核心价值: + +- 看得快:远程图片网格与专业检查。 +- 传得快:tar、rsync 和服务器直连。 +- 打得开:VS Code 原生文档能力。 +- 管得多:多工作区和独立终端。 +- 操作快:远程原生命令批量处理。 +- 用得简单:Windows 原生交互和内置工具链。 + +下一步规划: + +- 文件回收站。 +- 断点续传。 +- 更完善的传输冲突策略。 +- SSH 主机指纹验证和安全增强。 +- 自动化传输与定时任务。 + +## 实验数据设计 + +传输测试至少记录以下信息: + +- 测试日期。 +- 本机、源服务器和目标服务器配置。 +- 网络带宽及是否为有线内网。 +- 数据总大小。 +- 文件总数。 +- 大文件与小文件的分布。 +- 使用的 WinSCP 协议。 +- 极速传实际使用的协议与路线。 +- 首次出现数据进度的等待时间。 +- 总耗时。 +- 平均速度。 +- 峰值速度。 +- CPU、内存和本地临时磁盘占用情况。 + +建议准备三组测试: + +1. 单个大文件。 +2. 数千或数万个小图片。 +3. SSH A 到 SSH B 的跨服务器目录传输。 + +PPT 初稿只提供数据占位符,用户完成实验后直接替换。 + +## 文件产物 + +计划生成: + +- `docs/presentations/极速传领导汇报.pptx` +- `docs/presentations/极速传领导汇报讲稿与演示清单.md` + +如本机 PowerPoint 或 LibreOffice 支持可靠导出,可额外生成同名 PDF 作为预览;PDF 不是本次交付的强制项。 + +## 验收标准 + +- PPT 可正常打开,无损坏提示。 +- 页面数量约 9 页。 +- 页面文字简洁,适合现场讲解。 +- 不填入虚构的传输速度和耗时。 +- WinSCP 对比数据可以快速替换。 +- Markdown 包含逐页讲稿、演示步骤、实验记录模板和演示前检查表。 +- 现场演示顺序和 PPT 页序一致。 diff --git a/docs/superpowers/specs/2026-07-17-windows-recycle-bin-icon-and-delete-dialog-design.md b/docs/superpowers/specs/2026-07-17-windows-recycle-bin-icon-and-delete-dialog-design.md new file mode 100644 index 00000000..2a417aee --- /dev/null +++ b/docs/superpowers/specs/2026-07-17-windows-recycle-bin-icon-and-delete-dialog-design.md @@ -0,0 +1,34 @@ +# Windows 回收站图标与删除确认弹窗设计 + +## 目标 + +回收站按钮使用从 Windows 系统资源提取的原生彩色空/满回收站图标,并恢复普通删除操作原有的中文危险确认弹窗。删除后的数据仍进入当前设备的回收站,回收站恢复、永久删除和清空逻辑不变。 + +## 图标设计 + +- 从 Windows `SHGetStockIconInfo` 提取 `SIID_RECYCLER`(空)和 `SIID_RECYCLERFULL`(满)原生图标。 +- 将两个 PNG 编码为数据 URI 存放在专用浏览器模块中,使构建产物离线可用且不需要启动时调用系统 API。 +- 普通面板的回收站按钮根据现有 `trashHasItems` 状态选择空或满图标。 +- 删除当前单色 `Codicon.trash` 和橙色状态圆点。 +- 图标在现有 24×24 工具栏按钮内显示为 20×20,不改变按钮位置、点击区域、标题和辅助功能标签。 +- 状态切换继续使用当前删除、恢复、清空和设备刷新流程,不增加目录扫描、SSH 请求或传输等待。 + +## 删除确认弹窗 + +- 普通文件列表恢复原弹窗:标题“删除确认”,内容为“确定删除 … 吗?”,红色危险确认按钮“删除”。 +- 图片网格与图片预览恢复原弹窗:标题“删除图片”,内容为原来的单张或多张删除确认文本,红色危险确认按钮“删除”。 +- 弹窗确认后仍调用 `moveUserItemsToTrash`,所以“删除”只代表用户操作名称,不会永久删除数据。 +- 回收站视图中的“永久删除”和“清空回收站”继续使用现有不可恢复警告弹窗,不在本次修改范围内。 + +## 测试与验证 + +- 单元测试验证两个图标数据均为有效、不同的 PNG,并验证空/满状态选择正确。 +- 静态回归测试验证三类普通删除入口使用旧中文危险确认弹窗,同时仍路由到回收站服务。 +- 运行 `npm run compile-check-ts-native`、TurboFile 单元测试和客户端编译。 +- 启动桌面应用验证空/满图标切换、左右侧独立状态及旧删除弹窗。 + +## 自检 + +- 无占位项或待定项。 +- 图标状态、弹窗表现和实际回收站数据流互不矛盾。 +- 不改变传输、排序、图片预览、恢复或永久删除功能。 diff --git a/docs/superpowers/specs/2026-07-18-extreme-batch-stability-design.md b/docs/superpowers/specs/2026-07-18-extreme-batch-stability-design.md new file mode 100644 index 00000000..61409b21 --- /dev/null +++ b/docs/superpowers/specs/2026-07-18-extreme-batch-stability-design.md @@ -0,0 +1,64 @@ +# 极限批量操作稳定性设计 + +## 目标 + +让一万级文件传输、删除和移动在同一页面或多个传输页并发触发时保持可响应、可取消、可观测,不再长时间停在“准备传输”或“正在删除”。单个任务开始执行后不得人为限速,不增加文件大小预扫描,也不把 tar 降级为逐文件 SCP/SFTP。 + +## 已确认根因 + +1. 本地到 SSH 的批量 tar 在传输前对每个目标名称执行一次独立 `rm`;10883 个名称即使目标目录为空也会产生 10883 个远程进程。 +2. 显式选择普通文件仍逐项 `lstat`,随后默认同时启动 12 个 tar/SSH 通道。多个删除任务并发时会放大磁盘与 SSH 握手竞争。 +3. 协议回退的聚合进度每次事件都 `reduce` 整个项目数组,并对每项完成强制发 IPC,形成 O(N²) 工作量。 +4. 删除前,renderer 为每个项目重复读取 SSH 密码、私钥和口令;主进程再逐项串行写入/提交回收站元数据,产生数千次 SFTP 往返,且关键 SFTP 回调没有超时。 +5. 本地永久删除和部分移动使用无上限 `Promise.all`;多个重型操作可以同时争用同一设备。 + +## 架构 + +### 1. 按设备调度重型写操作 + +主进程维护全局的多键 FIFO 调度器。设备键为本地 Windows 或 SSH `username@host:port`;跨设备传输同时占用源和目标键。同一设备只运行一个传输、删除、移动或回收站写操作,键不相交的任务可以同时运行。排队任务可按 `progressId` 取消,开始执行后继续使用现有取消处理器。 + +调度只控制任务边界,不改变任务内部数据块大小、tar 管道和网络吞吐。目录读取、图片预览和终端不进入此队列。 + +### 2. tar 快速准备与有限并行 + +- 显式选择且全部为普通文件时直接使用已有名称,不执行逐项 `lstat`。 +- 只有包含目录时才递归收集 tar 成员。 +- tar 并行通道上限从 12 收敛为 4,避免超过常见 SSH `MaxStartups` 并给同任务保留足够并行吞吐。 +- 远程目标清理输入改为完整目标路径的 NUL 清单,通过一次 `xargs -0 rm -rf --` 批处理;不拼接命令行,不逐项启动 `rm`。 +- tar 数据仍是连续流,不计算目录或多文件总大小。 + +### 3. O(1) 聚合进度 + +回退传输维护累计已传输字节和累计总字节,每个子事件只按新旧值计算差量。逐项完成走父任务节流,仅任务最终完成强制发送。renderer 的状态标题直接使用事件中的当前名称和总数,不在每次刷新时展开一万项。 + +### 4. 回收站原子批处理 + +- renderer 每次批量操作只读取一次 SSH 连接信息,并复用于所有 endpoint 和 device 描述。 +- POSIX SSH 删除使用一个 NUL 输入命令完成:创建 pending 元数据、移动 payload、将 pending 提交为正式元数据、输出逐项结果。移动失败删除 pending;移动成功但提交失败保留 pending,继续由现有恢复逻辑处理。 +- 本地回收站按根目录只创建一次 `files/info`,普通文件最多 16 路、包含目录最多 4 路。 +- 仍保留的批量本地永久删除同样使用有界并发。 +- Windows SSH 的超长路径集合通过标准输入传给固定 PowerShell 命令,不再把全部路径拼入命令行。 + +## 错误处理 + +- 队列中的取消立即移除任务并返回取消结果,不启动底层命令。 +- tar 目标准备或任一管道失败时不报告成功;剪切只有目标完整成功后才清理源。 +- 回收站 pending 元数据始终先于 payload 移动创建,保证崩溃恢复语义不变。 +- 批量命令使用 NUL 分隔路径,支持中文、空格、引号和换行,不受 ARG_MAX 限制。 +- 错误明细有限保存,防止十万失败项再次耗尽界面内存。 + +## 性能约束 + +- 不新增递归大小扫描。 +- 不拆分为逐文件网络传输。 +- 小批量原有快速路径保持不变。 +- 大批量减少 SSH/SFTP 往返、进程数量和 IPC 数量,因此稳定性提升不能以降低单任务数据吞吐为代价。 + +## 验证 + +- 调度器:相同键串行、不同键并行、多键无死锁、排队取消。 +- tar:10883 个普通文件不触发 `lstat`;目标清理命令固定长度且 NUL 输入完整;并行上限为 4。 +- 进度:10883 个完成事件为 O(N),不会产生 10883 次强制 IPC。 +- 删除:一次凭据读取;SSH 元数据与移动单次批命令;本地并发有界;pending 恢复语义保持。 +- 运行 TypeScript 类型检查、聚焦单元测试、全部 TurboFile 单元测试与 `gulp compile-client`。 diff --git a/docs/superpowers/specs/2026-07-21-same-directory-copy-name-design.md b/docs/superpowers/specs/2026-07-21-same-directory-copy-name-design.md new file mode 100644 index 00000000..ee4065a2 --- /dev/null +++ b/docs/superpowers/specs/2026-07-21-same-directory-copy-name-design.md @@ -0,0 +1,27 @@ +# 同目录复制副本命名设计 + +## 目标 + +在极速传内部复制文件或文件夹并粘贴回同一设备的同一目录时,像 Windows 一样创建一份新副本,而不是将操作判定为来源与目标相同并跳过。 + +## 行为边界 + +- 仅内部剪贴板的“复制”操作生效。 +- 仅来源设备与目标设备相同、来源目录与目标目录相同时生效。 +- 文件命名为 `名称 - 副本.扩展名`,后续依次为 `名称 - 副本 (2).扩展名`、`名称 - 副本 (3).扩展名`。 +- 文件夹使用同样规则,但不拆分扩展名。 +- 批量复制时,每个项目独立分配当前目录内未占用的名称。 +- 剪切、移动、拖拽、跨目录复制、跨设备复制及已有冲突处理保持不变。 +- 不扫描文件内容或目录大小,不修改底层传输协议和并发配置。 + +## 实现 + +新增纯函数负责 Windows 风格副本名称分配。内部粘贴识别同目录复制后,使用当前目录列表构造已占用名称集合,为待复制项目生成目标名称,再复用现有单项或批量传输流程。 + +Windows 设备按大小写不敏感比较名称;SSH 设备按大小写敏感比较名称。名称分配会同时记录本批次已经生成的名称,避免批量项目之间冲突。 + +## 验证 + +- 纯函数单元测试覆盖文件、文件夹、连续编号、批量名称占用及大小写规则。 +- 静态交互测试确认只有内部同目录复制调用副本命名逻辑。 +- 运行 TypeScript 编译检查和对应 TurboFile 单元测试。 diff --git "a/docs/\346\265\274\347\212\272\347\267\255\347\273\257\350\215\244\347\262\272\351\226\262\345\266\206\347\200\257\347\222\201\346\215\220\356\205\270.md" "b/docs/\344\274\240\350\276\223\347\263\273\347\273\237\351\207\215\346\236\204\350\256\276\350\256\241.md" similarity index 100% rename from "docs/\346\265\274\347\212\272\347\267\255\347\273\257\350\215\244\347\262\272\351\226\262\345\266\206\347\200\257\347\222\201\346\215\220\356\205\270.md" rename to "docs/\344\274\240\350\276\223\347\263\273\347\273\237\351\207\215\346\236\204\350\256\276\350\256\241.md" diff --git a/out/vs/platform/turbofile/common/turbofileWindowsPath.js b/out/vs/platform/turbofile/common/turbofileWindowsPath.js new file mode 100644 index 00000000..e9124d92 --- /dev/null +++ b/out/vs/platform/turbofile/common/turbofileWindowsPath.js @@ -0,0 +1,30 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +export async function normalizeWindowsTransferPaths(paths, resolvePath) { + const result = []; + const seen = new Set(); + for (const rawPath of paths) { + const candidate = normalizeWindowsTransferPath(rawPath); + if (!candidate) { + continue; + } + const resolved = normalizeWindowsTransferPath(await resolvePath(candidate).catch(() => candidate)) ?? candidate; + const key = getWindowsTransferPathKey(resolved); + if (seen.has(key)) { + continue; + } + seen.add(key); + result.push(resolved); + } + return result; +} +export function getWindowsTransferPathKey(path) { + return path.replace(/\//g, '\\').replace(/\\+$/, '').toLowerCase(); +} +function normalizeWindowsTransferPath(path) { + const trimmed = path.trim().replace(/^"|"$/g, '').replace(/^file:\/\/\/?/i, ''); + return trimmed || undefined; +} +//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoidHVyYm9maWxlV2luZG93c1BhdGguanMiLCJzb3VyY2VSb290IjoiZmlsZTovLy9EOi9Xb3JrL0NvZGUvVHVyYm9GaWxlLVdpbmRvd3Mvc3JjLyIsInNvdXJjZXMiOlsidnMvcGxhdGZvcm0vdHVyYm9maWxlL2NvbW1vbi90dXJib2ZpbGVXaW5kb3dzUGF0aC50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFBQTs7O2dHQUdnRztBQUloRyxNQUFNLENBQUMsS0FBSyxVQUFVLDZCQUE2QixDQUFDLEtBQXdCLEVBQUUsV0FBZ0M7SUFDN0csTUFBTSxNQUFNLEdBQWEsRUFBRSxDQUFDO0lBQzVCLE1BQU0sSUFBSSxHQUFHLElBQUksR0FBRyxFQUFVLENBQUM7SUFFL0IsS0FBSyxNQUFNLE9BQU8sSUFBSSxLQUFLLEVBQUUsQ0FBQztRQUM3QixNQUFNLFNBQVMsR0FBRyw0QkFBNEIsQ0FBQyxPQUFPLENBQUMsQ0FBQztRQUN4RCxJQUFJLENBQUMsU0FBUyxFQUFFLENBQUM7WUFDaEIsU0FBUztRQUNWLENBQUM7UUFFRCxNQUFNLFFBQVEsR0FBRyw0QkFBNEIsQ0FBQyxNQUFNLFdBQVcsQ0FBQyxTQUFTLENBQUMsQ0FBQyxLQUFLLENBQUMsR0FBRyxFQUFFLENBQUMsU0FBUyxDQUFDLENBQUMsSUFBSSxTQUFTLENBQUM7UUFDaEgsTUFBTSxHQUFHLEdBQUcseUJBQXlCLENBQUMsUUFBUSxDQUFDLENBQUM7UUFDaEQsSUFBSSxJQUFJLENBQUMsR0FBRyxDQUFDLEdBQUcsQ0FBQyxFQUFFLENBQUM7WUFDbkIsU0FBUztRQUNWLENBQUM7UUFFRCxJQUFJLENBQUMsR0FBRyxDQUFDLEdBQUcsQ0FBQyxDQUFDO1FBQ2QsTUFBTSxDQUFDLElBQUksQ0FBQyxRQUFRLENBQUMsQ0FBQztJQUN2QixDQUFDO0lBRUQsT0FBTyxNQUFNLENBQUM7QUFDZixDQUFDO0FBRUQsTUFBTSxVQUFVLHlCQUF5QixDQUFDLElBQVk7SUFDckQsT0FBTyxJQUFJLENBQUMsT0FBTyxDQUFDLEtBQUssRUFBRSxJQUFJLENBQUMsQ0FBQyxPQUFPLENBQUMsTUFBTSxFQUFFLEVBQUUsQ0FBQyxDQUFDLFdBQVcsRUFBRSxDQUFDO0FBQ3BFLENBQUM7QUFFRCxTQUFTLDRCQUE0QixDQUFDLElBQVk7SUFDakQsTUFBTSxPQUFPLEdBQUcsSUFBSSxDQUFDLElBQUksRUFBRSxDQUFDLE9BQU8sQ0FBQyxRQUFRLEVBQUUsRUFBRSxDQUFDLENBQUMsT0FBTyxDQUFDLGdCQUFnQixFQUFFLEVBQUUsQ0FBQyxDQUFDO0lBQ2hGLE9BQU8sT0FBTyxJQUFJLFNBQVMsQ0FBQztBQUM3QixDQUFDIn0= \ No newline at end of file diff --git a/out/vs/platform/turbofile/electron-main/turbofileSshMainService.js b/out/vs/platform/turbofile/electron-main/turbofileSshMainService.js new file mode 100644 index 00000000..b163c8ac --- /dev/null +++ b/out/vs/platform/turbofile/electron-main/turbofileSshMainService.js @@ -0,0 +1,9861 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import { createReadStream, createWriteStream, existsSync, promises as fsp } from 'fs'; +import { basename, dirname, join } from 'node:path'; +import { networkInterfaces, platform, tmpdir } from 'node:os'; +import { createHash, randomUUID } from 'node:crypto'; +import { execFile, spawn } from 'node:child_process'; +import { PassThrough } from 'node:stream'; +import { pipeline } from 'node:stream/promises'; +import { connect as connectTcp } from 'node:net'; +import { deflateSync } from 'node:zlib'; +import { app, BrowserWindow, clipboard, nativeImage, protocol } from 'electron'; +import { Emitter, Event } from '../../../base/common/event.js'; +import { resolveTerminalEncoding } from '../../../base/node/terminalEncoding.js'; +import { buildTurboFileRemoteExtractCommand, isTurboFileTarFamilyArchivePath } from '../common/turbofileArchiveExtract.js'; +import { createLocalBatchTarPlans, createTarNullListBuffer, requiresLocalTarSafeRootAlias } from '../common/turbofileBatchTarPlan.js'; +import { TurboFileKeyedGeneration } from '../common/turbofileKeyedGeneration.js'; +import { shouldRejectDirectoryTargetConflict } from '../common/turbofileTransferConflictPolicy.js'; +import { getRemoteDirectoryReadRetryDelay, REMOTE_DIRECTORY_READ_MAX_ATTEMPTS, shouldRetryRemoteDirectoryRead } from '../common/turbofileRemoteDirectoryRetry.js'; +import { createPosixNativeBatchTransferPlan } from '../common/turbofileRemoteNativeBatchPlan.js'; +import { buildPosixStatEntriesCommand as buildRemotePosixStatEntriesCommand } from '../common/turbofileRemoteStat.js'; +import { withTurboFileTimeout } from '../common/turbofileTimeout.js'; +import { resolveTransferPlan } from '../common/turbofileTransferPlan.js'; +import { createWindowsDragFakeDirectoryName, createWindowsShellAssociationChangedScript, normalizeWindowsCopyHookDestination, TURBOFILE_WINDOWS_DRAG_ACTIVE_DIR, TURBOFILE_WINDOWS_DRAG_APP_DIR, TURBOFILE_WINDOWS_DRAG_ROOT_DIR, TURBOFILE_WINDOWS_DRAG_TARGETS_DIR } from '../common/turbofileWindowsDrag.js'; +import { normalizeWindowsTransferPaths } from '../common/turbofileWindowsPath.js'; +const DEFAULT_READ_LIMIT = 10 * 1024 * 1024; +const DEFAULT_IMAGE_SOURCE_LIMIT = 128 * 1024 * 1024; +const REMOTE_IMAGE_TOOL_CACHE_TTL_MS = 5 * 60 * 1000; +// Large source images can need 10-20s for ImageMagick to decode and downscale over SSH; a short +// budget aborts them before any bytes flow. The common case still returns in well under a second. +const REMOTE_IMAGE_THUMBNAIL_TIMEOUT_MS = 25000; +// Max concurrent SSH image reads (remote thumbnail transforms + downloads). The image grid renders +// cards as , and Chromium fetches every visible card at once. Dozens +// of simultaneous `client.exec` calls on one SSH connection blow past the server's MaxSessions +// limit and every command fails with "Unable to exec", so the whole grid stays on generic icons. +// A small global cap keeps thumbnail generation under that ceiling (leaving channels free for +// directory/SFTP work) while still loading several images in parallel. +const SSH_IMAGE_READ_MAX_CONCURRENCY = 6; +const REMOTE_DIRECTORY_READ_TIMEOUT_MS = 8000; +// Fast `find`-based listing is bounded by silence (no output) rather than total duration so large +// directories that legitimately take many seconds to enumerate still load instead of being killed. +const REMOTE_DIRECTORY_READ_INACTIVITY_TIMEOUT_MS = 12000; +const REMOTE_DIRECTORY_READ_MAX_TIMEOUT_MS = 120000; +const REMOTE_DIRECT_READ_MAX_BYTES = 2 * 1024 * 1024; +const REMOTE_DIRECT_READ_CHUNK_BYTES = 256 * 1024; +// Per-file cap for the one-shot image batch read. Files above this stream their bytes individually +// (and may be server-downscaled) so a single huge image cannot blow up the batched buffer. +const IMAGE_BATCH_MAX_FILE_BYTES = 24 * 1024 * 1024; +const SMALL_IMAGE_DIM_LIMIT = 100; +const WINDOWS_DRAG_ICON_DATA_URL = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='; +const RAW_BMP_FAST_PATH_BYTES = 2 * 1024 * 1024; +const IMAGE_THUMBNAIL_CACHE_VERSION = 1; +const IMAGE_THUMBNAIL_CACHE_MAX_BYTES = 512 * 1024 * 1024; +const IMAGE_THUMBNAIL_CACHE_MAX_ENTRY_BYTES = 16 * 1024 * 1024; +const TURBOFILE_IMAGE_RESOURCE_SCHEME = 'turbofile-image'; +const IMAGE_RESOURCE_TTL_MS = 10 * 60 * 1000; +const IMAGE_RESOURCE_CLEANUP_INTERVAL_MS = 60 * 1000; +const WINDOWS_DRAG_COPY_HOOK_TIMEOUT_MS = 45000; +const WINDOWS_DRAG_COPY_HOOK_POLL_MS = 50; +const TURBOFILE_WINDOWS_DRAG_COPY_HOOK_CLSID = '{D650A1FA-43C6-4B8F-8C83-80B028353F64}'; +const TRANSFER_CANCELED_MESSAGE = 'Transfer canceled.'; +const REMOTE_DIRECTORY_SFTP_IDLE_MS = 5 * 60 * 1000; +const REMOTE_DIRECTORY_LISTING_CACHE_TTL_MS = 30 * 1000; +const REMOTE_DIRECTORY_LISTING_CACHE_MAX_ENTRIES = 256; +const SFTP_TRANSFER_CONCURRENCY = 32; +const SFTP_TRANSFER_CHUNK_SIZE = 256 * 1024; +const SFTP_LARGE_FILE_THRESHOLD = 512 * 1024 * 1024; +const SFTP_LARGE_TRANSFER_CONCURRENCY = 32; +const SFTP_LARGE_TRANSFER_CHUNK_SIZE = 256 * 1024; +const SFTP_COMPAT_TRANSFER_CONCURRENCY = 32; +const SFTP_COMPAT_TRANSFER_CHUNK_SIZE = 32 * 1024; +const SFTP_RELAY_CHUNK_SIZE = 512 * 1024; +const SFTP_RELAY_CONCURRENCY = 64; +const SFTP_DIRECTORY_ENTRY_CONCURRENCY = 16; +const SFTP_DIRECTORY_DISCOVERY_CONCURRENCY = 64; +const SFTP_DIRECTORY_TRANSFER_CONNECTIONS = 16; +const SFTP_DIRECTORY_MKDIR_BATCH_SIZE = 256; +const LOCAL_DIRECTORY_STAT_CONCURRENCY = 64; +const LOCAL_COPY_DIRECTORY_CONCURRENCY = 16; +const LOCAL_COPY_FILE_HIGH_WATER_MARK = 1024 * 1024; +const BATCH_TRANSFER_WORKERS = 16; +const BATCH_TRANSFER_DIRECTORY_WORKERS = 4; +const REMOTE_NATIVE_DIRECTORY_COPY_PROGRESS_BYTES_PER_SECOND = 470 * 1024 * 1024; +const RSYNC_RELAY_BIND_HOST = '127.0.0.1'; +const SSH_TERMINAL_READY_MARKER = '__TURBOFILE_TERMINAL_READY__'; +const DIRECT_SSH_CONNECT_TIMEOUT_MS = 5 * 1000; +const DIRECT_SSH_ROUTE_FAILURE_COOLDOWN_MS = 10 * 60 * 1000; +const DIRECT_SSH_ROUTE_CACHE_TTL_MS = 10 * 60 * 1000; +const DIRECT_TRANSFER_STALL_TIMEOUT_MS = 10 * 1000; +const DIRECT_TRANSFER_STALL_CHECK_INTERVAL_MS = 1000; +const LOCAL_RSYNC_STALL_TIMEOUT_MS = 10 * 60 * 1000; +const TRANSFER_PROGRESS_EVENT_INTERVAL_MS = 500; +const LOCAL_WINDOWS_REVERSE_RSYNC_MIN_SPLIT_ENTRIES = 6; +const LOCAL_PARALLEL_TAR_CHUNK_COUNT = 12; +const LOCAL_PARALLEL_TAR_MIN_ENTRIES = 8; +const LOCAL_PARALLEL_TAR_CHUNK_RETRIES = 2; +const LOCAL_TAR_ZSTD_LEVEL = 3; +const FILE_ENTRY_NAME_COLLATOR = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }); +class TurboFileTransferCanceledError extends Error { + constructor() { + super(TRANSFER_CANCELED_MESSAGE); + this.name = 'TurboFileTransferCanceledError'; + } +} +export class TurboFileSshMainService { + static { this.imageProtocolRegistered = false; } + constructor() { + this.terminalSessionSequence = 1; + this.terminalSessions = new Map(); + this.transferProgressEmitters = new Map(); + this.transferProgressSnapshots = new Map(); + this.transferDataStartTimes = new Map(); + this.transferFirstPositiveProgressTimes = new Map(); + this.transferGrowthMonitoredProgressIds = new Set(); + this.rsyncTimingStates = new Map(); + this.transferCancellationRequests = new Set(); + this.transferCancelHandlers = new Map(); + this.directorySftpSessions = new Map(); + this.directorySftpSessionGenerations = new TurboFileKeyedGeneration(); + this.directorySftpIdleTimers = new Map(); + this.remoteDirectoryListingCache = new Map(); + this.remoteDirectoryListingGenerations = new TurboFileKeyedGeneration(); + this.remoteImageToolCache = new Map(); + this.remoteImageToolPending = new Map(); + this.directSshRouteCache = new Map(); + this.directSshRouteFailureCooldowns = new Map(); + this.remoteRsyncSshBinaryCache = new Map(); + this.imageResourceDescriptors = new Map(); + // Caps concurrent SSH image reads. The image grid renders cards as , + // and Chromium fetches every visible card at once — dozens of simultaneous `client.exec` calls on + // one SSH connection blow past the server's MaxSessions limit and every command fails with + // "Unable to exec". A small global limit keeps thumbnail generation under that ceiling (leaving + // channels free for directory/SFTP work) while still loading several images in parallel. + this.imageReadActiveCount = 0; + this.imageReadWaiters = []; + this.ensureTurboFileImageProtocolRegistered(); + // A freshly-registered CopyHook handler is not picked up by the already-running Explorer + // until Explorer reloads (WinSCP documents the same: its dragext needs a logoff/restart to + // load). Prepare the handler eagerly at startup so the very first drag-to-Windows works, + // instead of failing once and only working after a manual retry. + if (platform() === 'win32') { + void this.ensureWindowsDragCopyHookRegistered().catch(() => undefined); + } + } + onDynamicSshTerminalData(sessionId) { + const session = this.terminalSessions.get(sessionId); + if (!session) { + return Event.None; + } + return (listener, thisArgs, disposables) => { + session.hasDataListener = true; + const disposable = session.dataEmitter.event(listener, thisArgs, disposables); + const buffered = session.dataBuffer.splice(0); + for (const chunk of buffered) { + listener.call(thisArgs, chunk); + } + return disposable; + }; + } + onDynamicSshTerminalExit(sessionId) { + return this.terminalSessions.get(sessionId)?.exitEmitter.event ?? Event.None; + } + onDynamicTransferProgress(progressId) { + if (!progressId) { + return Event.None; + } + return this.getTransferProgressEmitter(progressId).event; + } + async cancelTransfer(progressId) { + if (!progressId) { + return { ok: false, message: 'Transfer progress id is required.' }; + } + this.transferCancellationRequests.add(progressId); + const handlers = this.transferCancelHandlers.get(progressId); + if (handlers) { + for (const handler of [...handlers]) { + try { + handler(); + } + catch { + // Cancellation is best-effort across local streams and SSH/SFTP channels. + } + } + } + return { ok: true, message: 'Transfer cancellation requested.' }; + } + async testConnection(options) { + const configResult = await this.createConnectConfig(options); + if (!configResult.ok) { + return configResult; + } + return this.doTestConnection(configResult.config); + } + async detectCapabilities(options) { + const configResult = await this.createConnectConfig(options); + if (!configResult.ok) { + return { ok: false, message: configResult.message }; + } + let client; + try { + client = await this.connect(configResult.config); + const hasCommand = async (command) => { + try { + await this.execRemote(client, `command -v ${command} >/dev/null 2>&1`); + return true; + } + catch { + return false; + } + }; + const sftp = await this.openSftp(client).then(handle => { + handle.end(); + return true; + }).catch(() => false); + const [scp, rsync, sshpass, tar, zip, unzip] = await Promise.all([ + hasCommand('scp'), + hasCommand('rsync'), + hasCommand('sshpass'), + hasCommand('tar'), + hasCommand('zip'), + hasCommand('unzip') + ]); + return { + ok: true, + message: 'Capabilities detected.', + capabilities: { + ssh: true, + sftp, + scp, + rsync, + sshpass, + tar, + shell: true, + archive: tar || zip || unzip + } + }; + } + catch (error) { + return { + ok: false, + message: error instanceof Error ? error.message : String(error) + }; + } + finally { + client?.end(); + } + } + async installTransferDependencies(options, packages) { + const requestedPackages = normalizeTransferDependencies(packages); + if (!requestedPackages.length) { + return { ok: true, message: 'No dependencies requested.', installedPackages: [], missingPackages: [] }; + } + const configResult = await this.createConnectConfig(options); + if (!configResult.ok) { + return { ok: false, message: configResult.message, installedPackages: [], missingPackages: requestedPackages }; + } + let installError; + let client; + try { + client = await this.connect(configResult.config); + await this.execRemote(client, buildTransferDependencyInstallCommand(requestedPackages, options)); + } + catch (error) { + installError = error instanceof Error ? error : new Error(String(error)); + } + finally { + client?.end(); + } + const capabilityResult = await this.detectCapabilities(options); + const capabilities = capabilityResult.capabilities; + const missingPackages = capabilities ? getMissingTransferDependencies(requestedPackages, capabilities) : requestedPackages; + const installedPackages = requestedPackages.filter(pkg => !missingPackages.includes(pkg)); + const ok = capabilityResult.ok && missingPackages.length === 0; + const missingMessage = missingPackages.length ? `缺少 ${missingPackages.join('、')}` : '依赖已安装'; + return { + ok, + message: ok ? 'rsync/sshpass 安装并检测成功。' : `${installError ? `${installError.message};` : ''}${missingMessage}`, + installedPackages, + missingPackages, + capabilities + }; + } + async listRemoteDirectory(options, path, refresh = false) { + return this.listRemoteDirectoryCore(options, path, refresh, false); + } + async listRemoteDirectoryNames(options, path, refresh = false) { + return this.listRemoteDirectoryCore(options, path, refresh, true); + } + async listRemoteDirectoryCore(options, path, refresh, namesOnly) { + const cacheKey = this.getRemoteDirectoryListingCacheKey(options, path); + if (!refresh) { + // A full (size+date) cached listing satisfies a names-only request too, but not vice versa: + // only reuse a cached entry that has real attributes when the caller asked names-only. + const cached = this.getRemoteDirectoryListingCache(cacheKey); + if (cached && (namesOnly || this.remoteListingHasAttributes(cached))) { + return cached; + } + } + const listingGeneration = this.remoteDirectoryListingGenerations.next(cacheKey); + const configResult = await this.createConnectConfig(options); + if (!configResult.ok) { + throw new Error(configResult.message); + } + let lastError; + for (let attempt = 0; attempt < REMOTE_DIRECTORY_READ_MAX_ATTEMPTS; attempt++) { + let session; + try { + session = await this.getDirectorySftpSession(options, configResult.config); + const listing = await this.readRemoteDirectoryWithFallback(session.sftp, session.client, options, path, namesOnly); + this.touchDirectorySftpSession(session.key, session.generation); + return this.createRemoteDirectoryListingResult(cacheKey, listingGeneration, namesOnly, listing); + } + catch (error) { + lastError = error; + if (this.remoteDirectoryListingGenerations.isCurrent(cacheKey, listingGeneration)) { + this.remoteDirectoryListingCache.delete(cacheKey); + } + if (session) { + this.disposeDirectorySftpSession(session.key, session.generation); + } + const retryDelayMs = getRemoteDirectoryReadRetryDelay(attempt); + if (!shouldRetryRemoteDirectoryRead(error) || retryDelayMs === undefined) { + throw error; + } + await delay(retryDelayMs); + } + } + throw lastError; + } + createRemoteDirectoryListingResult(requestedCacheKey, listingGeneration, namesOnly, listing) { + const result = { + path: listing.path, + entries: listing.entries + .filter(entry => entry.filename !== '.' && entry.filename !== '..') + .map(entry => this.toRemoteEntry(listing.path, entry)) + .sort(compareFileEntries) + }; + // Only cache full listings: a names-only listing has zeroed size/date and must not shadow a + // later full listing (it is cheap to recompute and gets enriched lazily on the client side). + if (!namesOnly && this.remoteDirectoryListingGenerations.isCurrent(requestedCacheKey, listingGeneration)) { + this.setRemoteDirectoryListingCache(requestedCacheKey, result); + } + return result; + } + remoteListingHasAttributes(listing) { + // A names-only listing zeroes every size and modified time; a real listing almost always has at + // least one non-zero. Treat an all-zero listing as attribute-less so a names-only result that + // somehow reached the cache never satisfies a request that needs sizes. + return listing.entries.some(entry => entry.size > 0 || entry.modified > 0); + } + async statRemoteEntries(options, directoryPath, names) { + const uniqueNames = [...new Set(names.filter(name => !!name && name !== '.' && name !== '..'))]; + if (!uniqueNames.length) { + return []; + } + if (looksLikeWindowsPath(directoryPath)) { + return []; + } + const configResult = await this.createConnectConfig(options); + if (!configResult.ok) { + throw new Error(configResult.message); + } + const session = await this.getDirectorySftpSession(options, configResult.config); + try { + const output = await this.execRemoteBytes(session.client, buildPosixStatEntriesCommand(directoryPath, uniqueNames), { + inactivityTimeoutMs: REMOTE_DIRECTORY_READ_INACTIVITY_TIMEOUT_MS, + timeoutMs: REMOTE_DIRECTORY_READ_MAX_TIMEOUT_MS + }); + this.touchDirectorySftpSession(session.key, session.generation); + const stats = parseRemoteStatEntries(output.toString('utf8')); + return stats.length ? stats : await this.statRemoteEntriesWithSftpFallback(session.sftp, directoryPath, uniqueNames); + } + catch { + return this.statRemoteEntriesWithSftpFallback(session.sftp, directoryPath, uniqueNames); + } + } + async statRemoteEntriesWithSftpFallback(sftp, directoryPath, names) { + const stats = await mapLimit(names, SFTP_DIRECTORY_ENTRY_CONCURRENCY, async (name) => { + try { + const stat = await this.remoteStat(sftp, joinRemotePath(directoryPath, name)); + return { + name, + size: stat.size, + modified: stat.mtime * 1000 + }; + } + catch { + return undefined; + } + }); + return stats.filter((stat) => !!stat); + } + async listLocalDirectory(path, _refresh = false) { + const dirents = await fsp.readdir(path, { withFileTypes: true }); + const visibleNames = await getWindowsExplorerVisibleLocalNames(path); + const entries = (await mapLimit(dirents.filter(dirent => !visibleNames || visibleNames.has(dirent.name)), LOCAL_DIRECTORY_STAT_CONCURRENCY, async (dirent) => { + try { + const fullPath = join(path, dirent.name); + const stat = await fsp.lstat(fullPath); + const type = stat.isDirectory() ? 'directory' : stat.isFile() ? 'file' : stat.isSymbolicLink() ? 'symlink' : 'unknown'; + return { + name: dirent.name, + path: fullPath, + type, + size: stat.size, + modified: stat.mtimeMs + }; + } + catch { + return undefined; + } + })).filter((entry) => !!entry); + return { + path, + entries: entries.sort(compareFileEntries) + }; + } + async listLocalDirectoryNames(path, _refresh = false) { + const dirents = await fsp.readdir(path, { withFileTypes: true }); + const visibleNames = await getWindowsExplorerVisibleLocalNames(path); + const entries = dirents + .filter(dirent => !visibleNames || visibleNames.has(dirent.name)) + .map(dirent => { + const type = dirent.isDirectory() ? 'directory' : dirent.isFile() ? 'file' : dirent.isSymbolicLink() ? 'symlink' : 'unknown'; + return { + name: dirent.name, + path: join(path, dirent.name), + type, + size: 0, + modified: 0 + }; + }); + return { + path, + entries: entries.sort(compareFileEntries) + }; + } + async statLocalEntries(directoryPath, names) { + const uniqueNames = Array.from(new Set(names.filter(name => !!name))); + const stats = await mapLimit(uniqueNames, LOCAL_DIRECTORY_STAT_CONCURRENCY, async (name) => { + try { + const stat = await fsp.lstat(join(directoryPath, name)); + return { + name, + size: stat.size, + modified: stat.mtimeMs + }; + } + catch { + return undefined; + } + }); + return stats.filter((stat) => !!stat); + } + async normalizeLocalWindowsPaths(paths) { + return normalizeWindowsTransferPaths(paths, resolveLocalWindowsRealPath); + } + async transferFile(request) { + const strategy = getTransferStrategy(request.source, request.target); + const startedAt = Date.now(); + if (request.progressId) { + this.transferCancellationRequests.delete(request.progressId); + } + try { + this.throwIfTransferCanceled(request.progressId); + if (request.source.kind === 'local' && request.target.kind === 'local') { + this.fireTransferProgress(request, 0, Math.max(0, request.expectedBytes ?? 0), 'prepare', 'Preparing local copy.', true); + const totalBytes = await getLocalPathTransferSize(request.source.path, request.expectedBytes); + this.fireTransferProgress(request, 0, totalBytes, 'transfer', 'Copying locally.', true); + let transferredBytes = 0; + const onDelta = (delta) => { + this.throwIfTransferCanceled(request.progressId); + transferredBytes += delta; + this.fireTransferProgress(request, Math.min(transferredBytes, totalBytes), totalBytes, 'transfer', 'Copying locally.'); + }; + await this.copyLocalPath(request.source.path, request.target.path, onDelta, request.progressId); + this.throwIfTransferCanceled(request.progressId); + const bytes = Math.max(totalBytes, transferredBytes); + this.fireTransferProgress(request, bytes, bytes, 'complete', 'Local copy completed.', true); + return this.createTransferResult(true, strategy, bytes, 'Local copy completed.', startedAt, request.progressId); + } + if (request.source.kind === 'local' && request.target.kind === 'ssh') { + this.throwIfTransferCanceled(request.progressId); + const stat = await fsp.stat(request.source.path); + const sourceType = stat.isDirectory() ? 'directory' : 'file'; + const effectiveRequest = { ...request, sourceType }; + const plan = resolveTransferPlan({ + source: effectiveRequest.source, + target: effectiveRequest.target, + sourceType, + itemCount: 1, + strategyHint: effectiveRequest.strategyHint + }); + await this.rejectNonRsyncDirectoryTransferTargetConflict(effectiveRequest, sourceType, plan); + let bytes = stat.isDirectory() ? Math.max(0, request.expectedBytes ?? 0) : stat.size; + if (sourceType === 'directory') { + if (shouldPreferTarForMultiItemTransfer(request.target.preferredProtocol)) { + const tarResult = await this.tryLocalToSshTarTransfer(effectiveRequest, bytes, startedAt); + if (tarResult?.ok) { + this.clearRemoteDirectoryListingCache(request.target); + return tarResult; + } + throw new Error('Tar upload unavailable for directory transfer.'); + } + if (request.target.preferredProtocol !== 'sftp') { + const commandOrder = getDirectoryLocalCommandTransferProtocolOrder(request.target.preferredProtocol); + const commandResult = await this.tryLocalToSshCommandTransfer(effectiveRequest, bytes, startedAt, commandOrder, false); + if (commandResult?.ok) { + this.clearRemoteDirectoryListingCache(request.target); + return commandResult; + } + } + } + else { + const commandResult = await this.tryLocalToSshCommandTransfer(request, bytes, startedAt); + if (commandResult?.ok) { + this.clearRemoteDirectoryListingCache(request.target); + return commandResult; + } + } + this.fireTransferProgress(request, 0, bytes, 'prepare', 'Preparing SFTP upload.', true, { strategy: 'sftp-upload' }); + let transferredBytes = 0; + const onDelta = (delta) => { + this.throwIfTransferCanceled(request.progressId); + transferredBytes += delta; + this.fireTransferProgress(request, transferredBytes, bytes, 'transfer', 'Uploading over SFTP.', false, { strategy: 'sftp-upload' }); + }; + const onTotal = (totalBytes) => { + bytes = Math.max(bytes, totalBytes); + this.fireTransferProgress(request, Math.min(transferredBytes, bytes), bytes, 'transfer', 'Uploading over SFTP.', true, { strategy: 'sftp-upload' }); + }; + if (stat.isDirectory()) { + await this.rejectDirectoryTargetPathConflict(request.target); + this.fireTransferProgress(request, 0, bytes, 'transfer', 'Scanning and uploading directory over SFTP.', true, { strategy: 'sftp-upload' }); + await this.uploadDirectoryConcurrent(request.target, request.source.path, request.target.path, onTotal, onDelta, request.progressId); + } + else { + await this.withSftp(request.target, async (sftp) => { + this.throwIfTransferCanceled(request.progressId); + await this.ensureRemoteDirectory(sftp, getRemoteParentPath(request.target.path)); + await this.fastPutWithFallback(sftp, request.source.path, request.target.path, bytes, onDelta, request.progressId); + }, request.progressId); + } + this.throwIfTransferCanceled(request.progressId); + await this.verifySshUploadSize(request.target, stat.isDirectory() ? 0 : bytes, 'SFTP upload'); + this.fireTransferProgress(request, bytes, bytes, 'complete', 'SFTP upload completed.', true, { strategy: 'sftp-upload' }); + this.clearRemoteDirectoryListingCache(request.target); + return this.createTransferResult(true, 'sftp-upload', bytes, 'SFTP upload completed.', startedAt, request.progressId); + } + if (request.source.kind === 'ssh' && request.target.kind === 'local') { + this.throwIfTransferCanceled(request.progressId); + await ensureLocalParentDirectory(request.target.path); + let bytes = 0; + const sourceType = request.sourceType ?? await this.withSftp(request.source, async (sftp) => getFileTypeFromStats(await this.remoteStat(sftp, request.source.path)), request.progressId).catch(() => 'unknown'); + const effectiveRequest = { ...request, sourceType }; + const plan = resolveTransferPlan({ + source: effectiveRequest.source, + target: effectiveRequest.target, + sourceType, + itemCount: 1, + strategyHint: effectiveRequest.strategyHint + }); + await this.rejectNonRsyncDirectoryTransferTargetConflict(effectiveRequest, sourceType, plan); + bytes = sourceType === 'directory' + ? Math.max(0, request.expectedBytes ?? 0) + : Math.max(0, request.expectedBytes ?? 0); + if (sourceType === 'directory') { + if (shouldPreferTarForMultiItemTransfer(request.source.preferredProtocol)) { + const tarResult = await this.trySshToLocalTarTransfer(effectiveRequest, bytes, startedAt); + if (tarResult?.ok) { + return tarResult; + } + throw new Error('Tar download unavailable for directory transfer.'); + } + if (request.source.preferredProtocol !== 'sftp') { + const commandOrder = getDirectoryLocalCommandTransferProtocolOrder(request.source.preferredProtocol); + const commandResult = await this.trySshToLocalCommandTransfer(effectiveRequest, bytes, startedAt, commandOrder); + if (commandResult?.ok) { + return commandResult; + } + } + } + else { + if (bytes <= 0) { + bytes = await this.withSftp(request.source, async (sftp) => (await this.remoteStat(sftp, request.source.path)).size, request.progressId).catch(() => bytes); + } + const commandResult = await this.trySshToLocalCommandTransfer(request, bytes, startedAt); + if (commandResult?.ok) { + return commandResult; + } + } + let transferredBytes = 0; + const onDelta = (delta) => { + this.throwIfTransferCanceled(request.progressId); + transferredBytes += delta; + this.fireTransferProgress(request, transferredBytes, bytes, 'transfer', 'Downloading over SFTP.', false, { strategy: 'sftp-download' }); + }; + const onTotal = (totalBytes) => { + bytes = Math.max(bytes, totalBytes); + this.fireTransferProgress(request, Math.min(transferredBytes, bytes), bytes, 'transfer', 'Downloading over SFTP.', true, { strategy: 'sftp-download' }); + }; + if (sourceType === 'directory') { + await this.rejectDirectoryTargetPathConflict(request.target); + this.fireTransferProgress(request, 0, bytes, 'transfer', 'Scanning and downloading directory over SFTP.', true, { strategy: 'sftp-download' }); + await this.downloadDirectoryConcurrent(request.source, request.source.path, request.target.path, onTotal, onDelta, request.progressId); + } + else { + await this.withSftp(request.source, async (sftp) => { + this.throwIfTransferCanceled(request.progressId); + if (bytes <= 0) { + bytes = (await this.remoteStat(sftp, request.source.path)).size; + } + this.fireTransferProgress(request, 0, bytes, 'prepare', 'Preparing SFTP download.', true, { strategy: 'sftp-download' }); + await this.fastGetWithFallback(sftp, request.source.path, request.target.path, bytes, onDelta, request.progressId); + }, request.progressId); + } + this.throwIfTransferCanceled(request.progressId); + this.fireTransferProgress(request, bytes, bytes, 'complete', 'SFTP download completed.', true, { strategy: 'sftp-download' }); + return this.createTransferResult(true, 'sftp-download', bytes, 'SFTP download completed.', startedAt, request.progressId); + } + if (request.source.kind === 'ssh' && request.target.kind === 'ssh') { + return await this.transferBetweenSshDevices(request, startedAt); + } + throw new Error('Unsupported transfer endpoint combination.'); + } + catch (error) { + if (error instanceof TurboFileTransferCanceledError || this.isTransferCanceled(request.progressId)) { + return { + ok: false, + strategy, + bytes: 0, + message: TRANSFER_CANCELED_MESSAGE, + canceled: true, + durationMs: Date.now() - startedAt + }; + } + return { + ok: false, + strategy, + bytes: 0, + message: error instanceof Error ? error.message : String(error), + durationMs: Date.now() - startedAt + }; + } + finally { + if (request.progressId) { + this.transferCancellationRequests.delete(request.progressId); + this.transferCancelHandlers.delete(request.progressId); + } + this.scheduleTransferProgressCleanup(request.progressId); + } + } + async transferBetweenSshDevices(request, startedAt) { + if (request.source.kind !== 'ssh' || request.target.kind !== 'ssh') { + throw new Error('SSH-to-SSH transfer requires SSH endpoints.'); + } + const sourceType = request.sourceType ?? await this.withDirectorySftp(request.source, async (sftp) => getFileTypeFromStats(await this.remoteStat(sftp, request.source.path))).catch(() => 'unknown'); + const effectiveRequest = { ...request, sourceType }; + const plan = resolveTransferPlan({ + source: effectiveRequest.source, + target: effectiveRequest.target, + sourceType, + itemCount: 1, + strategyHint: effectiveRequest.strategyHint + }); + await this.rejectNonRsyncDirectoryTransferTargetConflict(effectiveRequest, sourceType, plan); + const failedStrategy = transferAttemptToStrategy(plan.attempts[0]); + const failures = []; + if (request.strategyHint !== 'relay') { + const sameRemoteResult = await this.trySameRemoteCopy(effectiveRequest, startedAt); + this.throwIfTransferCanceled(request.progressId); + if (sameRemoteResult?.ok) { + this.fireTransferProgress(request, sameRemoteResult.bytes, sameRemoteResult.bytes, 'complete', 'Remote copy completed.', true, { strategy: sameRemoteResult.strategy }); + this.clearRemoteDirectoryListingCache(request.target); + return sameRemoteResult; + } + } + for (const [index, attempt] of plan.attempts.entries()) { + try { + this.fireTransferProgress(request, 0, attempt.protocol === 'rsync' ? 0 : Math.max(0, request.expectedBytes ?? 0), attempt.route === 'relay' ? 'relay-upload' : 'transfer', `Starting ${attempt.protocol} ${attempt.route} transfer.`, true, { + strategy: transferAttemptToStrategy(attempt), + route: attempt.route === 'relay' ? 'relay' : undefined + }); + const directResult = await this.trySshToSshTransferAttempt(attempt, effectiveRequest, startedAt); + this.throwIfTransferCanceled(request.progressId); + if (directResult?.ok) { + this.fireTransferProgress(request, directResult.bytes, directResult.bytes, 'complete', `${formatTransferStrategyName(directResult.strategy)} completed.`, true, { strategy: directResult.strategy }); + this.clearRemoteDirectoryListingCache(request.target); + return directResult; + } + } + catch (error) { + if (error instanceof TurboFileTransferCanceledError || this.isTransferCanceled(request.progressId)) { + throw new TurboFileTransferCanceledError(); + } + const failure = `${attempt.protocol} ${attempt.route}: ${formatTransferError(error)}`; + failures.push(failure); + const nextAttempt = plan.attempts[index + 1]; + if (nextAttempt) { + this.fireTransferProgress(request, 0, 0, nextAttempt.route === 'relay' ? 'relay-upload' : 'transfer', `${failure}; falling back to ${nextAttempt.protocol} ${nextAttempt.route}.`, true, { + strategy: transferAttemptToStrategy(nextAttempt), + route: nextAttempt.route === 'relay' ? 'relay' : undefined + }); + } + } + } + return { + ok: false, + strategy: failedStrategy, + bytes: 0, + message: `Transfer unavailable${failures.length ? `: ${failures.join('; ')}` : '.'}`, + durationMs: Date.now() - startedAt + }; + } + async trySshToSshTransferAttempt(attempt, request, startedAt) { + if (attempt.route === 'direct') { + return this.trySshToSshDirectTransfer(attempt.protocol, request, startedAt); + } + if (attempt.route === 'relay') { + return this.trySshToSshRelayTransfer(attempt.protocol, request, startedAt); + } + return undefined; + } + async trySshToSshDirectTransfer(protocol, request, startedAt) { + switch (protocol) { + case 'rsync': + return this.tryDirectRsyncTransfer(request, startedAt, true); + case 'tar': + return this.tryDirectTarTransfer(request, startedAt, true); + case 'sftp': + return this.tryDirectSftpTransfer(request, startedAt, true); + case 'scp': + default: + return this.tryDirectScpTransfer(request, startedAt, true); + } + } + async trySshToSshRelayTransfer(protocol, request, startedAt) { + switch (protocol) { + case 'rsync': + return this.tryTunneledRsyncRelayTransfer(request, startedAt, true); + case 'tar': + return this.tryTarRelayTransfer(request, startedAt, true); + case 'sftp': + return await this.tryStreamRelayTransfer(request, startedAt, true) ?? await this.runSftpRelayTransfer(request, startedAt, true); + case 'scp': + default: + return this.tryScpRelayTransfer(request, startedAt, true); + } + } + async tryLocalToSshReverseParallelRsyncUpload(request, bytes, startedAt) { + if (platform() !== 'win32' || request.source.kind !== 'local' || request.target.kind !== 'ssh' || !request.target.connection) { + return undefined; + } + if (request.target.supportsRsync !== true || looksLikeWindowsPath(request.target.path)) { + return undefined; + } + try { + const sourceStat = await fsp.stat(request.source.path); + if (!sourceStat.isDirectory()) { + return undefined; + } + const sourceConnection = await this.resolveLocalWindowsRsyncSourceConnection(); + if (!sourceConnection) { + return undefined; + } + const remoteTargetDirectory = trimTrailingRemoteSlash(request.target.path); + if (!remoteTargetDirectory || remoteTargetDirectory === '/') { + return undefined; + } + const items = await this.createLocalWindowsReverseRsyncItems(request.source.path, remoteTargetDirectory); + if (items.length < 2) { + return undefined; + } + this.fireTransferProgress(request, 0, 0, 'transfer', 'Starting parallel rsync target pull.', true, { + strategy: 'rsync-direct-parallel', + route: 'target-pull', + completedItems: 0, + totalItems: items.length, + currentName: items[0].name + }); + const targetConfigResult = await this.createConnectConfig(request.target.connection); + if (!targetConfigResult.ok) { + return undefined; + } + const client = await this.connect(targetConfigResult.config); + const unregisterCancel = this.registerTransferCancelHandler(request.progressId, () => client.end()); + let lastActivityAt = Date.now(); + try { + const progress = this.createParallelRsyncProgressReporter(request, 0, 'rsync-direct', 'Parallel rsync target pull in progress.', { + strategy: 'rsync-direct-parallel', + route: 'target-pull', + completedItems: 0, + totalItems: items.length + }, () => { + lastActivityAt = Date.now(); + }); + const handleProgressOutput = (chunk) => { + progress.handleOutput(chunk); + lastActivityAt = progress.getActivityTime() ?? lastActivityAt; + }; + const sshBinary = await this.resolveRemoteRsyncSshBinary(client, request.target.connection); + const sshTransport = buildOptimizedRsyncSshCommand(sourceConnection.port || 22, !!sourceConnection.password, sshBinary); + const command = buildLocalWindowsReverseParallelRsyncCommand(sourceConnection, items, sshTransport, [remoteTargetDirectory]); + this.throwIfTransferCanceled(request.progressId); + await this.execRemote(client, command, { + onStdout: handleProgressOutput, + onStderr: handleProgressOutput, + inactivityTimeoutMs: DIRECT_TRANSFER_STALL_TIMEOUT_MS, + inactivityMessage: `Parallel rsync target pull produced no transfer progress for ${DIRECT_TRANSFER_STALL_TIMEOUT_MS} ms.`, + getActivityTime: () => progress.getActivityTime() ?? lastActivityAt + }); + this.throwIfTransferCanceled(request.progressId); + const resultBytes = progress.getLastBytes() || bytes; + this.fireTransferProgress(request, resultBytes, 0, 'complete', 'Parallel rsync target pull completed.', true, { + strategy: 'rsync-direct-parallel', + route: 'target-pull', + completedItems: items.length, + totalItems: items.length + }); + return this.createTransferResult(true, 'rsync-direct-parallel', resultBytes, 'Parallel rsync target pull completed.', startedAt, request.progressId); + } + finally { + unregisterCancel(); + client.end(); + } + } + catch (error) { + if (error instanceof TurboFileTransferCanceledError || this.isTransferCanceled(request.progressId)) { + throw new TurboFileTransferCanceledError(); + } + return undefined; + } + } + async createLocalWindowsReverseRsyncItems(localDirectory, remoteTargetDirectory) { + const rootSourcePath = trimTrailingRemoteSlash(toRsyncLocalPath(localDirectory)); + const rootEntries = await this.readLocalRsyncSplitEntries(localDirectory); + if (!rootEntries.length) { + return []; + } + if (rootEntries.length === 1 && rootEntries[0].directory) { + const childLocalDirectory = join(localDirectory, rootEntries[0].name); + const childEntries = await this.readLocalRsyncSplitEntries(childLocalDirectory).catch(() => []); + if (childEntries.length >= LOCAL_WINDOWS_REVERSE_RSYNC_MIN_SPLIT_ENTRIES) { + return childEntries.map(entry => { + const relativePath = `${rootEntries[0].name}/${entry.name}`; + return { + name: relativePath, + directory: entry.directory, + sourcePath: joinRemotePath(rootSourcePath, relativePath), + targetPath: joinRemotePath(remoteTargetDirectory, relativePath) + }; + }); + } + } + if (rootEntries.length < LOCAL_WINDOWS_REVERSE_RSYNC_MIN_SPLIT_ENTRIES) { + return [{ + name: getTransferName(localDirectory), + directory: true, + sourcePath: rootSourcePath, + targetPath: remoteTargetDirectory + }]; + } + return rootEntries.map(entry => ({ + name: entry.name, + directory: entry.directory, + sourcePath: joinRemotePath(rootSourcePath, entry.name), + targetPath: joinRemotePath(remoteTargetDirectory, entry.name) + })); + } + async readLocalRsyncSplitEntries(localDirectory) { + const entries = await fsp.readdir(localDirectory, { withFileTypes: true }); + return entries + .filter(entry => entry.isDirectory() || entry.isFile()) + .map(entry => ({ name: entry.name, directory: entry.isDirectory() })) + .sort((a, b) => FILE_ENTRY_NAME_COLLATOR.compare(a.name, b.name)); + } + async tryLocalToSshReverseParallelRsyncBatchUpload(request, batchItems, operation, bytes, startedAt) { + if (platform() !== 'win32' || request.source.kind !== 'local' || request.targetDirectory.kind !== 'ssh' || !request.targetDirectory.connection) { + return undefined; + } + if (request.targetDirectory.supportsRsync !== true || looksLikeWindowsPath(request.targetDirectory.path)) { + return undefined; + } + if (request.targetDirectory.preferredProtocol !== 'rsync') { + return undefined; + } + if (!canBatchRsyncPreserveTargetNames(batchItems)) { + return undefined; + } + try { + const sourceConnection = await this.resolveLocalWindowsRsyncSourceConnection(); + if (!sourceConnection) { + return undefined; + } + const remoteTargetDirectory = trimTrailingRemoteSlash(request.targetDirectory.path); + if (!remoteTargetDirectory || remoteTargetDirectory === '/') { + return undefined; + } + const cleanupTargets = new Set(); + const rsyncItems = []; + let hasDirectory = false; + for (const item of batchItems) { + const stat = await fsp.stat(item.sourcePath).catch(() => undefined); + if (!stat) { + return undefined; + } + const targetPath = joinRemotePath(remoteTargetDirectory, item.targetName); + cleanupTargets.add(targetPath); + if (stat.isDirectory()) { + hasDirectory = true; + const splitItems = await this.createLocalWindowsReverseRsyncItems(item.sourcePath, targetPath); + if (splitItems.length) { + rsyncItems.push(...splitItems); + } + else { + rsyncItems.push({ + name: item.displayName ?? item.targetName, + directory: true, + sourcePath: trimTrailingRemoteSlash(toRsyncLocalPath(item.sourcePath)), + targetPath + }); + } + } + else if (stat.isFile()) { + rsyncItems.push({ + name: item.displayName ?? item.targetName, + directory: false, + sourcePath: trimTrailingRemoteSlash(toRsyncLocalPath(item.sourcePath)), + targetPath + }); + } + } + if (!rsyncItems.length || (!hasDirectory && batchItems.length <= 1)) { + return undefined; + } + this.fireTransferProgress(request, 0, 0, 'transfer', 'Starting parallel rsync target pull.', true, { + strategy: 'rsync-direct-parallel', + route: 'target-pull', + completedItems: 0, + totalItems: batchItems.length, + currentName: batchItems[0].displayName ?? batchItems[0].targetName + }); + const targetConfigResult = await this.createConnectConfig(request.targetDirectory.connection); + if (!targetConfigResult.ok) { + return undefined; + } + const client = await this.connect(targetConfigResult.config); + const unregisterCancel = this.registerTransferCancelHandler(request.progressId, () => client.end()); + let lastActivityAt = Date.now(); + try { + const progress = this.createParallelRsyncProgressReporter(request, 0, 'rsync-direct', 'Parallel rsync target pull in progress.', { + strategy: 'rsync-direct-parallel', + route: 'target-pull', + completedItems: 0, + totalItems: batchItems.length + }, () => { + lastActivityAt = Date.now(); + }); + const handleProgressOutput = (chunk) => { + progress.handleOutput(chunk); + lastActivityAt = progress.getActivityTime() ?? lastActivityAt; + }; + const sshBinary = await this.resolveRemoteRsyncSshBinary(client, request.targetDirectory.connection); + const sshTransport = buildOptimizedRsyncSshCommand(sourceConnection.port || 22, !!sourceConnection.password, sshBinary); + const command = buildLocalWindowsReverseParallelRsyncCommand(sourceConnection, rsyncItems, sshTransport, [...cleanupTargets]); + this.throwIfTransferCanceled(request.progressId); + await this.execRemote(client, command, { + onStdout: handleProgressOutput, + onStderr: handleProgressOutput, + inactivityTimeoutMs: DIRECT_TRANSFER_STALL_TIMEOUT_MS, + inactivityMessage: `Parallel rsync target pull produced no transfer progress for ${DIRECT_TRANSFER_STALL_TIMEOUT_MS} ms.`, + getActivityTime: () => progress.getActivityTime() ?? lastActivityAt + }); + this.throwIfTransferCanceled(request.progressId); + const cleanupFailures = operation === 'move' ? await this.deleteBatchSources(request.source, batchItems) : []; + const resultBytes = progress.getLastBytes() || bytes; + this.fireTransferProgress(request, resultBytes, 0, 'complete', 'Parallel rsync target pull completed.', true, { + strategy: 'rsync-direct-parallel', + route: 'target-pull', + completedItems: batchItems.length, + totalItems: batchItems.length + }); + this.clearRemoteDirectoryListingCache(request.targetDirectory); + return this.createBatchTransferResult(cleanupFailures.length === 0, 'rsync-direct-parallel', resultBytes, cleanupFailures.length ? `Parallel rsync completed, but source cleanup failed for ${cleanupFailures.length} item(s).` : 'Parallel rsync completed.', batchItems.length, cleanupFailures.length, cleanupFailures, startedAt, request.progressId); + } + finally { + unregisterCancel(); + client.end(); + } + } + catch (error) { + if (error instanceof TurboFileTransferCanceledError || this.isTransferCanceled(request.progressId)) { + throw new TurboFileTransferCanceledError(); + } + return undefined; + } + } + async resolveLocalWindowsRsyncSourceConnection() { + this.localWindowsRsyncSourceConnectionPromise ??= this.doResolveLocalWindowsRsyncSourceConnection(); + return this.localWindowsRsyncSourceConnectionPromise; + } + async doResolveLocalWindowsRsyncSourceConnection() { + const localAddresses = getLocalIpv4Addresses(); + if (!localAddresses.size) { + return undefined; + } + for (const configPath of getLocalWindowsRsyncConfigPaths()) { + try { + const raw = JSON.parse(await fsp.readFile(configPath, 'utf8')); + const config = asRecord(raw); + const servers = asRecord(config?.servers); + if (!servers) { + continue; + } + for (const [id, value] of Object.entries(servers)) { + const server = asRecord(value); + if (!server || readStringProperty(server, 'os_type') !== 'windows') { + continue; + } + const host = readStringProperty(server, 'host') || id; + if (!localAddresses.has(host)) { + continue; + } + const username = readStringProperty(server, 'user'); + const password = readStringProperty(server, 'password'); + if (!username || !password) { + continue; + } + return { + host, + port: readNumberProperty(server, 'port') || 22, + username, + password + }; + } + } + catch { + // Optional compatibility config; absence simply means this fast route is unavailable. + } + } + return undefined; + } + async tryLocalToSshCommandTransfer(request, bytes, startedAt, protocolOrder, verifyUploadSize = true) { + if (request.source.kind !== 'local' || request.target.kind !== 'ssh' || !request.target.connection) { + return undefined; + } + for (const protocol of protocolOrder ?? getLocalCommandTransferProtocolOrder(request.target.preferredProtocol, request.target.connection)) { + this.throwIfTransferCanceled(request.progressId); + try { + if (protocol === 'rsync') { + const initialTotalBytes = Math.max(0, bytes); + this.fireTransferProgress(request, 0, initialTotalBytes, 'transfer', 'Uploading over local rsync.', true, { strategy: 'rsync-upload' }); + const progress = this.createRsyncProgressReporter(request, initialTotalBytes, 'transfer', 'Uploading over local rsync.', { strategy: 'rsync-upload' }); + // Show the size from the first second by computing it concurrently with rsync's scan. + if (initialTotalBytes <= 0) { + void getLocalPathSize(request.source.path).then(total => progress.setTotalBytes(total)).catch(() => undefined); + } + await this.runLocalRsyncUpload(request, bytes, progress.handleOutput); + const resultBytes = progress.getLastBytes() || bytes; + await this.verifySshUploadSize(request.target, 0, 'Local rsync upload'); + this.fireTransferProgress(request, resultBytes, 0, 'complete', 'Local rsync upload completed.', true, { strategy: 'rsync-upload' }); + return this.createTransferResult(true, 'rsync-upload', resultBytes, 'Local rsync upload completed.', startedAt, request.progressId); + } + this.fireTransferProgress(request, 0, bytes, 'transfer', 'Uploading over local SCP.', true, { strategy: 'scp-upload' }); + await this.runLocalScpUpload(request, bytes, transferredBytes => { + this.fireTransferProgress(request, transferredBytes, bytes, 'transfer', 'Uploading over local SCP.', false, { strategy: 'scp-upload' }); + }); + await this.verifySshUploadSize(request.target, verifyUploadSize ? bytes : 0, 'Local SCP upload'); + this.fireTransferProgress(request, bytes, bytes, 'complete', 'Local SCP upload completed.', true, { strategy: 'scp-upload' }); + return this.createTransferResult(true, 'scp-upload', bytes, 'Local SCP upload completed.', startedAt, request.progressId); + } + catch (error) { + if (this.isTransferCanceled(request.progressId)) { + throw new TurboFileTransferCanceledError(); + } + if (!isRecoverableLocalCommandTransferError(error)) { + return undefined; + } + this.clearTransferDataTiming(request.progressId); + } + } + return undefined; + } + async trySshToLocalCommandTransfer(request, bytes, startedAt, protocolOrder) { + if (request.source.kind !== 'ssh' || request.target.kind !== 'local' || !request.source.connection) { + return undefined; + } + for (const protocol of protocolOrder ?? getLocalCommandTransferProtocolOrder(request.source.preferredProtocol, request.source.connection)) { + this.throwIfTransferCanceled(request.progressId); + try { + if (protocol === 'rsync') { + const initialTotalBytes = Math.max(0, bytes); + this.fireTransferProgress(request, 0, initialTotalBytes, 'transfer', 'Downloading over local rsync.', true, { strategy: 'rsync-download' }); + const progress = this.createRsyncProgressReporter(request, initialTotalBytes, 'transfer', 'Downloading over local rsync.', { strategy: 'rsync-download' }); + // Show the size from the first second by computing it concurrently with rsync's scan. + if (initialTotalBytes <= 0) { + void this.getRemotePathContentSizeByCommand(request.source).then(total => progress.setTotalBytes(total)).catch(() => undefined); + } + await this.runLocalRsyncDownload(request, bytes, progress.handleOutput); + const resultBytes = progress.getLastBytes() || bytes; + this.fireTransferProgress(request, resultBytes, 0, 'complete', 'Local rsync download completed.', true, { strategy: 'rsync-download' }); + return this.createTransferResult(true, 'rsync-download', resultBytes, 'Local rsync download completed.', startedAt, request.progressId); + } + this.fireTransferProgress(request, 0, bytes, 'transfer', 'Downloading over local SCP.', true, { strategy: 'scp-download' }); + await this.runLocalScpDownload(request, bytes, delta => { + this.fireTransferProgress(request, delta, bytes, 'transfer', 'Downloading over local SCP.', false, { strategy: 'scp-download' }); + }); + this.fireTransferProgress(request, bytes, bytes, 'complete', 'Local SCP download completed.', true, { strategy: 'scp-download' }); + return this.createTransferResult(true, 'scp-download', bytes, 'Local SCP download completed.', startedAt, request.progressId); + } + catch (error) { + if (this.isTransferCanceled(request.progressId)) { + throw new TurboFileTransferCanceledError(); + } + if (!isRecoverableLocalCommandTransferError(error)) { + return undefined; + } + this.clearTransferDataTiming(request.progressId); + } + } + return undefined; + } + async verifySshUploadSize(target, expectedBytes, label) { + if (target.kind !== 'ssh' || expectedBytes <= 0) { + return; + } + const actualBytes = await this.getRemotePathContentSizeByCommand(target); + if (actualBytes === expectedBytes) { + return; + } + await this.deletePath(target, true).catch(() => undefined); + throw new Error(`${label} size mismatch: expected ${expectedBytes}, got ${actualBytes}.`); + } + async tryLocalToSshTarTransfer(request, expectedBytes, startedAt) { + if (request.source.kind !== 'local' || request.target.kind !== 'ssh' || !request.target.connection) { + return undefined; + } + if (!shouldPreferTarForMultiItemTransfer(request.target.preferredProtocol)) { + return undefined; + } + if (request.target.supportsTar === false || looksLikeWindowsPath(request.target.path)) { + return undefined; + } + try { + const stat = await fsp.stat(request.source.path); + if (!stat.isDirectory()) { + return undefined; + } + let bytes = Math.max(0, expectedBytes); + // Compute the byte total concurrently with the remote prep + transfer instead of blocking + // the start on a full directory scan. The progress denominator fills in as soon as the + // scan resolves (well before the first byte flows), so the display stays scp-like. + const sizePromise = bytes <= 0 ? getLocalPathSize(request.source.path).catch(() => 0) : undefined; + this.fireTransferProgress(request, 0, bytes, 'prepare', 'Preparing tar upload.', true, { strategy: 'tar-upload' }); + let archiveBytes = 0; + const onArchiveDelta = (delta) => { + this.throwIfTransferCanceled(request.progressId); + archiveBytes += Math.max(0, delta); + this.fireTransferProgress(request, bytes > 0 ? Math.min(archiveBytes, bytes) : archiveBytes, bytes, 'transfer', 'Tar upload in progress.', false, { strategy: 'tar-upload' }); + }; + if (sizePromise) { + void sizePromise.then(total => { + if (total > bytes) { + bytes = total; + this.fireTransferProgress(request, Math.min(archiveBytes, bytes), bytes, 'transfer', 'Tar upload in progress.', true, { strategy: 'tar-upload' }); + } + }); + } + this.markTransferDataStarted(request.progressId); + await this.runLocalDirectoryTarUpload(request.source.path, request.target, request.progressId, onArchiveDelta); + this.throwIfTransferCanceled(request.progressId); + // Do NOT block completion on the size computation. The tar pipe already fails loudly + // (non-zero exit on either side) on a truncated archive, so the streamed archive byte count + // is an accurate completion size. Awaiting the local-size walk here (only needed for the live + // denominator) can add seconds on a cold cache while it contends with tar's own reads, which + // showed up as the bar sitting at 100% before the task finally ended. We only fall back to a + // remote size scan when neither the known total nor the streamed bytes are available. + const resultBytes = bytes > 0 ? bytes : (archiveBytes > 0 ? archiveBytes : await this.getRemotePathContentSizeByCommand(request.target).catch(() => 0)); + this.fireTransferProgress(request, resultBytes, resultBytes, 'complete', 'Tar upload completed.', true, { strategy: 'tar-upload' }); + return this.createTransferResult(true, 'tar-upload', resultBytes, 'Tar upload completed.', startedAt, request.progressId); + } + catch (error) { + if (error instanceof TurboFileTransferCanceledError || this.isTransferCanceled(request.progressId)) { + throw new TurboFileTransferCanceledError(); + } + this.clearTransferDataTiming(request.progressId); + return undefined; + } + } + async trySshToLocalTarTransfer(request, expectedBytes, startedAt) { + if (request.source.kind !== 'ssh' || request.target.kind !== 'local' || !request.source.connection) { + return undefined; + } + if (!shouldPreferTarForMultiItemTransfer(request.source.preferredProtocol)) { + return undefined; + } + if (request.source.supportsTar === false || looksLikeWindowsPath(request.source.path)) { + return undefined; + } + try { + const sourceType = request.sourceType ?? await this.withDirectorySftp(request.source, async (sftp) => getFileTypeFromStats(await this.remoteStat(sftp, request.source.path))).catch(() => 'unknown'); + if (sourceType !== 'directory') { + return undefined; + } + let bytes = Math.max(0, expectedBytes); + // Run the remote `du` concurrently with the local target prep + transfer rather than + // blocking the start on it (a remote scan of many files on a loaded host can take + // seconds). The denominator fills in as soon as it resolves, keeping the scp-like display. + const sizePromise = bytes <= 0 ? this.getRemotePathContentSizeByCommand(request.source).catch(() => 0) : undefined; + this.fireTransferProgress(request, 0, bytes, 'prepare', 'Preparing tar download.', true, { strategy: 'tar-download' }); + await ensureLocalDirectoryTransferTargetAvailable(request.target.path); + let archiveBytes = 0; + const onArchiveDelta = (delta) => { + this.throwIfTransferCanceled(request.progressId); + archiveBytes += Math.max(0, delta); + this.fireTransferProgress(request, bytes > 0 ? Math.min(archiveBytes, bytes) : archiveBytes, bytes, 'transfer', 'Tar download in progress.', false, { strategy: 'tar-download' }); + }; + if (sizePromise) { + void sizePromise.then(total => { + if (total > bytes) { + bytes = total; + this.fireTransferProgress(request, Math.min(archiveBytes, bytes), bytes, 'transfer', 'Tar download in progress.', true, { strategy: 'tar-download' }); + } + }); + } + this.markTransferDataStarted(request.progressId); + await this.runRemoteDirectoryTarDownload(request.source, request.target.path, request.progressId, onArchiveDelta); + this.throwIfTransferCanceled(request.progressId); + if (sizePromise) { + bytes = Math.max(bytes, await sizePromise); + } + // See the upload path: the tar pipe already errors out on a truncated archive, so when the + // byte total is known we skip the post-transfer local rescan. `getLocalPathSize` recursively + // stats every extracted file, which on a cold NTFS metadata cache takes tens of seconds for a + // 30k-file tree — the "finished but still spinning" delay on the first transfer. Only scan + // when the total is unknown so the completion size is still populated. + const resultBytes = bytes > 0 ? bytes : await getLocalPathSize(request.target.path).catch(() => 0); + this.fireTransferProgress(request, resultBytes, resultBytes, 'complete', 'Tar download completed.', true, { strategy: 'tar-download' }); + return this.createTransferResult(true, 'tar-download', resultBytes, 'Tar download completed.', startedAt, request.progressId); + } + catch (error) { + if (error instanceof TurboFileTransferCanceledError || this.isTransferCanceled(request.progressId)) { + throw new TurboFileTransferCanceledError(); + } + this.clearTransferDataTiming(request.progressId); + return undefined; + } + } + async runLocalDirectoryTarUpload(localDirectory, target, progressId, onArchiveDelta) { + if (target.kind !== 'ssh' || !target.connection) { + throw new Error('Missing SSH target.'); + } + const localTarRoot = await this.createLocalTarRootAliasIfNeeded(localDirectory); + try { + try { + await this.runParallelLocalDirectoryTarUpload(localTarRoot.path, target.connection, buildPrepareTarExtractToExactDirectoryCommand(target.path), buildTarExtractStreamToDirectoryCommand(target.path), progressId, onArchiveDelta); + return; + } + catch (error) { + if (error instanceof TurboFileTransferCanceledError || this.isTransferCanceled(progressId)) { + throw new TurboFileTransferCanceledError(); + } + // Parallel raw tar|ssh has no remote zstd dependency and is the stable default fast path. + } + try { + await this.runParallelLocalDirectoryTarZstdUpload(localTarRoot.path, target.connection, buildPrepareTarZstdExtractToExactDirectoryCommand(target.path), buildTarZstdExtractToDirectoryCommand(target.path), progressId, onArchiveDelta); + return; + } + catch (error) { + if (error instanceof TurboFileTransferCanceledError || this.isTransferCanceled(progressId)) { + throw new TurboFileTransferCanceledError(); + } + // Parallel tar+zstd+ssh is the measured fast path. If unavailable, try single native tar|ssh. + } + try { + await this.runNativeLocalTarUpload(['-cf', '-', '-C', localTarRoot.path, '.'], target.connection, buildTarExtractToExactDirectoryCommand(target.path), progressId, onArchiveDelta); + return; + } + catch (error) { + if (error instanceof TurboFileTransferCanceledError || this.isTransferCanceled(progressId)) { + throw new TurboFileTransferCanceledError(); + } + throw new Error(`Native tar upload failed: ${formatTransferError(error)}`); + } + } + finally { + await localTarRoot.dispose(); + } + } + async runRemoteDirectoryTarDownload(source, localDirectory, progressId, onArchiveDelta) { + if (source.kind !== 'ssh' || !source.connection) { + throw new Error('Missing SSH source.'); + } + try { + await this.runNativeLocalTarDownload(source.connection, buildTarCreateFromDirectoryCommand(source.path), ['-xf', '-', '-C', localDirectory], progressId, onArchiveDelta); + return; + } + catch (error) { + if (error instanceof TurboFileTransferCanceledError || this.isTransferCanceled(progressId)) { + throw new TurboFileTransferCanceledError(); + } + // Native ssh|tar is the fast path. If it is unavailable, fall back to the ssh2 stream path. + } + const configResult = await this.createConnectConfig(source.connection); + if (!configResult.ok) { + throw new Error(configResult.message); + } + let client; + let tarProcess; + const unregisterCancel = this.registerTransferCancelHandler(progressId, () => { + tarProcess?.kill(); + client?.end(); + }); + try { + client = await this.connect(configResult.config); + const sourceStream = await this.openRemoteCommandStream(client, buildTarCreateFromDirectoryCommand(source.path), 'tar download create'); + tarProcess = spawn('tar.exe', [...await this.getLocalTarCharsetArgs(), '-xf', '-', '-C', localDirectory], { windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] }); + if (!tarProcess.stdin || !tarProcess.stdout) { + throw new Error('Local tar download did not provide pipes.'); + } + tarProcess.stdout.resume(); + const tarClose = waitForLocalProcessClose(tarProcess, 'local tar download'); + await pipeline(sourceStream.stream, createProgressPassThrough(onArchiveDelta), tarProcess.stdin); + await sourceStream.close; + await tarClose; + } + finally { + unregisterCancel(); + client?.end(); + } + } + async createLocalTarRootAliasIfNeeded(localRoot, force = false) { + if (platform() !== 'win32' || !(force || requiresLocalTarSafeRootAlias(localRoot))) { + return { + path: localRoot, + dispose: async () => undefined + }; + } + const tempRoot = join(tmpdir(), `turbofile-tar-root-${randomUUID()}`); + const aliasPath = join(tempRoot, 'root'); + try { + await fsp.mkdir(tempRoot, { recursive: true }); + await fsp.symlink(localRoot, aliasPath, 'junction'); + } + catch (error) { + await fsp.rmdir(aliasPath).catch(() => undefined); + await fsp.rmdir(tempRoot).catch(() => undefined); + throw error; + } + return { + path: aliasPath, + dispose: async () => { + await fsp.rmdir(aliasPath).catch(() => undefined); + await fsp.rmdir(tempRoot).catch(() => undefined); + } + }; + } + async runNativeLocalTarUpload(tarArgs, connection, remoteCommand, progressId, onArchiveDelta) { + const tool = await resolveLocalSshTool(); + await withNativeOpenSshArgs(connection, tool, remoteCommand, async (sshArgs, env) => { + let tarProcess; + let sshProcess; + const unregisterCancel = this.registerTransferCancelHandler(progressId, () => { + if (tarProcess) { + killLocalProcessTree(tarProcess); + } + if (sshProcess) { + killLocalProcessTree(sshProcess); + } + }); + try { + tarProcess = spawn('tar.exe', [...await this.getLocalTarCharsetArgs(), ...tarArgs], { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] }); + sshProcess = spawn(tool.executable, sshArgs, { windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'], env }); + sshProcess.stdout?.resume(); + if (!tarProcess.stdout || !sshProcess.stdin) { + throw new Error('Native tar upload did not provide required pipes.'); + } + const tarClose = waitForLocalProcessClose(tarProcess, 'native tar upload create'); + const sshClose = waitForLocalProcessClose(sshProcess, 'native tar upload extract'); + await pipeline(tarProcess.stdout, createProgressPassThrough(onArchiveDelta), sshProcess.stdin); + await tarClose; + await sshClose; + } + finally { + unregisterCancel(); + } + }); + } + async runNativeLocalTarDownload(connection, remoteCommand, tarArgs, progressId, onArchiveDelta) { + const tool = await resolveLocalSshTool(); + await withNativeOpenSshArgs(connection, tool, remoteCommand, async (sshArgs, env) => { + let sshProcess; + let tarProcess; + const unregisterCancel = this.registerTransferCancelHandler(progressId, () => { + if (sshProcess) { + killLocalProcessTree(sshProcess); + } + if (tarProcess) { + killLocalProcessTree(tarProcess); + } + }); + try { + sshProcess = spawn(tool.executable, sshArgs, { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'], env }); + tarProcess = spawn('tar.exe', [...await this.getLocalTarCharsetArgs(), ...tarArgs], { windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] }); + tarProcess.stdout?.resume(); + if (!sshProcess.stdout || !tarProcess.stdin) { + throw new Error('Native tar download did not provide required pipes.'); + } + const sshClose = waitForLocalProcessClose(sshProcess, 'native tar download create'); + const tarClose = waitForLocalProcessClose(tarProcess, 'native tar download extract'); + await pipeline(sshProcess.stdout, createProgressPassThrough(onArchiveDelta), tarProcess.stdin); + await sshClose; + await tarClose; + } + finally { + unregisterCancel(); + } + }); + } + async runParallelLocalDirectoryTarUpload(localDirectory, connection, prepareRemoteCommand, extractRemoteCommand, progressId, onArchiveDelta) { + // Kick off the remote target preparation over its own ssh connection *concurrently* with the + // local tree walk, so its handshake overlaps the collection instead of adding a serial ~1s + // round trip before any byte is sent. + const sshTool = await resolveLocalSshTool(); + const preparePromise = this.runNativeSshCommand(connection, sshTool, prepareRemoteCommand, progressId); + let entries; + try { + entries = await this.collectLocalTarRelativeEntries(localDirectory); + } + catch (error) { + await preparePromise.catch(() => undefined); + throw error; + } + if (entries.length < LOCAL_PARALLEL_TAR_MIN_ENTRIES) { + await preparePromise.catch(() => undefined); + throw new Error('Not enough entries for parallel tar upload.'); + } + await this.runParallelLocalTarUpload(localDirectory, entries, connection, undefined, extractRemoteCommand, progressId, onArchiveDelta, preparePromise); + } + async runParallelLocalDirectoryTarZstdUpload(localDirectory, connection, prepareRemoteCommand, extractRemoteCommand, progressId, onArchiveDelta) { + const sshTool = await resolveLocalSshTool(); + const preparePromise = this.runNativeSshCommand(connection, sshTool, prepareRemoteCommand, progressId); + let entries; + try { + entries = await this.collectLocalTarRelativeEntries(localDirectory); + } + catch (error) { + await preparePromise.catch(() => undefined); + throw error; + } + if (entries.length < LOCAL_PARALLEL_TAR_MIN_ENTRIES) { + await preparePromise.catch(() => undefined); + throw new Error('Not enough entries for parallel tar+zstd upload.'); + } + await this.runParallelLocalTarZstdUpload(localDirectory, entries, connection, undefined, extractRemoteCommand, progressId, onArchiveDelta, preparePromise); + } + async runParallelLocalBatchTarUpload(plan, connection, targetDirectory, progressId, onArchiveDelta) { + // Prepare the remote target concurrently with the local tree walk so its ssh round trip + // overlaps the collection rather than adding a serial delay before the first byte. + const preparePromise = this.prepareRemoteBatchTarTarget(connection, targetDirectory, plan.targetNames, progressId, false); + let entries; + try { + entries = await this.collectLocalTarRelativeEntries(plan.parentPath, plan.names); + } + catch (error) { + await preparePromise.catch(() => undefined); + throw error; + } + if (entries.length < LOCAL_PARALLEL_TAR_MIN_ENTRIES) { + await preparePromise.catch(() => undefined); + throw new Error('Not enough entries for parallel batch tar upload.'); + } + await this.runParallelLocalTarUpload(plan.parentPath, entries, connection, undefined, buildTarExtractStreamToDirectoryCommand(targetDirectory), progressId, onArchiveDelta, preparePromise); + } + async runParallelLocalBatchTarZstdUpload(plan, connection, targetDirectory, progressId, onArchiveDelta) { + const preparePromise = this.prepareRemoteBatchTarTarget(connection, targetDirectory, plan.targetNames, progressId, true); + let entries; + try { + entries = await this.collectLocalTarRelativeEntries(plan.parentPath, plan.names); + } + catch (error) { + await preparePromise.catch(() => undefined); + throw error; + } + if (entries.length < LOCAL_PARALLEL_TAR_MIN_ENTRIES) { + await preparePromise.catch(() => undefined); + throw new Error('Not enough entries for parallel batch tar+zstd upload.'); + } + await this.runParallelLocalTarZstdUpload(plan.parentPath, entries, connection, undefined, buildTarZstdExtractToDirectoryCommand(targetDirectory), progressId, onArchiveDelta, preparePromise); + } + async runParallelLocalTarUpload(localRoot, entries, connection, prepareRemoteCommand, extractRemoteCommand, progressId, onArchiveDelta, preparePromise) { + const sshTool = await resolveLocalSshTool(); + const tempRoot = join(tmpdir(), `turbofile-tar-${randomUUID()}`); + await fsp.mkdir(tempRoot, { recursive: true }); + try { + const chunks = await this.writeLocalTarChunkLists(tempRoot, entries); + if (chunks.length < 2) { + throw new Error('Parallel tar upload needs at least two chunks.'); + } + await this.awaitTarPrepare(connection, sshTool, prepareRemoteCommand, preparePromise, progressId); + await mapLimit(chunks, chunks.length, chunk => this.runNativeLocalTarUploadChunkWithRetry(localRoot, chunk, connection, sshTool, extractRemoteCommand, progressId, onArchiveDelta)); + } + finally { + await fsp.rm(tempRoot, { recursive: true, force: true }).catch(() => undefined); + } + } + async runParallelLocalTarZstdUpload(localRoot, entries, connection, prepareRemoteCommand, extractRemoteCommand, progressId, onArchiveDelta, preparePromise) { + const sshTool = await resolveLocalSshTool(); + const zstdTool = await resolveLocalZstdTool(); + const tempRoot = join(tmpdir(), `turbofile-tar-zstd-${randomUUID()}`); + await fsp.mkdir(tempRoot, { recursive: true }); + try { + const chunks = await this.writeLocalTarChunkLists(tempRoot, entries); + if (chunks.length < 2) { + throw new Error('Parallel tar upload needs at least two chunks.'); + } + await this.awaitTarPrepare(connection, sshTool, prepareRemoteCommand, preparePromise, progressId); + await mapLimit(chunks, chunks.length, chunk => this.runNativeLocalTarZstdUploadChunk(localRoot, chunk, connection, sshTool, zstdTool, extractRemoteCommand, progressId, onArchiveDelta)); + } + finally { + await fsp.rm(tempRoot, { recursive: true, force: true }).catch(() => undefined); + } + } + /** + * Ensures the remote destination prep (rm+mkdir) is complete before chunk extraction starts. + * Callers either pass a `preparePromise` already running concurrently with the local tree walk, + * or a `prepareRemoteCommand` string to run here (the batch path prepares its target earlier). + */ + async awaitTarPrepare(connection, sshTool, prepareRemoteCommand, preparePromise, progressId) { + if (preparePromise) { + await preparePromise; + return; + } + if (prepareRemoteCommand) { + await this.runNativeSshCommand(connection, sshTool, prepareRemoteCommand, progressId); + } + } + async collectLocalTarRelativeEntries(localRoot, names) { + // Walk concurrently and trust the Dirent type from readdir instead of an extra lstat per + // entry: the parent readdir already tells us file/dir/symlink, so a 30k-file tree no longer + // pays 30k serial lstat round trips before the transfer can start. + const entries = []; + const walkDirectory = async (relativeDir) => { + const fullDir = relativeDir ? join(localRoot, ...relativeDir.split('/')) : localRoot; + const children = await fsp.readdir(fullDir, { withFileTypes: true }); + if (!children.length && relativeDir) { + entries.push(relativeDir); + return; + } + const subDirectories = []; + for (const child of children) { + const childRelativePath = normalizeTarListPath(relativeDir ? `${relativeDir}/${child.name}` : child.name); + if (child.isDirectory()) { + subDirectories.push(childRelativePath); + } + else if (child.isFile() || child.isSymbolicLink()) { + entries.push(childRelativePath); + } + } + await mapLimit(subDirectories, LOCAL_DIRECTORY_STAT_CONCURRENCY, walkDirectory); + }; + if (names?.length) { + await mapLimit(names, LOCAL_DIRECTORY_STAT_CONCURRENCY, async (name) => { + const relativePath = normalizeTarListPath(name); + const stat = await fsp.lstat(join(localRoot, ...relativePath.split('/'))); + if (stat.isDirectory()) { + await walkDirectory(relativePath); + } + else if (stat.isFile() || stat.isSymbolicLink()) { + entries.push(relativePath); + } + }); + } + else { + await walkDirectory(''); + } + return entries.sort((a, b) => FILE_ENTRY_NAME_COLLATOR.compare(a, b)); + } + /** + * Pre-walk the local source tree on the Node side and write a null-separated file list so the + * bundled cygwin `rsync.exe` can skip its own recursive scan via `--files-from`. cygwin's + * per-entry `stat()` is ~10-50× slower than Node's `readdir(... withFileTypes)`, so a directory + * of 30k tiny files (the `1024x40` test set) keeps rsync silent for 20+ seconds before the + * first byte is sent. Building the list ourselves drops that to well under a second. + * + * Only runs on Windows (where cygwin's stat is the bottleneck) and only when every item shares + * the same parent directory so a single rsync invocation with one source root suffices. + * Returns `undefined` to indicate the caller should fall back to the per-item-argument form. + */ + async buildLocalRsyncFastListFile(items) { + if (platform() !== 'win32' || !items.length) { + return undefined; + } + const sourceBase = dirname(items[0].sourcePath); + if (!sourceBase || sourceBase === items[0].sourcePath) { + return undefined; + } + for (const item of items) { + if (dirname(item.sourcePath) !== sourceBase) { + return undefined; + } + } + let entries; + try { + entries = await this.collectLocalTarRelativeEntries(sourceBase, items.map(item => basename(item.sourcePath))); + } + catch { + return undefined; + } + if (!entries.length) { + return undefined; + } + const listPath = join(tmpdir(), `turbofile-rsync-list-${randomUUID()}`); + try { + await fsp.writeFile(listPath, createTarNullListBuffer(entries)); + } + catch { + await fsp.rm(listPath, { force: true }).catch(() => undefined); + return undefined; + } + return { listPath, sourceBase }; + } + /** + * The bundled Windows `tar.exe` (libarchive bsdtar) reads `-T` list files using the process + * ANSI code page, not UTF-8. Writing the list as UTF-8 makes non-ASCII names (e.g. Chinese) + * unreadable and aborts the transfer ("Couldn't visit directory: No such file or directory"). + * Resolve the system code page once so list files are encoded the way bsdtar expects; falls + * back to UTF-8 on non-Windows or when detection fails. + */ + async getLocalTarListEncoding() { + if (!this.localTarListEncodingPromise) { + this.localTarListEncodingPromise = (async () => { + if (platform() !== 'win32') { + return 'utf8'; + } + try { + const encoding = await resolveTerminalEncoding(); + const iconv = await import('@vscode/iconv-lite-umd'); + return iconv.default.encodingExists(encoding) ? encoding : 'utf8'; + } + catch { + return 'utf8'; + } + })(); + } + return this.localTarListEncodingPromise; + } + /** + * Windows `tar.exe` is libarchive `bsdtar`, which interprets archive member names in the process + * ANSI code page (e.g. GBK) unless told otherwise. The peer on a transfer is a UTF-8 Linux host + * (GNU tar), so without this option non-ASCII names (e.g. Chinese) garble in both directions: + * on extract bsdtar reads the remote's raw UTF-8 bytes as GBK, and on create it would emit GBK. + * `--options hdrcharset=UTF-8` makes bsdtar read/write archive names as UTF-8 so both ends agree. + * The flag is only valid for bsdtar (GNU tar rejects --options and is already UTF-8 native), and + * only matters on Windows, so it is emitted just for a detected bsdtar. SSH↔SSH transfers never + * touch local tar, which is why they were never affected. + */ + getLocalTarCharsetArgs() { + if (!this.localTarCharsetArgsPromise) { + this.localTarCharsetArgsPromise = (async () => { + if (platform() !== 'win32') { + return []; + } + try { + const version = await new Promise((resolve, reject) => { + execFile('tar.exe', ['--version'], { windowsHide: true }, (error, stdout) => error ? reject(error) : resolve(stdout)); + }); + return /bsdtar|libarchive/i.test(version) ? ['--options', 'hdrcharset=UTF-8'] : []; + } + catch { + return []; + } + })(); + } + return this.localTarCharsetArgsPromise; + } + async createLocalTarListBuffer(entries) { + const encoding = await this.getLocalTarListEncoding(); + if (encoding === 'utf8') { + return createTarNullListBuffer(entries); + } + const iconv = await import('@vscode/iconv-lite-umd'); + return Buffer.from(iconv.default.encode(`${entries.join('\0')}\0`, encoding)); + } + async writeLocalTarChunkLists(tempRoot, entries) { + const chunkCount = Math.min(LOCAL_PARALLEL_TAR_CHUNK_COUNT, entries.length); + const chunks = Array.from({ length: chunkCount }, () => []); + for (const [index, entry] of entries.entries()) { + chunks[index % chunkCount].push(entry); + } + return Promise.all(chunks + .filter(chunk => chunk.length > 0) + .map(async (chunk, index) => { + const listPath = join(tempRoot, `chunk-${String(index).padStart(2, '0')}.lst`); + await fsp.writeFile(listPath, await this.createLocalTarListBuffer(chunk)); + return { listPath, entryCount: chunk.length }; + })); + } + async writeLocalTarListFile(tempRoot, names) { + const listPath = join(tempRoot, 'items.lst'); + await fsp.writeFile(listPath, await this.createLocalTarListBuffer(names.map(name => normalizeTarListPath(name)))); + return listPath; + } + async runNativeSshCommand(connection, tool, remoteCommand, progressId) { + await withNativeOpenSshArgs(connection, tool, remoteCommand, async (sshArgs, env) => { + await this.runCancelableLocalCommand(tool.executable, sshArgs, { env }, progressId); + }); + } + async runNativeSshCommandWithInput(connection, tool, remoteCommand, input, progressId) { + await withNativeOpenSshArgs(connection, tool, remoteCommand, async (sshArgs, env) => { + let sshProcess; + const unregisterCancel = this.registerTransferCancelHandler(progressId, () => { + if (sshProcess) { + killLocalProcessTree(sshProcess); + } + }); + try { + sshProcess = spawn(tool.executable, sshArgs, { windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'], env }); + sshProcess.stdout?.resume(); + if (!sshProcess.stdin) { + throw new Error('Native ssh command did not provide stdin.'); + } + const sshClose = waitForLocalProcessClose(sshProcess, 'native ssh command'); + sshProcess.stdin.end(input); + await sshClose; + } + finally { + unregisterCancel(); + } + }); + } + async prepareRemoteBatchTarTarget(connection, targetDirectory, targetNames, progressId, requireZstd) { + const sshTool = await resolveLocalSshTool(); + const command = buildPrepareTarExtractToDirectoryFromListCommand(targetDirectory, requireZstd); + await this.runNativeSshCommandWithInput(connection, sshTool, command, createTarNullListBuffer(targetNames), progressId); + } + async runNativeLocalTarUploadChunk(localRoot, chunk, connection, sshTool, remoteCommand, progressId, onArchiveDelta) { + await withNativeOpenSshArgs(connection, sshTool, remoteCommand, async (sshArgs, sshEnv) => { + let tarProcess; + let sshProcess; + const unregisterCancel = this.registerTransferCancelHandler(progressId, () => { + if (tarProcess) { + killLocalProcessTree(tarProcess); + } + if (sshProcess) { + killLocalProcessTree(sshProcess); + } + }); + try { + tarProcess = spawn('tar.exe', [...await this.getLocalTarCharsetArgs(), '-cf', '-', '-C', localRoot, '--null', '-T', chunk.listPath], { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] }); + sshProcess = spawn(sshTool.executable, sshArgs, { windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'], env: sshEnv }); + sshProcess.stdout?.resume(); + if (!tarProcess.stdout || !sshProcess.stdin) { + throw new Error('Parallel tar upload did not provide required pipes.'); + } + const tarClose = waitForLocalProcessClose(tarProcess, 'parallel tar upload create'); + const sshClose = waitForLocalProcessClose(sshProcess, 'parallel tar upload extract'); + await Promise.all([ + pipeline(tarProcess.stdout, createProgressPassThrough(onArchiveDelta), sshProcess.stdin), + tarClose, + sshClose + ]); + } + finally { + unregisterCancel(); + } + }); + } + async runNativeLocalTarUploadChunkWithRetry(localRoot, chunk, connection, sshTool, remoteCommand, progressId, onArchiveDelta) { + let lastError; + for (let attempt = 0; attempt <= LOCAL_PARALLEL_TAR_CHUNK_RETRIES; attempt++) { + if (attempt > 0) { + await delay(150 * attempt); + } + try { + await this.runNativeLocalTarUploadChunk(localRoot, chunk, connection, sshTool, remoteCommand, progressId, onArchiveDelta); + return; + } + catch (error) { + if (error instanceof TurboFileTransferCanceledError || this.isTransferCanceled(progressId)) { + throw new TurboFileTransferCanceledError(); + } + lastError = error; + } + } + throw new Error(`Parallel tar chunk failed after retries: ${formatTransferError(lastError)}`); + } + async runNativeLocalTarZstdUploadChunk(localRoot, chunk, connection, sshTool, zstdTool, remoteCommand, progressId, onArchiveDelta) { + await withNativeOpenSshArgs(connection, sshTool, remoteCommand, async (sshArgs, sshEnv) => { + let tarProcess; + let zstdProcess; + let sshProcess; + const unregisterCancel = this.registerTransferCancelHandler(progressId, () => { + if (tarProcess) { + killLocalProcessTree(tarProcess); + } + if (zstdProcess) { + killLocalProcessTree(zstdProcess); + } + if (sshProcess) { + killLocalProcessTree(sshProcess); + } + }); + try { + tarProcess = spawn('tar.exe', [...await this.getLocalTarCharsetArgs(), '-cf', '-', '-C', localRoot, '--null', '-T', chunk.listPath], { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] }); + zstdProcess = spawn(zstdTool.executable, [`-${LOCAL_TAR_ZSTD_LEVEL}`, '-T1', '-c'], { windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'], env: zstdTool.env }); + sshProcess = spawn(sshTool.executable, sshArgs, { windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'], env: sshEnv }); + sshProcess.stdout?.resume(); + if (!tarProcess.stdout || !zstdProcess.stdin || !zstdProcess.stdout || !sshProcess.stdin) { + throw new Error('Parallel tar upload did not provide required pipes.'); + } + const tarClose = waitForLocalProcessClose(tarProcess, 'parallel tar upload create'); + const zstdClose = waitForLocalProcessClose(zstdProcess, 'parallel tar upload compress'); + const sshClose = waitForLocalProcessClose(sshProcess, 'parallel tar upload extract'); + await Promise.all([ + pipeline(tarProcess.stdout, createProgressPassThrough(onArchiveDelta), zstdProcess.stdin), + pipeline(zstdProcess.stdout, sshProcess.stdin), + tarClose, + zstdClose, + sshClose + ]); + } + finally { + unregisterCancel(); + } + }); + } + async runLocalScpUpload(request, bytes, onProgress) { + if (request.target.kind !== 'ssh' || !request.target.connection) { + throw new Error('Missing SSH target.'); + } + const localStat = await fsp.stat(request.source.path); + const recursive = localStat.isDirectory(); + const initialRemoteSize = await this.withDirectorySftp(request.target, async (sftp) => { + this.throwIfTransferCanceled(request.progressId); + if (recursive) { + await this.withDirectorySshClient(request.target, client => this.execRemote(client, buildPrepareDirectoryTransferTargetCommand(request.target.path))); + } + else { + await this.ensureRemoteDirectory(sftp, getRemoteParentPath(request.target.path)); + await this.unlinkRemote(sftp, request.target.path).catch(() => undefined); + } + return 0; + }); + if (canUseNativeOpenSshScpCommand(request.target.connection)) { + try { + await this.uploadPathWithNativeOpenSshScp(request.target.connection, request.source.path, request.target.path, recursive, initialRemoteSize, bytes, onProgress, request.progressId); + return; + } + catch (error) { + if (this.isTransferCanceled(request.progressId)) { + throw error; + } + this.clearTransferDataTiming(request.progressId); + } + } + if (recursive) { + throw new Error('Native OpenSSH scp.exe is required for directory SCP upload.'); + } + await this.uploadFileWithScpProtocol(request.target.connection, request.source.path, request.target.path, bytes, onProgress, request.progressId); + } + async runLocalScpDownload(request, bytes, onProgress) { + if (request.source.kind !== 'ssh' || !request.source.connection) { + throw new Error('Missing SSH source.'); + } + await ensureLocalParentDirectory(request.target.path); + const sourceType = request.sourceType ?? await this.withDirectorySftp(request.source, async (sftp) => getFileTypeFromStats(await this.remoteStat(sftp, request.source.path))).catch(() => 'unknown'); + const recursive = sourceType === 'directory'; + if (canUseNativeOpenSshScpCommand(request.source.connection)) { + if (recursive) { + await ensureLocalDirectoryTransferTargetAvailable(request.target.path); + } + else { + await fsp.rm(request.target.path, { force: true }).catch(() => undefined); + } + const initialLocalSize = 0; + try { + await this.downloadPathWithNativeOpenSshScp(request.source.connection, request.source.path, request.target.path, recursive, initialLocalSize, bytes, onProgress, request.progressId); + return; + } + catch (error) { + if (this.isTransferCanceled(request.progressId)) { + throw error; + } + this.clearTransferDataTiming(request.progressId); + } + } + if (recursive) { + throw new Error('Native OpenSSH scp.exe is required for directory SCP download.'); + } + await this.downloadFileWithScpProtocol(request.source.connection, request.source.path, request.target.path, bytes, onProgress, request.progressId); + } + async uploadPathWithNativeOpenSshScp(connection, localPath, remotePath, recursive, initialRemoteSize, expectedBytes, onProgress, progressId) { + const stopGrowthTiming = recursive + ? () => undefined + : this.beginRemoteFileGrowthTiming(progressId, connection, remotePath, initialRemoteSize, expectedBytes, onProgress); + try { + if (recursive) { + this.markTransferDataStarted(progressId); + } + const sourceArg = recursive ? getNativeScpLocalDirectoryContentsPath(localPath) : localPath; + const targetArg = recursive ? ensureTrailingRemoteSlash(remotePath) : remotePath; + await withNativeOpenSshScpBaseArgs(connection, (args, env) => this.runCancelableLocalCommand('scp.exe', [ + ...args, + ...(recursive ? ['-r'] : []), + sourceArg, + formatNativeScpRemoteSpec(connection, targetArg) + ], { env }, progressId), shouldPreferLegacyScpMode(connection)); + } + finally { + stopGrowthTiming(); + } + } + async downloadPathWithNativeOpenSshScp(connection, remotePath, localPath, recursive, initialLocalSize, expectedBytes, onProgress, progressId) { + const stopGrowthTiming = recursive + ? () => undefined + : this.beginTransferGrowthTiming(progressId, () => fsp.stat(localPath).then(stat => stat.size).catch(() => 0), initialLocalSize, expectedBytes, onProgress); + try { + if (recursive) { + this.markTransferDataStarted(progressId); + } + const sourceArg = recursive ? getRemoteDirectoryContentsPath(remotePath) : remotePath; + await withNativeOpenSshScpBaseArgs(connection, (args, env) => this.runCancelableLocalCommand('scp.exe', [ + ...args, + ...(recursive ? ['-r'] : []), + formatNativeScpRemoteSpec(connection, sourceArg), + localPath + ], { env }, progressId), shouldPreferLegacyScpMode(connection)); + } + finally { + stopGrowthTiming(); + } + } + async uploadFileWithScpProtocol(connection, localPath, remotePath, expectedBytes, onProgress, progressId) { + const configResult = await this.createConnectConfig(connection); + if (!configResult.ok) { + throw new Error(configResult.message); + } + let client; + try { + client = await this.connect(configResult.config); + const stream = await this.openScpExec(client, `scp -t ${quotePosixShellArg(remotePath)}`); + const reader = new ScpProtocolReader(stream); + const closePromise = waitForScpClose(stream); + const stat = await fsp.stat(localPath); + const totalBytes = stat.size || expectedBytes; + let transferredBytes = 0; + await readScpResponse(reader, 'start upload'); + await writeScpData(stream, `C${formatScpMode(stat.mode)} ${totalBytes} ${sanitizeScpFileName(getTransferName(remotePath) || basename(localPath))}\n`); + await readScpResponse(reader, 'upload header'); + const stopGrowthTiming = this.beginRemoteFileGrowthTiming(progressId, connection, remotePath); + try { + for await (const chunk of createReadStream(localPath, { highWaterMark: 1024 * 1024 })) { + this.throwIfTransferCanceled(progressId); + const buffer = typeof chunk === 'string' ? Buffer.from(chunk) : chunk; + await writeScpData(stream, buffer); + transferredBytes += buffer.byteLength; + onProgress?.(Math.min(transferredBytes, totalBytes)); + } + } + finally { + stopGrowthTiming(); + } + await writeScpData(stream, Buffer.from([0])); + await readScpResponse(reader, 'complete upload'); + stream.end(); + await closePromise; + } + finally { + client?.end(); + } + } + async downloadFileWithScpProtocol(connection, remotePath, localPath, expectedBytes, onProgress, progressId) { + const configResult = await this.createConnectConfig(connection); + if (!configResult.ok) { + throw new Error(configResult.message); + } + let client; + try { + client = await this.connect(configResult.config); + const stream = await this.openScpExec(client, `scp -f ${quotePosixShellArg(remotePath)}`); + const reader = new ScpProtocolReader(stream); + const closePromise = waitForScpClose(stream); + await writeScpData(stream, Buffer.from([0])); + const header = await readScpFileHeader(reader, stream); + const totalBytes = header.size || expectedBytes; + await ensureLocalParentDirectory(localPath); + await writeScpData(stream, Buffer.from([0])); + const stopGrowthTiming = this.beginTransferGrowthTiming(progressId, async () => { + const stat = await fsp.stat(localPath).catch(() => undefined); + return stat?.size ?? 0; + }); + try { + await readScpFilePayload(reader, localPath, totalBytes, transferredBytes => { + this.throwIfTransferCanceled(progressId); + onProgress?.(transferredBytes); + }); + } + finally { + stopGrowthTiming(); + } + await readScpResponse(reader, 'complete download'); + await writeScpData(stream, Buffer.from([0])); + stream.end(); + await closePromise; + } + finally { + client?.end(); + } + } + openScpExec(client, command) { + return new Promise((resolve, reject) => { + client.exec(command, (error, stream) => { + if (error) { + reject(error); + return; + } + let stderr = ''; + stream.stderr.on('data', data => { + stderr += data.toString(); + }); + stream.on('error', reject); + stream.on('close', code => { + if (code !== 0 && stderr.trim()) { + reject(new Error(stderr.trim())); + } + }); + resolve(stream); + }); + }); + } + async runLocalRsyncUpload(request, expectedBytes, onProgressOutput) { + if (request.target.kind !== 'ssh' || !request.target.connection) { + throw new Error('Missing SSH target.'); + } + void expectedBytes; + const connection = request.target.connection; + const tool = await resolveLocalRsyncTool(); + const sourceIsDirectory = (await fsp.stat(request.source.path)).isDirectory(); + const mkdirTargetPath = sourceIsDirectory ? request.target.path : getRemoteParentPath(request.target.path); + const sourcePath = toRsyncLocalPath(request.source.path, tool.pathFlavor); + await withLocalRsyncSshEnvironment(connection, tool, (env, identityPath) => { + return this.runCancelableLocalCommand(tool.executable, [ + ...buildLocalRsyncOptionArgs(true), + buildRsyncPathWithMkdirOption(mkdirTargetPath), + '-e', + buildLocalRsyncSshTransport(connection, tool.sshExecutable, identityPath), + sourceIsDirectory ? ensureTrailingRemoteSlash(sourcePath) : sourcePath, + formatRsyncRemoteSpec(connection, request.target.path) + ], { + env, + onStdout: onProgressOutput, + onStderr: onProgressOutput, + inactivityTimeoutMs: LOCAL_RSYNC_STALL_TIMEOUT_MS, + inactivityMessage: `Local rsync upload produced no output for ${LOCAL_RSYNC_STALL_TIMEOUT_MS} ms.` + }, request.progressId); + }); + } + async runLocalRsyncDownload(request, expectedBytes, onProgressOutput) { + if (request.source.kind !== 'ssh' || !request.source.connection) { + throw new Error('Missing SSH source.'); + } + void expectedBytes; + const connection = request.source.connection; + const tool = await resolveLocalRsyncTool(); + const sourceType = request.sourceType ?? await this.withDirectorySftp(request.source, async (sftp) => getFileTypeFromStats(await this.remoteStat(sftp, request.source.path))).catch(() => 'unknown'); + const sourceIsDirectory = sourceType === 'directory'; + if (sourceIsDirectory) { + await ensureLocalDirectory(request.target.path); + } + else { + await ensureLocalParentDirectory(request.target.path); + } + await withLocalRsyncSshEnvironment(connection, tool, (env, identityPath) => { + return this.runCancelableLocalCommand(tool.executable, [ + ...buildLocalRsyncOptionArgs(true), + '-e', + buildLocalRsyncSshTransport(connection, tool.sshExecutable, identityPath), + formatRsyncRemoteSpec(connection, sourceIsDirectory ? ensureTrailingRemoteSlash(request.source.path) : request.source.path), + toRsyncLocalPath(request.target.path, tool.pathFlavor) + ], { + env, + onStdout: onProgressOutput, + onStderr: onProgressOutput, + inactivityTimeoutMs: LOCAL_RSYNC_STALL_TIMEOUT_MS, + inactivityMessage: `Local rsync download produced no output for ${LOCAL_RSYNC_STALL_TIMEOUT_MS} ms.` + }, request.progressId); + }); + } + async transferBatch(request) { + const startedAt = Date.now(); + const strategy = getTransferStrategy(request.source, request.targetDirectory); + const items = request.items.filter(item => !!item.sourcePath && !!item.targetName); + const operation = request.operation === 'move' ? 'move' : 'copy'; + if (request.progressId) { + this.transferCancellationRequests.delete(request.progressId); + } + if (!items.length) { + return this.createBatchTransferResult(false, strategy, 0, 'No transfer items.', 0, 0, [], startedAt, request.progressId); + } + const expectedBytes = this.getBatchExpectedBytes(request, items); + const initialAttempt = this.getInitialBatchTransferAttempt(request, items); + const initialStrategy = transferAttemptToDirectionalStrategy(request.source, request.targetDirectory, initialAttempt); + const initialIsRsync = initialAttempt?.protocol === 'rsync'; + const sourceType = items.some(item => item.sourceType === 'directory') ? 'directory' : items[0]?.sourceType; + const plan = resolveTransferPlan({ + source: request.source, + target: request.targetDirectory, + sourceType, + itemCount: items.length, + strategyHint: request.strategyHint + }); + try { + this.throwIfTransferCanceled(request.progressId); + await this.rejectNonRsyncBatchDirectoryTargetConflicts(request, items, plan); + this.fireTransferProgress(request, 0, initialIsRsync ? 0 : expectedBytes, initialIsRsync ? 'transfer' : 'prepare', initialIsRsync ? 'Starting batch rsync transfer.' : 'Preparing batch transfer.', true, { + ...(initialIsRsync ? { strategy: initialStrategy } : {}), + completedItems: 0, + totalItems: items.length, + currentName: items[0].displayName ?? items[0].targetName + }); + const sameLocal = request.source.kind === 'local' && request.targetDirectory.kind === 'local'; + const sameRemote = request.source.kind === 'ssh' + && request.targetDirectory.kind === 'ssh' + && !!request.source.connection + && !!request.targetDirectory.connection + && isSameSshConnection(request.source.connection, request.targetDirectory.connection); + if (sameLocal) { + return await this.runLocalNativeBatchTransfer(request, items, operation, expectedBytes, startedAt); + } + if (sameRemote) { + return await this.runSameRemoteNativeBatchTransfer(request, items, operation, expectedBytes, startedAt); + } + if (request.source.kind === 'ssh' && request.targetDirectory.kind === 'ssh') { + return await this.runSshToSshBatchTransferPlan(request, items, operation, expectedBytes, startedAt); + } + const tarBatch = await this.tryBatchTarTransfer(request, items, operation, expectedBytes, startedAt); + this.throwIfTransferCanceled(request.progressId); + if (tarBatch?.ok) { + return tarBatch; + } + if (shouldUseTarOnlyForBatchTransfer(request)) { + throw new Error('Tar transfer unavailable for batch transfer.'); + } + const localRsyncBatch = await this.tryLocalBatchRsyncTransfer(request, items, operation, expectedBytes, startedAt); + this.throwIfTransferCanceled(request.progressId); + if (localRsyncBatch?.ok) { + return localRsyncBatch; + } + const fallback = await this.runConcurrentBatchTransfer(request, items, operation, expectedBytes, startedAt, false); + this.clearRemoteDirectoryListingCache(request.targetDirectory); + return fallback; + } + catch (error) { + const canceled = error instanceof TurboFileTransferCanceledError || this.isTransferCanceled(request.progressId); + return this.createBatchTransferResult(false, strategy, 0, canceled ? TRANSFER_CANCELED_MESSAGE : error instanceof Error ? error.message : String(error), 0, items.length, canceled ? [] : [{ + name: items[0].displayName ?? items[0].targetName, + path: items[0].sourcePath, + message: error instanceof Error ? error.message : String(error) + }], startedAt, request.progressId, canceled); + } + finally { + if (request.progressId) { + this.transferCancellationRequests.delete(request.progressId); + this.transferCancelHandlers.delete(request.progressId); + } + this.scheduleTransferProgressCleanup(request.progressId); + } + } + getBatchExpectedBytes(request, items) { + const explicit = Math.max(0, request.expectedBytes ?? 0); + if (explicit > 0) { + return explicit; + } + return items.reduce((sum, item) => sum + Math.max(0, item.expectedBytes ?? 0), 0); + } + getInitialBatchTransferAttempt(request, items) { + const sourceType = items.some(item => item.sourceType === 'directory') ? 'directory' : items[0]?.sourceType; + return resolveTransferPlan({ + source: request.source, + target: request.targetDirectory, + sourceType, + itemCount: items.length, + strategyHint: request.strategyHint + }).attempts[0]; + } + async runSshToSshBatchTransferPlan(request, items, operation, expectedBytes, startedAt) { + const sourceType = items.some(item => item.sourceType === 'directory') ? 'directory' : items[0]?.sourceType; + const plan = resolveTransferPlan({ + source: request.source, + target: request.targetDirectory, + sourceType, + itemCount: items.length, + strategyHint: request.strategyHint + }); + const failures = []; + for (const [index, attempt] of plan.attempts.entries()) { + this.throwIfTransferCanceled(request.progressId); + try { + this.fireTransferProgress(request, 0, attempt.protocol === 'rsync' ? 0 : expectedBytes, attempt.route === 'relay' ? 'relay-upload' : 'transfer', `Starting batch ${attempt.protocol} ${attempt.route} transfer.`, true, { + strategy: transferAttemptToStrategy(attempt), + route: attempt.route === 'relay' ? 'relay' : undefined, + completedItems: 0, + totalItems: items.length, + currentName: items[0]?.displayName ?? items[0]?.targetName + }); + const result = await this.runSshToSshBatchTransferAttempt(request, items, operation, expectedBytes, startedAt, attempt); + this.throwIfTransferCanceled(request.progressId); + if (result?.ok) { + return result; + } + if (result) { + failures.push(...result.failures); + } + } + catch (error) { + if (error instanceof TurboFileTransferCanceledError || this.isTransferCanceled(request.progressId)) { + throw new TurboFileTransferCanceledError(); + } + failures.push({ + name: items[0]?.displayName ?? items[0]?.targetName ?? 'batch', + path: items[0]?.sourcePath ?? request.source.path, + message: `${attempt.protocol} ${attempt.route}: ${formatTransferError(error)}` + }); + const nextAttempt = plan.attempts[index + 1]; + if (nextAttempt) { + this.fireTransferProgress(request, 0, nextAttempt.protocol === 'rsync' ? 0 : expectedBytes, nextAttempt.route === 'relay' ? 'relay-upload' : 'transfer', `Falling back to batch ${nextAttempt.protocol} ${nextAttempt.route}.`, true, { + strategy: transferAttemptToStrategy(nextAttempt), + route: nextAttempt.route === 'relay' ? 'relay' : undefined, + completedItems: 0, + totalItems: items.length, + currentName: items[0]?.displayName ?? items[0]?.targetName + }); + } + } + } + return this.createBatchTransferResult(false, transferAttemptToStrategy(plan.attempts[0]), 0, failures[0]?.message ?? 'Batch transfer unavailable.', 0, items.length, failures.length ? failures : items.map(item => ({ + name: item.displayName ?? item.targetName, + path: item.sourcePath, + message: 'No transfer attempt was available.' + })), startedAt, request.progressId); + } + async runSshToSshBatchTransferAttempt(request, items, operation, expectedBytes, startedAt, attempt) { + if (attempt.protocol === 'rsync') { + const result = attempt.route === 'direct' + ? await this.tryDirectBatchRsyncTransfer(request, items, expectedBytes, startedAt, true) + : attempt.route === 'relay' + ? await this.tryTunneledBatchRsyncRelayTransfer(request, items, expectedBytes, startedAt, true) + : undefined; + if (result?.ok && operation === 'move') { + const cleanupFailures = await this.deleteBatchSources(request.source, items); + if (cleanupFailures.length) { + return this.createBatchTransferResult(false, result.strategy, result.bytes, `Batch transfer completed, but source cleanup failed for ${cleanupFailures.length} item(s).`, items.length, cleanupFailures.length, cleanupFailures, startedAt, request.progressId); + } + } + return result; + } + if (attempt.protocol === 'tar') { + return this.tryBatchTarTransfer({ + ...request, + strategyHint: attempt.route === 'relay' ? 'relay' : 'direct' + }, items, operation, expectedBytes, startedAt, true); + } + return this.runConcurrentBatchTransfer({ + ...request, + strategyHint: attempt.route === 'relay' ? 'relay' : 'direct' + }, items, operation, expectedBytes, startedAt, false); + } + async rejectNonRsyncDirectoryTransferTargetConflict(request, sourceType, plan) { + if (!shouldRejectDirectoryTargetConflict({ + source: request.source, + target: request.target, + sourceType, + operation: 'copy', + plan + })) { + return; + } + await this.rejectDirectoryTargetPathConflict(request.target); + } + async rejectNonRsyncBatchDirectoryTargetConflicts(request, items, plan) { + const sourceType = items.some(item => item.sourceType === 'directory') ? 'directory' : items[0]?.sourceType; + if (!shouldRejectDirectoryTargetConflict({ + source: request.source, + target: request.targetDirectory, + sourceType, + operation: request.operation === 'move' ? 'move' : 'copy', + plan + })) { + return; + } + const directoryTargetPaths = items + .filter(item => item.sourceType === 'directory') + .map(item => this.joinBatchTargetPath(request.targetDirectory, item.targetName)); + if (!directoryTargetPaths.length) { + return; + } + if (request.targetDirectory.kind === 'local') { + for (const targetPath of directoryTargetPaths) { + await throwIfLocalTargetDirectoryExists(targetPath); + } + return; + } + if (!request.targetDirectory.connection) { + throw new Error('Missing SSH target.'); + } + await this.withDirectorySftp(request.targetDirectory, async (sftp) => { + for (const targetPath of directoryTargetPaths) { + const stat = await this.remoteStat(sftp, targetPath).catch(error => { + if (isRemoteFileNotFoundError(error)) { + return undefined; + } + throw error; + }); + if (stat && getFileTypeFromStats(stat) === 'directory') { + throw new Error(`Target directory already exists: ${targetPath}`); + } + } + }); + } + async rejectDirectoryTargetPathConflict(target) { + if (target.kind === 'local') { + await throwIfLocalPathExists(target.path); + return; + } + if (!target.connection) { + throw new Error('Missing SSH target.'); + } + const exists = await this.withDirectorySftp(target, async (sftp) => { + try { + await this.remoteStat(sftp, target.path); + return true; + } + catch (error) { + if (isRemoteFileNotFoundError(error)) { + return false; + } + throw error; + } + }); + if (exists) { + throw new Error(`Target directory already exists: ${target.path}`); + } + } + async runLocalNativeBatchTransfer(request, items, operation, expectedBytes, startedAt) { + const totalBytes = operation === 'copy' ? await this.getLocalBatchTransferSize(items, expectedBytes) : expectedBytes; + this.fireTransferProgress(request, 0, totalBytes, 'transfer', operation === 'move' ? 'Moving locally.' : 'Copying locally.', true, { + completedItems: 0, + totalItems: items.length, + currentName: items[0]?.displayName ?? items[0]?.targetName + }); + const sources = items.map(item => item.sourcePath); + if (operation === 'copy') { + await ensureLocalDirectory(request.targetDirectory.path); + let transferredBytes = 0; + for (const [index, item] of items.entries()) { + this.throwIfTransferCanceled(request.progressId); + const currentName = item.displayName ?? item.targetName; + this.fireTransferProgress(request, Math.min(transferredBytes, totalBytes), totalBytes, 'transfer', 'Copying locally.', true, { + completedItems: index, + totalItems: items.length, + currentName + }); + await this.copyLocalPath(item.sourcePath, this.joinBatchTargetPath(request.targetDirectory, item.targetName), delta => { + this.throwIfTransferCanceled(request.progressId); + transferredBytes += delta; + this.fireTransferProgress(request, Math.min(transferredBytes, totalBytes), totalBytes, 'transfer', 'Copying locally.', false, { + completedItems: index, + totalItems: items.length, + currentName + }); + }, request.progressId); + this.fireTransferProgress(request, Math.min(transferredBytes, totalBytes), totalBytes, 'transfer', 'Copying locally.', true, { + completedItems: index + 1, + totalItems: items.length, + currentName + }); + } + } + else if (platform() === 'win32' || looksLikeWindowsPath(request.targetDirectory.path) || sources.some(looksLikeWindowsPath)) { + await ensureLocalParentDirectory(this.joinBatchTargetPath(request.targetDirectory, items[0].targetName)); + this.markTransferDataStarted(request.progressId); + await runLocalPowerShellScript(buildLocalWindowsBatchScript(sources, request.targetDirectory.path, operation)); + } + else { + await ensureLocalParentDirectory(this.joinBatchTargetPath(request.targetDirectory, items[0].targetName)); + await ensureLocalDirectory(request.targetDirectory.path); + this.markTransferDataStarted(request.progressId); + await runLocalCommand('mv', ['-f', ...sources, request.targetDirectory.path]); + } + this.throwIfTransferCanceled(request.progressId); + this.fireTransferProgress(request, totalBytes, totalBytes, 'complete', 'Batch local transfer completed.', true, { + completedItems: items.length, + totalItems: items.length + }); + return this.createBatchTransferResult(true, operation === 'move' ? 'local-copy' : 'local-copy', totalBytes, 'Batch local transfer completed.', items.length, 0, [], startedAt, request.progressId); + } + async getLocalBatchTransferSize(items, expectedBytes) { + if (expectedBytes > 0) { + return expectedBytes; + } + const sizes = await mapLimit(items, BATCH_TRANSFER_DIRECTORY_WORKERS, item => getLocalPathTransferSize(item.sourcePath, item.expectedBytes)); + return sizes.reduce((total, size) => total + size, 0); + } + async runSameRemoteNativeBatchTransfer(request, items, operation, expectedBytes, startedAt) { + this.fireTransferProgress(request, 0, expectedBytes, 'transfer', operation === 'move' ? 'Moving on remote host.' : 'Copying on remote host.', true, { + completedItems: 0, + totalItems: items.length, + currentName: items[0]?.displayName ?? items[0]?.targetName + }); + const sources = items.map(item => item.sourcePath); + this.markTransferDataStarted(request.progressId); + if (looksLikeWindowsPath(request.targetDirectory.path) || sources.some(looksLikeWindowsPath)) { + await this.withSshClient(request.source, client => this.execRemote(client, buildWindowsBatchCommand(items, request.targetDirectory.path, operation))); + } + else { + const plan = createPosixNativeBatchTransferPlan(items, request.targetDirectory.path, operation); + await this.withSshClient(request.source, client => this.execRemoteWithInput(client, plan.command, plan.input)); + } + this.throwIfTransferCanceled(request.progressId); + this.fireTransferProgress(request, expectedBytes, expectedBytes, 'complete', 'Batch remote native transfer completed.', true, { + completedItems: items.length, + totalItems: items.length + }); + this.clearRemoteDirectoryListingCache(request.source); + this.clearRemoteDirectoryListingCache(request.targetDirectory); + return this.createBatchTransferResult(true, 'remote-copy', expectedBytes, 'Batch remote native transfer completed.', items.length, 0, [], startedAt, request.progressId); + } + async tryLocalBatchRsyncTransfer(request, items, operation, expectedBytes, startedAt) { + const isUpload = request.source.kind === 'local' && request.targetDirectory.kind === 'ssh' && !!request.targetDirectory.connection; + const isDownload = request.source.kind === 'ssh' && request.targetDirectory.kind === 'local' && !!request.source.connection; + if (!isUpload && !isDownload) { + return undefined; + } + if (items.length <= 1 && items.every(item => item.sourceType !== 'directory')) { + return undefined; + } + const protocol = isUpload ? request.targetDirectory.preferredProtocol : request.source.preferredProtocol; + if (protocol !== 'rsync') { + return undefined; + } + if (isUpload && looksLikeWindowsPath(request.targetDirectory.path)) { + return undefined; + } + if (isDownload && (looksLikeWindowsPath(request.source.path) || items.some(item => looksLikeWindowsPath(item.sourcePath)))) { + return undefined; + } + if (!canBatchRsyncPreserveTargetNames(items)) { + return undefined; + } + const connection = isUpload ? request.targetDirectory.connection : request.source.connection; + try { + const tool = await resolveLocalRsyncTool(); + const initialTotalBytes = Math.max(0, expectedBytes); + const progress = this.createRsyncProgressReporter(request, initialTotalBytes, 'transfer', isUpload ? 'Batch rsync upload in progress.' : 'Batch rsync download in progress.', { + strategy: isUpload ? 'rsync-upload' : 'rsync-download', + completedItems: 0, + totalItems: items.length + }); + // Mirror the tar path's early phase: compute the byte total concurrently (a Node-side walk + // is ~50× faster than rsync's cygwin file-list scan) and publish it the moment it resolves. + // This makes the size and a real percentage appear within ~1s, so the transfer no longer + // looks stuck at "preparing / 0 B" during rsync's own (unavoidable) file-list build. The + // actual bytes are still moved by rsync — only the denominator is borrowed from tar's trick. + if (initialTotalBytes <= 0) { + const sizePromise = isUpload + ? this.getLocalBatchTarBytes(items) + : this.getRemoteBatchSourceContentSize(request.source, items); + void sizePromise.then(total => progress.setTotalBytes(total)).catch(() => undefined); + } + // Fire 'transfer' (not 'prepare') right away: rsync starts immediately and may stay quiet + // while scanning its file list, and the last phase label should not read as "preparing". + this.fireTransferProgress(request, 0, initialTotalBytes, 'transfer', isUpload ? 'Starting batch rsync upload.' : 'Starting batch rsync download.', true, { + strategy: isUpload ? 'rsync-upload' : 'rsync-download', + completedItems: 0, + totalItems: items.length, + currentName: items[0]?.displayName ?? items[0]?.targetName + }); + // Progress is driven by rsync's own --info=progress2 transferred byte count. + if (isUpload) { + // Walk the source on the Node side so rsync skips its slow cygwin scan and starts + // transferring within ~1s instead of stalling on "preparing" for 20+s on large trees. + const fastList = await this.buildLocalRsyncFastListFile(items); + try { + await withLocalRsyncSshEnvironment(connection, tool, (env, identityPath) => { + const args = [ + ...buildLocalRsyncOptionArgs(true), + buildRsyncPathWithMkdirOption(request.targetDirectory.path), + '-e', + buildLocalRsyncSshTransport(connection, tool.sshExecutable, identityPath) + ]; + if (fastList) { + args.push(`--files-from=${toRsyncLocalPath(fastList.listPath, tool.pathFlavor)}`, '--from0', `${toRsyncLocalPath(fastList.sourceBase, tool.pathFlavor)}/`); + } + else { + for (const item of items) { + args.push(toRsyncLocalPath(item.sourcePath, tool.pathFlavor)); + } + } + args.push(formatRsyncRemoteSpec(connection, ensureTrailingRemoteSlash(request.targetDirectory.path))); + return this.runCancelableLocalCommand(tool.executable, args, { + env, + onStdout: progress.handleOutput, + onStderr: progress.handleOutput, + inactivityTimeoutMs: LOCAL_RSYNC_STALL_TIMEOUT_MS, + inactivityMessage: `Batch rsync upload produced no output for ${LOCAL_RSYNC_STALL_TIMEOUT_MS} ms.` + }, request.progressId); + }); + } + finally { + if (fastList) { + await fsp.rm(fastList.listPath, { force: true }).catch(() => undefined); + } + } + } + else { + await ensureLocalDirectory(request.targetDirectory.path); + const sourceArgs = items.map(item => formatRsyncRemoteSpec(connection, item.sourcePath)); + await withLocalRsyncSshEnvironment(connection, tool, (env, identityPath) => { + return this.runCancelableLocalCommand(tool.executable, [ + ...buildLocalRsyncOptionArgs(true), + '-e', + buildLocalRsyncSshTransport(connection, tool.sshExecutable, identityPath), + ...sourceArgs, + toRsyncLocalPath(request.targetDirectory.path, tool.pathFlavor) + ], { + env, + onStdout: progress.handleOutput, + onStderr: progress.handleOutput, + inactivityTimeoutMs: LOCAL_RSYNC_STALL_TIMEOUT_MS, + inactivityMessage: `Batch rsync download produced no output for ${LOCAL_RSYNC_STALL_TIMEOUT_MS} ms.` + }, request.progressId); + }); + } + const cleanupFailures = operation === 'move' ? await this.deleteBatchSources(request.source, items) : []; + const bytes = progress.getLastBytes() || expectedBytes; + this.fireTransferProgress(request, bytes, 0, 'complete', isUpload ? 'Batch rsync upload completed.' : 'Batch rsync download completed.', true, { + strategy: isUpload ? 'rsync-upload' : 'rsync-download', + completedItems: items.length, + totalItems: items.length + }); + this.clearRemoteDirectoryListingCache(isUpload ? request.targetDirectory : request.source); + return this.createBatchTransferResult(cleanupFailures.length === 0, isUpload ? 'rsync-upload' : 'rsync-download', bytes, cleanupFailures.length ? `Batch rsync completed, but source cleanup failed for ${cleanupFailures.length} item(s).` : 'Batch rsync completed.', items.length, cleanupFailures.length, cleanupFailures, startedAt, request.progressId); + } + catch (error) { + if (error instanceof TurboFileTransferCanceledError || this.isTransferCanceled(request.progressId)) { + throw new TurboFileTransferCanceledError(); + } + return undefined; + } + } + async tryDirectBatchRsyncTransfer(request, items, expectedBytes, startedAt, allowAutoFast = false) { + if (request.source.kind !== 'ssh' || request.targetDirectory.kind !== 'ssh' || !request.source.connection || !request.targetDirectory.connection) { + return undefined; + } + const protocol = getSshToSshTransferProtocol(request.source); + if (protocol !== 'rsync' && !(allowAutoFast && protocol === 'auto-fast')) { + return undefined; + } + if (request.source.supportsRsync !== true || request.targetDirectory.supportsRsync !== true) { + return undefined; + } + if (looksLikeWindowsPath(request.source.path) || looksLikeWindowsPath(request.targetDirectory.path) || items.some(item => looksLikeWindowsPath(item.sourcePath))) { + return undefined; + } + if (!canBatchRsyncPreserveTargetNames(items)) { + return undefined; + } + const routes = await this.resolveDirectSshRoutes(request.source, request.targetDirectory, request.progressId); + if (!routes.length) { + return undefined; + } + for (const route of routes) { + try { + const result = await this.tryDirectBatchRsyncTransferRoute(request, items, expectedBytes, startedAt, route); + this.clearDirectSshRouteFailure(request.source, request.targetDirectory, route); + return result; + } + catch { + if (this.isTransferCanceled(request.progressId)) { + throw new TurboFileTransferCanceledError(); + } + this.markDirectSshRouteFailure(request.source, request.targetDirectory, route); + } + } + this.clearDirectSshRouteCache(request.source, request.targetDirectory); + return undefined; + } + async tryDirectBatchRsyncTransferRoute(request, items, expectedBytes, startedAt, route) { + const executorEndpoint = route === 'source-push' ? request.source : request.targetDirectory; + const remoteEndpoint = route === 'source-push' ? request.targetDirectory : request.source; + if (!executorEndpoint.connection || !remoteEndpoint.connection) { + throw new Error('Batch direct rsync route is missing SSH connection details.'); + } + const executorConfigResult = await this.createConnectConfig(executorEndpoint.connection); + if (!executorConfigResult.ok) { + throw new Error(executorConfigResult.message); + } + const client = await this.connectDirectSsh(executorConfigResult.config); + const unregisterCancel = this.registerTransferCancelHandler(request.progressId, () => client.end()); + let lastActivityAt = Date.now(); + const markActivity = () => { + lastActivityAt = Date.now(); + }; + try { + this.throwIfTransferCanceled(request.progressId); + this.fireTransferProgress(request, 0, 0, 'transfer', 'Starting batch rsync direct transfer.', true, { + strategy: 'rsync-direct', + route, + completedItems: 0, + totalItems: items.length, + currentName: items[0]?.displayName ?? items[0]?.targetName + }); + const remote = remoteEndpoint.connection; + const remoteUserHost = `${remote.username}@${remote.host}`; + const sshBinary = await this.resolveRemoteRsyncSshBinary(client, executorEndpoint.connection); + const sshTransport = buildOptimizedRsyncSshCommand(remote.port || 22, !!remote.password, sshBinary); + const sshpassPrefix = buildSshpassPrefix(remote.password); + const rsyncOptions = buildRsyncOptions(true); + const rsyncPathOption = route === 'source-push' ? ` ${buildRsyncPathWithMkdirShellOption(request.targetDirectory.path)}` : ''; + const sourceArgs = route === 'source-push' + ? items.map(item => quotePosixShellArg(item.sourcePath)).join(' ') + : items.map(item => quotePosixShellArg(`${remoteUserHost}:${item.sourcePath}`)).join(' '); + const targetSpec = route === 'source-push' + ? `${remoteUserHost}:${ensureTrailingRemoteSlash(request.targetDirectory.path)}` + : ensureTrailingRemoteSlash(request.targetDirectory.path); + const rsyncCommandCore = `${sshpassPrefix}rsync ${rsyncOptions}${rsyncPathOption} -e ${quotePosixShellArg(sshTransport)} ${sourceArgs} ${quotePosixShellArg(targetSpec)}`; + const rsyncCommand = route === 'source-push' ? rsyncCommandCore : `mkdir -p ${quotePosixShellArg(request.targetDirectory.path)} && ${rsyncCommandCore}`; + const progress = this.createRsyncProgressReporter(request, 0, 'rsync-direct', 'Batch direct rsync transfer in progress.', { + strategy: 'rsync-direct', + route, + completedItems: 0, + totalItems: items.length + }, markActivity); + this.throwIfTransferCanceled(request.progressId); + markActivity(); + await this.execRemote(client, rsyncCommand, { + onStdout: progress.handleOutput, + onStderr: progress.handleOutput, + inactivityTimeoutMs: DIRECT_TRANSFER_STALL_TIMEOUT_MS, + inactivityMessage: `Batch direct rsync ${route} produced no transfer progress for ${DIRECT_TRANSFER_STALL_TIMEOUT_MS} ms.`, + getActivityTime: () => lastActivityAt + }); + this.throwIfTransferCanceled(request.progressId); + const bytes = progress.getLastBytes() || expectedBytes; + this.fireTransferProgress(request, bytes, 0, 'complete', 'Batch direct rsync completed.', true, { + strategy: 'rsync-direct', + route, + completedItems: items.length, + totalItems: items.length + }); + this.clearRemoteDirectoryListingCache(request.targetDirectory); + return this.createBatchTransferResult(true, 'rsync-direct', bytes, 'Batch direct rsync completed.', items.length, 0, [], startedAt, request.progressId); + } + catch (error) { + if (this.isTransferCanceled(request.progressId)) { + throw new TurboFileTransferCanceledError(); + } + throw error; + } + finally { + unregisterCancel(); + client.end(); + } + } + async tryTunneledBatchRsyncRelayTransfer(request, items, expectedBytes, startedAt, allowAutoFast = false) { + if (request.source.kind !== 'ssh' || request.targetDirectory.kind !== 'ssh' || !request.source.connection || !request.targetDirectory.connection) { + return undefined; + } + const protocol = getSshToSshTransferProtocol(request.source); + if (protocol !== 'rsync' && !(allowAutoFast && protocol === 'auto-fast')) { + return undefined; + } + if (request.source.supportsRsync !== true || request.targetDirectory.supportsRsync !== true || !request.targetDirectory.connection.password) { + return undefined; + } + if (looksLikeWindowsPath(request.source.path) || looksLikeWindowsPath(request.targetDirectory.path) || items.some(item => looksLikeWindowsPath(item.sourcePath))) { + return undefined; + } + if (!canBatchRsyncPreserveTargetNames(items)) { + return undefined; + } + const sourceConfigResult = await this.createConnectConfig(request.source.connection); + if (!sourceConfigResult.ok) { + return undefined; + } + const client = await this.connect(sourceConfigResult.config); + const forwardedSockets = new Set(); + let forwardedPort; + const unregisterCancel = this.registerTransferCancelHandler(request.progressId, () => { + for (const socket of forwardedSockets) { + socket.destroy(); + } + client.end(); + }); + client.on('tcp connection', (_details, accept, reject) => { + if (this.isTransferCanceled(request.progressId)) { + reject(); + return; + } + const channel = accept(); + const socket = connectTcp(request.targetDirectory.connection.port || 22, request.targetDirectory.connection.host); + forwardedSockets.add(socket); + socket.on('close', () => forwardedSockets.delete(socket)); + socket.on('error', () => channel.destroy()); + channel.on('error', () => socket.destroy()); + channel.on('close', () => socket.destroy()); + socket.pipe(channel); + channel.pipe(socket); + }); + try { + this.throwIfTransferCanceled(request.progressId); + this.fireTransferProgress(request, 0, 0, 'transfer', 'Starting batch rsync relay transfer.', true, { + strategy: 'rsync-relay', + completedItems: 0, + totalItems: items.length, + currentName: items[0]?.displayName ?? items[0]?.targetName + }); + if (!await this.canUseLocalRsync(client)) { + return undefined; + } + forwardedPort = await this.forwardIn(client, RSYNC_RELAY_BIND_HOST, 0); + const target = request.targetDirectory.connection; + const targetUserHost = `${target.username}@${RSYNC_RELAY_BIND_HOST}`; + const sshBinary = await this.resolveRemoteRsyncSshBinary(client, request.source.connection); + const sshTransport = buildOptimizedRsyncSshCommand(forwardedPort, true, sshBinary); + const sshpassPrefix = buildSshpassPrefix(target.password); + if (!await this.canUseTargetRsyncThroughRelay(client, targetUserHost, forwardedPort, sshpassPrefix)) { + return undefined; + } + const rsyncOptions = buildRsyncOptions(true); + const sourceArgs = items.map(item => quotePosixShellArg(item.sourcePath)).join(' '); + const targetSpec = `${targetUserHost}:${ensureTrailingRemoteSlash(request.targetDirectory.path)}`; + const rsyncCommand = `${sshpassPrefix}rsync ${rsyncOptions} ${buildRsyncPathWithMkdirShellOption(request.targetDirectory.path)} -e ${quotePosixShellArg(sshTransport)} ${sourceArgs} ${quotePosixShellArg(targetSpec)}`; + const progress = this.createRsyncProgressReporter(request, 0, 'relay-upload', 'Batch rsync relay transfer in progress.', { + strategy: 'rsync-relay', + completedItems: 0, + totalItems: items.length + }); + await this.execRemote(client, rsyncCommand, { + onStdout: progress.handleOutput, + onStderr: progress.handleOutput, + inactivityTimeoutMs: DIRECT_TRANSFER_STALL_TIMEOUT_MS, + inactivityMessage: `Batch rsync relay produced no transfer progress for ${DIRECT_TRANSFER_STALL_TIMEOUT_MS} ms.`, + getActivityTime: progress.getActivityTime + }); + this.throwIfTransferCanceled(request.progressId); + const bytes = progress.getLastBytes() || expectedBytes; + this.fireTransferProgress(request, bytes, 0, 'complete', 'Batch rsync relay completed.', true, { + strategy: 'rsync-relay', + completedItems: items.length, + totalItems: items.length + }); + this.clearRemoteDirectoryListingCache(request.targetDirectory); + return this.createBatchTransferResult(true, 'rsync-relay', bytes, 'Batch rsync relay completed.', items.length, 0, [], startedAt, request.progressId); + } + catch { + if (this.isTransferCanceled(request.progressId)) { + throw new TurboFileTransferCanceledError(); + } + return undefined; + } + finally { + unregisterCancel(); + if (forwardedPort !== undefined) { + await this.unforwardIn(client, RSYNC_RELAY_BIND_HOST, forwardedPort).catch(() => undefined); + } + for (const socket of forwardedSockets) { + socket.destroy(); + } + client.end(); + } + } + async tryBatchTarTransfer(request, items, operation, expectedBytes, startedAt, forceProtocol = false) { + if (request.source.kind === 'local' && request.targetDirectory.kind === 'ssh') { + if (!forceProtocol && !shouldPreferTarForMultiItemTransfer(request.targetDirectory.preferredProtocol)) { + return undefined; + } + if (request.targetDirectory.supportsTar === false || looksLikeWindowsPath(request.targetDirectory.path)) { + return undefined; + } + const plans = createLocalBatchTarPlans(items); + if (!plans?.length) { + return undefined; + } + const targetNames = collectBatchTarTargetNames(plans); + let bytes = Math.max(0, expectedBytes); + const sizePromise = bytes <= 0 ? this.getLocalBatchTarBytes(items).catch(() => 0) : undefined; + this.fireTransferProgress(request, 0, bytes, 'prepare', 'Preparing batch tar upload.', true, { strategy: 'tar-upload', completedItems: 0, totalItems: items.length }); + let archiveBytes = 0; + const onArchiveDelta = (delta) => { + this.throwIfTransferCanceled(request.progressId); + archiveBytes += Math.max(0, delta); + this.fireTransferProgress(request, bytes > 0 ? Math.min(archiveBytes, bytes) : archiveBytes, bytes, 'transfer', 'Batch tar upload in progress.', false, { strategy: 'tar-upload', completedItems: 0, totalItems: items.length }); + }; + if (sizePromise) { + void sizePromise.then(total => { + if (total > bytes) { + bytes = total; + this.fireTransferProgress(request, Math.min(archiveBytes, bytes), bytes, 'transfer', 'Batch tar upload in progress.', true, { strategy: 'tar-upload', completedItems: 0, totalItems: items.length }); + } + }); + } + this.markTransferDataStarted(request.progressId); + await this.runLocalBatchTarUploads(plans, request.targetDirectory, request.progressId, onArchiveDelta); + if (sizePromise) { + bytes = Math.max(bytes, await sizePromise); + } + const resultBytes = bytes > 0 ? bytes : await this.getRemoteBatchTargetContentSize(request.targetDirectory, targetNames).catch(() => 0); + await this.cleanupBatchSourcesAfterTar(request, items, operation); + this.fireTransferProgress(request, resultBytes, resultBytes, 'complete', 'Batch tar upload completed.', true, { strategy: 'tar-upload', completedItems: items.length, totalItems: items.length }); + return this.createBatchTransferResult(true, 'tar-upload', resultBytes, 'Batch tar upload completed.', items.length, 0, [], startedAt, request.progressId); + } + if (request.source.kind === 'ssh' && request.targetDirectory.kind === 'local') { + if (!forceProtocol && !shouldPreferTarForMultiItemTransfer(request.source.preferredProtocol)) { + return undefined; + } + if (request.source.supportsTar === false || looksLikeWindowsPath(request.source.path)) { + return undefined; + } + const plan = this.createRemoteBatchTarPlan(items); + if (!plan) { + return undefined; + } + let bytes = Math.max(0, expectedBytes); + const sizePromise = bytes <= 0 ? this.getRemoteBatchSourceContentSize(request.source, items).catch(() => 0) : undefined; + this.fireTransferProgress(request, 0, bytes, 'prepare', 'Preparing batch tar download.', true, { strategy: 'tar-download', completedItems: 0, totalItems: items.length }); + await this.prepareLocalBatchTarTarget(request.targetDirectory.path, plan.targetNames); + let archiveBytes = 0; + const onArchiveDelta = (delta) => { + this.throwIfTransferCanceled(request.progressId); + archiveBytes += Math.max(0, delta); + this.fireTransferProgress(request, bytes > 0 ? Math.min(archiveBytes, bytes) : archiveBytes, bytes, 'transfer', 'Batch tar download in progress.', false, { strategy: 'tar-download', completedItems: 0, totalItems: items.length }); + }; + if (sizePromise) { + void sizePromise.then(total => { + if (total > bytes) { + bytes = total; + this.fireTransferProgress(request, Math.min(archiveBytes, bytes), bytes, 'transfer', 'Batch tar download in progress.', true, { strategy: 'tar-download', completedItems: 0, totalItems: items.length }); + } + }); + } + this.markTransferDataStarted(request.progressId); + await this.runRemoteBatchTarDownload(request.source, plan, request.targetDirectory.path, request.progressId, onArchiveDelta); + if (sizePromise) { + bytes = Math.max(bytes, await sizePromise); + } + const resultBytes = bytes > 0 ? bytes : await getLocalBatchTargetSize(request.targetDirectory.path, plan.targetNames).catch(() => 0); + await this.cleanupBatchSourcesAfterTar(request, items, operation); + this.fireTransferProgress(request, resultBytes, resultBytes, 'complete', 'Batch tar download completed.', true, { strategy: 'tar-download', completedItems: items.length, totalItems: items.length }); + return this.createBatchTransferResult(true, 'tar-download', resultBytes, 'Batch tar download completed.', items.length, 0, [], startedAt, request.progressId); + } + if (request.source.kind === 'ssh' && request.targetDirectory.kind === 'ssh') { + if (!forceProtocol && !shouldPreferTarForMultiItemTransfer(getSshToSshTransferProtocol(request.source))) { + return undefined; + } + if (request.source.supportsTar === false || request.targetDirectory.supportsTar === false || looksLikeWindowsPath(request.source.path) || looksLikeWindowsPath(request.targetDirectory.path)) { + return undefined; + } + const plan = this.createRemoteBatchTarPlan(items); + if (!plan) { + return undefined; + } + const bytes = expectedBytes > 0 ? expectedBytes : await this.getRemoteBatchSourceContentSize(request.source, items); + let directFailure; + if (request.strategyHint !== 'relay') { + try { + const directResult = await this.tryDirectBatchTarTransfer(request, items, plan, operation, bytes, startedAt); + if (directResult?.ok) { + return directResult; + } + } + catch (error) { + if (error instanceof TurboFileTransferCanceledError || this.isTransferCanceled(request.progressId)) { + throw new TurboFileTransferCanceledError(); + } + directFailure = error; + } + } + if (request.strategyHint === 'direct') { + if (directFailure) { + throw directFailure; + } + return undefined; + } + return this.tryRelayBatchTarTransfer(request, items, plan, operation, bytes, startedAt); + } + return undefined; + } + createRemoteBatchTarPlan(items) { + if (!items.length) { + return undefined; + } + const parentPath = getRemoteParentPath(items[0].sourcePath); + const names = []; + const targetNames = []; + for (const item of items) { + const name = getTransferName(item.sourcePath); + if (!name || getRemoteParentPath(item.sourcePath) !== parentPath || item.targetName !== name) { + return undefined; + } + names.push(name); + targetNames.push(item.targetName); + } + return { parentPath, names, targetNames }; + } + async getLocalBatchTarBytes(items) { + let total = 0; + for (const item of items) { + total += await getLocalPathSize(item.sourcePath).catch(() => Math.max(0, item.expectedBytes ?? 0)); + } + return total; + } + async getRemoteBatchSourceContentSize(source, items) { + let total = 0; + for (const item of items) { + total += await this.getRemotePathContentSizeByCommand({ ...source, path: item.sourcePath }).catch(() => Math.max(0, item.expectedBytes ?? 0)); + } + return total; + } + async getRemoteBatchTargetContentSize(targetDirectory, targetNames) { + let total = 0; + for (const targetName of targetNames) { + total += await this.getRemotePathContentSizeByCommand({ ...targetDirectory, path: joinRemotePath(targetDirectory.path, targetName) }).catch(() => 0); + } + return total; + } + beginRemoteBatchTargetGrowthTiming(progressId, targetDirectory, targetNames, baselineSize = 0, expectedBytes = 0, onProgress, pollIntervalMs = TRANSFER_PROGRESS_EVENT_INTERVAL_MS, initialDelayMs = 0) { + return this.beginTransferGrowthTiming(progressId, () => this.getRemoteBatchTargetContentSize(targetDirectory, targetNames), baselineSize, expectedBytes, onProgress, pollIntervalMs, initialDelayMs); + } + async prepareLocalBatchTarTarget(targetDirectory, targetNames) { + await ensureLocalDirectory(targetDirectory); + for (const targetName of targetNames) { + await throwIfLocalTargetDirectoryExists(join(targetDirectory, targetName)); + } + } + async cleanupBatchSourcesAfterTar(request, items, operation) { + if (operation !== 'move') { + return; + } + const cleanupFailures = await this.deleteBatchSources(request.source, items); + if (cleanupFailures.length) { + throw new Error(`Batch transfer completed, but source cleanup failed for ${cleanupFailures.length} item(s).`); + } + } + async runLocalBatchTarUploads(plans, targetDirectory, progressId, onArchiveDelta) { + for (const plan of plans) { + this.throwIfTransferCanceled(progressId); + await this.runLocalBatchTarUpload(plan, targetDirectory, progressId, onArchiveDelta); + } + } + async runLocalBatchTarUpload(plan, targetDirectory, progressId, onArchiveDelta) { + if (targetDirectory.kind !== 'ssh' || !targetDirectory.connection) { + throw new Error('Missing SSH target.'); + } + const localTarRoot = await this.createLocalTarRootAliasIfNeeded(plan.parentPath, plan.requiresSafeParentPath === true); + const tarPlan = localTarRoot.path === plan.parentPath + ? plan + : { ...plan, parentPath: localTarRoot.path }; + try { + try { + await this.runParallelLocalBatchTarUpload(tarPlan, targetDirectory.connection, targetDirectory.path, progressId, onArchiveDelta); + return; + } + catch (error) { + if (error instanceof TurboFileTransferCanceledError || this.isTransferCanceled(progressId)) { + throw new TurboFileTransferCanceledError(); + } + // Parallel raw tar|ssh is the stable default for local multi-file/folder uploads. + } + try { + await this.runParallelLocalBatchTarZstdUpload(tarPlan, targetDirectory.connection, targetDirectory.path, progressId, onArchiveDelta); + return; + } + catch (error) { + if (error instanceof TurboFileTransferCanceledError || this.isTransferCanceled(progressId)) { + throw new TurboFileTransferCanceledError(); + } + // Parallel tar+zstd+ssh is the measured fast path. If unavailable, try single native tar|ssh. + } + try { + await this.prepareRemoteBatchTarTarget(targetDirectory.connection, targetDirectory.path, tarPlan.targetNames, progressId, false); + await this.runNativeLocalBatchTarUploadFromList(tarPlan, targetDirectory.connection, buildTarExtractStreamToDirectoryCommand(targetDirectory.path), progressId, onArchiveDelta); + return; + } + catch (error) { + if (error instanceof TurboFileTransferCanceledError || this.isTransferCanceled(progressId)) { + throw new TurboFileTransferCanceledError(); + } + throw new Error(`Native batch tar upload failed: ${formatTransferError(error)}`); + } + } + finally { + await localTarRoot.dispose(); + } + } + async runNativeLocalBatchTarUploadFromList(plan, connection, remoteCommand, progressId, onArchiveDelta) { + const tempRoot = join(tmpdir(), `turbofile-batch-tar-${randomUUID()}`); + await fsp.mkdir(tempRoot, { recursive: true }); + try { + const listPath = await this.writeLocalTarListFile(tempRoot, plan.names); + await this.runNativeLocalTarUpload(['-cf', '-', '-C', plan.parentPath, '--null', '-T', listPath], connection, remoteCommand, progressId, onArchiveDelta); + } + finally { + await fsp.rm(tempRoot, { recursive: true, force: true }).catch(() => undefined); + } + } + async runRemoteBatchTarDownload(source, plan, targetDirectory, progressId, onArchiveDelta) { + if (source.kind !== 'ssh' || !source.connection) { + throw new Error('Missing SSH source.'); + } + try { + await this.runNativeLocalTarDownload(source.connection, buildTarCreateFromItemsCommand(plan.parentPath, plan.names), ['-xf', '-', '-C', targetDirectory], progressId, onArchiveDelta); + return; + } + catch (error) { + if (error instanceof TurboFileTransferCanceledError || this.isTransferCanceled(progressId)) { + throw new TurboFileTransferCanceledError(); + } + // Native ssh|tar is preferred for speed; ssh2 remains the compatibility fallback. + } + const configResult = await this.createConnectConfig(source.connection); + if (!configResult.ok) { + throw new Error(configResult.message); + } + let client; + let tarProcess; + const unregisterCancel = this.registerTransferCancelHandler(progressId, () => { + tarProcess?.kill(); + client?.end(); + }); + try { + client = await this.connect(configResult.config); + const sourceStream = await this.openRemoteCommandStream(client, buildTarCreateFromItemsCommand(plan.parentPath, plan.names), 'batch tar download create'); + tarProcess = spawn('tar.exe', [...await this.getLocalTarCharsetArgs(), '-xf', '-', '-C', targetDirectory], { windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] }); + if (!tarProcess.stdin || !tarProcess.stdout) { + throw new Error('Local batch tar download did not provide pipes.'); + } + tarProcess.stdout.resume(); + const tarClose = waitForLocalProcessClose(tarProcess, 'local batch tar download'); + await pipeline(sourceStream.stream, createProgressPassThrough(onArchiveDelta), tarProcess.stdin); + await sourceStream.close; + await tarClose; + } + finally { + unregisterCancel(); + client?.end(); + } + } + async tryDirectBatchTarTransfer(request, items, plan, operation, expectedBytes, startedAt) { + if (request.source.kind !== 'ssh' || request.targetDirectory.kind !== 'ssh' || !request.source.connection || !request.targetDirectory.connection) { + return undefined; + } + const routes = await this.resolveDirectSshRoutes(request.source, request.targetDirectory, request.progressId); + if (!routes.length) { + return undefined; + } + const failures = []; + for (const route of routes) { + try { + const result = await this.tryDirectBatchTarTransferRoute(request, items, plan, operation, expectedBytes, startedAt, route); + this.clearDirectSshRouteFailure(request.source, request.targetDirectory, route); + return result; + } + catch (error) { + if (this.isTransferCanceled(request.progressId)) { + throw new TurboFileTransferCanceledError(); + } + this.markDirectSshRouteFailure(request.source, request.targetDirectory, route); + failures.push(`${route}: ${formatTransferError(error)}`); + } + } + this.clearDirectSshRouteCache(request.source, request.targetDirectory); + throw new Error(`Direct batch tar failed: ${failures.join('; ')}`); + } + async tryDirectBatchTarTransferRoute(request, items, plan, operation, expectedBytes, startedAt, route) { + const executorEndpoint = route === 'source-push' ? request.source : request.targetDirectory; + const remoteEndpoint = route === 'source-push' ? request.targetDirectory : request.source; + if (!executorEndpoint.connection || !remoteEndpoint.connection) { + throw new Error('Direct batch tar route is missing SSH connection details.'); + } + const executorConfigResult = await this.createConnectConfig(executorEndpoint.connection); + if (!executorConfigResult.ok) { + throw new Error(executorConfigResult.message); + } + const client = await this.connectDirectSsh(executorConfigResult.config); + const unregisterCancel = this.registerTransferCancelHandler(request.progressId, () => client.end()); + let credential; + let stopGrowthTiming = () => undefined; + let lastActivityAt = Date.now(); + try { + credential = await this.prepareRemoteScpCredential(client, remoteEndpoint.connection); + const remote = remoteEndpoint.connection; + const remoteUserHost = `${remote.username}@${remote.host}`; + const sshpassPrefix = buildSshpassPrefix(remote.password); + const sshCommand = buildDirectScpSshCommand(remote.port || 22, !!remote.password, credential.identityPath); + const command = route === 'source-push' + ? buildTarItemsSourcePushCommand(plan.parentPath, plan.names, request.targetDirectory.path, plan.targetNames, remoteUserHost, sshCommand, sshpassPrefix) + : buildTarItemsTargetPullCommand(plan.parentPath, plan.names, request.targetDirectory.path, plan.targetNames, remoteUserHost, sshCommand, sshpassPrefix); + this.fireTransferProgress(request, 0, expectedBytes, 'prepare', 'Preparing batch direct tar transfer.', true, { strategy: 'tar-direct', route, completedItems: 0, totalItems: items.length }); + stopGrowthTiming = this.beginRemoteBatchTargetGrowthTiming(request.progressId, request.targetDirectory, plan.targetNames, 0, expectedBytes, transferredBytes => { + lastActivityAt = Date.now(); + this.fireTransferProgress(request, transferredBytes, expectedBytes, 'transfer', 'Batch direct tar transfer in progress.', false, { strategy: 'tar-direct', route, completedItems: 0, totalItems: items.length }); + }); + this.markTransferDataStarted(request.progressId); + await this.execRemote(client, command, { + inactivityTimeoutMs: DIRECT_TRANSFER_STALL_TIMEOUT_MS, + inactivityMessage: `Direct batch tar ${route} produced no target growth for ${DIRECT_TRANSFER_STALL_TIMEOUT_MS} ms.`, + getActivityTime: () => lastActivityAt, + progressId: request.progressId + }); + this.throwIfTransferCanceled(request.progressId); + const bytes = expectedBytes > 0 ? expectedBytes : await this.getRemoteBatchTargetContentSize(request.targetDirectory, plan.targetNames).catch(() => 0); + await this.cleanupBatchSourcesAfterTar(request, items, operation); + this.fireTransferProgress(request, bytes, bytes, 'complete', 'Batch direct tar completed.', true, { strategy: 'tar-direct', route, completedItems: items.length, totalItems: items.length }); + return this.createBatchTransferResult(true, 'tar-direct', bytes, 'Batch direct tar completed.', items.length, 0, [], startedAt, request.progressId); + } + finally { + stopGrowthTiming(); + if (credential?.cleanupCommand) { + await this.execRemote(client, credential.cleanupCommand).catch(() => undefined); + } + unregisterCancel(); + client.end(); + } + } + async tryRelayBatchTarTransfer(request, items, plan, operation, expectedBytes, startedAt) { + if (request.source.kind !== 'ssh' || request.targetDirectory.kind !== 'ssh' || !request.source.connection || !request.targetDirectory.connection) { + return undefined; + } + const sourceConfigResult = await this.createConnectConfig(request.source.connection); + const targetConfigResult = await this.createConnectConfig(request.targetDirectory.connection); + if (!sourceConfigResult.ok || !targetConfigResult.ok) { + return undefined; + } + let sourceClient; + let targetClient; + let stopGrowthTiming = () => undefined; + const unregisterCancel = this.registerTransferCancelHandler(request.progressId, () => { + sourceClient?.end(); + targetClient?.end(); + }); + try { + sourceClient = await this.connect(sourceConfigResult.config); + targetClient = await this.connect(targetConfigResult.config); + this.fireTransferProgress(request, 0, expectedBytes, 'prepare', 'Preparing batch tar relay.', true, { strategy: 'tar-relay', route: 'relay', completedItems: 0, totalItems: items.length }); + stopGrowthTiming = this.beginRemoteBatchTargetGrowthTiming(request.progressId, request.targetDirectory, plan.targetNames, 0, expectedBytes, transferredBytes => { + this.fireTransferProgress(request, transferredBytes, expectedBytes, 'relay-upload', 'Batch tar relay in progress.', false, { strategy: 'tar-relay', route: 'relay', completedItems: 0, totalItems: items.length }); + }); + this.markTransferDataStarted(request.progressId); + const sourceStream = await this.openRemoteCommandStream(sourceClient, buildTarCreateFromItemsCommand(plan.parentPath, plan.names), 'batch tar relay create'); + const targetStream = await this.openRemoteCommandStream(targetClient, buildTarExtractToDirectoryCommand(request.targetDirectory.path, plan.targetNames), 'batch tar relay extract', true); + await pipeline(sourceStream.stream, targetStream.stream); + await sourceStream.close; + await targetStream.close; + this.throwIfTransferCanceled(request.progressId); + const bytes = expectedBytes > 0 ? expectedBytes : await this.getRemoteBatchTargetContentSize(request.targetDirectory, plan.targetNames).catch(() => 0); + await this.cleanupBatchSourcesAfterTar(request, items, operation); + this.fireTransferProgress(request, bytes, bytes, 'complete', 'Batch tar relay completed.', true, { strategy: 'tar-relay', route: 'relay', completedItems: items.length, totalItems: items.length }); + return this.createBatchTransferResult(true, 'tar-relay', bytes, 'Batch tar relay completed.', items.length, 0, [], startedAt, request.progressId); + } + finally { + stopGrowthTiming(); + unregisterCancel(); + sourceClient?.end(); + targetClient?.end(); + } + } + async runConcurrentBatchTransfer(request, items, operation, expectedBytes, startedAt, disableRsync) { + const transferredByItem = new Array(items.length).fill(0); + const totalByItem = items.map(item => Math.max(0, item.expectedBytes ?? 0)); + const successfulItems = []; + const failures = []; + const strategies = new Set(); + const childProgressIds = new Set(); + let completedItems = 0; + let completedBytes = 0; + const unregisterCancel = this.registerTransferCancelHandler(request.progressId, () => { + for (const childProgressId of [...childProgressIds]) { + void this.cancelTransfer(childProgressId); + } + }); + const workerLimit = items.some(item => item.sourceType === 'directory') ? BATCH_TRANSFER_DIRECTORY_WORKERS : BATCH_TRANSFER_WORKERS; + try { + await mapLimit(items, workerLimit, async (item, index) => { + this.throwIfTransferCanceled(request.progressId); + const childProgressId = `${request.progressId ?? 'turbofile-batch'}-${index}-${randomUUID()}`; + childProgressIds.add(childProgressId); + const childSource = disableRsync ? { ...request.source, path: item.sourcePath, supportsRsync: false } : { ...request.source, path: item.sourcePath }; + const childTarget = disableRsync ? { ...request.targetDirectory, path: this.joinBatchTargetPath(request.targetDirectory, item.targetName), supportsRsync: false } : { ...request.targetDirectory, path: this.joinBatchTargetPath(request.targetDirectory, item.targetName) }; + const disposable = this.onDynamicTransferProgress(childProgressId)(event => { + transferredByItem[index] = event.transferredBytes; + totalByItem[index] = Math.max(totalByItem[index], event.totalBytes); + this.fireBatchAggregateProgress(request, transferredByItem, totalByItem, event.phase, event.detail ?? 'Batch transfer in progress.', completedItems, items.length, item.displayName ?? item.targetName, false, event.strategy); + }); + try { + const result = await this.transferFile({ + source: childSource, + target: childTarget, + sourceType: item.sourceType, + expectedBytes: item.expectedBytes, + progressId: childProgressId, + strategyHint: disableRsync && request.source.kind === 'ssh' && request.targetDirectory.kind === 'ssh' ? 'direct' : request.strategyHint + }); + if (result.ok) { + successfulItems.push(item); + strategies.add(result.strategy); + completedBytes += result.bytes; + transferredByItem[index] = Math.max(transferredByItem[index], result.bytes); + totalByItem[index] = Math.max(totalByItem[index], result.bytes); + } + else if (result.canceled) { + throw new TurboFileTransferCanceledError(); + } + else { + failures.push({ name: item.displayName ?? item.targetName, path: item.sourcePath, message: result.message }); + } + } + finally { + completedItems++; + childProgressIds.delete(childProgressId); + disposable.dispose(); + this.fireBatchAggregateProgress(request, transferredByItem, totalByItem, 'transfer', 'Batch transfer in progress.', completedItems, items.length, item.displayName ?? item.targetName, true); + } + }); + if (operation === 'move' && successfulItems.length) { + const cleanupFailures = await this.deleteBatchSources(request.source, successfulItems); + failures.push(...cleanupFailures); + } + const bytes = Math.max(completedBytes, transferredByItem.reduce((sum, value) => sum + value, 0), expectedBytes > 0 && failures.length === 0 ? expectedBytes : 0); + this.fireTransferProgress(request, bytes, Math.max(bytes, expectedBytes), 'complete', 'Batch transfer completed.', true, { + completedItems: successfulItems.length, + totalItems: items.length + }); + const failed = failures.length; + return this.createBatchTransferResult(failed === 0, [...strategies][0] ?? getTransferStrategy(request.source, request.targetDirectory), bytes, failed === 0 ? 'Batch transfer completed.' : `Batch transfer completed with ${failed} failure(s).`, successfulItems.length, failed, failures, startedAt, request.progressId); + } + catch (error) { + if (error instanceof TurboFileTransferCanceledError || this.isTransferCanceled(request.progressId)) { + return this.createBatchTransferResult(false, [...strategies][0] ?? getTransferStrategy(request.source, request.targetDirectory), completedBytes, TRANSFER_CANCELED_MESSAGE, successfulItems.length, failures.length, failures, startedAt, request.progressId, true); + } + throw error; + } + finally { + unregisterCancel(); + } + } + fireBatchAggregateProgress(request, transferredByItem, totalByItem, phase, detail, completedItems, totalItems, currentName, force = false, strategy) { + const transferredBytes = transferredByItem.reduce((sum, value) => sum + Math.max(0, value), 0); + const totalBytes = Math.max(this.getBatchExpectedBytes(request, request.items), totalByItem.reduce((sum, value) => sum + Math.max(0, value), 0)); + this.fireTransferProgress(request, transferredBytes, totalBytes, phase, detail, force, { + strategy, + completedItems, + totalItems, + currentName + }); + } + async deleteBatchSources(source, items) { + const failures = []; + if (source.kind === 'local') { + await mapLimit(items, BATCH_TRANSFER_WORKERS, async (item) => { + try { + await fsp.rm(item.sourcePath, { recursive: true, force: true }); + } + catch (error) { + failures.push({ name: item.displayName ?? item.targetName, path: item.sourcePath, message: error instanceof Error ? error.message : String(error) }); + } + }); + return failures; + } + try { + const paths = items.map(item => item.sourcePath); + const command = paths.some(looksLikeWindowsPath) + ? buildWindowsBatchDeleteCommand(paths) + : `rm -rf -- ${paths.map(path => quotePosixShellArg(path)).join(' ')}`; + await this.withSshClient(source, client => this.execRemote(client, command)); + } + catch (error) { + for (const item of items) { + failures.push({ name: item.displayName ?? item.targetName, path: item.sourcePath, message: error instanceof Error ? error.message : String(error) }); + } + } + return failures; + } + joinBatchTargetPath(targetDirectory, targetName) { + return joinRemotePath(targetDirectory.path, targetName); + } + createBatchTransferResult(ok, strategy, bytes, message, completed, failed, failures, startedAt, progressId, canceled = false) { + const durationMs = this.getTransferDurationMs(progressId, startedAt); + return { + ok, + strategy, + bytes, + message, + completed, + failed, + failures, + canceled, + durationMs, + averageBytesPerSecond: Math.round(bytes / (durationMs / 1000)) + }; + } + async createDirectory(endpoint) { + try { + if (endpoint.kind === 'local') { + await fsp.mkdir(endpoint.path, { recursive: true }); + } + else { + // Reuse the already-open directory session instead of withSftp, which opens a fresh SSH + // connection every call — that handshake is the bulk of "new folder is slow", especially + // on a high-latency remote host. + await this.withDirectorySftp(endpoint, sftp => this.ensureRemoteDirectory(sftp, endpoint.path)); + } + this.clearRemoteDirectoryListingCache(endpoint); + return { ok: true, message: 'Directory created.' }; + } + catch (error) { + return { ok: false, message: error instanceof Error ? error.message : String(error) }; + } + } + async deletePath(endpoint, recursive = true) { + try { + if (endpoint.kind === 'local') { + await fsp.rm(endpoint.path, { recursive, force: true }); + } + else { + try { + await this.deleteRemotePathNative(endpoint, recursive); + } + catch (error) { + if (error instanceof Error && error.message === 'Refusing to delete a filesystem root.') { + throw error; + } + await this.withSftp(endpoint, async (sftp) => { + const stats = await this.remoteStat(sftp, endpoint.path); + if (stats.isDirectory()) { + await this.deleteRemoteDirectory(sftp, endpoint.path); + } + else { + await this.unlinkRemote(sftp, endpoint.path); + } + }); + } + } + this.clearRemoteDirectoryListingCache(endpoint); + return { ok: true, message: 'Path deleted.' }; + } + catch (error) { + return { ok: false, message: error instanceof Error ? error.message : String(error) }; + } + } + async deletePaths(endpoints, recursive = true) { + const targets = endpoints.filter(endpoint => !!endpoint.path); + if (!targets.length) { + return { ok: true, message: 'Nothing to delete.' }; + } + if (targets.length === 1) { + return this.deletePath(targets[0], recursive); + } + try { + const first = targets[0]; + if (first.kind === 'local') { + // Local removals run in parallel; the OS handles each independently. + await Promise.all(targets.map(endpoint => fsp.rm(endpoint.path, { recursive, force: true }))); + for (const endpoint of targets) { + this.clearRemoteDirectoryListingCache(endpoint); + } + return { ok: true, message: 'Paths deleted.' }; + } + for (const endpoint of targets) { + this.assertSafeDeletePath(endpoint.path); + } + if (looksLikeWindowsPath(first.path)) { + // Windows remote: one PowerShell call removes the whole list. + await this.withSshClient(first, client => this.execRemote(client, buildWindowsDeleteManyCommand(targets.map(endpoint => endpoint.path), recursive))); + } + else { + // POSIX remote: feed the NUL-delimited paths to `xargs -0 rm` in a single round trip; + // xargs batches the actual rm invocations server-side so this scales to any selection + // size without ever hitting the shell's argument-length limit. + const input = Buffer.from(`${targets.map(endpoint => endpoint.path).join('\0')}\0`, 'utf8'); + const removeFlag = recursive ? '-rf' : '-f'; + await this.withSshClient(first, client => this.execRemoteWithInput(client, `LC_ALL=C xargs -0 -r rm ${removeFlag} --`, input, { timeoutMs: REMOTE_DIRECTORY_READ_MAX_TIMEOUT_MS })); + } + this.clearRemoteDirectoryListingCache(first); + return { ok: true, message: 'Paths deleted.' }; + } + catch (error) { + return { ok: false, message: error instanceof Error ? error.message : String(error) }; + } + } + async deleteRemotePathNative(endpoint, recursive) { + this.assertSafeDeletePath(endpoint.path); + const command = looksLikeWindowsPath(endpoint.path) + ? buildWindowsDeleteCommand(endpoint.path, recursive) + : buildPosixDeleteCommand(endpoint.path, recursive); + await this.withSshClient(endpoint, client => this.execRemote(client, command)); + } + assertSafeDeletePath(path) { + const trimmed = path.trim(); + const normalized = trimmed.replace(/[\\/]+$/, ''); + if (!trimmed || trimmed === '/' || trimmed === '\\' || normalized === '' || /^[A-Za-z]:$/.test(normalized)) { + throw new Error('Refusing to delete a filesystem root.'); + } + } + async renamePath(endpoint, targetPath) { + try { + if (endpoint.kind === 'local') { + await this.moveLocalPathNative(endpoint.path, targetPath); + } + else { + await this.moveRemotePathNative(endpoint, targetPath); + } + this.clearRemoteDirectoryListingCache(endpoint); + this.clearRemoteDirectoryListingCache(endpoint, targetPath); + return { ok: true, message: 'Path renamed.' }; + } + catch (error) { + return { ok: false, message: error instanceof Error ? error.message : String(error) }; + } + } + async movePathsIntoDirectory(sources, targetDirectory) { + const targets = sources.filter(endpoint => !!endpoint.path); + if (!targets.length) { + return { ok: true, message: 'Nothing to move.' }; + } + const first = targets[0]; + const windowsPath = looksLikeWindowsPath(targetDirectory) || looksLikeWindowsPath(first.path); + try { + if (first.kind === 'local') { + // Local moves are metadata-only and have no network cost; run them in parallel. + await Promise.all(targets.map(endpoint => this.moveLocalPathNative(endpoint.path, joinRemotePath(targetDirectory, getTransferName(endpoint.path))))); + } + else if (windowsPath) { + // Windows remote: move each into the directory. Rare path, kept simple for correctness. + for (const endpoint of targets) { + await this.moveRemotePathNative(endpoint, joinRemotePath(targetDirectory, getTransferName(endpoint.path))); + } + } + else { + // POSIX remote: a single round trip moves the whole selection. The NUL-delimited sources + // are fed to `xargs -0`, which runs `mv -- ` server-side (batching as needed + // to stay under the argument-length limit), so a 1000-file move is one near-instant rename + // instead of 1000 separate SSH round trips. `mv` drops each file into under its own + // name, so no per-file target path needs computing. + const dir = quotePosixShellArg(targetDirectory); + const command = `mkdir -p -- ${dir} && LC_ALL=C xargs -0 -r sh -c 'mv -f -- "$@" "$0"' ${dir}`; + const input = Buffer.from(`${targets.map(endpoint => endpoint.path).join('\0')}\0`, 'utf8'); + await this.withSshClient(first, client => this.execRemoteWithInput(client, command, input, { timeoutMs: REMOTE_DIRECTORY_READ_MAX_TIMEOUT_MS })); + } + this.clearRemoteDirectoryListingCache(first); + this.clearRemoteDirectoryListingCache(first, targetDirectory); + return { ok: true, message: 'Paths moved.' }; + } + catch (error) { + return { ok: false, message: error instanceof Error ? error.message : String(error) }; + } + } + async calculatePathSize(endpoint) { + try { + const size = endpoint.kind === 'local' + ? await getLocalPathSizeByCommand(endpoint.path) + : await this.getRemotePathSizeByCommand(endpoint); + const humanSize = await this.getPathHumanSize(endpoint, size); + return { ok: true, message: 'Path size calculated.', size, humanSize }; + } + catch (error) { + return { + ok: false, + message: error instanceof Error ? error.message : String(error), + size: 0 + }; + } + } + async readWindowsClipboardFiles() { + try { + const paths = await readWindowsClipboardFilePaths(); + return { + ok: paths.length > 0, + message: paths.length > 0 ? 'Windows clipboard files loaded.' : 'No Windows files in clipboard.', + paths, + operation: readWindowsClipboardOperation() + }; + } + catch (error) { + return { + ok: false, + message: error instanceof Error ? error.message : String(error), + paths: [], + operation: 'copy' + }; + } + } + async startWindowsFileDrag(request) { + if (platform() !== 'win32') { + return { ok: false, message: 'Windows file drag is only supported on Windows.' }; + } + const window = BrowserWindow.getFocusedWindow() ?? BrowserWindow.getAllWindows().find(candidate => !candidate.isDestroyed() && !candidate.webContents.isDestroyed()); + if (!window || window.isDestroyed() || window.webContents.isDestroyed()) { + return { ok: false, message: 'No TurboFile window is available for Windows drag.' }; + } + if (request.source.kind === 'ssh') { + return this.startRemoteWindowsFileDrag(request, window); + } + if (request.source.kind !== 'local') { + return { ok: false, message: 'Unsupported Windows drag source.' }; + } + const files = request.items + .map(item => item.sourcePath) + .filter(path => isWindowsClipboardPath(path)); + if (!files.length) { + return { ok: false, message: 'No local Windows files are available to drag.' }; + } + try { + window.webContents.startDrag({ + file: files[0], + files, + icon: createWindowsDragIcon() + }); + return { ok: true, message: 'Windows file drag started.' }; + } + catch (error) { + return { + ok: false, + message: error instanceof Error ? error.message : String(error) + }; + } + } + async startRemoteWindowsFileDrag(request, window) { + if (!request.source.connection) { + return { ok: false, message: 'Missing SSH source connection for Windows drag.' }; + } + if (!request.items.length) { + return { ok: false, message: 'No SSH files are available to drag.' }; + } + const token = randomUUID(); + const fakeDirectoryName = createWindowsDragFakeDirectoryName(token); + const paths = this.getWindowsDragCopyHookPaths(token, fakeDirectoryName); + try { + await this.ensureWindowsDragCopyHookRegistered(); + await fsp.rm(paths.activeRoot, { recursive: true, force: true }).catch(() => undefined); + await fsp.rm(paths.targetFile, { force: true }).catch(() => undefined); + await fsp.mkdir(paths.fakeDirectory, { recursive: true }); + await fsp.mkdir(dirname(paths.targetFile), { recursive: true }); + window.webContents.startDrag({ + file: paths.fakeDirectory, + files: [paths.fakeDirectory], + icon: createWindowsDragIcon() + }); + const targetDirectory = await this.waitForWindowsDragCopyHookTarget(paths.targetFile, fakeDirectoryName); + return { ok: true, message: 'Windows drop target captured.', targetDirectory }; + } + catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { + ok: false, + message, + canceled: message.includes('timed out') + }; + } + finally { + await fsp.rm(paths.activeRoot, { recursive: true, force: true }).catch(() => undefined); + await fsp.rm(paths.targetFile, { force: true }).catch(() => undefined); + } + } + getWindowsDragCopyHookPaths(token, fakeDirectoryName) { + const root = this.getWindowsDragCopyHookRoot(); + const activeRoot = join(root, TURBOFILE_WINDOWS_DRAG_ACTIVE_DIR, token); + return { + activeRoot, + fakeDirectory: join(activeRoot, fakeDirectoryName), + targetFile: join(root, TURBOFILE_WINDOWS_DRAG_TARGETS_DIR, `${token}.target`) + }; + } + getWindowsDragCopyHookRoot() { + const localAppData = process.env.LOCALAPPDATA; + if (!localAppData) { + throw new Error('LOCALAPPDATA is required for SSH drag to Windows.'); + } + return join(localAppData, TURBOFILE_WINDOWS_DRAG_APP_DIR, TURBOFILE_WINDOWS_DRAG_ROOT_DIR); + } + async waitForWindowsDragCopyHookTarget(targetFile, fakeDirectoryName) { + const deadline = Date.now() + WINDOWS_DRAG_COPY_HOOK_TIMEOUT_MS; + const desktopFallbackDirs = this.getWindowsDragDesktopFallbackDirs(); + while (Date.now() < deadline) { + // 1) The CopyHook captured the destination. This covers drops onto ordinary directories and + // open Explorer windows (the hook is registered for the file-system Directory class). + try { + const raw = await fsp.readFile(targetFile, 'utf8'); + const captured = raw.replace(/^\uFEFF/, ''); + if (captured) { + const targetDirectory = normalizeWindowsCopyHookDestination(captured, fakeDirectoryName); + if (!isWindowsClipboardPath(targetDirectory)) { + throw new Error(`Captured Windows drop target is invalid: ${targetDirectory}`); + } + return targetDirectory; + } + } + catch (error) { + if (error.code !== 'ENOENT') { + throw error; + } + } + // 2) Fallback for drops onto the live Desktop. The desktop background is a special shell + // folder, so its drop is handled by the desktop's own drop target and never consults the + // Directory CopyHook (confirmed via the hook log: ordinary folders on any drive fire the + // hook, the relocated D:\Desktop drop does not). In that case Explorer physically copies + // the empty fake folder onto the desktop, so detect it there and use the desktop \u2014 resolved + // through Electron so it follows a relocated (e.g. D:\Desktop) shell folder \u2014 as the target. + for (const desktopDir of desktopFallbackDirs) { + const landedFolder = join(desktopDir, fakeDirectoryName); + try { + if ((await fsp.stat(landedFolder)).isDirectory()) { + await fsp.rm(landedFolder, { recursive: true, force: true }).catch(() => undefined); + return desktopDir; + } + } + catch { + // The fake folder has not landed in this candidate yet; keep polling. + } + } + await delay(WINDOWS_DRAG_COPY_HOOK_POLL_MS); + } + throw new Error('Windows drop target capture timed out.'); + } + /** + * Resolves the real Desktop directory for the "桌面" quick-nav shortcut. `app.getPath('desktop')` + * follows a Desktop relocated to another drive (e.g. D:\Desktop), so clicking 桌面 lands on the + * folder the OS actually uses — which is also where Explorer drops dragged files. + */ + async getLocalDesktopPath() { + try { + const desktop = app.getPath('desktop'); + if (desktop) { + return desktop; + } + } + catch { + // Electron could not resolve the desktop; fall back to the profile path below. + } + if (process.env.USERPROFILE) { + return join(process.env.USERPROFILE, 'Desktop'); + } + return ''; + } + /** + * Candidate Desktop directories used to recover a drop the Directory CopyHook never saw (see + * {@link waitForWindowsDragCopyHookTarget}). `app.getPath('desktop')` honors a relocated Desktop + * (e.g. moved to D:\), and the raw profile Desktop is added as a defensive secondary. + */ + getWindowsDragDesktopFallbackDirs() { + const dirs = []; + const add = (dir) => { + if (dir && !dirs.some(existing => existing.toLowerCase() === dir.toLowerCase())) { + dirs.push(dir); + } + }; + try { + add(app.getPath('desktop')); + } + catch { + // Electron could not resolve the desktop; fall back to the profile path below. + } + if (process.env.USERPROFILE) { + add(join(process.env.USERPROFILE, 'Desktop')); + } + return dirs; + } + async ensureWindowsDragCopyHookRegistered() { + if (!this.windowsDragCopyHookRegistrationPromise) { + this.windowsDragCopyHookRegistrationPromise = this.registerWindowsDragCopyHook().catch(error => { + this.windowsDragCopyHookRegistrationPromise = undefined; + throw error; + }); + } + await this.windowsDragCopyHookRegistrationPromise; + } + async registerWindowsDragCopyHook() { + const dllPath = this.resolveWindowsDragCopyHookDllPath(); + const clsidKey = `HKCU\\Software\\Classes\\CLSID\\${TURBOFILE_WINDOWS_DRAG_COPY_HOOK_CLSID}`; + const inprocKey = `${clsidKey}\\InprocServer32`; + const handlerKey = 'HKCU\\Software\\Classes\\Directory\\shellex\\CopyHookHandlers\\TurboFileCopyHook'; + await runLocalCommand('reg.exe', ['add', clsidKey, '/ve', '/d', 'TurboFile Windows Drag CopyHook', '/f']); + await runLocalCommand('reg.exe', ['add', inprocKey, '/ve', '/d', dllPath, '/f']); + await runLocalCommand('reg.exe', ['add', inprocKey, '/v', 'ThreadingModel', '/t', 'REG_SZ', '/d', 'Apartment', '/f']); + await runLocalCommand('reg.exe', ['add', handlerKey, '/ve', '/d', TURBOFILE_WINDOWS_DRAG_COPY_HOOK_CLSID, '/f']); + await runLocalPowerShellScript(createWindowsShellAssociationChangedScript()); + await this.ensureWindowsExplorerLoadedDragCopyHook(dllPath); + } + /** + * The running Explorer caches its CopyHook handler list, so a newly-registered handler is + * ignored until Explorer reloads. We restart Explorer exactly once per DLL build (tracked by a + * signature marker), so the handler becomes active without forcing the user to log off/restart. + * Subsequent OS sessions load it on demand automatically because the registration is persistent. + */ + async ensureWindowsExplorerLoadedDragCopyHook(dllPath) { + const signature = await this.getWindowsDragCopyHookDllSignature(dllPath); + const markerPath = join(this.getWindowsDragCopyHookRoot(), 'explorer-loaded.signature'); + const stored = await fsp.readFile(markerPath, 'utf8').then(value => value.trim()).catch(() => ''); + if (stored === signature) { + return; + } + await this.restartWindowsExplorer(); + await fsp.mkdir(dirname(markerPath), { recursive: true }).catch(() => undefined); + await fsp.writeFile(markerPath, signature).catch(() => undefined); + } + async getWindowsDragCopyHookDllSignature(dllPath) { + const stat = await fsp.stat(dllPath).catch(() => undefined); + return stat ? `${dllPath}|${stat.size}|${Math.round(stat.mtimeMs)}` : dllPath; + } + async restartWindowsExplorer() { + // Stop every Explorer shell instance and relaunch one so the new shell process reads the + // freshly-registered CopyHook handler. Best-effort: never let a shell hiccup fail a transfer. + await runLocalPowerShellScript('Stop-Process -Name explorer -Force -ErrorAction SilentlyContinue; Start-Sleep -Milliseconds 400; if (-not (Get-Process -Name explorer -ErrorAction SilentlyContinue)) { Start-Process explorer.exe }').catch(() => undefined); + await delay(1200); + } + resolveWindowsDragCopyHookDllPath() { + const resourcesPath = process.resourcesPath; + const candidates = [ + join(process.cwd(), 'resources', 'turbofile', 'drag-ext', 'x64', 'TurboFileDragExt.dll'), + join(app.getAppPath(), 'resources', 'turbofile', 'drag-ext', 'x64', 'TurboFileDragExt.dll'), + resourcesPath ? join(resourcesPath, 'app', 'resources', 'turbofile', 'drag-ext', 'x64', 'TurboFileDragExt.dll') : undefined, + resourcesPath ? join(resourcesPath, 'resources', 'turbofile', 'drag-ext', 'x64', 'TurboFileDragExt.dll') : undefined + ].filter((candidate) => !!candidate); + const found = candidates.find(candidate => existsSync(candidate)); + if (!found) { + throw new Error(`TurboFile Windows drag extension DLL was not found. Run scripts\\build-turbofile-drag-ext.ps1. Checked: ${candidates.join(', ')}`); + } + return found; + } + async readFile(endpoint, options = {}) { + const encoding = options.encoding ?? 'utf8'; + const maxBytes = options.maxBytes ?? DEFAULT_READ_LIMIT; + const mime = getMimeType(endpoint.path); + try { + const buffer = endpoint.kind === 'local' + ? await this.readLocalFileBuffer(endpoint.path, maxBytes) + : await this.readRemoteFileBuffer(endpoint, maxBytes); + const imageSize = mime.startsWith('image/') ? getImageSize(buffer) : undefined; + return { + ok: true, + message: 'File loaded.', + content: encoding === 'base64' ? buffer.toString('base64') : buffer.toString('utf8'), + encoding, + mime, + size: buffer.byteLength, + width: imageSize?.width, + height: imageSize?.height, + originalWidth: imageSize?.width, + originalHeight: imageSize?.height + }; + } + catch (error) { + return { + ok: false, + message: error instanceof Error ? error.message : String(error), + content: '', + encoding, + mime, + size: 0 + }; + } + } + async readImage(endpoint, options = {}) { + const encoding = options.encoding ?? 'base64'; + try { + const result = await this.readImageBuffer(endpoint, options); + return { + ok: true, + message: 'Image loaded.', + content: encoding === 'base64' ? result.buffer.toString('base64') : result.buffer.toString('utf8'), + encoding, + mime: result.mime, + size: result.buffer.byteLength, + width: result.width, + height: result.height, + originalWidth: result.originalWidth, + originalHeight: result.originalHeight + }; + } + catch (error) { + return { + ok: false, + message: error instanceof Error ? error.message : String(error), + content: '', + encoding, + mime: getMimeType(endpoint.path), + size: 0 + }; + } + } + async readImageBatch(endpoint, paths, maxWidth = 0) { + const targets = [...new Set(paths.filter(path => !!path))]; + if (!targets.length) { + return []; + } + if (endpoint.kind !== 'ssh' || !endpoint.connection) { + return Promise.all(targets.map(async (path) => { + try { + const stat = await fsp.stat(path); + if (stat.size > IMAGE_BATCH_MAX_FILE_BYTES) { + return { path, ok: false, oversized: true }; + } + const buffer = await fsp.readFile(path); + return { path, ok: true, content: buffer.toString('base64'), mime: getMimeType(path) }; + } + catch (error) { + return { path, ok: false, message: error instanceof Error ? error.message : String(error) }; + } + })); + } + // The batch command is POSIX shell; a Windows SSH host can't run it. Report oversized so the + // caller transparently falls back to the per-image resource path that handles Windows remotes. + if (looksLikeWindowsPath(targets[0])) { + return targets.map(path => ({ path, ok: false, oversized: true })); + } + // One SSH round trip streams the whole page back, length-framed. Small originals stream raw + // (instant, zero processing — works on any weak board). Larger originals are downscaled to a + // small JPEG by a remote tool when one is available (vipsthumbnail preferred, then ImageMagick), + // so the transfer shrinks by ~10-50x and big images load nearly as fast as small ones; boards + // with no image tool simply stream the original bytes. The client does no re-encoding. + await this.acquireImageReadSlot(); + try { + const command = buildPosixReadImagesBatchCommand(targets, IMAGE_BATCH_MAX_FILE_BYTES, maxWidth); + const output = await this.withDirectorySshClient(endpoint, client => this.execRemoteBytes(client, command, { + inactivityTimeoutMs: REMOTE_DIRECTORY_READ_INACTIVITY_TIMEOUT_MS, + inactivityMessage: 'Image batch read produced no output.', + timeoutMs: REMOTE_DIRECTORY_READ_MAX_TIMEOUT_MS + })); + return parseImageBatchOutput(output); + } + catch (error) { + const message = error instanceof Error ? error.message : String(error); + return targets.map(path => ({ path, ok: false, message, transient: true })); + } + finally { + this.releaseImageReadSlot(); + } + } + async createImageResource(endpoint, options = {}) { + try { + const now = Date.now(); + this.trimExpiredImageResources(now); + this.ensureImageResourceCleanupTimer(); + const token = randomUUID(); + this.imageResourceDescriptors.set(token, { + endpoint, + options: { ...options }, + createdAt: now, + expiresAt: now + IMAGE_RESOURCE_TTL_MS + }); + return { + ok: true, + message: 'Image resource created.', + url: `${TURBOFILE_IMAGE_RESOURCE_SCHEME}://${token}`, + mime: getMimeType(endpoint.path) + }; + } + catch (error) { + return { + ok: false, + message: error instanceof Error ? error.message : String(error), + mime: getMimeType(endpoint.path) + }; + } + } + async releaseImageResource(url) { + const token = this.getImageResourceToken(url); + if (token) { + this.imageResourceDescriptors.delete(token); + } + } + ensureTurboFileImageProtocolRegistered() { + TurboFileSshMainService.imageProtocolService = this; + if (TurboFileSshMainService.imageProtocolRegistered) { + return; + } + protocol.handle(TURBOFILE_IMAGE_RESOURCE_SCHEME, request => { + const service = TurboFileSshMainService.imageProtocolService; + if (!service) { + return new Response('TurboFile image service is unavailable.', { status: 503 }); + } + return service.handleTurboFileImageProtocolRequest(request); + }); + TurboFileSshMainService.imageProtocolRegistered = true; + } + async handleTurboFileImageProtocolRequest(request) { + try { + const token = this.getImageResourceToken(request.url); + if (!token) { + return new Response('Invalid TurboFile image resource.', { status: 400 }); + } + const descriptor = this.imageResourceDescriptors.get(token); + if (!descriptor) { + return new Response('TurboFile image resource was not found.', { status: 404 }); + } + const now = Date.now(); + if (descriptor.expiresAt <= now) { + this.imageResourceDescriptors.delete(token); + return new Response('TurboFile image resource expired.', { status: 410 }); + } + descriptor.expiresAt = now + IMAGE_RESOURCE_TTL_MS; + const result = await this.readImageBuffer(descriptor.endpoint, descriptor.options); + const body = result.buffer.buffer.slice(result.buffer.byteOffset, result.buffer.byteOffset + result.buffer.byteLength); + return new Response(body, { + status: 200, + headers: { + 'Content-Type': result.mime || getMimeType(descriptor.endpoint.path), + 'Cache-Control': 'no-store' + } + }); + } + catch (error) { + return new Response(error instanceof Error ? error.message : String(error), { status: 500 }); + } + } + getImageResourceToken(url) { + try { + const parsed = new URL(url); + if (parsed.protocol !== `${TURBOFILE_IMAGE_RESOURCE_SCHEME}:`) { + return undefined; + } + const token = parsed.hostname || parsed.pathname.replace(/^\/+/, '').split('/')[0]; + return token ? decodeURIComponent(token) : undefined; + } + catch { + return undefined; + } + } + ensureImageResourceCleanupTimer() { + if (this.imageResourceCleanupTimer) { + return; + } + this.imageResourceCleanupTimer = setInterval(() => this.trimExpiredImageResources(Date.now()), IMAGE_RESOURCE_CLEANUP_INTERVAL_MS); + this.imageResourceCleanupTimer.unref?.(); + } + trimExpiredImageResources(now) { + for (const [token, descriptor] of this.imageResourceDescriptors) { + if (descriptor.expiresAt <= now) { + this.imageResourceDescriptors.delete(token); + } + } + if (this.imageResourceDescriptors.size === 0 && this.imageResourceCleanupTimer) { + clearInterval(this.imageResourceCleanupTimer); + this.imageResourceCleanupTimer = undefined; + } + } + async writeClipboardImage(content, mime, encoding = 'base64') { + try { + const buffer = Buffer.from(content, encoding); + const image = nativeImage.createFromBuffer(buffer); + if (image.isEmpty()) { + return { ok: false, message: `无法解析图片数据:${mime || 'unknown'}` }; + } + clipboard.writeImage(image); + return { ok: true, message: 'Image copied to clipboard.' }; + } + catch (error) { + return { + ok: false, + message: error instanceof Error ? error.message : String(error) + }; + } + } + async writeFile(endpoint, content, encoding = 'utf8') { + const buffer = encoding === 'base64' ? Buffer.from(content, 'base64') : Buffer.from(content, 'utf8'); + try { + if (endpoint.kind === 'local') { + await fsp.mkdir(dirname(endpoint.path), { recursive: true }); + await fsp.writeFile(endpoint.path, buffer); + } + else { + const tempRoot = join(tmpdir(), `turbofile-write-${randomUUID()}`); + const tempPath = join(tempRoot, basename(endpoint.path) || 'content'); + try { + await fsp.mkdir(tempRoot, { recursive: true }); + await fsp.writeFile(tempPath, buffer); + // Reuse the open directory session rather than opening a fresh SSH connection per call, + // so creating/saving a file on a high-latency remote host is not gated on a handshake. + await this.withDirectorySftp(endpoint, async (sftp) => { + await this.ensureRemoteDirectory(sftp, getRemoteParentPath(endpoint.path)); + await this.fastPut(sftp, tempPath, endpoint.path, this.createSftpTransferOptions()); + }); + } + finally { + await fsp.rm(tempRoot, { recursive: true, force: true }).catch(() => undefined); + } + } + this.clearRemoteDirectoryListingCache(endpoint); + return { ok: true, message: 'File saved.' }; + } + catch (error) { + return { ok: false, message: error instanceof Error ? error.message : String(error) }; + } + } + async compressPath(endpoint, targetPath) { + try { + const archivePath = targetPath ?? getDefaultArchivePath(endpoint.path, endpoint.kind === 'local' ? 'zip' : 'tar.gz'); + if (endpoint.kind === 'local') { + await runLocalCommand('tar.exe', [...await this.getLocalTarCharsetArgs(), '-a', '-cf', archivePath, '-C', dirname(endpoint.path), basename(endpoint.path)]); + } + else { + await this.withSshClient(endpoint, client => this.execRemote(client, `mkdir -p ${quotePosixShellArg(getRemoteParentPath(archivePath))} && tar -czf ${quotePosixShellArg(archivePath)} -C ${quotePosixShellArg(getRemoteParentPath(endpoint.path))} ${quotePosixShellArg(getTransferName(endpoint.path))}`)); + } + this.clearRemoteDirectoryListingCache(endpoint, archivePath); + return { ok: true, message: `Archive created: ${archivePath}` }; + } + catch (error) { + return { ok: false, message: error instanceof Error ? error.message : String(error) }; + } + } + async extractPath(endpoint, targetDirectory) { + try { + let outputDirectory = targetDirectory ?? getDefaultExtractDirectory(endpoint.path); + if (endpoint.kind === 'local') { + outputDirectory = await getAvailableLocalDirectory(outputDirectory); + await fsp.mkdir(outputDirectory, { recursive: true }); + // bsdtar (Windows tar.exe / libarchive) extracts tar*, zip, 7z and rar. Force UTF-8 only + // for tar-family archives; zip/rar/7z carry their own encoding (zip UTF-8 flag, otherwise + // the creator's code page) and libarchive must auto-detect it to avoid garbling names. + const charsetArgs = isTurboFileTarFamilyArchivePath(endpoint.path) ? await this.getLocalTarCharsetArgs() : []; + await runLocalCommand('tar.exe', [...charsetArgs, '-xf', endpoint.path, '-C', outputDirectory]); + } + else { + const quotedArchive = quotePosixShellArg(endpoint.path); + const outputSetup = `out=${quotePosixShellArg(outputDirectory)}; if [ -e "$out" ] && [ ! -d "$out" ]; then out="$out"_extracted; fi; mkdir -p "$out"`; + const command = `${outputSetup} && ${buildTurboFileRemoteExtractCommand(endpoint.path, quotedArchive)}`; + await this.withSshClient(endpoint, client => this.execRemote(client, command)); + } + this.clearRemoteDirectoryListingCache(endpoint, outputDirectory); + return { ok: true, message: `Archive extracted: ${outputDirectory}` }; + } + catch (error) { + return { ok: false, message: error instanceof Error ? error.message : String(error) }; + } + } + async createSshTerminal(options, cwd, cols, rows) { + const configResult = await this.createConnectConfig(options); + if (!configResult.ok) { + throw new Error(configResult.message); + } + // Use a dedicated connection per terminal instead of reusing the directory-browsing client. + // Sharing coupled the terminal's lifetime to the directory session's idle timer, so an + // unrelated idle eviction could close the client mid-task and kill the terminal. A dedicated + // connection lives until the user closes the terminal (or a real network failure). + const client = await this.connect(configResult.config); + let stream; + try { + stream = await this.openShell(client, cols, rows); + const sessionId = randomUUID(); + const session = { + client, + stream, + dataEmitter: new Emitter(), + exitEmitter: new Emitter(), + dataBuffer: [], + bootstrapBuffer: '', + bootstrapping: true, + hasDataListener: false, + closed: false + }; + this.terminalSessions.set(sessionId, session); + stream.on('data', data => { + const chunk = data.toString(); + if (session.bootstrapping) { + session.bootstrapBuffer += chunk; + const markerIndex = session.bootstrapBuffer.indexOf(SSH_TERMINAL_READY_MARKER); + if (markerIndex >= 0) { + const remaining = session.bootstrapBuffer.slice(markerIndex + SSH_TERMINAL_READY_MARKER.length).replace(/^\r?\n/, ''); + session.bootstrapBuffer = ''; + session.bootstrapping = false; + if (remaining) { + if (session.hasDataListener) { + session.dataEmitter.fire(remaining); + } + else { + session.dataBuffer.push(remaining); + } + } + } + return; + } + if (session.hasDataListener) { + session.dataEmitter.fire(chunk); + } + else { + session.dataBuffer.push(chunk); + } + }); + stream.on('error', error => { + const chunk = `\r\n极速传 SSH terminal error: ${error.message}\r\n`; + if (session.hasDataListener) { + session.dataEmitter.fire(chunk); + } + else { + session.dataBuffer.push(chunk); + } + }); + stream.on('close', code => this.cleanupSshTerminalSession(sessionId, code ?? undefined)); + client.on('close', () => this.cleanupSshTerminalSession(sessionId, undefined)); + stream.write(buildShellBootstrapCommand(cwd)); + return { + sessionId, + cwd, + pid: this.terminalSessionSequence++ + }; + } + catch (error) { + stream?.end(); + client.end(); + throw error; + } + } + async writeSshTerminal(sessionId, data) { + const session = this.terminalSessions.get(sessionId); + if (!session || session.closed) { + return; + } + session.stream.write(data); + } + async resizeSshTerminal(sessionId, cols, rows) { + const session = this.terminalSessions.get(sessionId); + if (!session || session.closed) { + return; + } + session.stream.setWindow?.(rows, cols, 0, 0); + } + async shutdownSshTerminal(sessionId) { + this.cleanupSshTerminalSession(sessionId, undefined); + } + async trySameRemoteCopy(request, startedAt) { + if (request.source.kind !== 'ssh' || request.target.kind !== 'ssh' || !request.source.connection || !request.target.connection) { + return undefined; + } + if (!isSameSshConnection(request.source.connection, request.target.connection)) { + return undefined; + } + this.throwIfTransferCanceled(request.progressId); + const sourceType = request.sourceType ?? await this.withDirectorySftp(request.source, async (sftp) => getFileTypeFromStats(await this.remoteStat(sftp, request.source.path))).catch(() => 'unknown'); + const expectedBytes = Math.max(0, request.expectedBytes ?? 0); + let bytes = expectedBytes; + if (sourceType === 'directory') { + bytes = bytes > 64 * 1024 ? bytes : await this.getRemotePathSizeByCommand(request.source).catch(() => expectedBytes); + } + else if (bytes <= 0) { + bytes = await this.getRemotePathSizeByCommand(request.source).catch(() => expectedBytes); + } + this.fireTransferProgress(request, 0, bytes, 'prepare', 'Preparing remote copy.', true); + const command = looksLikeWindowsPath(request.source.path) || looksLikeWindowsPath(request.target.path) + ? buildWindowsCopyCommand(request.source.path, request.target.path, sourceType) + : buildPosixCopyCommand(request.source.path, request.target.path, sourceType); + this.fireTransferProgress(request, 0, bytes, 'transfer', 'Remote copy in progress.', true); + await this.execRemoteCopyWithProgress(request, command, bytes, sourceType); + this.throwIfTransferCanceled(request.progressId); + if (bytes === 0 && expectedBytes === 0) { + bytes = await this.getRemotePathSizeByCommand(request.target).catch(() => 0); + } + return this.createTransferResult(true, 'remote-copy', bytes, 'Remote copy completed.', startedAt, request.progressId); + } + async tryDirectTarTransfer(request, startedAt, forceProtocol = false) { + if (request.source.kind !== 'ssh' || request.target.kind !== 'ssh' || !request.source.connection || !request.target.connection) { + return undefined; + } + if (request.strategyHint === 'relay') { + return undefined; + } + if (!forceProtocol && getSshToSshTransferProtocol(request.source) !== 'tar') { + return undefined; + } + if (request.source.supportsTar === false || request.target.supportsTar === false || looksLikeWindowsPath(request.source.path) || looksLikeWindowsPath(request.target.path)) { + return undefined; + } + const sourceType = request.sourceType ?? await this.withDirectorySftp(request.source, async (sftp) => getFileTypeFromStats(await this.remoteStat(sftp, request.source.path))).catch(() => 'unknown'); + if (sourceType !== 'directory') { + return undefined; + } + let bytes = Math.max(0, request.expectedBytes ?? 0); + if (bytes <= 0) { + bytes = await this.getRemotePathContentSizeByCommand(request.source).catch(() => 0); + } + const routes = await this.resolveDirectSshRoutes(request.source, request.target, request.progressId); + if (!routes.length) { + return undefined; + } + const failures = []; + for (const route of routes) { + try { + const result = await this.tryDirectTarTransferRoute(request, startedAt, bytes, route); + this.clearDirectSshRouteFailure(request.source, request.target, route); + return result; + } + catch (error) { + if (this.isTransferCanceled(request.progressId)) { + throw new TurboFileTransferCanceledError(); + } + this.markDirectSshRouteFailure(request.source, request.target, route); + failures.push(`${route}: ${formatTransferError(error)}`); + } + } + this.clearDirectSshRouteCache(request.source, request.target); + throw new Error(`Direct tar failed: ${failures.join('; ')}`); + } + async tryDirectTarTransferRoute(request, startedAt, expectedBytes, route) { + const executorEndpoint = route === 'source-push' ? request.source : request.target; + const remoteEndpoint = route === 'source-push' ? request.target : request.source; + if (!executorEndpoint.connection || !remoteEndpoint.connection) { + throw new Error('Direct tar route is missing SSH connection details.'); + } + const executorConfigResult = await this.createConnectConfig(executorEndpoint.connection); + if (!executorConfigResult.ok) { + throw new Error(executorConfigResult.message); + } + const client = await this.connectDirectSsh(executorConfigResult.config); + const unregisterCancel = this.registerTransferCancelHandler(request.progressId, () => client.end()); + let credential; + let stopGrowthTiming = () => undefined; + let lastActivityAt = Date.now(); + const markActivity = () => { + lastActivityAt = Date.now(); + }; + try { + this.throwIfTransferCanceled(request.progressId); + credential = await this.prepareRemoteScpCredential(client, remoteEndpoint.connection); + const remote = remoteEndpoint.connection; + const remoteUserHost = `${remote.username}@${remote.host}`; + const sshpassPrefix = buildSshpassPrefix(remote.password); + const sshCommand = buildDirectScpSshCommand(remote.port || 22, !!remote.password, credential.identityPath); + const command = route === 'source-push' + ? buildTarSourcePushCommand(request.source.path, request.target.path, remoteUserHost, sshCommand, sshpassPrefix) + : buildTarTargetPullCommand(request.source.path, request.target.path, remoteUserHost, sshCommand, sshpassPrefix); + this.fireTransferProgress(request, 0, expectedBytes, 'prepare', 'Preparing direct tar transfer.', true, { strategy: 'tar-direct', route }); + stopGrowthTiming = this.beginRemotePathGrowthTiming(request.progressId, request.target, 0, expectedBytes, transferredBytes => { + markActivity(); + this.fireTransferProgress(request, transferredBytes, expectedBytes, 'transfer', 'Direct tar transfer in progress.', false, { strategy: 'tar-direct', route }); + }); + this.markTransferDataStarted(request.progressId); + await this.execRemote(client, command, { + inactivityTimeoutMs: DIRECT_TRANSFER_STALL_TIMEOUT_MS, + inactivityMessage: `Direct tar ${route} produced no target growth for ${DIRECT_TRANSFER_STALL_TIMEOUT_MS} ms.`, + getActivityTime: () => lastActivityAt, + progressId: request.progressId + }); + this.throwIfTransferCanceled(request.progressId); + const targetSize = await this.getRemotePathContentSizeByCommand(request.target).catch(() => expectedBytes); + if (expectedBytes > 0 && targetSize !== expectedBytes) { + throw new Error(`Direct tar size mismatch: expected ${expectedBytes}, got ${targetSize}.`); + } + const bytes = expectedBytes > 0 ? expectedBytes : targetSize; + this.fireTransferProgress(request, bytes, bytes, 'complete', 'Direct tar completed.', true, { strategy: 'tar-direct', route }); + return this.createTransferResult(true, 'tar-direct', bytes, 'Direct tar completed.', startedAt, request.progressId); + } + catch (error) { + if (this.isTransferCanceled(request.progressId)) { + throw new TurboFileTransferCanceledError(); + } + throw error; + } + finally { + stopGrowthTiming(); + if (credential?.cleanupCommand) { + await this.execRemote(client, credential.cleanupCommand).catch(() => undefined); + } + unregisterCancel(); + client.end(); + } + } + async tryDirectRsyncTransfer(request, startedAt, allowAutoFast = false) { + if (request.source.kind !== 'ssh' || request.target.kind !== 'ssh' || !request.source.connection || !request.target.connection) { + return undefined; + } + if (request.strategyHint === 'relay') { + return undefined; + } + const protocol = getSshToSshTransferProtocol(request.source); + if (protocol !== 'rsync' && !(allowAutoFast && protocol === 'auto-fast')) { + return undefined; + } + if (request.source.supportsRsync !== true || request.target.supportsRsync !== true) { + return undefined; + } + if (looksLikeWindowsPath(request.source.path) || looksLikeWindowsPath(request.target.path)) { + return undefined; + } + this.throwIfTransferCanceled(request.progressId); + const routes = await this.resolveDirectSshRoutes(request.source, request.target, request.progressId); + if (!routes.length) { + return undefined; + } + const failures = []; + for (const route of routes) { + try { + const result = await this.tryDirectRsyncTransferRoute(request, startedAt, route); + this.clearDirectSshRouteFailure(request.source, request.target, route); + return result; + } + catch (error) { + if (this.isTransferCanceled(request.progressId)) { + throw new TurboFileTransferCanceledError(); + } + this.markDirectSshRouteFailure(request.source, request.target, route); + failures.push(`${route}: ${formatTransferError(error)}`); + } + } + this.clearDirectSshRouteCache(request.source, request.target); + throw new Error(`Direct rsync failed: ${failures.join('; ')}`); + } + async tryDirectRsyncTransferRoute(request, startedAt, route) { + const executorEndpoint = route === 'source-push' ? request.source : request.target; + const remoteEndpoint = route === 'source-push' ? request.target : request.source; + if (!executorEndpoint.connection || !remoteEndpoint.connection) { + throw new Error('Direct rsync route is missing SSH connection details.'); + } + const executorConfigResult = await this.createConnectConfig(executorEndpoint.connection); + if (!executorConfigResult.ok) { + throw new Error(executorConfigResult.message); + } + const client = await this.connectDirectSsh(executorConfigResult.config); + const unregisterCancel = this.registerTransferCancelHandler(request.progressId, () => client.end()); + let lastActivityAt = Date.now(); + const markActivity = () => { + lastActivityAt = Date.now(); + }; + try { + this.throwIfTransferCanceled(request.progressId); + this.fireTransferProgress(request, 0, 0, 'transfer', 'Starting direct rsync transfer.', true, { strategy: 'rsync-direct', route }); + const remote = remoteEndpoint.connection; + const remoteUserHost = `${remote.username}@${remote.host}`; + const sshBinary = await this.resolveRemoteRsyncSshBinary(client, executorEndpoint.connection); + const sshTransport = buildOptimizedRsyncSshCommand(remote.port || 22, !!remote.password, sshBinary); + const sshpassPrefix = buildSshpassPrefix(remote.password); + const sourceType = request.sourceType ?? await this.withSftp(request.source, async (sftp) => getFileTypeFromStats(await this.remoteStat(sftp, request.source.path)), request.progressId).catch(() => 'unknown'); + const sourceIsDirectory = sourceType === 'directory'; + let bytes = 0; + const remoteTargetPath = sourceIsDirectory ? request.target.path : getRemoteParentPath(request.target.path); + const rsyncOptions = buildRsyncOptions(true); + const rsyncPathOption = route === 'source-push' ? ` ${buildRsyncPathWithMkdirShellOption(remoteTargetPath)}` : ''; + const rsyncSourcePath = sourceIsDirectory ? ensureTrailingRemoteSlash(request.source.path) : request.source.path; + const rsyncTargetPath = sourceIsDirectory ? ensureTrailingRemoteSlash(request.target.path) : request.target.path; + const rsyncSourceArg = route === 'source-push' ? rsyncSourcePath : `${remoteUserHost}:${rsyncSourcePath}`; + const rsyncTargetArg = route === 'source-push' ? `${remoteUserHost}:${rsyncTargetPath}` : rsyncTargetPath; + const rsyncCommandCore = `${sshpassPrefix}rsync ${rsyncOptions}${rsyncPathOption} -e ${quotePosixShellArg(sshTransport)} ${quotePosixShellArg(rsyncSourceArg)} ${quotePosixShellArg(rsyncTargetArg)}`; + const rsyncCommand = route === 'source-push' ? rsyncCommandCore : `mkdir -p ${quotePosixShellArg(remoteTargetPath)} && ${rsyncCommandCore}`; + const progress = this.createRsyncProgressReporter(request, 0, 'rsync-direct', 'Direct rsync transfer in progress.', { strategy: 'rsync-direct', route }, markActivity); + this.throwIfTransferCanceled(request.progressId); + markActivity(); + await this.execRemote(client, rsyncCommand, { + onStdout: progress.handleOutput, + onStderr: progress.handleOutput, + inactivityTimeoutMs: DIRECT_TRANSFER_STALL_TIMEOUT_MS, + inactivityMessage: `Direct rsync ${route} produced no transfer progress for ${DIRECT_TRANSFER_STALL_TIMEOUT_MS} ms.`, + getActivityTime: () => lastActivityAt + }); + this.throwIfTransferCanceled(request.progressId); + bytes = progress.getLastBytes() || Math.max(0, request.expectedBytes ?? 0); + return this.createTransferResult(true, 'rsync-direct', bytes, 'Direct rsync completed.', startedAt, request.progressId); + } + catch (error) { + if (this.isTransferCanceled(request.progressId)) { + throw new TurboFileTransferCanceledError(); + } + throw error; + } + finally { + unregisterCancel(); + client.end(); + } + } + /** + * Unified direct-route detection for all SSH-to-SSH transfers (scp/sftp/rsync). + * + * Returns every currently viable direction in deterministic order: + * - `source-push`: source can reach target's SSH login and should push the payload. + * - `target-pull`: target can reach source's SSH login and should pull the payload. + * + * Both directions are probed in parallel and capped under ~1s. Transfer failures put only the + * failing direction into a short cooldown, so one bad route cannot keep every 62 -> * transfer + * stuck at 0 bytes; the next direct candidate or same-protocol relay can take over. + */ + async resolveDirectSshRoutes(source, target, progressId) { + if (source.kind !== 'ssh' || target.kind !== 'ssh' || !source.connection || !target.connection) { + return []; + } + const cacheKey = getDirectSshRouteCacheKey(source.connection, target.connection); + const now = Date.now(); + const cached = this.directSshRouteCache.get(cacheKey); + if (cached && cached.expiresAt > now) { + const routes = this.filterDirectSshRoutesByCooldown(cacheKey, cached.routes); + if (routes.length > 0) { + return routes; + } + } + this.throwIfTransferCanceled(progressId); + const optimisticRoutes = ['source-push', 'target-pull']; + this.directSshRouteCache.set(cacheKey, { + routes: optimisticRoutes, + expiresAt: now + DIRECT_SSH_ROUTE_CACHE_TTL_MS + }); + return this.filterDirectSshRoutesByCooldown(cacheKey, optimisticRoutes); + } + filterDirectSshRoutesByCooldown(cacheKey, routes) { + const now = Date.now(); + return routes.filter(route => (this.directSshRouteFailureCooldowns.get(getDirectSshRouteFailureKey(cacheKey, route)) ?? 0) <= now); + } + markDirectSshRouteFailure(source, target, route) { + if (source.kind !== 'ssh' || target.kind !== 'ssh' || !source.connection || !target.connection) { + return; + } + const cacheKey = getDirectSshRouteCacheKey(source.connection, target.connection); + this.directSshRouteFailureCooldowns.set(getDirectSshRouteFailureKey(cacheKey, route), Date.now() + DIRECT_SSH_ROUTE_FAILURE_COOLDOWN_MS); + } + clearDirectSshRouteFailure(source, target, route) { + if (source.kind !== 'ssh' || target.kind !== 'ssh' || !source.connection || !target.connection) { + return; + } + const cacheKey = getDirectSshRouteCacheKey(source.connection, target.connection); + this.directSshRouteFailureCooldowns.delete(getDirectSshRouteFailureKey(cacheKey, route)); + this.directSshRouteCache.set(cacheKey, { + routes: [route], + expiresAt: Date.now() + DIRECT_SSH_ROUTE_CACHE_TTL_MS + }); + } + async resolveRemoteRsyncSshBinary(client, connection) { + const cacheKey = getSshConnectionRouteKey(connection); + const cached = this.remoteRsyncSshBinaryCache.get(cacheKey); + if (cached) { + return cached; + } + try { + const output = await this.execRemoteBytes(client, [ + 'if [ -x /usr/bin/ssh.original ]; then printf %s /usr/bin/ssh.original;', + 'elif [ -x /usr/bin/ssh.bak ]; then printf %s /usr/bin/ssh.bak;', + 'else command -v ssh || printf %s ssh; fi' + ].join(' '), { timeoutMs: 2000 }); + const binary = output.toString('utf8').trim().split(/\r?\n/)[0]; + const normalized = binary && /^[A-Za-z0-9_./-]+$/.test(binary) ? binary : 'ssh'; + this.remoteRsyncSshBinaryCache.set(cacheKey, normalized); + return normalized; + } + catch { + this.remoteRsyncSshBinaryCache.set(cacheKey, 'ssh'); + return 'ssh'; + } + } + clearDirectSshRouteCache(source, target) { + if (source.kind !== 'ssh' || target.kind !== 'ssh' || !source.connection || !target.connection) { + return; + } + this.directSshRouteCache.delete(getDirectSshRouteCacheKey(source.connection, target.connection)); + } + async tryDirectScpTransfer(request, startedAt, forceProtocol = false) { + if (request.source.kind !== 'ssh' || request.target.kind !== 'ssh' || !request.source.connection || !request.target.connection) { + return undefined; + } + if (request.strategyHint === 'relay') { + return undefined; + } + const transferMode = getSshToSshTransferProtocol(request.source); + if (!forceProtocol && transferMode !== 'scp' && transferMode !== 'auto-fast') { + return undefined; + } + if (looksLikeWindowsPath(request.source.path) || looksLikeWindowsPath(request.target.path)) { + return undefined; + } + this.throwIfTransferCanceled(request.progressId); + const sourceType = request.sourceType ?? await this.withDirectorySftp(request.source, async (sftp) => getFileTypeFromStats(await this.remoteStat(sftp, request.source.path))).catch(() => 'unknown'); + if (sourceType !== 'file' && sourceType !== 'directory') { + return undefined; + } + const sourceIsDirectory = sourceType === 'directory'; + const bytes = Math.max(0, request.expectedBytes ?? 0) || await this.getRemotePathSizeByCommand(request.source).catch(() => 0); + if (bytes <= 0) { + return undefined; + } + const routes = await this.resolveDirectSshRoutes(request.source, request.target, request.progressId); + if (!routes.length) { + return undefined; + } + const failures = []; + for (const route of routes) { + try { + const result = await this.tryDirectScpTransferRoute(request, startedAt, sourceIsDirectory, bytes, route); + this.clearDirectSshRouteFailure(request.source, request.target, route); + return result; + } + catch (error) { + if (this.isTransferCanceled(request.progressId)) { + throw new TurboFileTransferCanceledError(); + } + this.markDirectSshRouteFailure(request.source, request.target, route); + failures.push(`${route}: ${formatTransferError(error)}`); + } + } + this.clearDirectSshRouteCache(request.source, request.target); + throw new Error(`Direct SCP failed: ${failures.join('; ')}`); + } + async tryDirectScpTransferRoute(request, startedAt, sourceIsDirectory, bytes, route) { + const executorEndpoint = route === 'source-push' ? request.source : request.target; + const remoteEndpoint = route === 'source-push' ? request.target : request.source; + if (!executorEndpoint.connection || !remoteEndpoint.connection) { + throw new Error('Direct SCP route is missing SSH connection details.'); + } + const executorConfigResult = await this.createConnectConfig(executorEndpoint.connection); + if (!executorConfigResult.ok) { + throw new Error(executorConfigResult.message); + } + const client = await this.connectDirectSsh(executorConfigResult.config); + const unregisterCancel = this.registerTransferCancelHandler(request.progressId, () => client.end()); + let credential; + let stopGrowthTiming = () => undefined; + let lastActivityAt = Date.now(); + const markActivity = () => { + lastActivityAt = Date.now(); + }; + try { + this.throwIfTransferCanceled(request.progressId); + credential = await this.prepareRemoteScpCredential(client, remoteEndpoint.connection); + const remote = remoteEndpoint.connection; + const remoteUserHost = `${remote.username}@${remote.host}`; + const sshpassPrefix = buildSshpassPrefix(remote.password); + const targetParentPath = getRemoteParentPath(request.target.path); + const prepareDirectoryTargetCommand = buildPrepareDirectoryTransferTargetCommand(request.target.path); + const prepareFileTargetCommand = `mkdir -p -- ${quotePosixShellArg(targetParentPath)} && rm -f -- ${quotePosixShellArg(request.target.path)}`; + const mkdirCommand = route === 'source-push' + ? `${sshpassPrefix}${buildDirectScpSshCommand(remote.port || 22, !!remote.password, credential.identityPath)} ${quotePosixShellArg(remoteUserHost)} ${quotePosixShellArg(sourceIsDirectory ? prepareDirectoryTargetCommand : prepareFileTargetCommand)}` + : sourceIsDirectory ? prepareDirectoryTargetCommand : prepareFileTargetCommand; + const sourcePath = sourceIsDirectory ? getRemoteDirectoryContentsPath(request.source.path) : request.source.path; + const targetPath = sourceIsDirectory ? ensureTrailingRemoteSlash(request.target.path) : request.target.path; + const sourceArg = route === 'source-push' ? sourcePath : `${remoteUserHost}:${sourcePath}`; + const targetArg = route === 'source-push' ? `${remoteUserHost}:${targetPath}` : targetPath; + const scpCommand = `${sshpassPrefix}${buildDirectScpCommand(remote.port || 22, !!remote.password, credential.identityPath, sourceIsDirectory)} ${quotePosixShellArg(sourceArg)} ${quotePosixShellArg(targetArg)}`; + this.fireTransferProgress(request, 0, bytes, 'prepare', 'Preparing direct SCP transfer.', true, { strategy: 'scp-direct', route }); + await this.execRemote(client, mkdirCommand, { timeoutMs: DIRECT_TRANSFER_STALL_TIMEOUT_MS }); + this.throwIfTransferCanceled(request.progressId); + markActivity(); + stopGrowthTiming = sourceIsDirectory + ? this.beginRemotePathGrowthTiming(request.progressId, request.target, 0, bytes, transferredBytes => { + markActivity(); + this.fireTransferProgress(request, transferredBytes, bytes, 'transfer', 'Direct SCP transfer in progress.', false, { strategy: 'scp-direct', route }); + }) + : this.beginRemoteFileGrowthTiming(request.progressId, request.target.connection, request.target.path, 0, bytes, transferredBytes => { + markActivity(); + this.fireTransferProgress(request, transferredBytes, bytes, 'transfer', 'Direct SCP transfer in progress.', false, { strategy: 'scp-direct', route }); + }); + this.markTransferDataStarted(request.progressId); + await this.execRemote(client, scpCommand, { + inactivityTimeoutMs: DIRECT_TRANSFER_STALL_TIMEOUT_MS, + inactivityMessage: `Direct SCP ${route} produced no target growth for ${DIRECT_TRANSFER_STALL_TIMEOUT_MS} ms.`, + getActivityTime: () => lastActivityAt + }); + this.throwIfTransferCanceled(request.progressId); + const targetSize = sourceIsDirectory + ? await this.getRemotePathSizeByCommand(request.target).catch(() => 0) + : await this.withDirectorySftp(request.target, async (sftp) => (await this.remoteStat(sftp, request.target.path)).size).catch(() => 0); + if (targetSize !== bytes) { + await this.withDirectorySftp(request.target, sftp => sourceIsDirectory ? this.rmdirRemote(sftp, request.target.path) : this.unlinkRemote(sftp, request.target.path)).catch(() => undefined); + throw new Error(`Direct SCP size mismatch: expected ${bytes}, got ${targetSize}.`); + } + this.fireTransferProgress(request, bytes, bytes, 'transfer', 'Direct SCP transfer completed.', true, { strategy: 'scp-direct', route }); + return this.createTransferResult(true, 'scp-direct', bytes, 'Direct SCP completed.', startedAt, request.progressId); + } + catch (error) { + if (this.isTransferCanceled(request.progressId)) { + throw new TurboFileTransferCanceledError(); + } + throw error; + } + finally { + stopGrowthTiming(); + if (credential?.cleanupCommand) { + await this.execRemote(client, credential.cleanupCommand).catch(() => undefined); + } + unregisterCancel(); + client.end(); + } + } + async prepareRemoteScpCredential(client, remoteConnection) { + if (remoteConnection.password) { + return {}; + } + const privateKey = remoteConnection.privateKey ?? await this.readPrivateKey(remoteConnection.privateKeyPath).catch(() => undefined); + if (!privateKey) { + return {}; + } + const identityPath = `/tmp/turbofile-scp-${randomUUID()}.key`; + const marker = `__TURBOFILE_SCP_KEY_${randomUUID().replace(/-/g, '')}__`; + const normalizedKey = privateKey.replace(/\r\n/g, '\n').replace(/\r/g, '\n').replace(/\n?$/, '\n'); + const setupCommand = `umask 077; cat > ${quotePosixShellArg(identityPath)} <<'${marker}'\n${normalizedKey}${marker}\nchmod 600 ${quotePosixShellArg(identityPath)}`; + await this.execRemote(client, setupCommand); + return { + identityPath, + cleanupCommand: `rm -f ${quotePosixShellArg(identityPath)}` + }; + } + async tryDirectSftpTransfer(request, startedAt, forceProtocol = false) { + if (request.source.kind !== 'ssh' || request.target.kind !== 'ssh' || !request.source.connection || !request.target.connection) { + return undefined; + } + if (request.strategyHint === 'relay') { + return undefined; + } + if (!forceProtocol && getSshToSshTransferProtocol(request.source) !== 'sftp') { + return undefined; + } + if (looksLikeWindowsPath(request.source.path) || looksLikeWindowsPath(request.target.path)) { + return undefined; + } + this.throwIfTransferCanceled(request.progressId); + const routes = await this.resolveDirectSshRoutes(request.source, request.target, request.progressId); + if (!routes.length) { + return undefined; + } + const failures = []; + for (const route of routes) { + try { + const result = await this.tryDirectSftpTransferRoute(request, startedAt, route); + this.clearDirectSshRouteFailure(request.source, request.target, route); + return result; + } + catch (error) { + if (error instanceof TurboFileTransferCanceledError || this.isTransferCanceled(request.progressId)) { + throw new TurboFileTransferCanceledError(); + } + this.markDirectSshRouteFailure(request.source, request.target, route); + failures.push(`${route}: ${formatTransferError(error)}`); + } + } + this.clearDirectSshRouteCache(request.source, request.target); + throw new Error(`Direct SFTP failed: ${failures.join('; ')}`); + } + async tryDirectSftpTransferRoute(request, startedAt, route) { + const executorEndpoint = route === 'source-push' ? request.source : request.target; + const remoteEndpoint = route === 'source-push' ? request.target : request.source; + if (!executorEndpoint.connection || !remoteEndpoint.connection) { + throw new Error('Direct SFTP route is missing SSH connection details.'); + } + const executorConfigResult = await this.createConnectConfig(executorEndpoint.connection); + if (!executorConfigResult.ok) { + throw new Error(executorConfigResult.message); + } + const remote = remoteEndpoint.connection; + const remoteUserHost = `${remote.username}@${remote.host}`; + const sshpassPrefix = buildSshpassPrefix(remote.password); + const sftpCommand = `${sshpassPrefix}${buildOptimizedSftpCommand(remote.port || 22, !!remote.password)} ${quotePosixShellArg(remoteUserHost)}`; + const sshTransport = buildCompatibleSshCommand(remote.port || 22, !!remote.password); + const client = await this.connectDirectSsh(executorConfigResult.config); + const unregisterCancel = this.registerTransferCancelHandler(request.progressId, () => client.end()); + let stopGrowthTiming = () => undefined; + let lastActivityAt = Date.now(); + const markActivity = () => { + lastActivityAt = Date.now(); + }; + try { + this.throwIfTransferCanceled(request.progressId); + const sourceType = request.sourceType ?? await this.withSftp(request.source, async (sftp) => getFileTypeFromStats(await this.remoteStat(sftp, request.source.path)), request.progressId).catch(() => 'unknown'); + if (sourceType !== 'file' && sourceType !== 'directory') { + throw new Error(`Unsupported direct SFTP source type: ${sourceType}.`); + } + const expectedBytes = Math.max(0, request.expectedBytes ?? 0); + let bytes = expectedBytes; + if (bytes === 0) { + bytes = await this.getRemotePathSizeByCommand(request.source).catch(() => expectedBytes); + } + this.fireTransferProgress(request, 0, bytes, 'prepare', 'Preparing direct SFTP transfer.', true, { strategy: 'sftp-direct', route }); + const targetParentPath = getRemoteParentPath(request.target.path); + const sourceIsDirectory = sourceType === 'directory'; + const prepareDirectoryTargetCommand = buildPrepareDirectoryTransferTargetCommand(request.target.path); + const prepareFileTargetCommand = `mkdir -p -- ${quotePosixShellArg(targetParentPath)}`; + const mkdirCommand = route === 'source-push' + ? `${sshpassPrefix}${sshTransport} ${quotePosixShellArg(remoteUserHost)} ${quotePosixShellArg(sourceIsDirectory ? prepareDirectoryTargetCommand : prepareFileTargetCommand)}` + : sourceIsDirectory ? prepareDirectoryTargetCommand : prepareFileTargetCommand; + const batchVerb = sourceIsDirectory + ? route === 'source-push' ? 'put -pr' : 'get -pr' + : route === 'source-push' ? 'put -p' : 'get -p'; + const batchSourcePath = sourceIsDirectory ? getRemoteDirectoryContentsPath(request.source.path) : request.source.path; + const batchTargetPath = sourceIsDirectory ? ensureTrailingRemoteSlash(request.target.path) : request.target.path; + const batch = `${batchVerb} ${quoteSftpBatchPath(batchSourcePath)} ${quoteSftpBatchPath(batchTargetPath)}\n`; + const transferCommand = `printf %s ${quotePosixShellArg(batch)} | ${sftpCommand}`; + this.throwIfTransferCanceled(request.progressId); + await this.execRemote(client, mkdirCommand, { timeoutMs: DIRECT_TRANSFER_STALL_TIMEOUT_MS }); + this.throwIfTransferCanceled(request.progressId); + markActivity(); + stopGrowthTiming = sourceType === 'directory' + ? this.beginRemotePathGrowthTiming(request.progressId, request.target, 0, bytes, transferredBytes => { + markActivity(); + this.fireTransferProgress(request, transferredBytes, bytes, 'transfer', 'Direct SFTP transfer in progress.', false, { strategy: 'sftp-direct', route }); + }) + : this.beginRemoteFileGrowthTiming(request.progressId, request.target.connection, request.target.path, 0, bytes, transferredBytes => { + markActivity(); + this.fireTransferProgress(request, transferredBytes, bytes, 'transfer', 'Direct SFTP transfer in progress.', false, { strategy: 'sftp-direct', route }); + }); + this.markTransferDataStarted(request.progressId); + await this.execRemote(client, transferCommand, { + inactivityTimeoutMs: DIRECT_TRANSFER_STALL_TIMEOUT_MS, + inactivityMessage: `Direct SFTP ${route} produced no target growth for ${DIRECT_TRANSFER_STALL_TIMEOUT_MS} ms.`, + getActivityTime: () => lastActivityAt + }); + this.throwIfTransferCanceled(request.progressId); + const targetSize = await this.getRemotePathSizeByCommand(request.target).catch(() => bytes); + if (bytes > 0 && targetSize !== bytes) { + throw new Error(`Direct SFTP size mismatch: expected ${bytes}, got ${targetSize}.`); + } + this.fireTransferProgress(request, bytes, bytes, 'transfer', 'Direct SFTP transfer completed.', true, { strategy: 'sftp-direct', route }); + return this.createTransferResult(true, 'sftp-direct', bytes, 'Direct SFTP completed.', startedAt, request.progressId); + } + catch (error) { + if (error instanceof TurboFileTransferCanceledError || this.isTransferCanceled(request.progressId)) { + throw new TurboFileTransferCanceledError(); + } + throw error; + } + finally { + stopGrowthTiming(); + unregisterCancel(); + client.end(); + } + } + async tryTunneledRsyncRelayTransfer(request, startedAt, allowAutoFast = false) { + if (request.source.kind !== 'ssh' || request.target.kind !== 'ssh' || !request.source.connection || !request.target.connection) { + return undefined; + } + const protocol = getSshToSshTransferProtocol(request.source); + if (protocol !== 'rsync' && !(allowAutoFast && protocol === 'auto-fast')) { + return undefined; + } + if (request.source.supportsRsync !== true || request.target.supportsRsync !== true || request.strategyHint === 'direct') { + return undefined; + } + if (!request.target.connection.password || looksLikeWindowsPath(request.source.path) || looksLikeWindowsPath(request.target.path)) { + return undefined; + } + const sourceConfigResult = await this.createConnectConfig(request.source.connection); + if (!sourceConfigResult.ok) { + return undefined; + } + const client = await this.connect(sourceConfigResult.config); + const forwardedSockets = new Set(); + let forwardedPort; + const unregisterCancel = this.registerTransferCancelHandler(request.progressId, () => { + for (const socket of forwardedSockets) { + socket.destroy(); + } + client.end(); + }); + const onForwardedConnection = (_details, accept, reject) => { + if (this.isTransferCanceled(request.progressId)) { + reject(); + return; + } + const channel = accept(); + const socket = connectTcp(request.target.connection.port || 22, request.target.connection.host); + forwardedSockets.add(socket); + socket.on('close', () => forwardedSockets.delete(socket)); + socket.on('error', () => channel.destroy()); + channel.on('error', () => socket.destroy()); + channel.on('close', () => socket.destroy()); + socket.pipe(channel); + channel.pipe(socket); + }; + client.on('tcp connection', onForwardedConnection); + try { + this.throwIfTransferCanceled(request.progressId); + this.fireTransferProgress(request, 0, 0, 'transfer', 'Starting rsync relay transfer.', true, { strategy: 'rsync-relay' }); + const canUseLocalRsync = await this.canUseLocalRsync(client); + if (!canUseLocalRsync) { + return undefined; + } + forwardedPort = await this.forwardIn(client, RSYNC_RELAY_BIND_HOST, 0); + const target = request.target.connection; + const targetUserHost = `${target.username}@${RSYNC_RELAY_BIND_HOST}`; + const sshBinary = await this.resolveRemoteRsyncSshBinary(client, request.source.connection); + const sshTransport = buildOptimizedRsyncSshCommand(forwardedPort, true, sshBinary); + const sshpassPrefix = buildSshpassPrefix(target.password); + const sourceType = request.sourceType ?? await this.withSftp(request.source, async (sftp) => getFileTypeFromStats(await this.remoteStat(sftp, request.source.path)), request.progressId).catch(() => 'unknown'); + const sourceIsDirectory = sourceType === 'directory'; + let bytes = 0; + const remoteTargetPath = sourceIsDirectory ? request.target.path : getRemoteParentPath(request.target.path); + const canUseTargetRsync = await this.canUseTargetRsyncThroughRelay(client, targetUserHost, forwardedPort, sshpassPrefix); + if (!canUseTargetRsync) { + return undefined; + } + const rsyncOptions = buildRsyncOptions(true); + const rsyncSourcePath = sourceIsDirectory ? ensureTrailingRemoteSlash(request.source.path) : request.source.path; + const rsyncTargetPath = sourceIsDirectory ? ensureTrailingRemoteSlash(request.target.path) : request.target.path; + const rsyncCommand = `${sshpassPrefix}rsync ${rsyncOptions} ${buildRsyncPathWithMkdirShellOption(remoteTargetPath)} -e ${quotePosixShellArg(sshTransport)} ${quotePosixShellArg(rsyncSourcePath)} ${quotePosixShellArg(`${targetUserHost}:${rsyncTargetPath}`)}`; + const progress = this.createRsyncProgressReporter(request, 0, 'relay-upload', 'Rsync relay transfer in progress.', { strategy: 'rsync-relay' }); + await this.execRemote(client, rsyncCommand, { + onStdout: progress.handleOutput, + onStderr: progress.handleOutput, + inactivityTimeoutMs: DIRECT_TRANSFER_STALL_TIMEOUT_MS, + inactivityMessage: `Rsync relay produced no transfer progress for ${DIRECT_TRANSFER_STALL_TIMEOUT_MS} ms.`, + getActivityTime: progress.getActivityTime + }); + this.throwIfTransferCanceled(request.progressId); + bytes = progress.getLastBytes() || Math.max(0, request.expectedBytes ?? 0); + return this.createTransferResult(true, 'rsync-relay', bytes, 'Rsync relay completed.', startedAt, request.progressId); + } + catch (error) { + if (error instanceof TurboFileTransferCanceledError || this.isTransferCanceled(request.progressId)) { + throw new TurboFileTransferCanceledError(); + } + throw new Error(`Rsync relay failed: ${formatTransferError(error)}`); + } + finally { + unregisterCancel(); + if (forwardedPort !== undefined) { + await this.unforwardIn(client, RSYNC_RELAY_BIND_HOST, forwardedPort).catch(() => undefined); + } + for (const socket of forwardedSockets) { + socket.destroy(); + } + client.end(); + } + } + async canUseLocalRsync(client) { + try { + await this.execRemote(client, 'command -v rsync >/dev/null 2>&1 && command -v sshpass >/dev/null 2>&1'); + return true; + } + catch { + return false; + } + } + async canUseTargetRsyncThroughRelay(client, targetUserHost, forwardedPort, sshpassPrefix) { + try { + await this.execRemote(client, `${sshpassPrefix}${buildCompatibleSshCommand(forwardedPort, true)} ${quotePosixShellArg(targetUserHost)} ${quotePosixShellArg('command -v rsync >/dev/null 2>&1')}`); + return true; + } + catch { + return false; + } + } + async tryTarRelayTransfer(request, startedAt, forceProtocol = false) { + if (request.source.kind !== 'ssh' || request.target.kind !== 'ssh' || !request.source.connection || !request.target.connection) { + return undefined; + } + if (!forceProtocol && getSshToSshTransferProtocol(request.source) !== 'tar') { + return undefined; + } + if (request.strategyHint === 'direct' || request.source.supportsTar === false || request.target.supportsTar === false || looksLikeWindowsPath(request.source.path) || looksLikeWindowsPath(request.target.path)) { + return undefined; + } + const sourceType = request.sourceType ?? await this.withDirectorySftp(request.source, async (sftp) => getFileTypeFromStats(await this.remoteStat(sftp, request.source.path))).catch(() => 'unknown'); + if (sourceType !== 'directory') { + return undefined; + } + let bytes = Math.max(0, request.expectedBytes ?? 0); + if (bytes <= 0) { + bytes = await this.getRemotePathContentSizeByCommand(request.source).catch(() => 0); + } + const sourceConfigResult = await this.createConnectConfig(request.source.connection); + const targetConfigResult = await this.createConnectConfig(request.target.connection); + if (!sourceConfigResult.ok || !targetConfigResult.ok) { + return undefined; + } + let sourceClient; + let targetClient; + let stopGrowthTiming = () => undefined; + const unregisterCancel = this.registerTransferCancelHandler(request.progressId, () => { + sourceClient?.end(); + targetClient?.end(); + }); + try { + sourceClient = await this.connect(sourceConfigResult.config); + targetClient = await this.connect(targetConfigResult.config); + this.fireTransferProgress(request, 0, bytes, 'prepare', 'Preparing tar relay.', true, { strategy: 'tar-relay', route: 'relay' }); + stopGrowthTiming = this.beginRemotePathGrowthTiming(request.progressId, request.target, 0, bytes, transferredBytes => { + this.fireTransferProgress(request, transferredBytes, bytes, 'relay-upload', 'Tar relay transfer in progress.', false, { strategy: 'tar-relay', route: 'relay' }); + }); + this.markTransferDataStarted(request.progressId); + const sourceStream = await this.openRemoteCommandStream(sourceClient, buildTarCreateFromDirectoryCommand(request.source.path), 'tar relay create'); + const targetStream = await this.openRemoteCommandStream(targetClient, buildTarExtractToExactDirectoryCommand(request.target.path), 'tar relay extract', true); + await pipeline(sourceStream.stream, targetStream.stream); + await sourceStream.close; + await targetStream.close; + this.throwIfTransferCanceled(request.progressId); + const targetSize = await this.getRemotePathContentSizeByCommand(request.target).catch(() => bytes); + if (bytes > 0 && targetSize !== bytes) { + throw new Error(`Tar relay size mismatch: expected ${bytes}, got ${targetSize}.`); + } + const resultBytes = bytes > 0 ? bytes : targetSize; + this.fireTransferProgress(request, resultBytes, resultBytes, 'complete', 'Tar relay completed.', true, { strategy: 'tar-relay', route: 'relay' }); + this.clearRemoteDirectoryListingCache(request.target); + return this.createTransferResult(true, 'tar-relay', resultBytes, 'Tar relay completed.', startedAt, request.progressId); + } + catch (error) { + if (error instanceof TurboFileTransferCanceledError || this.isTransferCanceled(request.progressId)) { + throw new TurboFileTransferCanceledError(); + } + throw new Error(`Tar relay failed: ${formatTransferError(error)}`); + } + finally { + stopGrowthTiming(); + unregisterCancel(); + sourceClient?.end(); + targetClient?.end(); + } + } + async tryStreamRelayTransfer(request, startedAt, forceProtocol = false) { + if (request.source.kind !== 'ssh' || request.target.kind !== 'ssh' || !request.source.connection || !request.target.connection) { + return undefined; + } + if (!forceProtocol && getSshToSshTransferProtocol(request.source) !== 'sftp') { + return undefined; + } + const sourceConfigResult = await this.createConnectConfig(request.source.connection); + const targetConfigResult = await this.createConnectConfig(request.target.connection); + if (!sourceConfigResult.ok || !targetConfigResult.ok) { + return undefined; + } + let sourceClient; + let targetClient; + let sourceSftp; + let targetSftp; + try { + sourceClient = await this.connect(sourceConfigResult.config); + targetClient = await this.connect(targetConfigResult.config); + sourceSftp = await this.openSftp(sourceClient); + targetSftp = await this.openSftp(targetClient); + this.throwIfTransferCanceled(request.progressId); + const sourceType = request.sourceType ?? getFileTypeFromStats(await this.remoteStat(sourceSftp, request.source.path)); + if (sourceType === 'directory') { + return undefined; + } + const sourceStat = await this.remoteStat(sourceSftp, request.source.path); + const bytes = sourceStat.size; + const totalBytes = bytes * 2; + this.fireTransferProgress(request, 0, totalBytes, 'prepare', 'Preparing SFTP parallel relay.', true, { strategy: 'sftp-relay' }); + await this.ensureRemoteDirectory(targetSftp, getRemoteParentPath(request.target.path)); + const unregisterCancel = this.registerTransferCancelHandler(request.progressId, () => { + sourceSftp?.end(); + targetSftp?.end(); + sourceClient?.end(); + targetClient?.end(); + }); + let streamedBytes = 0; + try { + await this.relayRemoteFileInParallel(sourceSftp, targetSftp, request.source.path, request.target.path, bytes, delta => { + streamedBytes += delta; + this.fireTransferProgress(request, Math.min(streamedBytes * 2, totalBytes), totalBytes, 'relay-upload', 'Parallel SFTP relay in progress.', false, { strategy: 'sftp-relay' }); + }, request.progressId); + } + finally { + unregisterCancel(); + } + this.throwIfTransferCanceled(request.progressId); + const targetStat = await this.remoteStat(targetSftp, request.target.path).catch(() => undefined); + if (!targetStat || targetStat.size !== bytes) { + await this.unlinkRemote(targetSftp, request.target.path).catch(() => undefined); + throw new Error(`SFTP relay size mismatch: expected ${bytes}, got ${targetStat?.size ?? 0}.`); + } + return this.createTransferResult(true, 'sftp-relay', bytes, 'SFTP parallel relay completed.', startedAt, request.progressId); + } + catch (error) { + if (error instanceof TurboFileTransferCanceledError || this.isTransferCanceled(request.progressId)) { + throw error; + } + return undefined; + } + finally { + sourceSftp?.end(); + targetSftp?.end(); + sourceClient?.end(); + targetClient?.end(); + } + } + async tryScpRelayTransfer(request, startedAt, forceProtocol = false) { + if (request.source.kind !== 'ssh' || request.target.kind !== 'ssh' || !request.source.connection || !request.target.connection) { + return undefined; + } + const transferMode = getSshToSshTransferProtocol(request.source); + if (!forceProtocol && transferMode !== 'scp' && transferMode !== 'auto-fast') { + return undefined; + } + const sourceType = request.sourceType ?? await this.withDirectorySftp(request.source, async (sftp) => getFileTypeFromStats(await this.remoteStat(sftp, request.source.path))).catch(() => 'unknown'); + if (sourceType !== 'file' && sourceType !== 'directory') { + throw new Error(`Unsupported SCP relay source type: ${sourceType}.`); + } + const bytes = Math.max(0, request.expectedBytes ?? 0) || await this.getRemotePathSizeByCommand(request.source).catch(() => 0); + if (bytes <= 0) { + return undefined; + } + const relayRoot = join(tmpdir(), `turbofile-scp-relay-${randomUUID()}`); + const relayPath = join(relayRoot, getTransferName(request.source.path) || 'payload'); + const relayTotalBytes = bytes * 2; + try { + await fsp.mkdir(relayRoot, { recursive: true }); + this.fireTransferProgress(request, 0, relayTotalBytes, 'prepare', 'Preparing SCP relay.', true, { strategy: 'scp-relay' }); + let downloadedBytes = 0; + await this.runLocalScpDownload({ + ...request, + target: { kind: 'local', path: relayPath }, + expectedBytes: bytes + }, bytes, transferredBytes => { + downloadedBytes = Math.max(downloadedBytes, transferredBytes); + this.fireTransferProgress(request, Math.min(downloadedBytes, relayTotalBytes), relayTotalBytes, 'relay-download', 'Downloading to relay cache over SCP.', false, { strategy: 'scp-relay' }); + }); + this.throwIfTransferCanceled(request.progressId); + this.fireTransferProgress(request, bytes, relayTotalBytes, 'relay-download', 'SCP relay download completed.', true, { strategy: 'scp-relay' }); + let uploadedBytes = 0; + await this.runLocalScpUpload({ + ...request, + source: { kind: 'local', path: relayPath }, + expectedBytes: bytes + }, bytes, transferredBytes => { + uploadedBytes = Math.max(uploadedBytes, transferredBytes); + this.fireTransferProgress(request, Math.min(bytes + uploadedBytes, relayTotalBytes), relayTotalBytes, 'relay-upload', 'Uploading from relay cache over SCP.', false, { strategy: 'scp-relay' }); + }); + this.throwIfTransferCanceled(request.progressId); + const targetSize = await this.getRemotePathSizeByCommand(request.target).catch(() => 0); + if (targetSize !== bytes) { + throw new Error(`SCP relay size mismatch: expected ${bytes}, got ${targetSize}.`); + } + this.fireTransferProgress(request, relayTotalBytes, relayTotalBytes, 'relay-upload', 'SCP relay completed.', true, { strategy: 'scp-relay' }); + return this.createTransferResult(true, 'scp-relay', bytes, 'SCP relay completed.', startedAt, request.progressId); + } + finally { + await fsp.rm(relayRoot, { recursive: true, force: true }).catch(() => undefined); + } + } + async runSftpRelayTransfer(request, startedAt, forceProtocol = false) { + if (request.source.kind !== 'ssh' || request.target.kind !== 'ssh' || !request.source.connection || !request.target.connection) { + return undefined; + } + if (!forceProtocol && getSshToSshTransferProtocol(request.source) !== 'sftp') { + return undefined; + } + const relayRoot = join(tmpdir(), `turbofile-${randomUUID()}`); + const relayPath = join(relayRoot, getTransferName(request.source.path) || 'payload'); + try { + this.throwIfTransferCanceled(request.progressId); + await fsp.mkdir(relayRoot, { recursive: true }); + let bytes = 0; + const sourceType = request.sourceType ?? await this.withSftp(request.source, async (sftp) => getFileTypeFromStats(await this.remoteStat(sftp, request.source.path)), request.progressId).catch(() => 'unknown'); + if (sourceType === 'directory') { + await this.rejectDirectoryTargetPathConflict(request.target); + } + bytes = await this.getRemotePathSizeByCommand(request.source).catch(() => 0); + await this.withSftp(request.source, async (sftp) => { + this.throwIfTransferCanceled(request.progressId); + const relayTotalBytes = bytes * 2; + this.fireTransferProgress(request, 0, relayTotalBytes, 'prepare', 'Preparing SFTP relay.', true, { strategy: 'sftp-relay' }); + let downloadedBytes = 0; + const onDownloadDelta = (delta) => { + this.throwIfTransferCanceled(request.progressId); + downloadedBytes += delta; + this.fireTransferProgress(request, downloadedBytes, relayTotalBytes, 'relay-download', 'Downloading to relay cache.', false, { strategy: 'sftp-relay' }); + }; + if (sourceType === 'directory') { + await this.downloadDirectory(sftp, request.source.path, relayPath, onDownloadDelta, request.progressId); + } + else { + await this.fastGetWithFallback(sftp, request.source.path, relayPath, bytes, onDownloadDelta, request.progressId); + } + }, request.progressId); + await this.withSftp(request.target, async (sftp) => { + this.throwIfTransferCanceled(request.progressId); + const relayStat = await fsp.stat(relayPath); + const relayTotalBytes = bytes * 2; + let uploadedBytes = 0; + const onUploadDelta = (delta) => { + this.throwIfTransferCanceled(request.progressId); + uploadedBytes += delta; + this.fireTransferProgress(request, bytes + uploadedBytes, relayTotalBytes, 'relay-upload', 'Uploading from relay cache.', false, { strategy: 'sftp-relay' }); + }; + if (relayStat.isDirectory()) { + await this.uploadDirectory(sftp, relayPath, request.target.path, onUploadDelta, request.progressId); + } + else { + await this.ensureRemoteDirectory(sftp, getRemoteParentPath(request.target.path)); + await this.fastPutWithFallback(sftp, relayPath, request.target.path, bytes, onUploadDelta, request.progressId); + } + }, request.progressId); + this.throwIfTransferCanceled(request.progressId); + this.fireTransferProgress(request, bytes * 2, bytes * 2, 'complete', 'SFTP relay completed.', true, { strategy: 'sftp-relay' }); + this.clearRemoteDirectoryListingCache(request.target); + return this.createTransferResult(true, 'sftp-relay', bytes, 'SFTP relay completed.', startedAt, request.progressId); + } + finally { + await fsp.rm(relayRoot, { recursive: true, force: true }).catch(() => undefined); + } + } + async createConnectConfig(options) { + const nativeRequire = await this.getNativeRequire(); + nativeRequire('ssh2'); + if (!options.host || !options.username) { + return { ok: false, message: 'Host and username are required.' }; + } + const config = { + host: options.host, + port: options.port || 22, + username: options.username, + readyTimeout: options.timeout ?? 10000, + tryKeyboard: false, + // Keep idle connections alive so long-running terminal tasks (and slow transfers) are not + // dropped by NAT/firewall/server idle timeouts. ~15s pings, tolerate ~3min of unanswered + // pings before treating the link as dead. + keepaliveInterval: 15000, + keepaliveCountMax: 12 + }; + if (options.password !== undefined) { + config.password = options.password; + } + let privateKey; + try { + privateKey = options.privateKey ?? await this.readPrivateKey(options.privateKeyPath); + } + catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { ok: false, message }; + } + if (privateKey) { + config.privateKey = privateKey; + } + if (options.passphrase) { + config.passphrase = options.passphrase; + } + return { ok: true, config }; + } + async readPrivateKey(privateKeyPath) { + if (!privateKeyPath) { + return undefined; + } + return fsp.readFile(privateKeyPath, 'utf8'); + } + async doTestConnection(config) { + try { + const client = await this.connect(config); + client.end(); + return { ok: true, message: 'SSH connection succeeded.' }; + } + catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { ok: false, message }; + } + } + async connect(config) { + const nativeRequire = await this.getNativeRequire(); + const ssh2Module = nativeRequire('ssh2'); + const client = new ssh2Module.Client(); + return new Promise((resolve, reject) => { + let settled = false; + const settle = (callback) => { + if (settled) { + return; + } + settled = true; + callback(); + }; + client + .on('ready', () => settle(() => resolve(client))) + .on('error', error => settle(() => reject(error))) + .on('close', () => { + if (!settled) { + settle(() => reject(new Error('SSH connection closed before authentication completed.'))); + } + }); + try { + client.connect(config); + } + catch (error) { + settle(() => reject(error)); + } + }); + } + connectDirectSsh(config) { + const readyTimeout = typeof config.readyTimeout === 'number' + ? Math.min(config.readyTimeout, DIRECT_SSH_CONNECT_TIMEOUT_MS) + : DIRECT_SSH_CONNECT_TIMEOUT_MS; + return this.connect({ ...config, readyTimeout }); + } + async getDirectorySftpSession(options, config) { + const key = this.getDirectorySftpSessionKey(options); + while (true) { + const existing = this.directorySftpSessions.get(key); + if (existing) { + let session; + try { + session = await existing; + } + catch (error) { + if (this.directorySftpSessions.get(key) !== existing) { + continue; + } + throw error; + } + if (this.directorySftpSessions.get(key) !== existing || !this.directorySftpSessionGenerations.isCurrent(key, session.generation)) { + continue; + } + if (!session.closed) { + this.touchDirectorySftpSession(key, session.generation); + return session; + } + if (this.directorySftpSessionGenerations.clearIfCurrent(key, session.generation)) { + this.directorySftpSessions.delete(key); + this.clearDirectorySftpIdleTimer(key); + } + continue; + } + const generation = this.directorySftpSessionGenerations.next(key); + let sessionPromise; + sessionPromise = this.createDirectorySftpSession(key, generation, config).catch(error => { + if (this.directorySftpSessions.get(key) === sessionPromise && this.directorySftpSessionGenerations.clearIfCurrent(key, generation)) { + this.directorySftpSessions.delete(key); + this.clearDirectorySftpIdleTimer(key); + } + throw error; + }); + this.directorySftpSessions.set(key, sessionPromise); + const session = await sessionPromise; + if (session.closed || this.directorySftpSessions.get(key) !== sessionPromise || !this.directorySftpSessionGenerations.isCurrent(key, generation)) { + continue; + } + this.touchDirectorySftpSession(key, session.generation); + return session; + } + } + async getReusableDirectorySftpSession(options) { + const key = this.getDirectorySftpSessionKey(options); + while (true) { + const existing = this.directorySftpSessions.get(key); + if (!existing) { + return undefined; + } + try { + const session = await existing; + if (this.directorySftpSessions.get(key) !== existing || !this.directorySftpSessionGenerations.isCurrent(key, session.generation)) { + continue; + } + if (session.closed) { + this.directorySftpSessionGenerations.clearIfCurrent(key, session.generation); + this.directorySftpSessions.delete(key); + this.clearDirectorySftpIdleTimer(key); + return undefined; + } + // Keep the shared SSH client alive while the interactive terminal owns a shell channel. + this.clearDirectorySftpIdleTimer(key); + return session; + } + catch { + if (this.directorySftpSessions.get(key) !== existing) { + continue; + } + return undefined; + } + } + } + async createDirectorySftpSession(key, generation, config) { + const client = await this.connect(config); + let sftp; + try { + sftp = await this.openSftp(client); + } + catch (error) { + client.end(); + throw error; + } + const session = { key, generation, client, sftp, closed: false }; + const markClosed = () => { + if (session.closed) { + return; + } + session.closed = true; + if (this.directorySftpSessionGenerations.clearIfCurrent(key, generation)) { + this.directorySftpSessions.delete(key); + this.clearDirectorySftpIdleTimer(key); + } + }; + client.on('close', markClosed); + client.on('error', markClosed); + return session; + } + touchDirectorySftpSession(key, generation) { + if (!this.directorySftpSessionGenerations.isCurrent(key, generation)) { + return; + } + this.clearDirectorySftpIdleTimer(key); + const timer = setTimeout(() => this.disposeDirectorySftpSession(key, generation), REMOTE_DIRECTORY_SFTP_IDLE_MS); + this.directorySftpIdleTimers.set(key, timer); + } + disposeDirectorySftpSession(key, generation) { + if (!this.directorySftpSessionGenerations.clearIfCurrent(key, generation)) { + return; + } + const sessionPromise = this.directorySftpSessions.get(key); + this.directorySftpSessions.delete(key); + this.clearDirectorySftpIdleTimer(key); + if (!sessionPromise) { + return; + } + void sessionPromise.then(session => { + if (session.closed) { + return; + } + session.closed = true; + try { + session.sftp.end(); + } + catch { + // Ignore cleanup errors from already closed SFTP sessions. + } + try { + session.client.end(); + } + catch { + // Ignore cleanup errors from already closed SSH clients. + } + }, () => undefined); + } + clearDirectorySftpIdleTimer(key) { + const timer = this.directorySftpIdleTimers.get(key); + if (timer) { + clearTimeout(timer); + this.directorySftpIdleTimers.delete(key); + } + } + getDirectorySftpSessionKey(options) { + return JSON.stringify({ + host: options.host, + port: options.port || 22, + username: options.username, + password: options.password ?? '', + privateKeyPath: options.privateKeyPath ?? '', + privateKey: options.privateKey ?? '', + passphrase: options.passphrase ?? '' + }); + } + getRemoteDirectoryListingCacheKey(options, path) { + return `${this.getDirectorySftpSessionKey(options)}:${path.replace(/\\/g, '/')}`; + } + getRemoteDirectoryListingCache(cacheKey) { + const cached = this.remoteDirectoryListingCache.get(cacheKey); + if (!cached) { + return undefined; + } + if (Date.now() >= cached.expiresAt) { + this.remoteDirectoryListingCache.delete(cacheKey); + return undefined; + } + return cached.listing; + } + setRemoteDirectoryListingCache(cacheKey, listing) { + this.remoteDirectoryListingCache.set(cacheKey, { + expiresAt: Date.now() + REMOTE_DIRECTORY_LISTING_CACHE_TTL_MS, + listing + }); + if (this.remoteDirectoryListingCache.size <= REMOTE_DIRECTORY_LISTING_CACHE_MAX_ENTRIES) { + return; + } + const overflow = this.remoteDirectoryListingCache.size - REMOTE_DIRECTORY_LISTING_CACHE_MAX_ENTRIES; + for (const key of [...this.remoteDirectoryListingCache.keys()].slice(0, overflow)) { + this.remoteDirectoryListingCache.delete(key); + } + } + clearRemoteDirectoryListingCache(endpoint, extraPath) { + if (endpoint.kind !== 'ssh' || !endpoint.connection) { + return; + } + const paths = new Set(); + const path = extraPath ?? endpoint.path; + paths.add(path); + paths.add(getRemoteParentPath(path)); + for (const candidate of paths) { + const cacheKey = this.getRemoteDirectoryListingCacheKey(endpoint.connection, candidate); + this.remoteDirectoryListingGenerations.next(cacheKey); + this.remoteDirectoryListingCache.delete(cacheKey); + } + } + openSftp(client) { + return new Promise((resolve, reject) => { + client.sftp((error, sftp) => { + if (error) { + reject(error); + return; + } + resolve(sftp); + }); + }); + } + forwardIn(client, bindAddr, bindPort) { + return new Promise((resolve, reject) => { + client.forwardIn(bindAddr, bindPort, (error, port) => { + if (error) { + reject(error); + return; + } + resolve(port); + }); + }); + } + unforwardIn(client, bindAddr, bindPort) { + return new Promise((resolve, reject) => { + client.unforwardIn(bindAddr, bindPort, error => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); + } + openRemoteFile(sftp, remotePath, flags) { + return new Promise((resolve, reject) => { + sftp.open(remotePath, flags, (error, handle) => { + if (error) { + reject(error); + return; + } + resolve(handle); + }); + }); + } + readRemoteFileChunk(sftp, handle, buffer, length, position) { + return new Promise((resolve, reject) => { + sftp.read(handle, buffer, 0, length, position, (error, bytesRead) => { + if (error) { + reject(error); + return; + } + resolve(bytesRead); + }); + }); + } + writeRemoteFileChunk(sftp, handle, buffer, length, position) { + return new Promise((resolve, reject) => { + sftp.write(handle, buffer, 0, length, position, error => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); + } + closeRemoteFile(sftp, handle) { + return new Promise(resolve => { + sftp.close(handle, () => resolve()); + }); + } + async relayRemoteFileInParallel(sourceSftp, targetSftp, sourcePath, targetPath, bytes, onDelta, progressId) { + if (bytes <= 0) { + const targetHandle = await this.openRemoteFile(targetSftp, targetPath, 'w'); + await this.closeRemoteFile(targetSftp, targetHandle); + return; + } + const sourceHandle = await this.openRemoteFile(sourceSftp, sourcePath, 'r'); + let targetHandle; + try { + targetHandle = await this.openRemoteFile(targetSftp, targetPath, 'w'); + const chunks = Math.ceil(bytes / SFTP_RELAY_CHUNK_SIZE); + await mapLimit(Array.from({ length: chunks }, (_, index) => index), SFTP_RELAY_CONCURRENCY, async (index) => { + this.throwIfTransferCanceled(progressId); + const position = index * SFTP_RELAY_CHUNK_SIZE; + const length = Math.min(SFTP_RELAY_CHUNK_SIZE, bytes - position); + const buffer = Buffer.allocUnsafe(length); + let bytesReadTotal = 0; + while (bytesReadTotal < length) { + const bytesRead = await this.readRemoteFileChunk(sourceSftp, sourceHandle, buffer.subarray(bytesReadTotal), length - bytesReadTotal, position + bytesReadTotal); + this.throwIfTransferCanceled(progressId); + if (bytesRead <= 0) { + throw new Error(`Unexpected EOF while relaying ${sourcePath} at ${position + bytesReadTotal}.`); + } + bytesReadTotal += bytesRead; + } + await this.writeRemoteFileChunk(targetSftp, targetHandle, buffer, bytesReadTotal, position); + onDelta?.(bytesReadTotal); + }); + } + finally { + if (targetHandle) { + await this.closeRemoteFile(targetSftp, targetHandle).catch(() => undefined); + } + await this.closeRemoteFile(sourceSftp, sourceHandle).catch(() => undefined); + } + } + openShell(client, cols, rows) { + return new Promise((resolve, reject) => { + client.shell({ + term: 'xterm-256color', + cols: Math.max(1, cols || 80), + rows: Math.max(1, rows || 24), + width: 0, + height: 0, + modes: { + ECHO: 0 + } + }, (error, stream) => { + if (error) { + reject(error); + return; + } + resolve(stream); + }); + }); + } + cleanupSshTerminalSession(sessionId, exitCode) { + const session = this.terminalSessions.get(sessionId); + if (!session || session.closed) { + return; + } + session.closed = true; + this.terminalSessions.delete(sessionId); + try { + session.stream.end(); + } + catch { + // Ignore cleanup errors from already closed SSH channels. + } + try { + session.client.end(); + } + catch { + // Ignore cleanup errors from already closed SSH clients. + } + session.exitEmitter.fire(exitCode); + session.dataEmitter.dispose(); + session.exitEmitter.dispose(); + } + async withSftp(endpoint, task, progressId) { + if (!endpoint.connection) { + throw new Error('SSH endpoint is missing connection options.'); + } + this.throwIfTransferCanceled(progressId); + const configResult = await this.createConnectConfig(endpoint.connection); + if (!configResult.ok) { + throw new Error(configResult.message); + } + const client = await this.connect(configResult.config); + const unregisterClientCancel = this.registerTransferCancelHandler(progressId, () => client.end()); + try { + this.throwIfTransferCanceled(progressId); + const sftp = await this.openSftp(client); + const unregisterSftpCancel = this.registerTransferCancelHandler(progressId, () => sftp.end()); + try { + this.throwIfTransferCanceled(progressId); + return await task(sftp); + } + finally { + unregisterSftpCancel(); + sftp.end(); + } + } + finally { + unregisterClientCancel(); + client.end(); + } + } + async withDirectorySftp(endpoint, task) { + if (!endpoint.connection) { + throw new Error('SSH endpoint is missing connection options.'); + } + const configResult = await this.createConnectConfig(endpoint.connection); + if (!configResult.ok) { + throw new Error(configResult.message); + } + const session = await this.getDirectorySftpSession(endpoint.connection, configResult.config); + try { + const result = await task(session.sftp); + this.touchDirectorySftpSession(session.key, session.generation); + return result; + } + catch (error) { + this.disposeDirectorySftpSession(session.key, session.generation); + throw error; + } + } + async withDirectorySshClient(endpoint, task) { + if (!endpoint.connection) { + throw new Error('SSH endpoint is missing connection options.'); + } + const configResult = await this.createConnectConfig(endpoint.connection); + if (!configResult.ok) { + throw new Error(configResult.message); + } + const session = await this.getDirectorySftpSession(endpoint.connection, configResult.config); + try { + const result = await task(session.client); + this.touchDirectorySftpSession(session.key, session.generation); + return result; + } + catch (error) { + this.disposeDirectorySftpSession(session.key, session.generation); + throw error; + } + } + async withSshClient(endpoint, task) { + if (!endpoint.connection) { + throw new Error('SSH endpoint is missing connection options.'); + } + const configResult = await this.createConnectConfig(endpoint.connection); + if (!configResult.ok) { + throw new Error(configResult.message); + } + const client = await this.connect(configResult.config); + try { + return await task(client); + } + finally { + client.end(); + } + } + async copyLocalPath(sourcePath, targetPath, onDelta, progressId) { + this.throwIfTransferCanceled(progressId); + const stat = await fsp.stat(sourcePath); + if (stat.isDirectory()) { + if (platform() === 'win32' || looksLikeWindowsPath(sourcePath) || looksLikeWindowsPath(targetPath)) { + await this.copyLocalDirectoryNativeWithProgress(sourcePath, targetPath, onDelta, progressId); + return; + } + await ensureLocalDirectory(targetPath); + const children = await fsp.readdir(sourcePath, { withFileTypes: true }); + await mapLimit(children, LOCAL_COPY_DIRECTORY_CONCURRENCY, async (child) => { + this.throwIfTransferCanceled(progressId); + await this.copyLocalPath(join(sourcePath, child.name), join(targetPath, child.name), onDelta, progressId); + }); + return; + } + await ensureLocalParentDirectory(targetPath); + await this.copyLocalFileWithProgress(sourcePath, targetPath, onDelta, progressId); + } + async copyLocalDirectoryNativeWithProgress(sourcePath, targetPath, onDelta, progressId) { + await ensureLocalDirectory(targetPath); + let previousBytes = await getLocalPathSize(targetPath).catch(() => 0); + let completed = false; + const copy = runLocalRobocopyCommand([ + sourcePath, + targetPath, + '/E', + '/MT:32', + '/R:0', + '/W:0', + '/NFL', + '/NDL', + '/NJH', + '/NJS', + '/NP', + '/NC', + '/NS' + ]).finally(() => { + completed = true; + }); + try { + while (!completed) { + await delay(500); + this.throwIfTransferCanceled(progressId); + const currentBytes = await getLocalPathSize(targetPath).catch(() => previousBytes); + if (currentBytes > previousBytes) { + onDelta?.(currentBytes - previousBytes); + previousBytes = currentBytes; + } + } + } + finally { + await copy; + } + const finalBytes = await getLocalPathSize(targetPath).catch(() => previousBytes); + if (finalBytes > previousBytes) { + onDelta?.(finalBytes - previousBytes); + } + this.throwIfTransferCanceled(progressId); + } + async moveLocalPathNative(sourcePath, targetPath) { + if (platform() === 'win32' || looksLikeWindowsPath(sourcePath) || looksLikeWindowsPath(targetPath)) { + await ensureLocalParentDirectory(targetPath); + await runLocalPowerShellScript(buildLocalWindowsMoveScript(sourcePath, targetPath)); + return; + } + await ensureLocalParentDirectory(targetPath); + await runLocalCommand('mv', ['-f', sourcePath, targetPath]); + } + async moveRemotePathNative(endpoint, targetPath) { + const command = looksLikeWindowsPath(endpoint.path) || looksLikeWindowsPath(targetPath) + ? buildWindowsMoveCommand(endpoint.path, targetPath) + : `mkdir -p ${quotePosixShellArg(getRemoteParentPath(targetPath))} && mv -f ${quotePosixShellArg(endpoint.path)} ${quotePosixShellArg(targetPath)}`; + await this.withSshClient(endpoint, client => this.execRemote(client, command)); + } + async execRemoteCopyWithProgress(request, command, bytes, sourceType) { + let completed = false; + const copy = this.withDirectorySshClient(request.source, client => this.execRemote(client, command)) + .finally(() => { + completed = true; + }); + if (bytes <= 0) { + await copy; + return; + } + const startedAt = Date.now(); + const estimateDirectoryProgress = sourceType === 'directory'; + let lastCopiedBytes = 0; + try { + while (!completed) { + await delay(500); + if (completed) { + break; + } + this.throwIfTransferCanceled(request.progressId); + if (estimateDirectoryProgress) { + const elapsedSeconds = Math.max(0, (Date.now() - startedAt) / 1000); + const estimatedBytes = Math.min(bytes * 0.95, elapsedSeconds * REMOTE_NATIVE_DIRECTORY_COPY_PROGRESS_BYTES_PER_SECOND); + const copiedBytes = Math.max(0, Math.floor(estimatedBytes)); + if (copiedBytes > lastCopiedBytes) { + lastCopiedBytes = copiedBytes; + this.fireTransferProgress(request, copiedBytes, bytes, 'transfer', 'Remote copy in progress.'); + } + continue; + } + const copiedBytes = await this.getRemotePathSizeByCommand(request.target).catch(() => 0); + if (copiedBytes > 0 && copiedBytes !== lastCopiedBytes) { + lastCopiedBytes = copiedBytes; + this.fireTransferProgress(request, Math.min(copiedBytes, bytes), bytes, 'transfer', 'Remote copy in progress.'); + } + } + } + finally { + await copy; + } + } + async copyLocalFileWithProgress(sourcePath, targetPath, onDelta, progressId) { + const source = createReadStream(sourcePath, { highWaterMark: LOCAL_COPY_FILE_HIGH_WATER_MARK }); + const target = createWriteStream(targetPath, { highWaterMark: LOCAL_COPY_FILE_HIGH_WATER_MARK }); + const unregisterCancel = this.registerTransferCancelHandler(progressId, () => { + const error = new TurboFileTransferCanceledError(); + source.destroy(error); + target.destroy(error); + }); + source.on('data', chunk => { + try { + this.throwIfTransferCanceled(progressId); + } + catch (error) { + source.destroy(error instanceof Error ? error : new TurboFileTransferCanceledError()); + target.destroy(error instanceof Error ? error : new TurboFileTransferCanceledError()); + return; + } + if (typeof chunk === 'string') { + onDelta?.(Buffer.byteLength(chunk)); + } + else { + onDelta?.(chunk.byteLength); + } + }); + try { + this.throwIfTransferCanceled(progressId); + await pipeline(source, target); + this.throwIfTransferCanceled(progressId); + } + finally { + unregisterCancel(); + } + } + createSftpTransferOptions(onDelta, progressId, bytes = 0, adaptive = true) { + const useLargeOptions = adaptive && bytes >= SFTP_LARGE_FILE_THRESHOLD; + const options = { + concurrency: adaptive ? (useLargeOptions ? SFTP_LARGE_TRANSFER_CONCURRENCY : SFTP_TRANSFER_CONCURRENCY) : SFTP_COMPAT_TRANSFER_CONCURRENCY, + chunkSize: adaptive ? (useLargeOptions ? SFTP_LARGE_TRANSFER_CHUNK_SIZE : SFTP_TRANSFER_CHUNK_SIZE) : SFTP_COMPAT_TRANSFER_CHUNK_SIZE + }; + if (!onDelta) { + return options; + } + let lastTransferred = 0; + options.step = (totalTransferred, chunk) => { + this.throwIfTransferCanceled(progressId); + const nextTransferred = Number.isFinite(totalTransferred) ? totalTransferred : lastTransferred + Math.max(0, chunk || 0); + const delta = Math.max(0, nextTransferred - lastTransferred); + lastTransferred = nextTransferred; + if (delta > 0) { + onDelta(delta); + } + }; + return options; + } + createTransferResult(ok, strategy, bytes, message, startedAt, progressId) { + const durationMs = this.getTransferDurationMs(progressId, startedAt); + return { + ok, + strategy, + bytes, + message, + durationMs, + averageBytesPerSecond: Math.round(bytes / (durationMs / 1000)) + }; + } + markTransferDataStarted(progressId) { + if (!progressId || this.transferDataStartTimes.has(progressId)) { + return; + } + this.transferDataStartTimes.set(progressId, Date.now()); + } + clearTransferDataTiming(progressId) { + if (!progressId) { + return; + } + this.transferDataStartTimes.delete(progressId); + this.transferFirstPositiveProgressTimes.delete(progressId); + this.transferGrowthMonitoredProgressIds.delete(progressId); + this.rsyncTimingStates.delete(progressId); + } + getRsyncTimingState(request) { + const progressId = request.progressId; + if (!progressId) { + return undefined; + } + const existing = this.rsyncTimingStates.get(progressId); + if (existing) { + return existing; + } + const now = Date.now(); + const state = { + clientStartedAt: request.clientStartedAt, + serviceStartedAt: now, + lastMarkAt: request.clientStartedAt ?? now, + firstProgressEventSeen: false, + visibleTransferEventSeen: false, + firstPositiveBytesSeen: false + }; + this.rsyncTimingStates.set(progressId, state); + return state; + } + markRsyncTiming(request, state, stage, extra = {}) { + const progressId = request.progressId; + if (!progressId) { + return; + } + const now = Date.now(); + const payload = { + time: new Date(now).toISOString(), + progressId, + stage, + sinceClientMs: state.clientStartedAt !== undefined ? now - state.clientStartedAt : undefined, + sinceServiceMs: now - state.serviceStartedAt, + sincePreviousMs: now - state.lastMarkAt, + ...extra + }; + state.lastMarkAt = now; + const logPath = join(app.getPath('userData'), 'logs', 'turbofile-rsync-timing.log'); + void fsp.mkdir(dirname(logPath), { recursive: true }) + .then(() => fsp.appendFile(logPath, `${JSON.stringify(payload)}\n`, 'utf8')) + .catch(() => undefined); + } + createRsyncProgressReporter(request, totalBytes, phase, detail, extra = {}, onAcceptedProgress) { + // `knownTotalBytes` is the denominator the UI shows. rsync's --info=progress2 only emits a + // transferred count once data starts flowing, which on a large tree can be 10s+ after the + // process starts; publishing a concurrently-computed total here keeps the display showing the + // size (and a real percentage) from the first second instead of a blank "preparing" state. + let knownTotalBytes = Math.max(0, totalBytes); + let lastBytes = 0; + let rsyncProgressBuffer = ''; + let lastActivityAt; + let accepted = false; + let lastToCheck; + let lastTotalChecks; + let lastTransferIndex; + let lastSpeedBytesPerSecond; + const reportProgress = (transferredBytes, force = false, toCheck, totalChecks, transferIndex, _percent, speedBytesPerSecond) => { + if (!accepted) { + accepted = true; + onAcceptedProgress?.(); + } + const normalizedTransferred = Math.max(0, transferredBytes); + const hasMetricChange = (toCheck !== undefined && toCheck !== lastToCheck) + || (totalChecks !== undefined && totalChecks !== lastTotalChecks) + || (transferIndex !== undefined && transferIndex !== lastTransferIndex); + const hasSpeedChange = speedBytesPerSecond !== undefined && speedBytesPerSecond !== lastSpeedBytesPerSecond; + if (!force && normalizedTransferred <= lastBytes && !hasMetricChange && !hasSpeedChange) { + return; + } + lastBytes = normalizedTransferred; + if (toCheck !== undefined) { + lastToCheck = toCheck; + } + if (totalChecks !== undefined) { + lastTotalChecks = totalChecks; + } + if (transferIndex !== undefined) { + lastTransferIndex = transferIndex; + } + if (speedBytesPerSecond !== undefined) { + lastSpeedBytesPerSecond = speedBytesPerSecond; + } + lastActivityAt = Date.now(); + // Never let an estimated total round down below what rsync has already moved, otherwise the + // progress bar would jump backwards when the transferred count overtakes the estimate. + const emittedTotal = knownTotalBytes > 0 ? Math.max(knownTotalBytes, normalizedTransferred) : 0; + this.fireTransferProgress(request, normalizedTransferred, emittedTotal, phase, detail, force, { + ...extra, + ...(toCheck !== undefined ? { toCheck } : {}), + ...(totalChecks !== undefined ? { totalChecks } : {}), + ...(transferIndex !== undefined ? { transferIndex } : {}), + ...(speedBytesPerSecond !== undefined ? { speedBytesPerSecond } : {}) + }); + }; + return { + handleOutput: chunk => { + rsyncProgressBuffer = `${rsyncProgressBuffer}${chunk}`.slice(-2048); + const snapshot = this.parseRsyncProgressSnapshot(rsyncProgressBuffer); + if (!snapshot) { + if (!accepted && onAcceptedProgress) { + accepted = true; + onAcceptedProgress(); + } + return; + } + reportProgress(snapshot.bytes ?? lastBytes, false, snapshot.toCheck, snapshot.totalChecks, snapshot.transferIndex, snapshot.percent, snapshot.speedBytesPerSecond); + }, + reportProgress, + getLastBytes: () => lastBytes, + getActivityTime: () => lastActivityAt, + setTotalBytes: total => { + const normalizedTotal = Math.max(0, total); + if (normalizedTotal <= knownTotalBytes) { + return; + } + knownTotalBytes = normalizedTotal; + // Force one event so the size appears immediately, even before rsync moves a byte. + reportProgress(lastBytes, true); + } + }; + } + createParallelRsyncProgressReporter(request, totalBytes, phase, detail, extra = {}, onAcceptedProgress) { + let knownTotalBytes = Math.max(0, totalBytes); + const transferredByIndex = new Map(); + const speedByIndex = new Map(); + let outputBuffer = ''; + let lastBytes = 0; + let lastSpeedBytesPerSecond = 0; + let lastActivityAt; + let accepted = false; + const report = (force = false) => { + if (!accepted) { + accepted = true; + onAcceptedProgress?.(); + } + const transferredBytes = Array.from(transferredByIndex.values()).reduce((sum, value) => sum + value, 0); + const speedBytesPerSecond = Array.from(speedByIndex.values()).reduce((sum, value) => sum + value, 0); + const previousSpeedBytesPerSecond = lastSpeedBytesPerSecond; + if (!force && transferredBytes <= lastBytes && speedBytesPerSecond === lastSpeedBytesPerSecond) { + return; + } + lastBytes = transferredBytes; + lastSpeedBytesPerSecond = speedBytesPerSecond; + lastActivityAt = Date.now(); + const emittedTotal = knownTotalBytes > 0 ? Math.max(knownTotalBytes, transferredBytes) : 0; + this.fireTransferProgress(request, transferredBytes, emittedTotal, phase, detail, force, { + ...extra, + ...(speedBytesPerSecond > 0 || previousSpeedBytesPerSecond > 0 ? { speedBytesPerSecond } : {}) + }); + }; + const parseLine = (line) => { + const tagged = line.match(/^__TURBOFILE_RSYNC_(\d+)__\s*(.*)$/); + const index = tagged ? Number(tagged[1]) : 0; + const content = tagged ? tagged[2] : line; + if (!Number.isFinite(index)) { + return; + } + const snapshot = this.parseRsyncProgressSnapshot(`\n${content}`); + if (!snapshot) { + if (!accepted) { + accepted = true; + lastActivityAt = Date.now(); + onAcceptedProgress?.(); + } + return; + } + if (snapshot.bytes !== undefined) { + transferredByIndex.set(index, Math.max(transferredByIndex.get(index) ?? 0, snapshot.bytes)); + } + if (snapshot.speedBytesPerSecond !== undefined) { + if (snapshot.percent !== undefined && snapshot.percent >= 100) { + speedByIndex.delete(index); + } + else { + speedByIndex.set(index, snapshot.speedBytesPerSecond); + } + } + report(false); + }; + return { + handleOutput: chunk => { + outputBuffer = `${outputBuffer}${chunk}`.slice(-8192); + const lines = outputBuffer.split(/\r?\n/); + outputBuffer = lines.pop() ?? ''; + for (const line of lines) { + parseLine(line); + } + }, + reportProgress: transferredBytes => { + transferredByIndex.set(0, Math.max(transferredByIndex.get(0) ?? 0, transferredBytes)); + report(true); + }, + getLastBytes: () => lastBytes, + getActivityTime: () => lastActivityAt, + setTotalBytes: total => { + const normalizedTotal = Math.max(0, total); + if (normalizedTotal <= knownTotalBytes) { + return; + } + knownTotalBytes = normalizedTotal; + report(true); + } + }; + } + beginTransferGrowthTiming(progressId, probeSize, baselineSize = 0, expectedBytes = 0, onProgress, pollIntervalMs = TRANSFER_PROGRESS_EVENT_INTERVAL_MS, initialDelayMs = 0) { + if (!progressId) { + return () => undefined; + } + let stopped = false; + this.transferGrowthMonitoredProgressIds.add(progressId); + const poll = async () => { + if (initialDelayMs > 0) { + await delay(initialDelayMs); + } + if (stopped) { + return; + } + while (!stopped) { + try { + const size = await probeSize(); + const transferredBytes = this.normalizeGrowthProgressBytes(size, baselineSize, expectedBytes); + if (transferredBytes > 0) { + this.markTransferDataStarted(progressId); + onProgress?.(transferredBytes); + if (!onProgress) { + break; + } + } + } + catch { + // Growth timing is best-effort; normal progress remains the fallback. + } + await delay(onProgress ? pollIntervalMs : 100); + } + }; + void poll(); + return () => { + stopped = true; + this.transferGrowthMonitoredProgressIds.delete(progressId); + }; + } + normalizeGrowthProgressBytes(currentSize, baselineSize, expectedBytes) { + const normalizedSize = Math.max(0, currentSize); + const normalizedBaseline = Math.max(0, baselineSize); + const transferredBytes = Math.max(0, normalizedSize - normalizedBaseline); + const normalizedExpected = Math.max(0, expectedBytes); + return normalizedExpected > 0 ? Math.min(transferredBytes, normalizedExpected) : transferredBytes; + } + getTransferDurationMs(progressId, fallbackStartedAt) { + const startedAt = progressId + ? this.transferDataStartTimes.get(progressId) ?? this.transferFirstPositiveProgressTimes.get(progressId) ?? fallbackStartedAt + : fallbackStartedAt; + return Math.max(1, Date.now() - startedAt); + } + beginRemoteFileGrowthTiming(progressId, connection, path, baselineSize = 0, expectedBytes = 0, onProgress, pollIntervalMs = TRANSFER_PROGRESS_EVENT_INTERVAL_MS, initialDelayMs = 0) { + let monitorClient; + let monitorSftp; + let reusableDirectorySession; + let stopped = false; + const stopGrowthTiming = this.beginTransferGrowthTiming(progressId, async () => { + if (stopped) { + return 0; + } + if (!monitorSftp) { + const candidateDirectorySession = await this.getReusableDirectorySftpSession(connection); + if (candidateDirectorySession && !candidateDirectorySession.closed) { + reusableDirectorySession = candidateDirectorySession; + monitorSftp = candidateDirectorySession.sftp; + } + else { + const configResult = await this.createConnectConfig(connection); + if (!configResult.ok) { + return 0; + } + monitorClient = await this.connect(configResult.config); + monitorSftp = await this.openSftp(monitorClient); + } + } + const stat = monitorSftp ? await this.remoteStat(monitorSftp, path).catch(() => undefined) : undefined; + return stat?.size ?? 0; + }, baselineSize, expectedBytes, onProgress, pollIntervalMs, initialDelayMs); + return () => { + stopped = true; + stopGrowthTiming(); + if (reusableDirectorySession) { + this.touchDirectorySftpSession(reusableDirectorySession.key, reusableDirectorySession.generation); + } + else { + monitorSftp?.end(); + monitorClient?.end(); + } + monitorSftp = undefined; + monitorClient = undefined; + reusableDirectorySession = undefined; + }; + } + beginRemotePathGrowthTiming(progressId, endpoint, baselineSize = 0, expectedBytes = 0, onProgress, pollIntervalMs = TRANSFER_PROGRESS_EVENT_INTERVAL_MS, initialDelayMs = 0) { + return this.beginTransferGrowthTiming(progressId, () => this.getRemotePathSizeByCommand(endpoint), baselineSize, expectedBytes, onProgress, pollIntervalMs, initialDelayMs); + } + isTransferCanceled(progressId) { + return !!progressId && this.transferCancellationRequests.has(progressId); + } + throwIfTransferCanceled(progressId) { + if (this.isTransferCanceled(progressId)) { + throw new TurboFileTransferCanceledError(); + } + } + registerTransferCancelHandler(progressId, handler) { + if (!progressId) { + return () => undefined; + } + let handlers = this.transferCancelHandlers.get(progressId); + if (!handlers) { + handlers = new Set(); + this.transferCancelHandlers.set(progressId, handlers); + } + handlers.add(handler); + return () => { + const currentHandlers = this.transferCancelHandlers.get(progressId); + if (!currentHandlers) { + return; + } + currentHandlers.delete(handler); + if (!currentHandlers.size) { + this.transferCancelHandlers.delete(progressId); + } + }; + } + runCancelableLocalCommand(command, args, options = {}, progressId) { + this.throwIfTransferCanceled(progressId); + return new Promise((resolve, reject) => { + let settled = false; + let stderrTail = ''; + let unregisterCancel = () => undefined; + let inactivityTimer; + let lastActivityAt = Date.now(); + const finish = (callback) => { + if (settled) { + return; + } + settled = true; + if (inactivityTimer) { + clearInterval(inactivityTimer); + } + unregisterCancel(); + callback(); + }; + const child = spawn(command, args, { windowsHide: true, env: options.env, stdio: ['ignore', 'pipe', 'pipe'] }); + child.stdout?.on('data', data => { + lastActivityAt = Date.now(); + options.onStdout?.(data.toString()); + }); + child.stderr?.on('data', data => { + lastActivityAt = Date.now(); + const chunk = data.toString(); + stderrTail = `${stderrTail}${chunk}`.slice(-64 * 1024); + options.onStderr?.(chunk); + }); + if (options.inactivityTimeoutMs && options.inactivityTimeoutMs > 0) { + inactivityTimer = setInterval(() => { + if (settled || Date.now() - lastActivityAt <= options.inactivityTimeoutMs) { + return; + } + killLocalProcessTree(child); + finish(() => reject(new Error(options.inactivityMessage ?? `Local command timed out after ${options.inactivityTimeoutMs} ms without output.`))); + }, DIRECT_TRANSFER_STALL_CHECK_INTERVAL_MS); + } + child.on('error', error => { + finish(() => { + if (this.isTransferCanceled(progressId)) { + reject(new TurboFileTransferCanceledError()); + } + else { + reject(new Error(formatLocalCommandError(stderrTail, error.message))); + } + }); + }); + child.on('close', code => { + finish(() => { + if (code && code !== 0) { + if (this.isTransferCanceled(progressId)) { + reject(new TurboFileTransferCanceledError()); + return; + } + reject(new Error(formatLocalCommandError(stderrTail, `Local command failed with exit code ${code}.`))); + return; + } + if (this.isTransferCanceled(progressId)) { + reject(new TurboFileTransferCanceledError()); + return; + } + resolve(); + }); + }); + unregisterCancel = this.registerTransferCancelHandler(progressId, () => { + if (this.isTransferCanceled(progressId)) { + killLocalProcessTree(child); + return; + } + }); + child.once('spawn', () => { + if (!settled && this.isTransferCanceled(progressId)) { + killLocalProcessTree(child); + } + }); + }); + } + fireTransferProgress(request, transferredBytes, totalBytes, phase, detail, force = false, extra = {}) { + const progressId = request.progressId; + if (!progressId) { + return; + } + const now = Date.now(); + const previous = this.transferProgressSnapshots.get(progressId); + const normalizedTransferred = Math.max(0, transferredBytes); + const normalizedTotal = Math.max(0, totalBytes); + const isDataProgress = phase !== 'prepare' && phase !== 'complete'; + const strategy = typeof extra.strategy === 'string' ? extra.strategy : undefined; + const isRsyncProgress = strategy?.startsWith('rsync') ?? false; + const speedBytesPerSecond = typeof extra.speedBytesPerSecond === 'number' && Number.isFinite(extra.speedBytesPerSecond) + ? Math.max(0, extra.speedBytesPerSecond) + : 0; + const hasPositiveTransferProgress = normalizedTransferred > 0 || speedBytesPerSecond > 0; + const shouldStartDataTiming = isDataProgress && (!isRsyncProgress || hasPositiveTransferProgress); + const rsyncTimingState = isRsyncProgress ? this.getRsyncTimingState(request) : undefined; + if (rsyncTimingState && !rsyncTimingState.firstProgressEventSeen) { + rsyncTimingState.firstProgressEventSeen = true; + this.markRsyncTiming(request, rsyncTimingState, 'first-progress-event', { + strategy, + phase, + detail, + transferredBytes: normalizedTransferred, + totalBytes: normalizedTotal + }); + } + if (rsyncTimingState && isDataProgress && !rsyncTimingState.visibleTransferEventSeen) { + rsyncTimingState.visibleTransferEventSeen = true; + this.markRsyncTiming(request, rsyncTimingState, 'visible-transfer-event', { + strategy, + phase, + detail, + transferredBytes: normalizedTransferred, + totalBytes: normalizedTotal + }); + } + if (rsyncTimingState && hasPositiveTransferProgress && !rsyncTimingState.firstPositiveBytesSeen) { + rsyncTimingState.firstPositiveBytesSeen = true; + this.markRsyncTiming(request, rsyncTimingState, 'first-positive-bytes', { + strategy, + phase, + detail, + transferredBytes: normalizedTransferred, + totalBytes: normalizedTotal, + speedBytesPerSecond + }); + } + if (rsyncTimingState && phase === 'complete') { + this.markRsyncTiming(request, rsyncTimingState, 'complete', { + strategy, + phase, + detail, + transferredBytes: normalizedTransferred, + totalBytes: normalizedTotal, + speedBytesPerSecond + }); + this.rsyncTimingStates.delete(progressId); + } + const wasTransferStarted = this.transferDataStartTimes.has(progressId); + if (shouldStartDataTiming && !wasTransferStarted) { + this.transferDataStartTimes.set(progressId, now); + } + if (normalizedTransferred > 0 && !this.transferFirstPositiveProgressTimes.has(progressId)) { + this.transferFirstPositiveProgressTimes.set(progressId, now); + } + const isGrowthMonitored = this.transferGrowthMonitoredProgressIds.has(progressId); + const isFirstDataProgress = shouldStartDataTiming && hasPositiveTransferProgress && !isGrowthMonitored && !wasTransferStarted; + if (isFirstDataProgress) { + this.markTransferDataStarted(progressId); + } + // Throttle in-progress events to one per interval. The completion event (transferred reaches a + // known total) always goes through immediately. NOTE: when the total is unknown (0) every + // rsync stdout line would otherwise bypass the throttle (transferred is never < 0), flooding + // the renderer with thousands of IPC events and starving the main-process event loop, which + // slows the transfer itself — so an unknown total must still be throttled. + const isCompletionEvent = normalizedTotal > 0 && normalizedTransferred >= normalizedTotal; + if (!force && !isFirstDataProgress && !isCompletionEvent && previous && now - previous.lastAt < TRANSFER_PROGRESS_EVENT_INTERVAL_MS) { + return; + } + this.transferProgressSnapshots.set(progressId, { lastAt: now, lastBytes: normalizedTransferred }); + this.getTransferProgressEmitter(progressId).fire({ + progressId, + transferredBytes: normalizedTransferred, + totalBytes: normalizedTotal, + phase, + detail, + ...extra + }); + } + getTransferProgressEmitter(progressId) { + let emitter = this.transferProgressEmitters.get(progressId); + if (!emitter) { + emitter = new Emitter(); + this.transferProgressEmitters.set(progressId, emitter); + } + return emitter; + } + scheduleTransferProgressCleanup(progressId) { + if (!progressId) { + return; + } + setTimeout(() => { + this.transferProgressSnapshots.delete(progressId); + this.transferDataStartTimes.delete(progressId); + this.transferFirstPositiveProgressTimes.delete(progressId); + this.transferGrowthMonitoredProgressIds.delete(progressId); + this.rsyncTimingStates.delete(progressId); + const emitter = this.transferProgressEmitters.get(progressId); + if (emitter) { + this.transferProgressEmitters.delete(progressId); + emitter.dispose(); + } + }, 30000); + } + async readLocalFileBuffer(path, maxBytes) { + const stat = await fsp.stat(path); + if (stat.isDirectory()) { + throw new Error('Cannot preview a directory.'); + } + if (stat.size > maxBytes) { + throw new Error(`File is too large to preview (${formatBytes(stat.size)}).`); + } + return fsp.readFile(path); + } + async readRemoteFileBuffer(endpoint, maxBytes) { + let tempRoot; + try { + return await this.withDirectorySftp(endpoint, async (sftp) => { + const stat = await this.remoteStat(sftp, endpoint.path); + if (stat.isDirectory()) { + throw new Error('Cannot preview a directory.'); + } + if (stat.size > maxBytes) { + throw new Error(`File is too large to preview (${formatBytes(stat.size)}).`); + } + if (stat.size <= REMOTE_DIRECT_READ_MAX_BYTES) { + return this.readRemoteFileBufferDirect(sftp, endpoint.path, stat.size); + } + tempRoot = join(tmpdir(), `turbofile-read-${randomUUID()}`); + const tempPath = join(tempRoot, basename(endpoint.path) || 'content'); + await fsp.mkdir(tempRoot, { recursive: true }); + await this.fastGet(sftp, endpoint.path, tempPath); + return await fsp.readFile(tempPath); + }); + } + finally { + if (tempRoot) { + await fsp.rm(tempRoot, { recursive: true, force: true }).catch(() => undefined); + } + } + } + async readRemoteFileBufferDirect(sftp, remotePath, size) { + if (size <= 0) { + return Buffer.alloc(0); + } + if (sftp.createReadStream) { + try { + return await this.readRemoteFileBufferStream(sftp, remotePath, size); + } + catch { + // Some embedded SFTP servers advertise streams but fail at runtime; keep the older + // positioned-read path as a compatibility fallback. + } + } + const handle = await this.openRemoteFile(sftp, remotePath, 'r'); + try { + const buffer = Buffer.alloc(size); + let position = 0; + while (position < size) { + const length = Math.min(REMOTE_DIRECT_READ_CHUNK_BYTES, size - position); + const bytesRead = await this.readRemoteFileChunk(sftp, handle, buffer.subarray(position, position + length), length, position); + if (bytesRead <= 0) { + break; + } + position += bytesRead; + } + return position === size ? buffer : buffer.subarray(0, position); + } + finally { + await this.closeRemoteFile(sftp, handle); + } + } + readRemoteFileBufferStream(sftp, remotePath, size) { + return new Promise((resolve, reject) => { + const stream = sftp.createReadStream?.(remotePath, { + highWaterMark: REMOTE_DIRECT_READ_CHUNK_BYTES + }); + if (!stream) { + reject(new Error('SFTP read stream is unavailable.')); + return; + } + const chunks = []; + let total = 0; + let settled = false; + const finish = (callback) => { + if (settled) { + return; + } + settled = true; + callback(); + }; + stream.on('data', chunk => { + const buffer = typeof chunk === 'string' ? Buffer.from(chunk) : Buffer.from(chunk); + total += buffer.byteLength; + if (total > size) { + stream.destroy?.(); + finish(() => reject(new Error('Remote file stream exceeded expected size.'))); + return; + } + chunks.push(buffer); + }); + stream.on('error', error => finish(() => reject(error))); + stream.on('end', () => finish(() => resolve(Buffer.concat(chunks, total)))); + }); + } + /** + * Acquires a slot from the SSH image-read concurrency limiter, awaiting a free slot when the cap + * is reached. Hand-off on release keeps the active count exact. Prevents the grid's burst of + * parallel fetches from exhausting the SSH connection's channels ("Unable to exec"). + */ + async acquireImageReadSlot() { + if (this.imageReadActiveCount < SSH_IMAGE_READ_MAX_CONCURRENCY) { + this.imageReadActiveCount++; + return; + } + await new Promise(resolve => this.imageReadWaiters.push(resolve)); + } + releaseImageReadSlot() { + const next = this.imageReadWaiters.shift(); + if (next) { + // Hand the slot directly to the next waiter; active count stays the same. + next(); + return; + } + this.imageReadActiveCount = Math.max(0, this.imageReadActiveCount - 1); + } + async readImageBuffer(endpoint, options) { + // Only SSH reads contend for connection channels; local reads run unthrottled. + if (endpoint.kind !== 'ssh') { + return this.readImageBufferInner(endpoint, options); + } + await this.acquireImageReadSlot(); + try { + return await this.readImageBufferInner(endpoint, options); + } + finally { + this.releaseImageReadSlot(); + } + } + async readImageBufferInner(endpoint, options) { + const targetWidth = clampPositiveInt(options.width); + const targetHeight = clampPositiveInt(options.height); + const quality = clampQuality(options.quality); + const format = options.format === 'png' ? 'png' : options.format === 'webp' ? 'webp' : 'jpeg'; + const interpolation = options.interpolation === 'lanczos' ? 'lanczos' : 'area'; + const forceOriginal = Boolean(options.forceOriginal || (!targetWidth && !targetHeight)); + const sourceMaxBytes = Math.max(options.maxBytes ?? DEFAULT_READ_LIMIT, DEFAULT_IMAGE_SOURCE_LIMIT); + if (forceOriginal) { + return this.readOriginalImageBuffer(endpoint, sourceMaxBytes); + } + const sourceFingerprint = this.getImageSourceFingerprintFromOptions(options) + ?? await this.getImageSourceFingerprint(endpoint).catch(() => undefined); + const thumbnailCacheKey = sourceFingerprint + ? this.getImageThumbnailCacheKey(endpoint, sourceFingerprint, targetWidth, targetHeight, quality, format, interpolation) + : undefined; + if (thumbnailCacheKey) { + const cached = await this.readCachedImageThumbnail(thumbnailCacheKey); + if (cached) { + return cached; + } + } + const result = await this.readScaledImageBuffer(endpoint, sourceMaxBytes, targetWidth, targetHeight, quality, format, interpolation, sourceFingerprint); + if (thumbnailCacheKey) { + void this.writeCachedImageThumbnail(thumbnailCacheKey, result); + } + return result; + } + async readOriginalImageBuffer(endpoint, sourceMaxBytes) { + const buffer = endpoint.kind === 'local' + ? await this.readLocalFileBuffer(endpoint.path, sourceMaxBytes) + : await this.readRemoteFileBuffer(endpoint, sourceMaxBytes); + if (getMimeType(endpoint.path) === 'image/bmp') { + const bmp = tryCreateBmpPngVariant(buffer, 0, 0); + if (bmp) { + return bmp; + } + } + const size = getImageSize(buffer); + return { + buffer, + mime: getMimeType(endpoint.path), + width: size?.width, + height: size?.height, + originalWidth: size?.width, + originalHeight: size?.height + }; + } + async readScaledImageBuffer(endpoint, sourceMaxBytes, targetWidth, targetHeight, quality, format, interpolation, sourceFingerprint) { + const shouldTryRemoteTransform = endpoint.kind === 'ssh' + && !looksLikeWindowsPath(endpoint.path) + && (!sourceFingerprint || sourceFingerprint.size > REMOTE_DIRECT_READ_MAX_BYTES); + if (shouldTryRemoteTransform) { + const thumbnail = await this.tryReadRemoteThumbnailViaImageMagick(endpoint, targetWidth, targetHeight, quality, format, interpolation); + if (thumbnail) { + return thumbnail; + } + } + const sourceBuffer = endpoint.kind === 'local' + ? await this.readLocalFileBuffer(endpoint.path, sourceMaxBytes) + : await this.readRemoteFileBuffer(endpoint, sourceMaxBytes); + const sourceSize = getImageSize(sourceBuffer); + const sourceMime = getMimeType(endpoint.path); + if (sourceMime === 'image/bmp') { + const bmp = tryCreateBmpPngVariant(sourceBuffer, targetWidth, targetHeight); + if (bmp) { + return bmp; + } + } + if (sourceMime === 'image/bmp' && sourceBuffer.byteLength <= RAW_BMP_FAST_PATH_BYTES && shouldKeepOriginalImageSize(sourceSize, targetWidth, targetHeight)) { + return { + buffer: sourceBuffer, + mime: sourceMime, + width: sourceSize?.width, + height: sourceSize?.height, + originalWidth: sourceSize?.width, + originalHeight: sourceSize?.height + }; + } + if (sourceSize && sourceSize.width > 0 && sourceSize.height > 0 && sourceSize.width < SMALL_IMAGE_DIM_LIMIT && sourceSize.height < SMALL_IMAGE_DIM_LIMIT) { + return { + buffer: sourceBuffer, + mime: sourceMime, + width: sourceSize.width, + height: sourceSize.height, + originalWidth: sourceSize.width, + originalHeight: sourceSize.height + }; + } + try { + const transformed = this.transformImageBuffer(sourceBuffer, endpoint.path, targetWidth, targetHeight, quality, format); + return transformed; + } + catch (error) { + if (sourceMime.startsWith('image/')) { + return { + buffer: sourceBuffer, + mime: sourceMime, + width: sourceSize?.width, + height: sourceSize?.height, + originalWidth: sourceSize?.width, + originalHeight: sourceSize?.height + }; + } + throw error; + } + } + getImageSourceFingerprintFromOptions(options) { + const size = Number(options.sourceSize); + const modified = Number(options.sourceModified); + if (!Number.isFinite(size) || size < 0 || !Number.isFinite(modified) || modified <= 0) { + return undefined; + } + return { size, modified }; + } + async getImageSourceFingerprint(endpoint) { + if (endpoint.kind === 'local') { + const stat = await fsp.stat(endpoint.path); + if (!stat.isFile()) { + return undefined; + } + return { + size: stat.size, + modified: Math.round(stat.mtimeMs) + }; + } + return this.withDirectorySftp(endpoint, async (sftp) => { + const stat = await this.remoteStat(sftp, endpoint.path); + if (!stat.isFile()) { + return undefined; + } + return { + size: stat.size, + modified: stat.mtime * 1000 + }; + }); + } + getImageThumbnailCacheKey(endpoint, sourceFingerprint, width, height, quality, format, interpolation) { + const endpointKey = endpoint.kind === 'local' + ? { kind: endpoint.kind, path: endpoint.path } + : { + kind: endpoint.kind, + host: endpoint.connection?.host ?? '', + port: endpoint.connection?.port || 22, + username: endpoint.connection?.username ?? '', + path: endpoint.path + }; + return createHash('sha256').update(JSON.stringify({ + version: IMAGE_THUMBNAIL_CACHE_VERSION, + endpoint: endpointKey, + source: sourceFingerprint, + width, + height, + quality, + format, + interpolation + })).digest('hex'); + } + async readCachedImageThumbnail(cacheKey) { + const paths = this.getImageThumbnailCachePaths(cacheKey); + try { + const [metadataContent, buffer] = await Promise.all([ + fsp.readFile(paths.metadataPath, 'utf8'), + fsp.readFile(paths.dataPath) + ]); + const metadata = JSON.parse(metadataContent); + if (metadata.version !== IMAGE_THUMBNAIL_CACHE_VERSION || metadata.size !== buffer.byteLength || typeof metadata.mime !== 'string') { + return undefined; + } + const now = new Date(); + void fsp.utimes(paths.dataPath, now, now).catch(() => undefined); + return { + buffer, + mime: metadata.mime, + width: typeof metadata.width === 'number' ? metadata.width : undefined, + height: typeof metadata.height === 'number' ? metadata.height : undefined, + originalWidth: typeof metadata.originalWidth === 'number' ? metadata.originalWidth : undefined, + originalHeight: typeof metadata.originalHeight === 'number' ? metadata.originalHeight : undefined + }; + } + catch { + return undefined; + } + } + async writeCachedImageThumbnail(cacheKey, result) { + if (!result.buffer.byteLength || result.buffer.byteLength > IMAGE_THUMBNAIL_CACHE_MAX_ENTRY_BYTES) { + return; + } + const paths = this.getImageThumbnailCachePaths(cacheKey); + const metadata = { + version: IMAGE_THUMBNAIL_CACHE_VERSION, + mime: result.mime, + size: result.buffer.byteLength, + width: result.width, + height: result.height, + originalWidth: result.originalWidth, + originalHeight: result.originalHeight, + createdAt: Date.now() + }; + try { + await fsp.mkdir(dirname(paths.dataPath), { recursive: true }); + await Promise.all([ + fsp.writeFile(paths.dataPath, result.buffer), + fsp.writeFile(paths.metadataPath, JSON.stringify(metadata)) + ]); + this.scheduleTrimImageThumbnailCache(); + } + catch { + // Thumbnail cache is purely an acceleration layer; failed writes must never block image loading. + } + } + getImageThumbnailCachePaths(cacheKey) { + const bucket = cacheKey.slice(0, 2) || '00'; + const root = this.getImageThumbnailCacheRoot(); + return { + dataPath: join(root, bucket, `${cacheKey}.bin`), + metadataPath: join(root, bucket, `${cacheKey}.json`) + }; + } + getImageThumbnailCacheRoot() { + try { + return join(app.getPath('userData'), 'turbofile-image-cache', `v${IMAGE_THUMBNAIL_CACHE_VERSION}`); + } + catch { + return join(tmpdir(), 'turbofile-image-cache', `v${IMAGE_THUMBNAIL_CACHE_VERSION}`); + } + } + scheduleTrimImageThumbnailCache() { + if (this.imageThumbnailCacheTrimPromise) { + return; + } + this.imageThumbnailCacheTrimPromise = this.trimImageThumbnailCache() + .finally(() => { + this.imageThumbnailCacheTrimPromise = undefined; + }); + } + async trimImageThumbnailCache() { + const root = this.getImageThumbnailCacheRoot(); + const entries = []; + const visit = async (directory) => { + let children; + try { + children = await fsp.readdir(directory, { withFileTypes: true }); + } + catch { + return; + } + for (const child of children) { + const childPath = join(directory, child.name); + if (child.isDirectory()) { + await visit(childPath); + continue; + } + if (!child.isFile() || !child.name.endsWith('.bin')) { + continue; + } + const stat = await fsp.stat(childPath).catch(() => undefined); + if (!stat) { + continue; + } + entries.push({ + dataPath: childPath, + metadataPath: join(directory, `${child.name.slice(0, -4)}.json`), + size: stat.size, + mtimeMs: stat.mtimeMs + }); + } + }; + await visit(root); + let totalBytes = entries.reduce((sum, entry) => sum + entry.size, 0); + if (totalBytes <= IMAGE_THUMBNAIL_CACHE_MAX_BYTES) { + return; + } + entries.sort((a, b) => a.mtimeMs - b.mtimeMs); + for (const entry of entries) { + if (totalBytes <= IMAGE_THUMBNAIL_CACHE_MAX_BYTES) { + break; + } + await Promise.all([ + fsp.rm(entry.dataPath, { force: true }).catch(() => undefined), + fsp.rm(entry.metadataPath, { force: true }).catch(() => undefined) + ]); + totalBytes -= entry.size; + } + } + async tryReadRemoteThumbnailViaImageMagick(endpoint, width, height, quality, format, interpolation) { + if (!endpoint.connection || (!width && !height)) { + return undefined; + } + const tool = await this.getRemoteImageTool(endpoint); + if (!tool) { + return undefined; + } + const geometry = buildImageGeometry(width, height); + if (!geometry) { + return undefined; + } + // Always emit JPEG (or PNG when explicitly requested) from the remote transform, never WebP. + // Some servers' ImageMagick WebP delegate produced byte streams that passed a shallow magic + // check but that this build's Chromium decoder rejected ("Failed to load image") for + // certain sources (e.g. super-resolution PNGs), while small images that take the local + // nativeImage path (JPEG/PNG output) always rendered. JPEG decodes universally and is + // verifiable via nativeImage, so it removes that whole failure class. + const effectiveFormat = format === 'png' ? 'png' : 'jpeg'; + // `[0]` selects the first page of a multi-page TIFF; without it ImageMagick would stream every + // page and produce an undecodable multi-frame JPEG. + const lowerPath = endpoint.path.toLowerCase(); + const frame = lowerPath.endsWith('.tif') || lowerPath.endsWith('.tiff') ? '[0]' : ''; + const source = `${quotePosixShellArg(endpoint.path)}${frame}`; + const thumbnail = ` -thumbnail ${quotePosixShellArg(geometry)}`; + const filter = interpolation === 'lanczos' ? ' -filter Lanczos' : ''; + const qualityOption = effectiveFormat === 'png' ? '' : ` -quality ${quality}`; + const outputFormat = effectiveFormat === 'png' ? 'png:-' : 'jpg:-'; + const mime = effectiveFormat === 'png' ? 'image/png' : 'image/jpeg'; + const command = `${tool} ${source}${thumbnail}${filter} -strip${qualityOption} ${outputFormat}`; + try { + const buffer = await this.withDirectorySshClient(endpoint, client => this.execRemoteBytes(client, command, { timeoutMs: REMOTE_IMAGE_THUMBNAIL_TIMEOUT_MS })); + assertImageBuffer(buffer, mime); + const size = getImageSize(buffer); + if (!size) { + // The header looked valid but nativeImage (same decoder family as the renderer's ) + // could not actually decode the bytes — truncated output, a timeout cutting the stream + // short, or an incompatible encoder. Reject so the caller falls back to the local + // download + transform path, which is guaranteed renderable, instead of handing the + // renderer an source it cannot decode. + throw new Error('Remote thumbnail produced an undecodable image.'); + } + return { + buffer, + mime, + width: size.width, + height: size.height + }; + } + catch (error) { + // A single corrupt/unsupported image should not disable thumbnail acceleration for + // the entire SSH device; keep the tool cache intact and fall back for this image only. + return undefined; + } + } + async getRemoteImageTool(endpoint) { + if (!endpoint.connection) { + return undefined; + } + const cacheKey = this.getRemoteImageToolCacheKey(endpoint.connection); + const cached = this.remoteImageToolCache.get(cacheKey); + const now = Date.now(); + if (cached && (now - cached.checkedAt) < REMOTE_IMAGE_TOOL_CACHE_TTL_MS) { + return cached.tool; + } + const pending = this.remoteImageToolPending.get(cacheKey); + if (pending) { + return pending; + } + const probe = this.resolveRemoteImageTool(endpoint, cacheKey, now); + this.remoteImageToolPending.set(cacheKey, probe); + try { + return await probe; + } + finally { + if (this.remoteImageToolPending.get(cacheKey) === probe) { + this.remoteImageToolPending.delete(cacheKey); + } + } + } + async resolveRemoteImageTool(endpoint, cacheKey, checkedAt) { + let output = ''; + try { + await this.withDirectorySshClient(endpoint, client => this.execRemote(client, `command -v magick >/dev/null 2>&1 && printf magick || (command -v convert >/dev/null 2>&1 && printf convert)`, { + onStdout: chunk => { + output += chunk; + } + })); + const normalized = output.trim() === 'convert' ? 'convert' : output.trim() === 'magick' ? 'magick' : undefined; + this.remoteImageToolCache.set(cacheKey, { checkedAt, tool: normalized }); + return normalized; + } + catch { + this.remoteImageToolCache.set(cacheKey, { checkedAt, tool: undefined }); + return undefined; + } + } + getRemoteImageToolCacheKey(options) { + return JSON.stringify({ + host: options.host, + port: options.port || 22, + username: options.username + }); + } + openRemoteCommandStream(client, command, context, drainStdout = false) { + return new Promise((resolve, reject) => { + client.exec(command, (error, stream) => { + if (error) { + reject(error); + return; + } + let stderr = ''; + let settled = false; + const close = new Promise((closeResolve, closeReject) => { + const finish = (callback) => { + if (settled) { + return; + } + settled = true; + callback(); + }; + stream.stderr.on('data', data => { + stderr += data.toString(); + }); + if (drainStdout) { + stream.on('data', () => undefined); + } + stream.on('error', err => finish(() => closeReject(err))); + stream.on('close', code => { + if (code === 0) { + finish(() => closeResolve()); + return; + } + finish(() => closeReject(new Error(stderr.trim() || `${context} failed with exit code ${code ?? 'unknown'}.`))); + }); + }); + close.catch(() => undefined); + resolve({ stream, close }); + }); + }); + } + transformImageBuffer(buffer, path, targetWidth, targetHeight, quality, format) { + const image = nativeImage.createFromBuffer(buffer); + if (image.isEmpty()) { + throw new Error('Unsupported image format.'); + } + const sourceSize = image.getSize(); + if (!sourceSize.width || !sourceSize.height) { + throw new Error('Unable to read image dimensions.'); + } + const resized = this.resizeNativeImage(image, sourceSize, targetWidth, targetHeight); + const outputFormat = format === 'png' ? 'png' : 'jpeg'; + const output = outputFormat === 'png' + ? resized.toPNG() + : resized.toJPEG(quality); + const outputSize = resized.getSize(); + return { + buffer: output, + mime: outputFormat === 'png' ? 'image/png' : 'image/jpeg', + width: outputSize.width, + height: outputSize.height, + originalWidth: sourceSize.width, + originalHeight: sourceSize.height + }; + } + resizeNativeImage(image, sourceSize, targetWidth, targetHeight) { + let width = targetWidth; + let height = targetHeight; + if (!width && !height) { + return image; + } + if (!width) { + width = Math.max(1, Math.round(sourceSize.width * (height / sourceSize.height))); + } + if (!height) { + height = Math.max(1, Math.round(sourceSize.height * (width / sourceSize.width))); + } + const ratio = Math.min(width / sourceSize.width, height / sourceSize.height, 1); + if (!Number.isFinite(ratio) || ratio <= 0 || ratio >= 1) { + return image; + } + return image.resize({ + width: Math.max(1, Math.round(sourceSize.width * ratio)), + height: Math.max(1, Math.round(sourceSize.height * ratio)), + quality: 'best' + }); + } + execRemote(client, command, options = {}) { + return new Promise((resolve, reject) => { + let settled = false; + let timeout; + let inactivityTimer; + let streamRef; + let unregisterCancel; + let lastStreamActivityAt = Date.now(); + const finish = (callback) => { + if (settled) { + return; + } + settled = true; + unregisterCancel?.(); + if (timeout) { + clearTimeout(timeout); + timeout = undefined; + } + if (inactivityTimer) { + clearInterval(inactivityTimer); + inactivityTimer = undefined; + } + callback(); + }; + const closeStream = () => { + try { + streamRef?.close?.(); + } + catch { + // Best-effort cleanup only. + } + try { + streamRef?.destroy?.(); + } + catch { + // Best-effort cleanup only. + } + }; + // Cancelling the owning transfer forcibly closes this exec channel so the remote command is + // actually terminated (SIGHUP) rather than orphaned. Registered before exec so a cancel that + // arrives during the channel grant still settles the promise. + if (options.progressId) { + unregisterCancel = this.registerTransferCancelHandler(options.progressId, () => { + closeStream(); + finish(() => reject(new TurboFileTransferCanceledError())); + }); + } + client.exec(command, (error, stream) => { + if (error) { + finish(() => reject(error)); + return; + } + streamRef = stream; + let stderr = ''; + if (options.timeoutMs && options.timeoutMs > 0) { + timeout = setTimeout(() => { + closeStream(); + finish(() => reject(new Error(`Remote command timed out after ${options.timeoutMs} ms.`))); + }, options.timeoutMs); + } + if (options.inactivityTimeoutMs && options.inactivityTimeoutMs > 0) { + inactivityTimer = setInterval(() => { + const externalActivityAt = options.getActivityTime?.() ?? 0; + const lastActivityAt = Math.max(lastStreamActivityAt, externalActivityAt); + if (Date.now() - lastActivityAt <= options.inactivityTimeoutMs) { + return; + } + closeStream(); + finish(() => reject(new Error(options.inactivityMessage ?? `Remote command produced no progress for ${options.inactivityTimeoutMs} ms.`))); + }, DIRECT_TRANSFER_STALL_CHECK_INTERVAL_MS); + } + stream.on('data', data => { + // Drain stdout so remote commands can finish reliably. + lastStreamActivityAt = Date.now(); + options.onStdout?.(data.toString()); + }); + stream.stderr.on('data', data => { + lastStreamActivityAt = Date.now(); + const chunk = data.toString(); + stderr += chunk; + options.onStderr?.(chunk); + }); + stream.on('close', code => { + if (code === 0) { + finish(() => resolve()); + return; + } + finish(() => reject(new Error(stderr.trim() || `Remote command failed with exit code ${code ?? 'unknown'}.`))); + }); + }); + }); + } + execRemoteBytes(client, command, options = {}) { + return new Promise((resolve, reject) => { + let settled = false; + let totalTimeout; + let inactivityTimer; + let activeStream; + const clearTimers = () => { + if (totalTimeout) { + clearTimeout(totalTimeout); + totalTimeout = undefined; + } + if (inactivityTimer) { + clearTimeout(inactivityTimer); + inactivityTimer = undefined; + } + }; + const finish = (callback) => { + if (settled) { + return; + } + settled = true; + clearTimers(); + callback(); + }; + const abort = (message) => { + const stream = activeStream; + try { + stream?.close?.(); + } + catch { + // Best-effort: older ssh2 channel typings may not expose close. + } + try { + stream?.destroy?.(); + } + catch { + // Best-effort cleanup only. + } + finish(() => reject(new Error(message))); + }; + // An inactivity timeout (reset on every chunk) keeps a long but actively-streaming command + // alive — a `find` listing a directory of tens of thousands of files streams output the whole + // time and must not be killed by a fixed total-duration cap, which previously forced a fall + // back to the much slower per-batch SFTP readdir and ultimately a "read timed out" failure. + const armInactivity = () => { + if (!options.inactivityTimeoutMs || options.inactivityTimeoutMs <= 0) { + return; + } + if (inactivityTimer) { + clearTimeout(inactivityTimer); + } + inactivityTimer = setTimeout(() => abort(options.inactivityMessage ?? `Remote command produced no output for ${options.inactivityTimeoutMs} ms.`), options.inactivityTimeoutMs); + }; + // Arm the timeouts BEFORE calling exec so a stalled channel grant can never hang forever. + // When the connection's MaxSessions pool is momentarily exhausted, ssh2 may not invoke the + // exec callback at all — with the timers armed only inside that callback (as before) the + // promise never settled, which left directory listings stuck waiting until the + // window was reopened. Arming here bounds the whole operation, including the channel wait. + if (options.timeoutMs && options.timeoutMs > 0) { + totalTimeout = setTimeout(() => abort(`Remote command timed out after ${options.timeoutMs} ms.`), options.timeoutMs); + } + armInactivity(); + client.exec(command, (error, stream) => { + if (error) { + finish(() => reject(error)); + return; + } + // If a timeout already aborted this operation, immediately release the channel that was + // granted late instead of leaking it — a leaked channel would make MaxSessions exhaustion + // (the very cause of the stall) progressively worse. + if (settled) { + try { + stream.close?.(); + } + catch { + // Best-effort cleanup only. + } + try { + stream.destroy?.(); + } + catch { + // Best-effort cleanup only. + } + return; + } + activeStream = stream; + const chunks = []; + let stderr = ''; + // Reset the inactivity clock now that the channel is live and output can begin streaming. + armInactivity(); + stream.on('data', data => { + armInactivity(); + chunks.push(typeof data === 'string' ? Buffer.from(data) : data); + }); + stream.stderr.on('data', data => { + armInactivity(); + stderr += data.toString(); + }); + stream.on('close', code => { + if (code === 0) { + finish(() => resolve(Buffer.concat(chunks))); + return; + } + finish(() => reject(new Error(stderr.trim() || `Remote command failed with exit code ${code ?? 'unknown'}.`))); + }); + }); + }); + } + /** + * Runs a remote command, writing `input` to its stdin in one shot. Used to feed a NUL-delimited + * path list to `xargs -0 rm` so deleting any number of files is a single SSH round trip — xargs + * batches the removals server-side, so it never hits the shell argument-length limit. + */ + execRemoteWithInput(client, command, input, options = {}) { + return new Promise((resolve, reject) => { + let settled = false; + let timeout; + const finish = (callback) => { + if (settled) { + return; + } + settled = true; + if (timeout) { + clearTimeout(timeout); + timeout = undefined; + } + callback(); + }; + client.exec(command, (error, stream) => { + if (error) { + finish(() => reject(error)); + return; + } + const chunks = []; + let stderr = ''; + if (options.timeoutMs && options.timeoutMs > 0) { + timeout = setTimeout(() => { + try { + stream.close?.(); + } + catch { + // Best-effort cleanup only. + } + finish(() => reject(new Error(`Remote command timed out after ${options.timeoutMs} ms.`))); + }, options.timeoutMs); + } + stream.on('data', data => chunks.push(typeof data === 'string' ? Buffer.from(data) : data)); + stream.stderr.on('data', data => { stderr += data.toString(); }); + stream.on('close', code => { + if (code === 0) { + finish(() => resolve(Buffer.concat(chunks))); + return; + } + finish(() => reject(new Error(stderr.trim() || `Remote command failed with exit code ${code ?? 'unknown'}.`))); + }); + stream.end(input); + }); + }); + } + parseRsyncProgressSnapshot(chunk) { + let parsed; + const progressPattern = /(?:^|[\r\n])\s*([0-9][0-9., ]*)\s+([0-9]{1,3})%/g; + const speedPattern = /([0-9]+(?:[.,][0-9]+)?)\s*([kmgtpe]?)(?:i?B|bytes?)\/s/gi; + const toCheckPattern = /(?:to-(?:check|chk)|ir-chk)=(\d+)\/(\d+)/g; + const transferIndexPattern = /xfr#(\d+)/g; + let hasParsedMetric = false; + let match; + while ((match = progressPattern.exec(chunk)) !== null) { + const bytes = Number(match[1].replace(/[^0-9]/g, '')); + const percent = Number(match[2]); + if (Number.isFinite(bytes)) { + hasParsedMetric = true; + parsed = { + bytes, + ...(Number.isFinite(percent) ? { percent } : {}) + }; + } + } + let speedMatch; + while ((speedMatch = speedPattern.exec(chunk)) !== null) { + const speedBytesPerSecond = parseRsyncSpeedBytesPerSecond(speedMatch[1], speedMatch[2]); + if (speedBytesPerSecond !== undefined) { + hasParsedMetric = true; + parsed = parsed ?? {}; + parsed = { + ...parsed, + speedBytesPerSecond + }; + } + } + let toCheckMatch; + while ((toCheckMatch = toCheckPattern.exec(chunk)) !== null) { + const toCheck = Number(toCheckMatch[1]); + const totalChecks = Number(toCheckMatch[2]); + if (Number.isFinite(toCheck) && Number.isFinite(totalChecks)) { + hasParsedMetric = true; + parsed = parsed ?? { bytes: 0 }; + parsed = { + ...parsed, + toCheck, + totalChecks + }; + } + } + let transferIndexMatch; + while ((transferIndexMatch = transferIndexPattern.exec(chunk)) !== null) { + const transferIndex = Number(transferIndexMatch[1]); + if (Number.isFinite(transferIndex)) { + hasParsedMetric = true; + parsed = parsed ?? { bytes: 0 }; + parsed = { + ...parsed, + transferIndex + }; + } + } + return hasParsedMetric ? parsed : undefined; + } + async readRemoteDirectoryWithFallback(sftp, client, options, remotePath, namesOnly = false) { + try { + return { path: remotePath, entries: await this.readRemoteDirectoryFast(client, sftp, remotePath, namesOnly) }; + } + catch (firstError) { + for (const fallbackPath of getRemoteDirectoryFallbackPaths(options, remotePath)) { + try { + return { path: fallbackPath, entries: await this.readRemoteDirectoryFast(client, sftp, fallbackPath, namesOnly) }; + } + catch { + // Try the next SFTP-visible spelling for devices with chrooted home paths. + } + } + throw firstError; + } + } + async readRemoteDirectoryFastCommand(client, remotePath, namesOnly = false) { + // Bound by inactivity, not total duration: a `find` over a directory with tens of thousands + // of entries streams output for many seconds and must be allowed to finish. Only callers decide + // whether to fall back to slow SFTP readdir. + const command = namesOnly ? buildPosixNamesAndTypesListCommand(remotePath) : buildPosixFastDirectoryListCommand(remotePath); + return this.parseFastRemoteDirectoryEntries(await this.execRemoteBytes(client, command, { + inactivityTimeoutMs: REMOTE_DIRECTORY_READ_INACTIVITY_TIMEOUT_MS, + inactivityMessage: `Remote directory read produced no output for ${REMOTE_DIRECTORY_READ_INACTIVITY_TIMEOUT_MS} ms: ${remotePath}`, + timeoutMs: REMOTE_DIRECTORY_READ_MAX_TIMEOUT_MS + }), namesOnly); + } + async readRemoteDirectoryFast(client, sftp, remotePath, namesOnly = false) { + try { + return await this.readRemoteDirectoryFastCommand(client, remotePath, namesOnly); + } + catch { + return this.readRemoteDirectory(sftp, remotePath); + } + } + parseFastRemoteDirectoryEntries(output, namesOnly = false) { + const text = output.toString('utf8'); + if (!text) { + return []; + } + const parts = text.split('\0'); + const entries = []; + const stride = namesOnly ? 2 : 4; + for (let index = 0; index + stride - 1 < parts.length; index += stride) { + const filename = parts[index]; + if (!filename || filename === '.' || filename === '..') { + continue; + } + const kind = parts[index + 1]; + const size = namesOnly ? 0 : Number(parts[index + 2]); + const modifiedSeconds = namesOnly ? 0 : Number(parts[index + 3]); + const type = kind === 'd' ? 'directory' : kind === 'f' ? 'file' : kind === 'l' ? 'symlink' : 'unknown'; + entries.push({ + filename, + longname: type === 'directory' ? 'd' : type === 'symlink' ? 'l' : '-', + attrs: createFastDirectoryEntryStats(type, Number.isFinite(size) ? size : 0, Number.isFinite(modifiedSeconds) ? Math.floor(modifiedSeconds) : 0) + }); + } + return entries; + } + readRemoteDirectory(sftp, remotePath) { + return withTurboFileTimeout(new Promise((resolve, reject) => { + sftp.readdir(remotePath, (error, list) => { + if (error) { + reject(error); + return; + } + resolve(list); + }); + }), REMOTE_DIRECTORY_READ_TIMEOUT_MS, `Remote directory read timed out after ${REMOTE_DIRECTORY_READ_TIMEOUT_MS} ms: ${remotePath}`); + } + async getRemotePathSize(sftp, remotePath) { + const stats = await this.remoteStat(sftp, remotePath); + if (!stats.isDirectory()) { + return stats.size; + } + let total = 0; + const entries = await this.readRemoteDirectory(sftp, remotePath); + for (const entry of entries) { + if (entry.filename === '.' || entry.filename === '..') { + continue; + } + if (entry.attrs.isDirectory() || entry.attrs.isFile()) { + total += await this.getRemotePathSize(sftp, joinRemotePath(remotePath, entry.filename)); + } + } + return total; + } + async getRemotePathSizeByCommand(endpoint) { + if (endpoint.kind !== 'ssh') { + return getLocalPathSize(endpoint.path); + } + try { + if (looksLikeWindowsPath(endpoint.path)) { + return await this.withDirectorySshClient(endpoint, async (client) => { + const output = await this.execRemoteBytes(client, buildWindowsSizeCommand(endpoint.path)); + return parseSizeCommandOutput(output.toString()); + }); + } + return await this.withDirectorySshClient(endpoint, async (client) => { + const output = await this.execRemoteBytes(client, buildPosixSizeCommand(endpoint.path)); + return parseSizeCommandOutput(output.toString()); + }); + } + catch { + return this.withDirectorySftp(endpoint, sftp => this.getRemotePathSize(sftp, endpoint.path)); + } + } + async getRemotePathContentSizeByCommand(endpoint) { + if (endpoint.kind !== 'ssh' || looksLikeWindowsPath(endpoint.path)) { + return this.getRemotePathSizeByCommand(endpoint); + } + try { + return await this.withDirectorySshClient(endpoint, async (client) => { + const output = await this.execRemoteBytes(client, buildPosixContentSizeCommand(endpoint.path)); + return parseSizeCommandOutput(output.toString()); + }); + } + catch { + return this.withDirectorySftp(endpoint, sftp => this.getRemotePathSize(sftp, endpoint.path)); + } + } + async getPathHumanSize(endpoint, size) { + if (endpoint.kind === 'ssh' && !looksLikeWindowsPath(endpoint.path)) { + try { + return await this.withDirectorySshClient(endpoint, async (client) => { + const output = await this.execRemoteBytes(client, buildPosixHumanSizeCommand(endpoint.path)); + const humanSize = output.toString().trim().split(/\r?\n/)[0]?.trim(); + if (!humanSize) { + throw new Error('Empty human size output.'); + } + return humanSize; + }); + } + catch { + // Keep the exact byte count usable even when the display-only du command is unavailable. + } + } + return formatHumanSize(size); + } + remoteStat(sftp, remotePath) { + return new Promise((resolve, reject) => { + sftp.stat(remotePath, (error, stats) => { + if (error) { + reject(error); + return; + } + resolve(stats); + }); + }); + } + mkdirRemote(sftp, remotePath) { + return new Promise((resolve, reject) => { + sftp.mkdir(remotePath, error => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); + } + rmdirRemote(sftp, remotePath) { + return new Promise((resolve, reject) => { + sftp.rmdir(remotePath, error => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); + } + unlinkRemote(sftp, remotePath) { + return new Promise((resolve, reject) => { + sftp.unlink(remotePath, error => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); + } + fastGet(sftp, remotePath, localPath, options) { + return new Promise((resolve, reject) => { + const callback = (error) => { + if (error) { + reject(error); + return; + } + resolve(); + }; + if (options) { + sftp.fastGet(remotePath, localPath, options, callback); + } + else { + sftp.fastGet(remotePath, localPath, callback); + } + }); + } + fastPut(sftp, localPath, remotePath, options) { + return new Promise((resolve, reject) => { + const callback = (error) => { + if (error) { + reject(error); + return; + } + resolve(); + }; + if (options) { + sftp.fastPut(localPath, remotePath, options, callback); + } + else { + sftp.fastPut(localPath, remotePath, callback); + } + }); + } + async fastGetWithFallback(sftp, remotePath, localPath, bytes, onDelta, progressId) { + await this.fastTransferWithFallback(bytes, onDelta, progressId, report => this.fastGet(sftp, remotePath, localPath, this.createSftpTransferOptions(report, progressId, bytes, true)), report => this.fastGet(sftp, remotePath, localPath, this.createSftpTransferOptions(report, progressId, bytes, false))); + } + async fastPutWithFallback(sftp, localPath, remotePath, bytes, onDelta, progressId) { + await this.fastTransferWithFallback(bytes, onDelta, progressId, report => this.fastPut(sftp, localPath, remotePath, this.createSftpTransferOptions(report, progressId, bytes, true)), report => this.fastPut(sftp, localPath, remotePath, this.createSftpTransferOptions(report, progressId, bytes, false))); + } + async fastTransferWithFallback(bytes, onDelta, progressId, primary, fallback) { + const useLargeOptions = bytes >= SFTP_LARGE_FILE_THRESHOLD; + let reportedBytes = 0; + const createAttemptReporter = () => { + let attemptBytes = 0; + return (delta) => { + attemptBytes += delta; + if (attemptBytes > reportedBytes) { + const nextDelta = attemptBytes - reportedBytes; + reportedBytes = attemptBytes; + onDelta?.(nextDelta); + } + }; + }; + try { + await primary(createAttemptReporter()); + } + catch (error) { + if (!useLargeOptions || error instanceof TurboFileTransferCanceledError || this.isTransferCanceled(progressId)) { + throw error; + } + await fallback(createAttemptReporter()); + } + } + async ensureRemoteDirectory(sftp, remotePath) { + const normalized = remotePath.replace(/\\/g, '/'); + if (!normalized || normalized === '/' || normalized === '.') { + return; + } + const drive = /^[A-Za-z]:/.exec(normalized)?.[0]; + const isAbsolute = normalized.startsWith('/'); + let current = isAbsolute ? '/' : drive ?? ''; + const rest = drive ? normalized.substring(drive.length).replace(/^\/+/, '') : normalized.replace(/^\/+/, ''); + for (const segment of rest.split('/').filter(Boolean)) { + current = current ? joinRemotePath(current, segment) : segment; + let stats; + try { + stats = await this.remoteStat(sftp, current); + } + catch { + await this.mkdirRemote(sftp, current); + stats = await this.remoteStat(sftp, current).catch(() => undefined); + } + if (stats && !stats.isDirectory()) { + throw new Error(`Remote path is not a directory: ${current}`); + } + } + } + async uploadDirectoryConcurrent(endpoint, localPath, remotePath, onTotal, onDelta, progressId) { + this.throwIfTransferCanceled(progressId); + const plan = await this.collectLocalDirectoryTransferPlan(localPath, remotePath, progressId); + onTotal?.(plan.totalBytes); + await this.withSftpPool(endpoint, this.getDirectorySftpConnectionCount(plan.files.length, plan.totalBytes), progressId, async (sftps) => { + this.throwIfTransferCanceled(progressId); + await this.ensureRemoteDirectoriesForUpload(endpoint, sftps[0], plan.directories); + await this.runSftpWorkers(sftps, plan.files, progressId, async (sftp, file) => { + this.throwIfTransferCanceled(progressId); + await this.fastPutWithFallback(sftp, file.localPath, file.remotePath, file.size, onDelta, progressId); + }); + }); + } + async downloadDirectoryConcurrent(endpoint, remotePath, localPath, onTotal, onDelta, progressId) { + this.throwIfTransferCanceled(progressId); + const plan = await this.withSftp(endpoint, sftp => this.collectRemoteDirectoryTransferPlan(sftp, remotePath, localPath, progressId), progressId); + onTotal?.(plan.totalBytes); + await mapLimit(plan.directories, LOCAL_DIRECTORY_STAT_CONCURRENCY, directory => fsp.mkdir(directory, { recursive: true })); + await this.withSftpPool(endpoint, this.getDirectorySftpConnectionCount(plan.files.length, plan.totalBytes), progressId, async (sftps) => { + await this.runSftpWorkers(sftps, plan.files, progressId, async (sftp, file) => { + this.throwIfTransferCanceled(progressId); + await fsp.mkdir(dirname(file.localPath), { recursive: true }); + await this.fastGetWithFallback(sftp, file.remotePath, file.localPath, file.size, onDelta, progressId); + }); + }); + } + getDirectorySftpConnectionCount(fileCount, totalBytes) { + if (fileCount <= 1) { + return 1; + } + if (fileCount < 16 && totalBytes < SFTP_LARGE_FILE_THRESHOLD) { + return Math.min(4, fileCount); + } + if (fileCount >= 256 || totalBytes >= SFTP_LARGE_FILE_THRESHOLD) { + return Math.min(SFTP_DIRECTORY_TRANSFER_CONNECTIONS, fileCount); + } + return Math.min(8, fileCount); + } + async collectLocalDirectoryTransferPlan(localPath, remotePath, progressId) { + const directories = []; + const files = []; + let totalBytes = 0; + const walk = async (currentLocalPath, currentRemotePath) => { + this.throwIfTransferCanceled(progressId); + directories.push(currentRemotePath); + const entries = await fsp.readdir(currentLocalPath, { withFileTypes: true }); + await mapLimit(entries, SFTP_DIRECTORY_DISCOVERY_CONCURRENCY, async (entry) => { + this.throwIfTransferCanceled(progressId); + const childLocalPath = join(currentLocalPath, entry.name); + const childRemotePath = joinRemotePath(currentRemotePath, entry.name); + if (entry.isDirectory()) { + await walk(childLocalPath, childRemotePath); + return; + } + if (!entry.isFile()) { + return; + } + const stat = await fsp.stat(childLocalPath); + totalBytes += stat.size; + files.push({ + localPath: childLocalPath, + remotePath: childRemotePath, + size: stat.size + }); + }); + }; + await walk(localPath, remotePath); + return { directories, files, totalBytes }; + } + async collectRemoteDirectoryTransferPlan(sftp, remotePath, localPath, progressId) { + const directories = []; + const files = []; + let totalBytes = 0; + const walk = async (currentRemotePath, currentLocalPath) => { + this.throwIfTransferCanceled(progressId); + directories.push(currentLocalPath); + const entries = await this.readRemoteDirectory(sftp, currentRemotePath); + await mapLimit(entries, SFTP_DIRECTORY_DISCOVERY_CONCURRENCY, async (entry) => { + this.throwIfTransferCanceled(progressId); + if (entry.filename === '.' || entry.filename === '..') { + return; + } + const childRemotePath = joinRemotePath(currentRemotePath, entry.filename); + const childLocalPath = join(currentLocalPath, entry.filename); + if (entry.attrs.isDirectory()) { + await walk(childRemotePath, childLocalPath); + return; + } + if (!entry.attrs.isFile() && !entry.attrs.isSymbolicLink()) { + return; + } + totalBytes += entry.attrs.size; + files.push({ + remotePath: childRemotePath, + localPath: childLocalPath, + size: entry.attrs.size + }); + }); + }; + await walk(remotePath, localPath); + return { directories, files, totalBytes }; + } + async ensureRemoteDirectoriesForUpload(endpoint, fallbackSftp, directories) { + const uniqueDirectories = [...new Set(directories)].filter(directory => !!directory && directory !== '.' && directory !== '/'); + if (!uniqueDirectories.length) { + return; + } + if (endpoint.kind === 'ssh' && !uniqueDirectories.some(looksLikeWindowsPath)) { + try { + await this.withSshClient(endpoint, async (client) => { + for (let index = 0; index < uniqueDirectories.length; index += SFTP_DIRECTORY_MKDIR_BATCH_SIZE) { + const chunk = uniqueDirectories.slice(index, index + SFTP_DIRECTORY_MKDIR_BATCH_SIZE); + await this.execRemote(client, `mkdir -p -- ${chunk.map(directory => quotePosixShellArg(directory)).join(' ')}`); + } + }); + return; + } + catch { + // Some devices expose SFTP but not a POSIX shell; fall back to SFTP mkdir. + } + } + await mapLimit(uniqueDirectories, SFTP_DIRECTORY_ENTRY_CONCURRENCY, directory => this.ensureRemoteDirectory(fallbackSftp, directory)); + } + async withSftpPool(endpoint, requestedConnections, progressId, task) { + if (!endpoint.connection) { + throw new Error('SSH endpoint is missing connection options.'); + } + const configResult = await this.createConnectConfig(endpoint.connection); + if (!configResult.ok) { + throw new Error(configResult.message); + } + const desiredConnections = Math.max(1, Math.min(requestedConnections, SFTP_DIRECTORY_TRANSFER_CONNECTIONS)); + const clients = []; + const sftps = []; + const closeAll = () => { + for (const sftp of sftps) { + try { + sftp.end(); + } + catch { + // Best-effort cleanup. + } + } + for (const client of clients) { + try { + client.end(); + } + catch { + // Best-effort cleanup. + } + } + }; + const unregisterCancel = this.registerTransferCancelHandler(progressId, closeAll); + try { + for (let index = 0; index < desiredConnections; index++) { + this.throwIfTransferCanceled(progressId); + try { + const client = await this.connect(configResult.config); + const sftp = await this.openSftp(client); + clients.push(client); + sftps.push(sftp); + } + catch (error) { + if (!sftps.length) { + throw error; + } + break; + } + } + return await task(sftps); + } + finally { + unregisterCancel(); + closeAll(); + } + } + async runSftpWorkers(sftps, items, progressId, worker) { + let nextIndex = 0; + await Promise.all(sftps.map(async (sftp) => { + while (true) { + this.throwIfTransferCanceled(progressId); + const index = nextIndex; + if (index >= items.length) { + return; + } + nextIndex = index + 1; + await worker(sftp, items[index], index); + } + })); + } + async uploadDirectory(sftp, localPath, remotePath, onDelta, progressId) { + this.throwIfTransferCanceled(progressId); + await this.ensureRemoteDirectory(sftp, remotePath); + const entries = await fsp.readdir(localPath, { withFileTypes: true }); + await mapLimit(entries, SFTP_DIRECTORY_ENTRY_CONCURRENCY, async (entry) => { + this.throwIfTransferCanceled(progressId); + const localChild = join(localPath, entry.name); + const remoteChild = joinRemotePath(remotePath, entry.name); + if (entry.isDirectory()) { + await this.uploadDirectory(sftp, localChild, remoteChild, onDelta, progressId); + } + else if (entry.isFile()) { + await this.fastPut(sftp, localChild, remoteChild, this.createSftpTransferOptions(onDelta, progressId)); + } + }); + } + async downloadDirectory(sftp, remotePath, localPath, onDelta, progressId) { + this.throwIfTransferCanceled(progressId); + await fsp.mkdir(localPath, { recursive: true }); + const entries = await this.readRemoteDirectory(sftp, remotePath); + await mapLimit(entries, SFTP_DIRECTORY_ENTRY_CONCURRENCY, async (entry) => { + this.throwIfTransferCanceled(progressId); + if (entry.filename === '.' || entry.filename === '..') { + return; + } + const remoteChild = joinRemotePath(remotePath, entry.filename); + const localChild = join(localPath, entry.filename); + if (entry.attrs.isDirectory()) { + await this.downloadDirectory(sftp, remoteChild, localChild, onDelta, progressId); + } + else if (entry.attrs.isFile() || entry.attrs.isSymbolicLink()) { + await fsp.mkdir(dirname(localChild), { recursive: true }); + await this.fastGet(sftp, remoteChild, localChild, this.createSftpTransferOptions(onDelta, progressId)); + } + }); + } + async deleteRemoteDirectory(sftp, remotePath) { + const entries = await this.readRemoteDirectory(sftp, remotePath); + for (const entry of entries) { + if (entry.filename === '.' || entry.filename === '..') { + continue; + } + const childPath = joinRemotePath(remotePath, entry.filename); + if (entry.attrs.isDirectory()) { + await this.deleteRemoteDirectory(sftp, childPath); + } + else { + await this.unlinkRemote(sftp, childPath); + } + } + await this.rmdirRemote(sftp, remotePath); + } + toRemoteEntry(parentPath, entry) { + const type = getFileTypeFromStats(entry.attrs); + return { + name: entry.filename, + path: joinRemotePath(parentPath, entry.filename), + type, + size: entry.attrs.size, + modified: entry.attrs.mtime * 1000, + permissions: entry.longname.split(/\s+/)[0] + }; + } + async getNativeRequire() { + if (!this.nativeRequire) { + const nodeModule = await import('node:module'); + this.nativeRequire = nodeModule.createRequire(import.meta.url); + } + return this.nativeRequire; + } +} +async function readWindowsClipboardFilePaths() { + const paths = []; + const acceptPath = (path) => { + const candidate = path.trim().replace(/^"|"$/g, '').replace(/^file:\/\/\/?/i, ''); + if (!isWindowsClipboardPath(candidate)) { + return; + } + paths.push(candidate); + }; + for (const path of await readWindowsClipboardDropListPaths().catch(() => [])) { + acceptPath(path); + } + for (const path of parseWindowsClipboardBuffer(clipboard.readBuffer('FileNameW'), 'utf16le')) { + acceptPath(path); + } + for (const path of parseWindowsClipboardBuffer(clipboard.readBuffer('FileName'), 'utf8')) { + acceptPath(path); + } + for (const path of clipboard.readText('clipboard').split(/\r?\n/)) { + acceptPath(path); + } + return normalizeWindowsTransferPaths(paths, resolveLocalWindowsRealPath); +} +async function readWindowsClipboardDropListPaths() { + if (platform() !== 'win32') { + return []; + } + const script = withPowerShellDefaults(` +Add-Type -AssemblyName System.Windows.Forms +$items = [System.Windows.Forms.Clipboard]::GetFileDropList() +foreach ($item in $items) { + if ([string]::IsNullOrWhiteSpace($item)) { continue } + [Console]::Out.WriteLine([Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($item))) +} +`); + const stdout = await runLocalPowerShellStaScriptBuffer(script, 16 * 1024 * 1024); + return stdout + .toString('utf8') + .split(/\r?\n/) + .map(line => line.trim()) + .filter(Boolean) + .map(line => Buffer.from(line, 'base64').toString('utf8')); +} +function parseWindowsClipboardBuffer(buffer, encoding) { + if (!buffer.byteLength) { + return []; + } + return buffer.toString(encoding).replace(/\0+$/g, '').split('\0').filter(Boolean); +} +function readWindowsClipboardOperation() { + const dropEffect = clipboard.readBuffer('Preferred DropEffect'); + if (dropEffect.byteLength >= 4 && (dropEffect.readUInt32LE(0) & 2) === 2) { + return 'move'; + } + return 'copy'; +} +function isWindowsClipboardPath(path) { + return /^[A-Za-z]:[\\/]/.test(path) || /^\\\\[^\\]+\\[^\\]+/.test(path); +} +function resolveLocalWindowsRealPath(path) { + const realpath = fsp.realpath; + return realpath.native ? realpath.native(path) : fsp.realpath(path); +} +// Match WinSCP's SCP path: run remote "scp -t/-f" and speak the SCP wire protocol over the SSH channel. +class ScpProtocolReader { + constructor(stream) { + this.chunks = []; + this.waiters = []; + this.closed = false; + stream.on('data', data => { + this.chunks.push(typeof data === 'string' ? Buffer.from(data) : data); + this.flushWaiters(); + }); + stream.on('error', error => { + this.streamError = error; + this.flushWaiters(); + }); + stream.on('close', () => { + this.closed = true; + this.flushWaiters(); + }); + } + async readByte() { + const buffer = await this.readBytes(1); + return buffer[0]; + } + async readLine() { + const parts = []; + for (;;) { + await this.waitForData(); + const chunk = this.chunks[0]; + const newlineIndex = chunk.indexOf(10); + if (newlineIndex >= 0) { + parts.push(this.takeFromFirstChunk(newlineIndex + 1)); + return Buffer.concat(parts).toString('utf8').replace(/\n$/, '').replace(/\r$/, ''); + } + parts.push(this.takeFromFirstChunk(chunk.byteLength)); + } + } + async readBytes(byteLength) { + const parts = []; + let remaining = byteLength; + while (remaining > 0) { + await this.waitForData(); + const chunk = this.takeFromFirstChunk(Math.min(remaining, this.chunks[0].byteLength)); + parts.push(chunk); + remaining -= chunk.byteLength; + } + return Buffer.concat(parts, byteLength); + } + async waitForData() { + if (this.streamError) { + throw this.streamError; + } + if (this.chunks.length) { + return; + } + if (this.closed) { + throw new Error('SCP stream closed unexpectedly.'); + } + await new Promise(resolve => this.waiters.push(resolve)); + if (this.streamError) { + throw this.streamError; + } + if (!this.chunks.length && this.closed) { + throw new Error('SCP stream closed unexpectedly.'); + } + } + takeFromFirstChunk(byteLength) { + const chunk = this.chunks[0]; + if (byteLength >= chunk.byteLength) { + this.chunks.shift(); + return chunk; + } + const head = chunk.subarray(0, byteLength); + this.chunks[0] = chunk.subarray(byteLength); + return head; + } + flushWaiters() { + const waiters = this.waiters.splice(0); + for (const waiter of waiters) { + waiter(); + } + } +} +async function readScpResponse(reader, context) { + const code = await reader.readByte(); + if (code === 0) { + return; + } + const message = (await reader.readLine()).trim(); + if (code === 1 || code === 2) { + throw new Error(message || `SCP failed during ${context}.`); + } + throw new Error(`Unexpected SCP response ${code} during ${context}${message ? `: ${message}` : ''}.`); +} +async function readScpFileHeader(reader, stream) { + for (;;) { + const marker = await reader.readByte(); + if (marker === 1 || marker === 2) { + const message = (await reader.readLine()).trim(); + throw new Error(message || 'SCP download failed.'); + } + const line = await reader.readLine(); + if (marker === 84 /* T */) { + await writeScpData(stream, Buffer.from([0])); + continue; + } + if (marker !== 67 /* C */) { + throw new Error(`Unexpected SCP file header: ${String.fromCharCode(marker)}${line}`); + } + const match = line.match(/^[0-7]{4}\s+(\d+)\s+(.+)$/); + if (!match) { + throw new Error(`Invalid SCP file header: C${line}`); + } + const size = Number(match[1]); + if (!Number.isFinite(size) || size < 0) { + throw new Error(`Invalid SCP file size: ${match[1]}`); + } + return { size, name: match[2] }; + } +} +async function readScpFilePayload(reader, localPath, byteLength, onProgress) { + const output = createWriteStream(localPath); + let transferredBytes = 0; + try { + while (transferredBytes < byteLength) { + const chunk = await reader.readBytes(Math.min(1024 * 1024, byteLength - transferredBytes)); + await writeLocalStream(output, chunk); + transferredBytes += chunk.byteLength; + onProgress?.(transferredBytes); + } + await endLocalStream(output); + } + catch (error) { + output.destroy(); + throw error; + } +} +function writeScpData(stream, data) { + return new Promise((resolve, reject) => { + try { + if (stream.write(data)) { + resolve(); + return; + } + const cleanup = () => { + stream.off('drain', onDrain); + stream.off('error', onError); + }; + const onDrain = () => { + cleanup(); + resolve(); + }; + const onError = (error) => { + cleanup(); + reject(error); + }; + stream.once('drain', onDrain); + stream.once('error', onError); + } + catch (error) { + reject(error); + } + }); +} +function writeLocalStream(stream, data) { + return new Promise((resolve, reject) => { + if (stream.write(data)) { + resolve(); + return; + } + const cleanup = () => { + stream.off('drain', onDrain); + stream.off('error', onError); + }; + const onDrain = () => { + cleanup(); + resolve(); + }; + const onError = (error) => { + cleanup(); + reject(error); + }; + stream.once('drain', onDrain); + stream.once('error', onError); + }); +} +function endLocalStream(stream) { + return new Promise((resolve, reject) => { + stream.once('error', reject); + stream.end(resolve); + }); +} +function waitForScpClose(stream) { + return new Promise((resolve, reject) => { + stream.on('close', code => { + if (code === 0 || code === null) { + resolve(); + return; + } + reject(new Error(`SCP command failed with exit code ${code}.`)); + }); + stream.on('error', reject); + }); +} +function formatScpMode(mode) { + return (mode & 0o777).toString(8).padStart(4, '0'); +} +function sanitizeScpFileName(name) { + return name.replace(/[\r\n]/g, '_') || 'payload'; +} +function getTransferStrategy(source, target) { + if (source.kind === 'local' && target.kind === 'local') { + return 'local-copy'; + } + if (source.kind === 'local') { + if (target.preferredProtocol === 'tar') { + return 'tar-upload'; + } + if (target.preferredProtocol !== 'sftp') { + return 'scp-upload'; + } + return 'sftp-upload'; + } + if (target.kind === 'local') { + if (source.preferredProtocol === 'tar') { + return 'tar-download'; + } + if (source.preferredProtocol !== 'sftp') { + return 'scp-download'; + } + return 'sftp-download'; + } + if (source.connection && target.connection && isSameSshConnection(source.connection, target.connection)) { + return 'remote-copy'; + } + return transferAttemptToStrategy(resolveTransferPlan({ source, target, itemCount: 1 }).attempts[0]); +} +function transferAttemptToStrategy(attempt) { + if (!attempt) { + return 'scp-direct'; + } + if (attempt.route === 'direct') { + switch (attempt.protocol) { + case 'rsync': + return 'rsync-direct'; + case 'tar': + return 'tar-direct'; + case 'sftp': + return 'sftp-direct'; + case 'scp': + return 'scp-direct'; + } + } + if (attempt.route === 'relay') { + switch (attempt.protocol) { + case 'rsync': + return 'rsync-relay'; + case 'tar': + return 'tar-relay'; + case 'sftp': + return 'sftp-relay'; + case 'scp': + return 'scp-relay'; + } + } + switch (attempt.protocol) { + case 'rsync': + return 'rsync-upload'; + case 'tar': + return 'tar-upload'; + case 'sftp': + return 'sftp-upload'; + case 'scp': + return 'scp-upload'; + } +} +function transferAttemptToDirectionalStrategy(source, target, attempt) { + if (!attempt || attempt.route !== 'local' || target.kind !== 'local') { + return transferAttemptToStrategy(attempt); + } + switch (attempt.protocol) { + case 'rsync': + return 'rsync-download'; + case 'tar': + return 'tar-download'; + case 'sftp': + return 'sftp-download'; + case 'scp': + return source.kind === 'ssh' ? 'scp-download' : 'scp-upload'; + } +} +function getSshToSshTransferProtocol(source) { + return source.preferredProtocol ?? 'auto-fast'; +} +function formatTransferStrategyName(strategy) { + return strategy.replace(/-/g, ' '); +} +function formatTransferError(error) { + return error instanceof Error ? error.message : String(error); +} +function canBatchRsyncPreserveTargetNames(items) { + return items.every(item => item.targetName === getTransferName(item.sourcePath)); +} +function getLocalCommandTransferProtocolOrder(preferredProtocol, connection) { + if (preferredProtocol === 'auto-fast' || preferredProtocol === undefined) { + return ['scp']; + } + if (preferredProtocol === 'rsync') { + return ['scp']; + } + if (preferredProtocol === 'scp') { + return ['scp']; + } + return preferredProtocol === 'sftp' ? [] : ['scp']; +} +function getDirectoryLocalCommandTransferProtocolOrder(preferredProtocol) { + if (preferredProtocol === 'rsync') { + return ['rsync', 'scp']; + } + if (preferredProtocol === 'scp') { + return ['scp']; + } + if (preferredProtocol === 'sftp') { + return []; + } + return []; +} +function shouldPreferTarForMultiItemTransfer(preferredProtocol) { + return preferredProtocol === undefined || preferredProtocol === 'auto-fast' || preferredProtocol === 'tar'; +} +function shouldUseTarOnlyForBatchTransfer(request) { + if (request.source.kind === 'local' && request.targetDirectory.kind === 'ssh') { + return shouldPreferTarForMultiItemTransfer(request.targetDirectory.preferredProtocol); + } + if (request.source.kind === 'ssh' && request.targetDirectory.kind === 'local') { + return shouldPreferTarForMultiItemTransfer(request.source.preferredProtocol); + } + if (request.source.kind === 'ssh' && request.targetDirectory.kind === 'ssh') { + return shouldPreferTarForMultiItemTransfer(getSshToSshTransferProtocol(request.source)); + } + return false; +} +function canUseNativeOpenSshScpCommand(connection) { + if (!connection) { + return false; + } + return !!connection.password || !!connection.privateKeyPath || !!connection.privateKey || !connection.passphrase; +} +async function withNativeOpenSshArgs(connection, tool, remoteCommand, task) { + let tempPrivateKeyPath; + let tempPasswordPath; + let tempAskPassPath; + const args = [ + '-T', + '-o', 'StrictHostKeyChecking=accept-new', + '-o', 'UserKnownHostsFile=/dev/null', + '-o', 'Compression=no', + '-o', 'IPQoS=throughput', + '-o', 'RekeyLimit=16G', + '-o', 'ConnectTimeout=5', + '-o', 'ConnectionAttempts=1', + '-o', 'ServerAliveInterval=15', + '-o', 'ServerAliveCountMax=2', + '-o', 'LogLevel=ERROR' + ]; + let env = tool.env; + const askPassSecret = connection.password ?? connection.passphrase; + if (askPassSecret) { + tempPasswordPath = join(tmpdir(), `turbofile-native-ssh-${randomUUID()}.secret`); + tempAskPassPath = join(tmpdir(), `turbofile-native-ssh-${randomUUID()}.cmd`); + await fsp.writeFile(tempPasswordPath, askPassSecret, { mode: 0o600 }); + await fsp.writeFile(tempAskPassPath, `@echo off\r\ntype "${tempPasswordPath.replace(/"/g, '""')}"\r\n`, { mode: 0o700 }); + env = { + ...tool.env, + SSH_ASKPASS: tempAskPassPath, + SSH_ASKPASS_REQUIRE: 'force', + DISPLAY: 'turbofile' + }; + args.push('-o', 'NumberOfPasswordPrompts=1'); + args.push('-o', 'PreferredAuthentications=password,keyboard-interactive,publickey'); + } + else { + args.push('-o', 'BatchMode=yes'); + } + if (connection.port && connection.port !== 22) { + args.push('-p', String(connection.port)); + } + if (connection.privateKeyPath) { + args.push('-i', toOpenSshLocalPath(connection.privateKeyPath)); + } + else if (connection.privateKey) { + tempPrivateKeyPath = join(tmpdir(), `turbofile-native-ssh-${randomUUID()}.key`); + await fsp.writeFile(tempPrivateKeyPath, connection.privateKey, { mode: 0o600 }); + args.push('-i', toOpenSshLocalPath(tempPrivateKeyPath)); + } + args.push(`${connection.username}@${connection.host}`, remoteCommand); + try { + return await task(args, env); + } + finally { + await Promise.all([ + tempPrivateKeyPath ? fsp.rm(tempPrivateKeyPath, { force: true }).catch(() => undefined) : undefined, + tempPasswordPath ? fsp.rm(tempPasswordPath, { force: true }).catch(() => undefined) : undefined, + tempAskPassPath ? fsp.rm(tempAskPassPath, { force: true }).catch(() => undefined) : undefined + ]); + } +} +async function withNativeOpenSshScpBaseArgs(connection, task, legacyProtocol = false) { + let tempPrivateKeyPath; + let tempPasswordPath; + let tempAskPassPath; + // Use scp's default (SFTP-based) protocol — measured ~12% faster and more consistent for a + // single large file than the legacy rcp protocol (`-O`), which is also unsupported on older + // scp.exe builds. RekeyLimit=16G avoids mid-transfer key renegotiation stalls on large files. + // NAS-style non-standard SCP ports use legacy scp because their SFTP subsystem can reject + // shell-valid absolute paths even though the SCP wire protocol accepts them. + const args = [ + ...(legacyProtocol ? ['-O'] : []), + '-q', + '-o', 'StrictHostKeyChecking=accept-new', + '-o', 'UserKnownHostsFile=/dev/null', + '-o', 'Compression=no', + '-o', 'IPQoS=throughput', + '-o', 'RekeyLimit=16G', + '-o', 'LogLevel=ERROR' + ]; + let env; + const askPassSecret = connection.password ?? connection.passphrase; + if (askPassSecret) { + tempPasswordPath = join(tmpdir(), `turbofile-native-scp-${randomUUID()}.secret`); + tempAskPassPath = join(tmpdir(), `turbofile-native-scp-${randomUUID()}.cmd`); + await fsp.writeFile(tempPasswordPath, askPassSecret, { mode: 0o600 }); + await fsp.writeFile(tempAskPassPath, `@echo off\r\ntype "${tempPasswordPath.replace(/"/g, '""')}"\r\n`, { mode: 0o700 }); + env = { + ...process.env, + SSH_ASKPASS: tempAskPassPath, + SSH_ASKPASS_REQUIRE: 'force', + DISPLAY: 'turbofile' + }; + args.push('-o', 'NumberOfPasswordPrompts=1'); + } + else { + args.push('-o', 'BatchMode=yes'); + } + if (connection.port && connection.port !== 22) { + args.push('-P', String(connection.port)); + } + if (connection.privateKeyPath) { + args.push('-i', toOpenSshLocalPath(connection.privateKeyPath)); + } + else if (connection.privateKey) { + tempPrivateKeyPath = join(tmpdir(), `turbofile-native-scp-${randomUUID()}.key`); + await fsp.writeFile(tempPrivateKeyPath, connection.privateKey, { mode: 0o600 }); + args.push('-i', toOpenSshLocalPath(tempPrivateKeyPath)); + } + try { + return await task(args, env); + } + finally { + await Promise.all([ + tempPrivateKeyPath ? fsp.rm(tempPrivateKeyPath, { force: true }).catch(() => undefined) : undefined, + tempPasswordPath ? fsp.rm(tempPasswordPath, { force: true }).catch(() => undefined) : undefined, + tempAskPassPath ? fsp.rm(tempAskPassPath, { force: true }).catch(() => undefined) : undefined + ]); + } +} +function formatNativeScpRemoteSpec(connection, remotePath) { + return `${connection.username}@${connection.host}:${remotePath}`; +} +function getNativeScpLocalDirectoryContentsPath(localPath) { + const trimmed = localPath.replace(/[\\/]+$/g, ''); + const separator = trimmed.includes('\\') && !trimmed.includes('/') ? '\\' : '/'; + return `${trimmed}${separator}.`; +} +function getRemoteDirectoryContentsPath(remotePath) { + return `${ensureTrailingRemoteSlash(trimTrailingRemoteSlash(remotePath))}.`; +} +function isRecoverableLocalCommandTransferError(error) { + const message = error instanceof Error ? error.message : String(error); + return /not recognized|ENOENT|No such file|could not create directory|Permission denied|Connection|timed out|failed|lost connection|rsync error|size mismatch/i.test(message); +} +let localSshToolPromise; +let localZstdToolPromise; +let localRsyncToolPromise; +function resolveLocalSshTool() { + localSshToolPromise ??= doResolveLocalSshTool(); + return localSshToolPromise; +} +async function doResolveLocalSshTool() { + if (platform() !== 'win32') { + return createLocalSshTool('ssh'); + } + for (const candidate of getBundledLocalSshCandidates()) { + if (await localPathExists(candidate)) { + return createLocalSshTool(candidate); + } + } + return createLocalSshTool('ssh.exe'); +} +function createLocalSshTool(executable) { + const executableDirectory = /[\\/]/.test(executable) ? dirname(executable) : undefined; + const env = createLocalToolEnvironment(executableDirectory); + return { executable, env }; +} +function resolveLocalZstdTool() { + localZstdToolPromise ??= doResolveLocalZstdTool(); + return localZstdToolPromise; +} +async function doResolveLocalZstdTool() { + if (platform() !== 'win32') { + return createLocalZstdTool('zstd'); + } + const whereOutput = await runLocalCommandBuffer('where.exe', ['zstd.exe'], 1024 * 1024); + const executable = whereOutput.toString().split(/\r?\n/).map(line => line.trim()).find(line => !!line) ?? 'zstd.exe'; + return createLocalZstdTool(executable); +} +function createLocalZstdTool(executable) { + const executableDirectory = /[\\/]/.test(executable) ? dirname(executable) : undefined; + const env = createLocalToolEnvironment(executableDirectory); + return { executable, env }; +} +function resolveLocalRsyncTool() { + localRsyncToolPromise ??= doResolveLocalRsyncTool(); + return localRsyncToolPromise; +} +async function doResolveLocalRsyncTool() { + if (platform() !== 'win32') { + return createLocalRsyncTool('rsync', 'posix'); + } + for (const candidate of getBundledLocalRsyncCandidates()) { + if (await localPathExists(candidate)) { + return createLocalRsyncTool(candidate, 'cygwin'); + } + } + throw new Error('No such file: bundled TurboFile rsync.exe was not found.'); +} +async function createLocalRsyncTool(executable, pathFlavor) { + const executableDirectory = /[\\/]/.test(executable) ? dirname(executable) : undefined; + const sshCandidate = executableDirectory ? join(executableDirectory, 'ssh.exe') : undefined; + const sshExecutable = sshCandidate && await localPathExists(sshCandidate) ? sshCandidate : undefined; + const env = createLocalToolEnvironment(executableDirectory); + if (executableDirectory) { + env.CWRSYNCHOME ??= executableDirectory; + } + return { executable, sshExecutable, pathFlavor, env }; +} +function createLocalToolEnvironment(executableDirectory) { + const env = { ...process.env }; + if (executableDirectory) { + const pathDelimiter = platform() === 'win32' ? ';' : ':'; + env.PATH = [executableDirectory, env.PATH].filter((value) => !!value).join(pathDelimiter); + } + if (platform() === 'win32' && env.USERPROFILE) { + env.HOME = env.USERPROFILE; + } + env.LC_ALL ??= 'C'; + env.LANG ??= 'C'; + return env; +} +function getBundledLocalRsyncCandidates() { + const relativePath = join('resources', 'turbofile', 'rsync', 'win32', 'rsync.exe'); + const appPath = safeGetElectronAppPath(); + const cwd = process.cwd(); + const roots = [ + appPath, + join(appPath, 'out'), + dirname(appPath), + process.resourcesPath, + process.resourcesPath ? join(process.resourcesPath, 'app') : undefined, + cwd, + join(cwd, 'out'), + dirname(cwd) + ].filter((value) => !!value); + return [...new Set(roots.map(root => join(root, relativePath)))]; +} +function getBundledLocalSshCandidates() { + return getBundledLocalRsyncCandidates().map(candidate => join(dirname(candidate), 'ssh.exe')); +} +function safeGetElectronAppPath() { + try { + return app.getAppPath(); + } + catch { + return process.cwd(); + } +} +async function localPathExists(path) { + try { + await fsp.access(path); + return true; + } + catch { + return false; + } +} +async function withLocalRsyncSshEnvironment(connection, tool, task) { + const askPassSecret = connection.password ?? connection.passphrase; + let tempPrivateKeyPath; + let tempPasswordPath; + let tempAskPassPath; + try { + if (connection.privateKey && !connection.privateKeyPath) { + tempPrivateKeyPath = join(tmpdir(), `turbofile-rsync-${randomUUID()}.key`); + await fsp.writeFile(tempPrivateKeyPath, connection.privateKey.replace(/\r\n/g, '\n').replace(/\r/g, '\n').replace(/\n?$/, '\n'), { mode: 0o600 }); + await fsp.chmod(tempPrivateKeyPath, 0o600).catch(() => undefined); + } + if (!askPassSecret) { + return await task(tool.env, tempPrivateKeyPath); + } + const isWindowsHost = platform() === 'win32'; + tempPasswordPath = join(tmpdir(), `turbofile-rsync-${randomUUID()}.secret`); + tempAskPassPath = join(tmpdir(), `turbofile-rsync-${randomUUID()}${isWindowsHost ? '.cmd' : '.sh'}`); + await fsp.writeFile(tempPasswordPath, askPassSecret, { mode: 0o600 }); + if (isWindowsHost) { + // Keep SSH_ASKPASS as a native Windows path. The bundled rsync still needs cygwin-style + // file paths, but MSYS2 ssh.exe can exec a .cmd askpass only when it receives C:\... here. + await fsp.writeFile(tempAskPassPath, `@echo off\r\ntype "${tempPasswordPath.replace(/"/g, '""')}"\r\n`, { mode: 0o700 }); + } + else { + await fsp.writeFile(tempAskPassPath, `#!/bin/sh\ncat ${quotePosixShellArg(tempPasswordPath)}\n`, { mode: 0o700 }); + } + await fsp.chmod(tempAskPassPath, 0o700).catch(() => undefined); + return await task({ + ...tool.env, + SSH_ASKPASS: isWindowsHost ? tempAskPassPath : toRsyncLocalPath(tempAskPassPath, tool.pathFlavor), + SSH_ASKPASS_REQUIRE: 'force', + DISPLAY: 'turbofile' + }, tempPrivateKeyPath); + } + finally { + await Promise.all([ + tempPrivateKeyPath ? fsp.rm(tempPrivateKeyPath, { force: true }).catch(() => undefined) : undefined, + tempPasswordPath ? fsp.rm(tempPasswordPath, { force: true }).catch(() => undefined) : undefined, + tempAskPassPath ? fsp.rm(tempAskPassPath, { force: true }).catch(() => undefined) : undefined + ]); + } +} +function buildLocalRsyncSshTransport(connection, sshExecutable, identityPathOverride) { + const hasAskPassSecret = !!(connection.password ?? connection.passphrase); + const parts = [ + formatLocalRsyncCommand(sshExecutable ?? 'ssh'), + '-T', + '-F /dev/null', + '-o StrictHostKeyChecking=accept-new', + '-o UserKnownHostsFile=/dev/null', + '-o Compression=no', + '-o LogLevel=ERROR', + '-o ConnectTimeout=8', + '-o ConnectionAttempts=1', + '-o ServerAliveInterval=15', + '-o ServerAliveCountMax=2' + ]; + if (hasAskPassSecret) { + parts.push('-o BatchMode=no'); + parts.push('-o NumberOfPasswordPrompts=1'); + parts.push('-o PreferredAuthentications=password,keyboard-interactive,publickey'); + } + else { + parts.push('-o BatchMode=yes'); + } + const identityPath = identityPathOverride ?? connection.privateKeyPath; + if (identityPath) { + const formattedIdentityPath = sshExecutable ? toRsyncLocalPath(identityPath) : toOpenSshLocalPath(identityPath); + parts.push(`-i ${quoteLocalShellArg(formattedIdentityPath)}`); + } + if (connection.port && connection.port !== 22) { + parts.push(`-p ${connection.port}`); + } + return parts.join(' '); +} +function formatLocalRsyncCommand(command) { + return /[\\/]/.test(command) ? quoteLocalShellArg(toRsyncLocalPath(command)) : command; +} +function formatRsyncRemoteSpec(connection, remotePath) { + return `${connection.username}@${connection.host}:${remotePath}`; +} +function toRsyncLocalPath(path, flavor = 'cygwin') { + const normalized = path.replace(/\\/g, '/'); + const driveMatch = normalized.match(/^([A-Za-z]):\/(.*)$/); + if (driveMatch) { + if (flavor === 'posix') { + return normalized; + } + const drive = driveMatch[1].toLowerCase(); + return `/cygdrive/${drive}/${driveMatch[2]}`; + } + return normalized; +} +function toOpenSshLocalPath(path) { + return path.replace(/\\/g, '/'); +} +function normalizeTarListPath(path) { + return path.replace(/\\/g, '/').replace(/^\/+/, '').replace(/\/+$/, ''); +} +function quoteLocalShellArg(value) { + return "'" + value.replace(/'/g, "'\\''") + "'"; +} +function getDirectSshRouteCacheKey(source, target) { + return `${getSshConnectionRouteKey(source)}=>${getSshConnectionRouteKey(target)}`; +} +function getDirectSshRouteFailureKey(cacheKey, route) { + return `${cacheKey}:${route}`; +} +function getSshConnectionRouteKey(connection) { + const auth = connection.password ? 'password' : connection.privateKeyPath || connection.privateKey ? 'privateKey' : 'agent'; + return `${connection.username}@${connection.host}:${connection.port || 22}:${auth}`; +} +function getFileTypeFromStats(stats) { + return stats.isDirectory() ? 'directory' : stats.isFile() ? 'file' : stats.isSymbolicLink() ? 'symlink' : 'unknown'; +} +function joinRemotePath(parentPath, name) { + if (!parentPath || parentPath === '/') { + return `/${name}`; + } + const separator = parentPath.includes('\\') && !parentPath.includes('/') ? '\\' : '/'; + return parentPath.endsWith('/') || parentPath.endsWith('\\') ? `${parentPath}${name}` : `${parentPath}${separator}${name}`; +} +function getRemoteParentPath(path) { + const normalized = path.replace(/\\/g, '/').replace(/\/+$/, ''); + if (!normalized || normalized === '/') { + return '/'; + } + const index = normalized.lastIndexOf('/'); + if (index < 0) { + return '.'; + } + if (index === 0) { + return '/'; + } + return normalized.substring(0, index); +} +function buildShellBootstrapCommand(cwd) { + const commands = [ + 'stty echo', + 'printf "\\033[H\\033[2J\\033[3J"', + cwd ? `cd ${quotePosixShellArg(cwd)} 2>/dev/null || true` : undefined, + `printf "\\n${SSH_TERMINAL_READY_MARKER}\\n"` + ].filter((command) => !!command); + return `${commands.join('; ')}\n`; +} +function ensureTrailingRemoteSlash(path) { + return path.endsWith('/') || path.endsWith('\\') ? path : `${path}/`; +} +function trimTrailingRemoteSlash(path) { + const trimmed = path.replace(/[\\/]+$/g, ''); + return trimmed || '/'; +} +function getRemoteDirectoryFallbackPaths(options, path) { + const normalized = path.replace(/\\/g, '/').replace(/\/+$/, '') || '/'; + const username = options.username; + const candidates = []; + const add = (candidate) => { + const value = candidate.replace(/\\/g, '/').replace(/\/+$/, '') || '/'; + if (value !== normalized && !candidates.includes(value)) { + candidates.push(value); + } + }; + for (const prefix of [`/var/services/homes/${username}`, `/volume1/homes/${username}`]) { + if (normalized === prefix || normalized.startsWith(`${prefix}/`)) { + const suffix = normalized.substring(prefix.length); + add(`/home${suffix}`); + add(`/homes/${username}${suffix}`); + } + } + return candidates; +} +async function mapLimit(items, limit, mapper) { + const results = new Array(items.length); + let nextIndex = 0; + const workerCount = Math.max(1, Math.min(limit, items.length)); + await Promise.all(Array.from({ length: workerCount }, async () => { + while (nextIndex < items.length) { + const currentIndex = nextIndex++; + results[currentIndex] = await mapper(items[currentIndex], currentIndex); + } + })); + return results; +} +function collectBatchTarTargetNames(plans) { + const targetNames = []; + for (const plan of plans) { + targetNames.push(...plan.targetNames); + } + return targetNames; +} +function getTransferName(path) { + const slash = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')); + if (slash >= 0) { + return path.substring(slash + 1); + } + return basename(path); +} +function quotePosixShellArg(value) { + return "'" + value.replace(/'/g, "'\\''") + "'"; +} +function normalizeTransferDependencies(packages) { + const allowed = new Set(['rsync', 'sshpass']); + const normalized = []; + for (const pkg of packages) { + if (allowed.has(pkg) && !normalized.includes(pkg)) { + normalized.push(pkg); + } + } + return normalized; +} +function getMissingTransferDependencies(packages, capabilities) { + return packages.filter(pkg => pkg === 'rsync' ? !capabilities.rsync : !capabilities.sshpass); +} +function buildTransferDependencyInstallCommand(packages, options) { + const packageArgs = packages.map(pkg => quotePosixShellArg(pkg)).join(' '); + const installScript = [ + 'set -e', + `packages="${packages.join(' ')}"`, + 'if command -v apt-get >/dev/null 2>&1; then export DEBIAN_FRONTEND=noninteractive; apt-get update && apt-get install -y "$@"', + 'elif command -v dnf >/dev/null 2>&1; then dnf install -y "$@"', + 'elif command -v yum >/dev/null 2>&1; then yum install -y "$@"', + 'elif command -v pacman >/dev/null 2>&1; then pacman -Sy --noconfirm "$@"', + 'elif command -v zypper >/dev/null 2>&1; then zypper --non-interactive install "$@"', + 'elif command -v apk >/dev/null 2>&1; then apk add --no-cache "$@"', + 'else echo "未找到支持的包管理器,请手动安装 rsync 和 sshpass。" >&2; exit 42', + 'fi' + ].join('; '); + const installViaRoot = `sh -c ${quotePosixShellArg(installScript)} sh ${packageArgs}`; + const installViaPasswordSudo = options.password + ? ` || (printf %s ${quotePosixShellArg(`${options.password}\n`)} | sudo -S -p '' sh -c ${quotePosixShellArg(installScript)} sh ${packageArgs})` + : ''; + // Prefer root or passwordless sudo; password sudo is only attempted when the SSH password is available. + return [ + 'if [ "$(id -u)" -eq 0 ]; then', + installViaRoot, + '; elif command -v sudo >/dev/null 2>&1; then', + `(sudo -n sh -c ${quotePosixShellArg(installScript)} sh ${packageArgs})${installViaPasswordSudo}`, + '; else echo "当前用户不是 root,且远端没有 sudo,无法自动安装。" >&2; exit 43; fi' + ].join(' '); +} +function buildTarCreateFromDirectoryCommand(path) { + return `command -v tar >/dev/null 2>&1 && tar -cf - -C ${quotePosixShellArg(path)} .`; +} +function buildTarCreateFromItemsCommand(parentPath, names) { + const itemArgs = names.map(name => quotePosixShellArg(name)).join(' '); + return `command -v tar >/dev/null 2>&1 && tar -cf - -C ${quotePosixShellArg(parentPath)} -- ${itemArgs}`; +} +function buildRejectExistingDirectoryTransferTargetCommand(path) { + const target = quotePosixShellArg(path); + const message = quotePosixShellArg(`Target directory already exists: ${path}`); + return `if [ -e ${target} ]; then printf '%s\\n' ${message} >&2; exit 73; fi`; +} +function buildPrepareDirectoryTransferTargetCommand(path) { + const target = quotePosixShellArg(path); + return `${buildRejectExistingDirectoryTransferTargetCommand(path)} && mkdir -p -- ${target}`; +} +function buildRejectExistingTargetDirectoriesCommand(targetDirectory, targetNames) { + return targetNames + .map(name => { + const path = joinRemotePath(targetDirectory, name); + const target = quotePosixShellArg(path); + const message = quotePosixShellArg(`Target directory already exists: ${path}`); + return `if [ -d ${target} ]; then printf '%s\\n' ${message} >&2; exit 73; fi`; + }) + .join(' && '); +} +function buildTarExtractToExactDirectoryCommand(path) { + const target = quotePosixShellArg(path); + return `command -v tar >/dev/null 2>&1 && ${buildPrepareDirectoryTransferTargetCommand(path)} && tar -xf - -C ${target}`; +} +function buildPrepareTarExtractToExactDirectoryCommand(path) { + return `command -v tar >/dev/null 2>&1 && ${buildPrepareDirectoryTransferTargetCommand(path)}`; +} +function buildTarExtractStreamToDirectoryCommand(path) { + const target = quotePosixShellArg(path); + return `command -v tar >/dev/null 2>&1 && tar -xf - -C ${target}`; +} +function buildPrepareTarZstdExtractToExactDirectoryCommand(path) { + return `command -v tar >/dev/null 2>&1 && command -v zstd >/dev/null 2>&1 && ${buildPrepareDirectoryTransferTargetCommand(path)}`; +} +function buildTarZstdExtractToDirectoryCommand(path) { + const target = quotePosixShellArg(path); + return `command -v tar >/dev/null 2>&1 && command -v zstd >/dev/null 2>&1 && zstd -d -c | tar -xf - -C ${target}`; +} +function buildTarExtractToDirectoryCommand(targetDirectory, targetNames) { + const target = quotePosixShellArg(targetDirectory); + const conflictCheck = buildRejectExistingTargetDirectoriesCommand(targetDirectory, targetNames); + return `command -v tar >/dev/null 2>&1 && mkdir -p -- ${target} && ${conflictCheck ? `${conflictCheck} && ` : ''}tar -xf - -C ${target}`; +} +function buildPrepareTarExtractToDirectoryFromListCommand(targetDirectory, requireZstd) { + const target = quotePosixShellArg(targetDirectory); + const commands = [ + 'set -e', + 'command -v tar >/dev/null 2>&1', + requireZstd ? 'command -v zstd >/dev/null 2>&1' : undefined, + `target=${target}`, + 'mkdir -p -- "$target"', + 'while IFS= read -r -d \'\' name; do if [ -n "$name" ] && [ -d "$target/$name" ]; then printf \'%s\\n\' "Target directory already exists: $target/$name" >&2; exit 73; fi; done' + ].filter((command) => !!command); + return `bash -lc ${quotePosixShellArg(commands.join('; '))}`; +} +function buildTarSourcePushCommand(sourcePath, targetPath, targetUserHost, sshCommand, sshpassPrefix) { + const prepareTarget = `command -v tar >/dev/null 2>&1 && ${buildPrepareDirectoryTransferTargetCommand(targetPath)}`; + const extractTarget = `tar -xf - -C ${quotePosixShellArg(targetPath)}`; + const remoteShell = `${sshpassPrefix}${sshCommand} ${quotePosixShellArg(targetUserHost)}`; + return `command -v tar >/dev/null 2>&1 && ${remoteShell} ${quotePosixShellArg(prepareTarget)} && tar -cf - -C ${quotePosixShellArg(sourcePath)} . | ${remoteShell} ${quotePosixShellArg(extractTarget)}`; +} +function buildTarItemsSourcePushCommand(parentPath, names, targetDirectory, targetNames, targetUserHost, sshCommand, sshpassPrefix) { + const target = quotePosixShellArg(targetDirectory); + const conflictCheck = buildRejectExistingTargetDirectoriesCommand(targetDirectory, targetNames); + const prepareTarget = `command -v tar >/dev/null 2>&1 && mkdir -p -- ${target} && ${conflictCheck ? `${conflictCheck} && ` : ''}true`; + const extractTarget = `tar -xf - -C ${target}`; + const remoteShell = `${sshpassPrefix}${sshCommand} ${quotePosixShellArg(targetUserHost)}`; + return `${remoteShell} ${quotePosixShellArg(prepareTarget)} && ${buildTarCreateFromItemsCommand(parentPath, names)} | ${remoteShell} ${quotePosixShellArg(extractTarget)}`; +} +function buildTarTargetPullCommand(sourcePath, targetPath, sourceUserHost, sshCommand, sshpassPrefix) { + const createSource = `command -v tar >/dev/null 2>&1 && tar -cf - -C ${quotePosixShellArg(sourcePath)} .`; + return `command -v tar >/dev/null 2>&1 && ${buildPrepareDirectoryTransferTargetCommand(targetPath)} && ${sshpassPrefix}${sshCommand} ${quotePosixShellArg(sourceUserHost)} ${quotePosixShellArg(createSource)} | tar -xf - -C ${quotePosixShellArg(targetPath)}`; +} +function buildTarItemsTargetPullCommand(parentPath, names, targetDirectory, targetNames, sourceUserHost, sshCommand, sshpassPrefix) { + const target = quotePosixShellArg(targetDirectory); + const conflictCheck = buildRejectExistingTargetDirectoriesCommand(targetDirectory, targetNames); + const createSource = buildTarCreateFromItemsCommand(parentPath, names); + return `command -v tar >/dev/null 2>&1 && mkdir -p -- ${target} && ${conflictCheck ? `${conflictCheck} && ` : ''}${sshpassPrefix}${sshCommand} ${quotePosixShellArg(sourceUserHost)} ${quotePosixShellArg(createSource)} | tar -xf - -C ${target}`; +} +function waitForLocalProcessClose(child, context) { + return new Promise((resolve, reject) => { + let stderr = ''; + child.stderr?.on('data', data => { + stderr += data.toString(); + }); + child.on('error', reject); + child.on('close', code => { + if (code === 0) { + resolve(); + return; + } + reject(new Error(stderr.trim() || `${context} failed with exit code ${code ?? 'unknown'}.`)); + }); + }); +} +function createProgressPassThrough(onDelta) { + const stream = new PassThrough({ highWaterMark: 4 * 1024 * 1024 }); + if (onDelta) { + stream.on('data', chunk => { + onDelta(Buffer.isBuffer(chunk) ? chunk.byteLength : Buffer.byteLength(String(chunk))); + }); + } + return stream; +} +function runLocalCommand(command, args, options = {}) { + return new Promise((resolve, reject) => { + execFile(command, args, { windowsHide: true, maxBuffer: 10 * 1024 * 1024, env: options.env }, (error, _stdout, stderr) => { + if (error) { + reject(new Error(formatLocalCommandError(stderr, error.message))); + return; + } + resolve(); + }); + }); +} +function killLocalProcessTree(child) { + if (child.pid && platform() === 'win32') { + execFile('taskkill.exe', ['/pid', String(child.pid), '/t', '/f'], { windowsHide: true }, () => undefined); + return; + } + child.kill('SIGTERM'); +} +function runLocalCommandBuffer(command, args, maxBuffer = 10 * 1024 * 1024) { + return new Promise((resolve, reject) => { + execFile(command, args, { windowsHide: true, maxBuffer, encoding: 'buffer' }, (error, stdout, stderr) => { + if (error) { + reject(new Error(formatLocalCommandError(stderr?.toString(), error.message))); + return; + } + resolve(stdout); + }); + }); +} +function formatLocalCommandError(stderr, fallback) { + const message = (stderr ?? '').trim(); + if (!message) { + return fallback; + } + if (message.startsWith('#< CLIXML') && message.includes('System.Management.Automation.PSCustomObject')) { + return fallback; + } + return message; +} +function runLocalPowerShellScript(script) { + return runLocalCommand('powershell.exe', ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-EncodedCommand', encodePowerShellCommand(script)]); +} +function runLocalPowerShellStaScriptBuffer(script, maxBuffer = 10 * 1024 * 1024) { + return runLocalCommandBuffer('powershell.exe', ['-NoProfile', '-STA', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-EncodedCommand', encodePowerShellCommand(script)], maxBuffer); +} +function createWindowsDragIcon() { + const icon = nativeImage.createFromDataURL(WINDOWS_DRAG_ICON_DATA_URL); + return icon.isEmpty() ? nativeImage.createEmpty() : icon; +} +async function getWindowsExplorerVisibleLocalNames(path) { + if (platform() !== 'win32') { + return undefined; + } + try { + const stdout = await runLocalCommandBuffer('cmd.exe', ['/d', '/u', '/c', 'dir', '/b', '/a:-h-s', path], 64 * 1024 * 1024); + return new Set(stdout.toString('utf16le').replace(/^\uFEFF/, '').split(/\r?\n/).filter(name => !!name)); + } + catch { + return undefined; + } +} +function runLocalRobocopyCommand(args) { + return runLocalRobocopyCommandBuffer(args).then(() => undefined); +} +function runLocalRobocopyCommandBuffer(args) { + return new Promise((resolve, reject) => { + execFile('robocopy.exe', args, { windowsHide: true, maxBuffer: 1024 * 1024 }, (error, stdout, stderr) => { + if (!error) { + resolve(stdout); + return; + } + const exitCode = typeof error.code === 'number' ? error.code : Number.NaN; + if (Number.isFinite(exitCode) && exitCode <= 7) { + resolve(stdout); + return; + } + reject(new Error(formatLocalCommandError(stderr, error.message))); + }); + }); +} +async function ensureLocalParentDirectory(path) { + const parent = dirname(path); + await ensureLocalDirectory(parent); +} +async function ensureLocalDirectoryTransferTargetAvailable(path) { + await throwIfLocalPathExists(path); + await ensureLocalDirectory(path); +} +async function throwIfLocalPathExists(path) { + const stat = await fsp.stat(path).catch(error => { + if (isFileNotFoundError(error)) { + return undefined; + } + throw error; + }); + if (stat) { + throw new Error(`Target directory already exists: ${path}`); + } +} +async function throwIfLocalTargetDirectoryExists(path) { + const stat = await fsp.stat(path).catch(error => { + if (isFileNotFoundError(error)) { + return undefined; + } + throw error; + }); + if (stat?.isDirectory()) { + throw new Error(`Target directory already exists: ${path}`); + } +} +function isFileNotFoundError(error) { + return error?.code === 'ENOENT'; +} +function isRemoteFileNotFoundError(error) { + const code = error?.code; + if (code === 2 || code === 'ENOENT') { + return true; + } + const message = error instanceof Error ? error.message : String(error); + return /ENOENT|No such file|not found/i.test(message); +} +async function ensureLocalDirectory(path) { + if (!path || isLocalFilesystemRoot(path)) { + return; + } + await fsp.mkdir(path, { recursive: true }); +} +function isLocalFilesystemRoot(path) { + const normalized = path.replace(/[\\/]+$/, ''); + return !normalized || normalized === '/' || normalized === '\\' || /^[A-Za-z]:$/.test(normalized) || /^\\\\[^\\]+\\[^\\]+$/.test(normalized); +} +function delay(milliseconds) { + return new Promise(resolve => setTimeout(resolve, milliseconds)); +} +function isSameSshConnection(left, right) { + return left.host === right.host + && (left.port || 22) === (right.port || 22) + && left.username === right.username; +} +function buildOptimizedRsyncSshCommand(port, hasPassword = false, sshBinary = 'ssh') { + const parts = [ + sshBinary, + '-T', + '-o Compression=no', + '-o Ciphers=aes128-ctr', + '-o ConnectTimeout=5', + '-o ConnectionAttempts=1', + '-o NumberOfPasswordPrompts=1', + '-o ServerAliveInterval=15', + '-o ServerAliveCountMax=2', + '-o StrictHostKeyChecking=no', + '-o UserKnownHostsFile=/dev/null', + '-o LogLevel=ERROR', + '-o MACs=umac-64@openssh.com', + '-o ControlMaster=auto', + '-o ControlPersist=300', + '-o ControlPath=/tmp/turbofile-ssh-%r@%h:%p' + ]; + if (hasPassword) { + parts.push('-o BatchMode=no'); + parts.push('-o PreferredAuthentications=password,keyboard-interactive,publickey'); + } + else { + parts.push('-o BatchMode=yes'); + } + if (port && port !== 22) { + parts.push(`-p ${port}`); + } + return parts.join(' '); +} +function buildOptimizedSftpCommand(port, hasPassword = false) { + const parts = [ + 'sftp', + '-q', + '-b -', + '-o Compression=no', + '-o ConnectTimeout=5', + '-o ConnectionAttempts=1', + '-o NumberOfPasswordPrompts=1', + '-o ServerAliveInterval=15', + '-o ServerAliveCountMax=2', + '-o StrictHostKeyChecking=no', + '-o UserKnownHostsFile=/dev/null', + '-o LogLevel=ERROR' + ]; + if (hasPassword) { + parts.push('-o BatchMode=no'); + parts.push('-o PreferredAuthentications=password,keyboard-interactive,publickey'); + } + else { + parts.push('-o BatchMode=yes'); + } + if (port && port !== 22) { + parts.push(`-P ${port}`); + } + return parts.join(' '); +} +function shouldPreferLegacyScpMode(connection) { + return !!connection.port && connection.port !== 22; +} +function buildDirectScpCommand(port, hasPassword = false, identityPath, recursive = false) { + const parts = [ + 'scp', + '-q', + '-o Compression=no', + '-o IPQoS=throughput', + '-o RekeyLimit=16G', + '-o ConnectTimeout=5', + '-o ConnectionAttempts=1', + '-o NumberOfPasswordPrompts=1', + '-o ServerAliveInterval=15', + '-o ServerAliveCountMax=2', + '-o StrictHostKeyChecking=no', + '-o UserKnownHostsFile=/dev/null', + '-o LogLevel=ERROR' + ]; + if (hasPassword) { + parts.push('-o BatchMode=no'); + parts.push('-o PreferredAuthentications=password,keyboard-interactive,publickey'); + } + else { + parts.push('-o BatchMode=yes'); + } + if (identityPath) { + parts.push(`-i ${quotePosixShellArg(identityPath)}`); + } + if (recursive) { + parts.push('-r'); + } + if (port && port !== 22) { + parts.push(`-P ${port}`); + } + return parts.join(' '); +} +function buildDirectScpSshCommand(port, hasPassword = false, identityPath) { + const parts = [ + 'ssh', + '-o Compression=no', + '-o IPQoS=throughput', + '-o RekeyLimit=16G', + '-o ConnectTimeout=5', + '-o ConnectionAttempts=1', + '-o NumberOfPasswordPrompts=1', + '-o ServerAliveInterval=15', + '-o ServerAliveCountMax=2', + '-o StrictHostKeyChecking=no', + '-o UserKnownHostsFile=/dev/null', + '-o LogLevel=ERROR' + ]; + if (hasPassword) { + parts.push('-o BatchMode=no'); + parts.push('-o PreferredAuthentications=password,keyboard-interactive,publickey'); + } + else { + parts.push('-o BatchMode=yes'); + } + if (identityPath) { + parts.push(`-i ${quotePosixShellArg(identityPath)}`); + } + if (port && port !== 22) { + parts.push(`-p ${port}`); + } + return parts.join(' '); +} +function buildCompatibleSshCommand(port, hasPassword = false) { + const parts = [ + 'ssh', + '-o Compression=no', + '-o ConnectTimeout=5', + '-o ConnectionAttempts=1', + '-o NumberOfPasswordPrompts=1', + '-o ServerAliveInterval=15', + '-o ServerAliveCountMax=2', + '-o StrictHostKeyChecking=no', + '-o UserKnownHostsFile=/dev/null', + '-o LogLevel=ERROR' + ]; + if (hasPassword) { + parts.push('-o BatchMode=no'); + parts.push('-o PreferredAuthentications=password,keyboard-interactive,publickey'); + } + else { + parts.push('-o BatchMode=yes'); + } + if (port && port !== 22) { + parts.push(`-p ${port}`); + } + return parts.join(' '); +} +function buildSshpassPrefix(password) { + return password ? `sshpass -p ${quotePosixShellArg(password)} ` : ''; +} +function quoteSftpBatchPath(value) { + return `"${value.replace(/(["\\])/g, '\\$1')}"`; +} +function quotePowerShellLiteral(value) { + return `'${value.replace(/'/g, "''")}'`; +} +function encodePowerShellCommand(script) { + return Buffer.from(script, 'utf16le').toString('base64'); +} +function buildPowerShellCommand(script) { + return `powershell -NoProfile -NonInteractive -EncodedCommand ${encodePowerShellCommand(script)}`; +} +function withPowerShellDefaults(script) { + return `$ErrorActionPreference='Stop'; $ProgressPreference='SilentlyContinue'; ${script}`; +} +function buildLocalWindowsMoveScript(sourcePath, targetPath) { + const source = quotePowerShellLiteral(sourcePath); + const target = quotePowerShellLiteral(targetPath); + return withPowerShellDefaults(`$src=${source}; $dst=${target}; Move-Item -LiteralPath $src -Destination $dst -Force`); +} +function buildLocalWindowsBatchScript(sourcePaths, targetDirectory, operation) { + const sources = sourcePaths.map(path => quotePowerShellLiteral(path)).join(','); + const target = quotePowerShellLiteral(targetDirectory); + const command = operation === 'move' ? 'Move-Item' : 'Copy-Item'; + const recurse = operation === 'copy' ? ' -Recurse' : ''; + return withPowerShellDefaults(`$srcs=@(${sources}); $dst=${target}; if (-not (Test-Path -LiteralPath $dst)) { New-Item -ItemType Directory -Force -Path $dst | Out-Null }; foreach ($src in $srcs) { ${command} -LiteralPath $src -Destination $dst -Force${recurse} }`); +} +function buildWindowsCopyCommand(sourcePath, targetPath, sourceType) { + const source = quotePowerShellLiteral(sourcePath); + const target = quotePowerShellLiteral(targetPath); + if (sourceType === 'directory') { + return buildPowerShellCommand(withPowerShellDefaults(`$src=${source}; $dst=${target}; $parent=[System.IO.Path]::GetDirectoryName($dst); if ($parent -and -not (Test-Path -LiteralPath $parent)) { New-Item -ItemType Directory -Force -Path $parent | Out-Null }; if ((Test-Path -LiteralPath $dst) -and -not (Test-Path -LiteralPath $dst -PathType Container)) { throw "Target directory conflicts with a file: $dst" }; if (-not (Test-Path -LiteralPath $dst)) { New-Item -ItemType Directory -Force -Path $dst | Out-Null }; Get-ChildItem -LiteralPath $src -Force | ForEach-Object { Copy-Item -LiteralPath $_.FullName -Destination $dst -Force -Recurse }`)); + } + return buildPowerShellCommand(withPowerShellDefaults(`$src=${source}; $dst=${target}; $parent=[System.IO.Path]::GetDirectoryName($dst); if ($parent -and -not (Test-Path -LiteralPath $parent)) { New-Item -ItemType Directory -Force -Path $parent | Out-Null }; Copy-Item -LiteralPath $src -Destination $dst -Force -Recurse`)); +} +function buildWindowsMoveCommand(sourcePath, targetPath) { + const source = quotePowerShellLiteral(sourcePath); + const target = quotePowerShellLiteral(targetPath); + return buildPowerShellCommand(withPowerShellDefaults(`$src=${source}; $dst=${target}; $parent=[System.IO.Path]::GetDirectoryName($dst); if ($parent -and -not (Test-Path -LiteralPath $parent)) { New-Item -ItemType Directory -Force -Path $parent | Out-Null }; Move-Item -LiteralPath $src -Destination $dst -Force`)); +} +function buildPosixCopyCommand(sourcePath, targetPath, sourceType) { + const source = quotePosixShellArg(sourcePath); + const target = quotePosixShellArg(targetPath); + const parent = quotePosixShellArg(getRemoteParentPath(targetPath)); + if (sourceType === 'directory') { + const conflictMessage = quotePosixShellArg(`Target directory conflicts with a file: ${targetPath}`); + return `mkdir -p ${parent} && if [ -e ${target} ] && [ ! -d ${target} ]; then printf '%s\\n' ${conflictMessage} >&2; exit 73; fi && mkdir -p -- ${target} && (cp -a --reflink=auto --sparse=always -- ${source}/. ${target}/ 2>/dev/null || cp -a -- ${source}/. ${target}/)`; + } + return `mkdir -p ${parent} && (cp -a --reflink=auto --sparse=always ${source} ${target} 2>/dev/null || cp -a ${source} ${target})`; +} +function buildWindowsBatchCommand(items, targetDirectory, operation) { + const entries = items.map(item => `@{Source=${quotePowerShellLiteral(item.sourcePath)};Name=${quotePowerShellLiteral(item.targetName)};Type=${quotePowerShellLiteral(item.sourceType ?? 'unknown')}}`).join(','); + const target = quotePowerShellLiteral(targetDirectory); + const moveCommand = 'Move-Item -LiteralPath $src -Destination $dst -Force'; + const copyCommand = 'if ($item.Type -eq \'directory\') { if ((Test-Path -LiteralPath $dst) -and -not (Test-Path -LiteralPath $dst -PathType Container)) { throw "Target directory conflicts with a file: $dst" }; if (-not (Test-Path -LiteralPath $dst)) { New-Item -ItemType Directory -Force -Path $dst | Out-Null }; Get-ChildItem -LiteralPath $src -Force | ForEach-Object { Copy-Item -LiteralPath $_.FullName -Destination $dst -Force -Recurse } } else { Copy-Item -LiteralPath $src -Destination $dst -Force }'; + return buildPowerShellCommand(withPowerShellDefaults(`$items=@(${entries}); $target=${target}; if (-not (Test-Path -LiteralPath $target)) { New-Item -ItemType Directory -Force -Path $target | Out-Null }; foreach ($item in $items) { $src=[string]$item.Source; $dst=Join-Path $target ([string]$item.Name); ${operation === 'move' ? moveCommand : copyCommand} }`)); +} +function buildWindowsBatchDeleteCommand(sourcePaths) { + const sources = sourcePaths.map(path => quotePowerShellLiteral(path)).join(','); + return buildPowerShellCommand(withPowerShellDefaults(`$srcs=@(${sources}); foreach ($src in $srcs) { if (Test-Path -LiteralPath $src) { Remove-Item -LiteralPath $src -Force -Recurse } }`)); +} +function buildLocalWindowsReverseParallelRsyncCommand(sourceConnection, items, sshTransport, cleanupTargets) { + const rsyncOptions = [...buildLocalRsyncOptionArgs(true), '--iconv=UTF-8,UTF-8'].join(' '); + const sshpassPrefix = buildSshpassPrefix(sourceConnection.password); + const sourceUserHost = `${sourceConnection.username}@${sourceConnection.host}`; + const commands = items.map((item, index) => { + const sourcePath = item.directory ? ensureTrailingRemoteSlash(item.sourcePath) : item.sourcePath; + const targetPath = item.directory ? ensureTrailingRemoteSlash(item.targetPath) : ensureTrailingRemoteSlash(getRemoteParentPath(item.targetPath)); + const mkdirPath = item.directory ? item.targetPath : getRemoteParentPath(item.targetPath); + const sourceSpec = `${sourceUserHost}:${sourcePath}`; + const rsyncCommand = `${sshpassPrefix}rsync ${rsyncOptions} -e ${quotePosixShellArg(sshTransport)} ${quotePosixShellArg(sourceSpec)} ${quotePosixShellArg(targetPath)}`; + const progressTag = quotePosixShellArg(`__TURBOFILE_RSYNC_${index}__ `); + const progressFilter = `tr '\\r' '\\n' | awk -v tag=${progressTag} ${quotePosixShellArg('$0 ~ /%/ { now=systime(); if (now != last || $0 ~ /100%/) { print tag $0; fflush(); last=now } }')}`; + return `(mkdir -p -- ${quotePosixShellArg(mkdirPath)} && ${rsyncCommand} 2>&1 | ${progressFilter}) & pids="$pids $!"`; + }); + const cleanupTargetArgs = Array.from(new Set(cleanupTargets.map(target => trimTrailingRemoteSlash(target)).filter(target => target && target !== '/'))).map(target => quotePosixShellArg(target)).join(' '); + const script = [ + 'set -o pipefail', + 'command -v rsync >/dev/null 2>&1', + 'command -v sshpass >/dev/null 2>&1', + cleanupTargetArgs ? `rm -rf -- ${cleanupTargetArgs}` : undefined, + 'pids=""', + ...commands, + 'failed=0', + 'for pid in $pids; do if ! wait "$pid"; then failed=1; fi; done', + 'if [ "$failed" -ne 0 ]; then exit 1; fi' + ].filter((command) => !!command); + return `bash -lc ${quotePosixShellArg(script.join('; '))}`; +} +function getLocalWindowsRsyncConfigPaths() { + const candidates = [ + process.env.TURBOFILE_LOCAL_WINDOWS_CONFIG, + process.env.TURBOFILE_WEB_CONFIG, + join(process.cwd(), '..', 'TurboFile-web', 'data', 'config.json'), + join(app.getAppPath(), '..', 'TurboFile-web', 'data', 'config.json') + ].filter((value) => !!value); + return Array.from(new Set(candidates)); +} +function getLocalIpv4Addresses() { + const addresses = new Set(); + for (const entries of Object.values(networkInterfaces())) { + for (const entry of entries ?? []) { + if (entry.family === 'IPv4' && !entry.internal) { + addresses.add(entry.address); + } + } + } + return addresses; +} +function asRecord(value) { + return value && typeof value === 'object' && !Array.isArray(value) ? value : undefined; +} +function readStringProperty(source, key) { + const value = source[key]; + return typeof value === 'string' && value ? value : undefined; +} +function readNumberProperty(source, key) { + const value = source[key]; + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + if (typeof value === 'string' && value.trim()) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; + } + return undefined; +} +function buildRsyncOptions(includeProgress) { + return buildLocalRsyncOptionArgs(includeProgress).join(' '); +} +function parseRsyncSpeedBytesPerSecond(value, unit) { + const parsed = Number(value.replace(',', '.')); + if (!Number.isFinite(parsed) || parsed < 0) { + return undefined; + } + const normalizedUnit = unit.toUpperCase(); + const multiplier = normalizedUnit === 'P' + ? 1024 ** 5 + : normalizedUnit === 'T' + ? 1024 ** 4 + : normalizedUnit === 'G' + ? 1024 ** 3 + : normalizedUnit === 'M' + ? 1024 ** 2 + : normalizedUnit === 'K' + ? 1024 + : 1; + return Math.max(0, Math.round(parsed * multiplier)); +} +function buildLocalRsyncOptionArgs(includeProgress) { + const options = [ + '-a', + '--inplace', + '--whole-file', + '--no-compress', + '--numeric-ids', + '--timeout=600', + '-s', + '--no-perms', + '--no-owner', + '--no-group', + '--omit-dir-times' + ]; + if (includeProgress) { + options.push('--info=progress2', '--outbuf=N'); + } + return options; +} +function buildRsyncPathWithMkdirOption(remoteDirectory) { + return `--rsync-path=mkdir -p ${quotePosixShellArg(remoteDirectory)} && rsync`; +} +function buildRsyncPathWithMkdirShellOption(remoteDirectory) { + return `--rsync-path=${quotePosixShellArg(`mkdir -p ${quotePosixShellArg(remoteDirectory)} && rsync`)}`; +} +function buildWindowsDeleteCommand(path, recursive) { + const literalPath = path.replace(/'/g, "''"); + const recurseFlag = recursive ? ' -Recurse' : ''; + return `powershell -NoProfile -NonInteractive -Command "$ErrorActionPreference='Stop'; $path='${literalPath}'; if (Test-Path -LiteralPath $path) { Remove-Item -LiteralPath $path -Force${recurseFlag} }"`; +} +function buildWindowsDeleteManyCommand(paths, recursive) { + const list = paths.map(path => `'${path.replace(/'/g, "''")}'`).join(','); + const recurseFlag = recursive ? ' -Recurse' : ''; + return `powershell -NoProfile -NonInteractive -Command "$ErrorActionPreference='Stop'; foreach ($p in @(${list})) { if (Test-Path -LiteralPath $p) { Remove-Item -LiteralPath $p -Force${recurseFlag} } }"`; +} +function buildWindowsSizeCommand(path) { + const target = quotePowerShellLiteral(path); + return buildPowerShellCommand(withPowerShellDefaults(`$path=${target}; if (Test-Path -LiteralPath $path -PathType Container) { $dest=Join-Path ([System.IO.Path]::GetTempPath()) 'turbofile-size-probe'; $output=& robocopy $path $dest /L /E /BYTES /R:0 /W:0 /NFL /NDL /NJH /NP /NC /NS /XJ 2>&1; if ($LASTEXITCODE -gt 7) { throw (($output | Out-String).Trim()) }; $match=[regex]::Match(($output | Out-String), '(?im)^\\s*Bytes\\s*:\\s*([0-9][0-9,\\.]*)'); if (-not $match.Success) { throw 'Unable to parse robocopy size output.' }; [Int64]($match.Groups[1].Value -replace '[^0-9]', '') } elseif (Test-Path -LiteralPath $path -PathType Leaf) { [Int64](Get-Item -LiteralPath $path).Length } else { throw 'Path not found' }`)); +} +function buildPosixSizeCommand(path) { + const quotedPath = quotePosixShellArg(path); + return `p=${quotedPath}; if [ -d "$p" ]; then size=''; if command -v du >/dev/null 2>&1; then size=$(du -sb -- "$p" 2>/dev/null | awk 'NR==1 {print $1; exit}'); if [ -z "$size" ]; then size=$(du -sk -- "$p" 2>/dev/null | awk 'NR==1 {printf "%.0f\\n", $1 * 1024; exit}'); fi; fi; if [ -z "$size" ]; then size=$(find "$p" -type f -printf '%s\\n' 2>/dev/null | awk '{s += $1} END {printf "%.0f\\n", s + 0}'); fi; printf '%s\\n' "$size"; elif [ -f "$p" ] || [ -L "$p" ]; then stat -c '%s' -- "$p"; else echo 'Path not found' >&2; exit 2; fi`; +} +function buildPosixContentSizeCommand(path) { + const quotedPath = quotePosixShellArg(path); + return `p=${quotedPath}; if [ -d "$p" ]; then find "$p" -type f -printf '%s\\n' 2>/dev/null | awk '{s += $1} END {printf "%.0f\\n", s + 0}'; elif [ -f "$p" ] || [ -L "$p" ]; then stat -c '%s' -- "$p"; else echo 'Path not found' >&2; exit 2; fi`; +} +function buildPosixHumanSizeCommand(path) { + const quotedPath = quotePosixShellArg(path); + return `du -sh -- ${quotedPath} 2>/dev/null | awk 'NR==1 {print $1; exit}'`; +} +function buildPosixFastDirectoryListCommand(path) { + const quotedPath = quotePosixShellArg(path); + return `LC_ALL=C find ${quotedPath} -mindepth 1 -maxdepth 1 -printf '%f\\0%y\\0%s\\0%T@\\0'`; +} +// Names + type only. `%y` is resolved from the directory entry's d_type on common filesystems, so +// this avoids a stat() per file and returns instantly even for directories with hundreds of +// thousands of entries. Size and modified time are filled in lazily for visible rows afterwards. +function buildPosixNamesAndTypesListCommand(path) { + const quotedPath = quotePosixShellArg(path); + return `LC_ALL=C find ${quotedPath} -mindepth 1 -maxdepth 1 -printf '%f\\0%y\\0'`; +} +// Size + modified time for specific entries in one round trip. Names are passed relative to the +// directory (via `cd`) so `%n` echoes them back exactly, letting the caller map results by name. +function buildPosixStatEntriesCommand(directoryPath, names) { + return buildRemotePosixStatEntriesCommand(directoryPath, names); +} +function parseRemoteStatEntries(text) { + if (!text) { + return []; + } + const parts = text.split('\0'); + const stats = []; + for (let index = 0; index + 2 < parts.length; index += 3) { + const rawName = parts[index]; + if (!rawName) { + continue; + } + // `stat` echoes the argument exactly, i.e. with the leading "./" we passed; strip it back off. + const name = rawName.replace(/^\.\//, '').replace(/\r?\n$/, ''); + if (!name) { + continue; + } + const size = Number(parts[index + 1]); + const modifiedSeconds = Number(parts[index + 2]); + stats.push({ + name, + size: Number.isFinite(size) ? size : 0, + // `%Y` is epoch seconds; the rest of the app (see toRemoteEntry) carries modified time in + // milliseconds and formatDate expects ms, so convert here — otherwise every row renders 1970. + modified: Number.isFinite(modifiedSeconds) ? Math.floor(modifiedSeconds) * 1000 : 0 + }); + } + return stats; +} +function parseSizeCommandOutput(output) { + const match = output.trim().match(/\d+/); + if (!match) { + throw new Error(`Unable to parse path size: ${output.trim() || 'empty output'}`); + } + const size = Number(match[0]); + if (!Number.isFinite(size)) { + throw new Error(`Invalid path size: ${match[0]}`); + } + return size; +} +// Streams each file as "\n\n" in one round trip. Small originals (<= the raw +// passthrough size) stream as-is. Larger ones are downscaled to a small JPEG by a remote tool when +// present (vipsthumbnail — very fast and light on ARM boards — else ImageMagick magick/convert); this +// shrinks the transfer dramatically so big images load almost as fast as small ones. With no tool, +// files within `maxBytes` stream raw and larger ones report size -1 (caller falls back per-image). +// All of this is pure shell + an optional resize; nothing depends on the client. +function buildPosixReadImagesBatchCommand(paths, maxBytes, maxWidth) { + const list = paths.map(path => quotePosixShellArg(path)).join(' '); + // maxWidth <= 0 means "raw original, no downscale" (used by the single-image preview). Otherwise we + // downscale large originals to a thumbnail when a remote tool exists. Everything is pure shell + + // `cat` (and an optional resize), so it works on minimal busybox shells and never depends on SFTP — + // boards/NAS where SFTP is flaky or no image tool exists still stream the raw bytes for the client + // to decode. A trailing `:` guarantees a zero exit so a single unreadable file never fails the batch. + const forceRaw = !maxWidth || maxWidth <= 0; + const width = Math.max(96, Math.floor(maxWidth) || 480); + const rawPassthrough = 768 * 1024; + // TIFF has no in-browser decoder, so it must be transcoded to JPEG by a remote tool regardless of + // size: full resolution for the single-image preview (forceRaw), the thumbnail width otherwise. + const tiffWidth = forceRaw ? 0 : width; + const lines = []; + // Always probe for an image tool: it downscales large originals AND is the only way to render TIFF. + // Probing is just a couple of `command -v` calls, so it stays cheap even on weak boards. + lines.push(`if command -v vipsthumbnail >/dev/null 2>&1; then T=vips`); + lines.push(`elif command -v magick >/dev/null 2>&1; then T=magick`); + lines.push(`elif command -v convert >/dev/null 2>&1; then T=convert`); + lines.push(`else T=; fi`); + lines.push(`emit(){ if [ "$2" -le ${maxBytes} ]; then printf '%s\\n%s\\n' "$1" "$2"; cat "$1"; else printf '%s\\n-1\\n' "$1"; fi; }`); + // Transcode $1 to JPEG file $2. $3>0 downscales to that width; $3=0 keeps the native resolution. + // `[0]` takes the first page of a multi-page TIFF for ImageMagick; vips reads page 0 by default. + lines.push(`mkjpg(){ if [ "$3" -gt 0 ]; then case "$T" in vips) vipsthumbnail "$1" --size "$3x" -o "$2[Q=82,strip]" >/dev/null 2>&1;; magick|convert) "$T" "$1[0]" -resize "$3x" -strip -quality 82 jpg:"$2" >/dev/null 2>&1;; *) return 1;; esac; else case "$T" in vips) vipsthumbnail "$1" --size 30000x30000 -o "$2[Q=88,strip]" >/dev/null 2>&1;; magick|convert) "$T" "$1[0]" -strip -quality 88 jpg:"$2" >/dev/null 2>&1;; *) return 1;; esac; fi }`); + // Emit a transcoded JPEG (length-framed); returns non-zero without emitting if transcoding fails. + lines.push(`tojpg(){ o=$(mktemp 2>/dev/null) || o=/tmp/tfthumb.$$; oj="$o.jpg"; if mkjpg "$1" "$oj" "$3" && [ -s "$oj" ]; then tz=$(wc -c < "$oj" 2>/dev/null | tr -d ' \\t\\n'); if [ -n "$tz" ] && [ "$tz" -le ${maxBytes} ]; then printf '%s\\n%s\\n' "$1" "$tz"; cat "$oj"; rm -f "$o" "$oj" 2>/dev/null; return 0; fi; fi; rm -f "$o" "$oj" 2>/dev/null; return 1; }`); + lines.push(`for f in ${list}; do`); + lines.push(`[ -f "$f" ] || continue`); + lines.push(`sz=$(wc -c < "$f" 2>/dev/null | tr -d ' \\t\\n'); [ -n "$sz" ] || continue`); + // TIFF can never stream raw (browsers can't decode it): always transcode, or report -1 if no tool. + lines.push(`case $(printf '%s' "$f" | tr 'A-Z' 'a-z') in *.tif|*.tiff) if [ -n "$T" ] && tojpg "$f" "$sz" ${tiffWidth}; then :; else printf '%s\\n-1\\n' "$f"; fi; continue;; esac`); + if (forceRaw) { + lines.push(`emit "$f" "$sz"`); + } + else { + lines.push(`if [ "$sz" -le ${rawPassthrough} ] || [ -z "$T" ]; then emit "$f" "$sz"; continue; fi`); + lines.push(`if tojpg "$f" "$sz" ${width}; then :; else emit "$f" "$sz"; fi`); + } + lines.push(`done`); + lines.push(`:`); + return lines.join('\n'); +} +function parseImageBatchOutput(buffer) { + const entries = []; + let position = 0; + while (position < buffer.length) { + const pathEnd = buffer.indexOf(0x0a, position); + if (pathEnd < 0) { + break; + } + const path = buffer.toString('utf8', position, pathEnd); + position = pathEnd + 1; + const sizeEnd = buffer.indexOf(0x0a, position); + if (sizeEnd < 0) { + break; + } + const size = parseInt(buffer.toString('utf8', position, sizeEnd), 10); + position = sizeEnd + 1; + if (!path) { + break; + } + if (!Number.isFinite(size) || size < 0) { + entries.push({ path, ok: false, oversized: true }); + continue; + } + if (position + size > buffer.length) { + entries.push({ path, ok: false, message: 'Truncated image batch stream.', transient: true }); + break; + } + const bytes = buffer.subarray(position, position + size); + position += size; + // TIFF is always returned transcoded to JPEG (browsers can't decode TIFF), so tag the bytes as + // JPEG rather than by the source extension. + const lowerPath = path.toLowerCase(); + const mime = lowerPath.endsWith('.tif') || lowerPath.endsWith('.tiff') ? 'image/jpeg' : getMimeType(path); + entries.push({ path, ok: true, content: bytes.toString('base64'), mime }); + } + return entries; +} +function buildPosixDeleteCommand(path, recursive) { + const quotedPath = quotePosixShellArg(path); + const removeCommand = recursive ? `rm -rf -- ${quotedPath}` : `rm -f -- ${quotedPath}`; + return `if [ -e ${quotedPath} ] || [ -L ${quotedPath} ]; then ${removeCommand}; fi`; +} +function getMimeType(path) { + const lower = path.toLowerCase(); + if (lower.endsWith('.png')) { + return 'image/png'; + } + if (lower.endsWith('.jpg') || lower.endsWith('.jpeg')) { + return 'image/jpeg'; + } + if (lower.endsWith('.gif')) { + return 'image/gif'; + } + if (lower.endsWith('.webp')) { + return 'image/webp'; + } + if (lower.endsWith('.bmp')) { + return 'image/bmp'; + } + if (lower.endsWith('.tif') || lower.endsWith('.tiff')) { + return 'image/tiff'; + } + if (lower.endsWith('.svg')) { + return 'image/svg+xml'; + } + if (lower.endsWith('.json')) { + return 'application/json'; + } + if (lower.endsWith('.html') || lower.endsWith('.htm')) { + return 'text/html'; + } + if (lower.endsWith('.md') || lower.endsWith('.txt') || lower.endsWith('.log') || lower.endsWith('.csv') || lower.endsWith('.xml') || lower.endsWith('.yaml') || lower.endsWith('.yml')) { + return 'text/plain'; + } + return 'application/octet-stream'; +} +function clampPositiveInt(value) { + if (!Number.isFinite(value) || !value || value < 0) { + return 0; + } + return Math.max(0, Math.round(value)); +} +function clampQuality(value) { + if (!Number.isFinite(value) || !value) { + return 82; + } + return Math.min(95, Math.max(1, Math.round(value))); +} +function shouldKeepOriginalImageSize(size, targetWidth, targetHeight) { + if (!targetWidth && !targetHeight) { + return true; + } + if (!size?.width || !size.height) { + return false; + } + const widthRatio = targetWidth ? targetWidth / size.width : 1; + const heightRatio = targetHeight ? targetHeight / size.height : 1; + const ratio = Math.min(widthRatio, heightRatio, 1); + return Number.isFinite(ratio) && ratio >= 1; +} +function buildImageGeometry(width, height) { + if (width > 0 && height > 0) { + return `${width}x${height}>`; + } + if (width > 0) { + return `${width}x${width}>`; + } + if (height > 0) { + return `${height}x${height}>`; + } + return ''; +} +function looksLikeWindowsPath(path) { + return /^[A-Za-z]:[\\/]/.test(path); +} +function getImageSize(buffer) { + const bmpSize = getBmpImageSize(buffer); + if (bmpSize) { + return bmpSize; + } + try { + const image = nativeImage.createFromBuffer(buffer); + if (image.isEmpty()) { + return undefined; + } + const size = image.getSize(); + if (!size.width || !size.height) { + return undefined; + } + return { + width: size.width, + height: size.height + }; + } + catch { + return undefined; + } +} +function getBmpImageSize(buffer) { + if (buffer.length < 30 || buffer[0] !== 0x42 || buffer[1] !== 0x4d) { + return undefined; + } + const headerSize = buffer.readUInt32LE(14); + if (headerSize < 12) { + return undefined; + } + try { + if (headerSize === 12) { + const width = buffer.readUInt16LE(18); + const height = buffer.readUInt16LE(20); + return width && height ? { width, height } : undefined; + } + if (buffer.length < 26) { + return undefined; + } + const width = Math.abs(buffer.readInt32LE(18)); + const height = Math.abs(buffer.readInt32LE(22)); + return width && height ? { width, height } : undefined; + } + catch { + return undefined; + } +} +function tryCreateBmpPngVariant(buffer, targetWidth, targetHeight) { + const decoded = decodeBmpPixels(buffer); + if (!decoded) { + return undefined; + } + const outputSize = getResizedImageSize(decoded.width, decoded.height, targetWidth, targetHeight); + const png = encodeBmpPixelsToPng(decoded, outputSize.width, outputSize.height); + return { + buffer: png, + mime: 'image/png', + width: outputSize.width, + height: outputSize.height, + originalWidth: decoded.width, + originalHeight: decoded.height + }; +} +function getResizedImageSize(sourceWidth, sourceHeight, targetWidth, targetHeight) { + if (!targetWidth && !targetHeight) { + return { width: sourceWidth, height: sourceHeight }; + } + let width = targetWidth; + let height = targetHeight; + if (!width) { + width = Math.max(1, Math.round(sourceWidth * (height / sourceHeight))); + } + if (!height) { + height = Math.max(1, Math.round(sourceHeight * (width / sourceWidth))); + } + const ratio = Math.min(width / sourceWidth, height / sourceHeight, 1); + if (!Number.isFinite(ratio) || ratio <= 0) { + return { width: sourceWidth, height: sourceHeight }; + } + return { + width: Math.max(1, Math.round(sourceWidth * ratio)), + height: Math.max(1, Math.round(sourceHeight * ratio)) + }; +} +function decodeBmpPixels(buffer) { + if (buffer.length < 54 || buffer[0] !== 0x42 || buffer[1] !== 0x4d) { + return undefined; + } + const pixelOffset = buffer.readUInt32LE(10); + const headerSize = buffer.readUInt32LE(14); + if (headerSize < 40 || pixelOffset >= buffer.length) { + return undefined; + } + const rawWidth = buffer.readInt32LE(18); + const rawHeight = buffer.readInt32LE(22); + const planes = buffer.readUInt16LE(26); + const bitsPerPixel = buffer.readUInt16LE(28); + const compression = buffer.readUInt32LE(30); + if (planes !== 1 || compression !== 0 || (bitsPerPixel !== 24 && bitsPerPixel !== 32)) { + return undefined; + } + const width = Math.abs(rawWidth); + const height = Math.abs(rawHeight); + if (!width || !height) { + return undefined; + } + const bytesPerPixel = bitsPerPixel / 8; + const rowStride = Math.floor((bitsPerPixel * width + 31) / 32) * 4; + if (pixelOffset + rowStride * height > buffer.length) { + return undefined; + } + return { + buffer, + pixelOffset, + width, + height, + bytesPerPixel, + rowStride, + topDown: rawHeight < 0 + }; +} +function encodeBmpPixelsToPng(image, outputWidth, outputHeight) { + const bytesPerRow = outputWidth * 3; + const raw = Buffer.alloc((bytesPerRow + 1) * outputHeight); + for (let y = 0; y < outputHeight; y++) { + const rowStart = y * (bytesPerRow + 1); + raw[rowStart] = 0; + const sourceY = Math.min(image.height - 1, Math.floor((y + 0.5) * image.height / outputHeight)); + const bmpY = image.topDown ? sourceY : image.height - 1 - sourceY; + const bmpRowStart = image.pixelOffset + bmpY * image.rowStride; + for (let x = 0; x < outputWidth; x++) { + const sourceX = Math.min(image.width - 1, Math.floor((x + 0.5) * image.width / outputWidth)); + const bmpPixel = bmpRowStart + sourceX * image.bytesPerPixel; + const outputPixel = rowStart + 1 + x * 3; + raw[outputPixel] = image.buffer[bmpPixel + 2]; + raw[outputPixel + 1] = image.buffer[bmpPixel + 1]; + raw[outputPixel + 2] = image.buffer[bmpPixel]; + } + } + return encodePngRgb(outputWidth, outputHeight, raw); +} +function encodePngRgb(width, height, rawRgbRows) { + const signature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + const ihdr = Buffer.alloc(13); + ihdr.writeUInt32BE(width, 0); + ihdr.writeUInt32BE(height, 4); + ihdr[8] = 8; + ihdr[9] = 2; + ihdr[10] = 0; + ihdr[11] = 0; + ihdr[12] = 0; + return Buffer.concat([ + signature, + createPngChunk('IHDR', ihdr), + createPngChunk('IDAT', deflateSync(rawRgbRows, { level: 1 })), + createPngChunk('IEND', Buffer.alloc(0)) + ]); +} +function createPngChunk(type, data) { + const typeBuffer = Buffer.from(type, 'ascii'); + const chunk = Buffer.alloc(12 + data.length); + chunk.writeUInt32BE(data.length, 0); + typeBuffer.copy(chunk, 4); + data.copy(chunk, 8); + const crc = pngCrc32(Buffer.concat([typeBuffer, data])); + chunk.writeUInt32BE(crc, 8 + data.length); + return chunk; +} +let pngCrcTable; +function pngCrc32(buffer) { + const table = getPngCrcTable(); + let crc = 0xffffffff; + for (const byte of buffer) { + crc = table[(crc ^ byte) & 0xff] ^ (crc >>> 8); + } + return (crc ^ 0xffffffff) >>> 0; +} +function getPngCrcTable() { + if (pngCrcTable) { + return pngCrcTable; + } + const table = new Uint32Array(256); + for (let index = 0; index < table.length; index++) { + let value = index; + for (let bit = 0; bit < 8; bit++) { + value = (value & 1) ? 0xedb88320 ^ (value >>> 1) : value >>> 1; + } + table[index] = value >>> 0; + } + pngCrcTable = table; + return table; +} +function assertImageBuffer(buffer, mime) { + if (!buffer.byteLength) { + throw new Error('Remote thumbnail command returned empty output.'); + } + if (isProbablyImageBuffer(buffer, mime)) { + return; + } + throw new Error('Remote thumbnail command did not return a decodable image.'); +} +function isProbablyImageBuffer(buffer, mime) { + if (mime === 'image/jpeg') { + return buffer.length >= 3 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff; + } + if (mime === 'image/png') { + return buffer.length >= 8 && buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4e && buffer[3] === 0x47 && buffer[4] === 0x0d && buffer[5] === 0x0a && buffer[6] === 0x1a && buffer[7] === 0x0a; + } + if (mime === 'image/webp') { + return buffer.length >= 12 && buffer.toString('ascii', 0, 4) === 'RIFF' && buffer.toString('ascii', 8, 12) === 'WEBP'; + } + if (mime === 'image/gif') { + return buffer.length >= 6 && (buffer.toString('ascii', 0, 6) === 'GIF87a' || buffer.toString('ascii', 0, 6) === 'GIF89a'); + } + return !!getImageSize(buffer); +} +function getDefaultArchivePath(path, extension) { + return `${path}.${extension}`; +} +function getDefaultExtractDirectory(path) { + const name = getTransferName(stripArchiveExtension(path)) || 'archive'; + return joinRemotePath(getRemoteParentPath(path), name); +} +function stripArchiveExtension(path) { + const lower = path.toLowerCase(); + for (const extension of ['.tar.gz', '.tgz', '.tar.bz2', '.tbz2', '.tar.xz', '.txz', '.tar.zst', '.tzst', '.zip', '.rar', '.7z', '.tar', '.gz', '.bz2', '.xz']) { + if (lower.endsWith(extension)) { + return path.substring(0, path.length - extension.length); + } + } + return path; +} +function formatBytes(size) { + if (size < 1024) { + return `${size} B`; + } + const kb = size / 1024; + if (kb < 1024) { + return `${kb.toFixed(1)} KB`; + } + const mb = kb / 1024; + if (mb < 1024) { + return `${mb.toFixed(1)} MB`; + } + return `${(mb / 1024).toFixed(1)} GB`; +} +function formatHumanSize(size) { + const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']; + let value = Number(size); + if (!Number.isFinite(value)) { + return `${size} bytes`; + } + let unitIndex = 0; + while (value >= 1024 && unitIndex < units.length - 1) { + value /= 1024; + unitIndex++; + } + return `${value.toFixed(2)} ${units[unitIndex]}`; +} +async function getAvailableLocalDirectory(path) { + let candidate = path; + for (let index = 0; index < 100; index++) { + try { + const stat = await fsp.stat(candidate); + if (stat.isDirectory()) { + return candidate; + } + candidate = `${path}_extracted${index ? index : ''}`; + } + catch { + return candidate; + } + } + return `${path}_extracted`; +} +async function getLocalPathSize(path) { + const stat = await fsp.stat(path); + if (!stat.isDirectory()) { + return stat.size; + } + const entries = await fsp.readdir(path, { withFileTypes: true }); + const sizes = await mapLimit(entries, LOCAL_DIRECTORY_STAT_CONCURRENCY, async (entry) => { + const childPath = join(path, entry.name); + if (entry.isDirectory() || entry.isFile()) { + return getLocalPathSize(childPath); + } + return 0; + }); + return sizes.reduce((total, size) => total + size, 0); +} +async function getLocalPathSizeByCommand(path) { + const stat = await fsp.stat(path); + if (!stat.isDirectory()) { + return stat.size; + } + if (platform() !== 'win32') { + return getLocalPathSize(path); + } + try { + const output = await runLocalRobocopyCommandBuffer(buildWindowsDirectorySizeRobocopyArgs(path, join(tmpdir(), 'turbofile-size-probe'))); + return parseWindowsRobocopySizeOutput(output); + } + catch { + return getLocalPathSize(path); + } +} +async function getLocalBatchTargetSize(targetDirectory, targetNames) { + let total = 0; + for (const targetName of targetNames) { + total += await getLocalPathSize(join(targetDirectory, targetName)).catch(() => 0); + } + return total; +} +async function getLocalPathTransferSize(path, expectedBytes) { + if (expectedBytes && expectedBytes > 0) { + return expectedBytes; + } + return getLocalPathSize(path); +} +function buildWindowsDirectorySizeRobocopyArgs(sourcePath, destinationPath) { + return [ + sourcePath, + destinationPath, + '/L', + '/E', + '/BYTES', + '/R:0', + '/W:0', + '/NFL', + '/NDL', + '/NJH', + '/NP', + '/NC', + '/NS', + '/XJ' + ]; +} +function parseWindowsRobocopySizeOutput(output) { + const line = output.split(/\r?\n/).find(candidate => /^\s*Bytes\s*:/i.test(candidate)); + if (!line) { + throw new Error(`Unable to parse robocopy size output: ${output.trim() || 'empty output'}`); + } + const firstToken = line.slice(line.indexOf(':') + 1).trim().split(/\s+/)[0] ?? ''; + const digits = firstToken.replace(/[^\d]/g, ''); + if (!digits) { + throw new Error(`Invalid robocopy size output: ${line.trim()}`); + } + const size = Number(digits); + if (!Number.isFinite(size)) { + throw new Error(`Invalid robocopy size: ${digits}`); + } + return size; +} +function compareFileEntries(a, b) { + if (a.type === 'directory' && b.type !== 'directory') { + return -1; + } + if (a.type !== 'directory' && b.type === 'directory') { + return 1; + } + return FILE_ENTRY_NAME_COLLATOR.compare(a.name, b.name); +} +function createFastDirectoryEntryStats(type, size, mtime) { + return { + size, + mtime, + isDirectory: () => type === 'directory', + isFile: () => type === 'file', + isSymbolicLink: () => type === 'symlink' + }; +} +//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoidHVyYm9maWxlU3NoTWFpblNlcnZpY2UuanMiLCJzb3VyY2VSb290IjoiZmlsZTovLy9EOi9Xb3JrL0NvZGUvVHVyYm9GaWxlLVdpbmRvd3Mvc3JjLyIsInNvdXJjZXMiOlsidnMvcGxhdGZvcm0vdHVyYm9maWxlL2VsZWN0cm9uLW1haW4vdHVyYm9maWxlU3NoTWFpblNlcnZpY2UudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBQUE7OztnR0FHZ0c7QUFHaEcsT0FBTyxFQUFFLGdCQUFnQixFQUFFLGlCQUFpQixFQUFFLFVBQVUsRUFBRSxRQUFRLElBQUksR0FBRyxFQUFFLE1BQU0sSUFBSSxDQUFDO0FBQ3RGLE9BQU8sRUFBRSxRQUFRLEVBQUUsT0FBTyxFQUFFLElBQUksRUFBRSxNQUFNLFdBQVcsQ0FBQztBQUNwRCxPQUFPLEVBQUUsaUJBQWlCLEVBQUUsUUFBUSxFQUFFLE1BQU0sRUFBRSxNQUFNLFNBQVMsQ0FBQztBQUM5RCxPQUFPLEVBQUUsVUFBVSxFQUFFLFVBQVUsRUFBRSxNQUFNLGFBQWEsQ0FBQztBQUNyRCxPQUFPLEVBQUUsUUFBUSxFQUFFLEtBQUssRUFBcUIsTUFBTSxvQkFBb0IsQ0FBQztBQUN4RSxPQUFPLEVBQUUsV0FBVyxFQUFFLE1BQU0sYUFBYSxDQUFDO0FBQzFDLE9BQU8sRUFBRSxRQUFRLEVBQUUsTUFBTSxzQkFBc0IsQ0FBQztBQUNoRCxPQUFPLEVBQUUsT0FBTyxJQUFJLFVBQVUsRUFBVSxNQUFNLFVBQVUsQ0FBQztBQUN6RCxPQUFPLEVBQUUsV0FBVyxFQUFFLE1BQU0sV0FBVyxDQUFDO0FBQ3hDLE9BQU8sRUFBRSxHQUFHLEVBQUUsYUFBYSxFQUFFLFNBQVMsRUFBRSxXQUFXLEVBQUUsUUFBUSxFQUFFLE1BQU0sVUFBVSxDQUFDO0FBQ2hGLE9BQU8sRUFBRSxPQUFPLEVBQUUsS0FBSyxFQUFFLE1BQU0sK0JBQStCLENBQUM7QUFFL0QsT0FBTyxFQUFFLHVCQUF1QixFQUFFLE1BQU0sd0NBQXdDLENBQUM7QUFDakYsT0FB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 \ No newline at end of file diff --git a/out/vs/platform/turbofile/test/common/turbofileWindowsPath.test.js b/out/vs/platform/turbofile/test/common/turbofileWindowsPath.test.js new file mode 100644 index 00000000..8dcd62a8 --- /dev/null +++ b/out/vs/platform/turbofile/test/common/turbofileWindowsPath.test.js @@ -0,0 +1,26 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import * as assert from 'assert'; +import { normalizeWindowsTransferPaths } from '../../common/turbofileWindowsPath.js'; +suite('TurboFileWindowsPath', () => { + test('deduplicates Windows short and long names after realpath resolution', async () => { + const paths = await normalizeWindowsTransferPaths([ + 'C:\\Users\\me\\Desktop\\BAOYU_~1', + 'C:\\Users\\me\\Desktop\\BaoYu_1344_8_0_3_2' + ], async (path) => path.includes('BAOYU_~1') + ? 'C:\\Users\\me\\Desktop\\BaoYu_1344_8_0_3_2' + : path); + assert.deepStrictEqual(paths, ['C:\\Users\\me\\Desktop\\BaoYu_1344_8_0_3_2']); + }); + test('keeps the original path when realpath is unavailable', async () => { + const paths = await normalizeWindowsTransferPaths([ + 'C:\\Users\\me\\Desktop\\missing' + ], async () => { + throw new Error('not found'); + }); + assert.deepStrictEqual(paths, ['C:\\Users\\me\\Desktop\\missing']); + }); +}); +//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoidHVyYm9maWxlV2luZG93c1BhdGgudGVzdC5qcyIsInNvdXJjZVJvb3QiOiJmaWxlOi8vL0Q6L1dvcmsvQ29kZS9UdXJib0ZpbGUtV2luZG93cy9zcmMvIiwic291cmNlcyI6WyJ2cy9wbGF0Zm9ybS90dXJib2ZpbGUvdGVzdC9jb21tb24vdHVyYm9maWxlV2luZG93c1BhdGgudGVzdC50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFBQTs7O2dHQUdnRztBQUVoRyxPQUFPLEtBQUssTUFBTSxNQUFNLFFBQVEsQ0FBQztBQUNqQyxPQUFPLEVBQUUsNkJBQTZCLEVBQUUsTUFBTSxzQ0FBc0MsQ0FBQztBQUVyRixLQUFLLENBQUMsc0JBQXNCLEVBQUUsR0FBRyxFQUFFO0lBQ2xDLElBQUksQ0FBQyxxRUFBcUUsRUFBRSxLQUFLLElBQUksRUFBRTtRQUN0RixNQUFNLEtBQUssR0FBRyxNQUFNLDZCQUE2QixDQUFDO1lBQ2pELGtDQUFrQztZQUNsQyw0Q0FBNEM7U0FDNUMsRUFBRSxLQUFLLEVBQUMsSUFBSSxFQUFDLEVBQUUsQ0FBQyxJQUFJLENBQUMsUUFBUSxDQUFDLFVBQVUsQ0FBQztZQUN6QyxDQUFDLENBQUMsNENBQTRDO1lBQzlDLENBQUMsQ0FBQyxJQUFJLENBQUMsQ0FBQztRQUVULE1BQU0sQ0FBQyxlQUFlLENBQUMsS0FBSyxFQUFFLENBQUMsNENBQTRDLENBQUMsQ0FBQyxDQUFDO0lBQy9FLENBQUMsQ0FBQyxDQUFDO0lBRUgsSUFBSSxDQUFDLHNEQUFzRCxFQUFFLEtBQUssSUFBSSxFQUFFO1FBQ3ZFLE1BQU0sS0FBSyxHQUFHLE1BQU0sNkJBQTZCLENBQUM7WUFDakQsaUNBQWlDO1NBQ2pDLEVBQUUsS0FBSyxJQUFJLEVBQUU7WUFDYixNQUFNLElBQUksS0FBSyxDQUFDLFdBQVcsQ0FBQyxDQUFDO1FBQzlCLENBQUMsQ0FBQyxDQUFDO1FBRUgsTUFBTSxDQUFDLGVBQWUsQ0FBQyxLQUFLLEVBQUUsQ0FBQyxpQ0FBaUMsQ0FBQyxDQUFDLENBQUM7SUFDcEUsQ0FBQyxDQUFDLENBQUM7QUFDSixDQUFDLENBQUMsQ0FBQyJ9 \ No newline at end of file diff --git a/out/vs/workbench/contrib/turbofile/browser/media/turbofile.css b/out/vs/workbench/contrib/turbofile/browser/media/turbofile.css new file mode 100644 index 00000000..372a18c6 --- /dev/null +++ b/out/vs/workbench/contrib/turbofile/browser/media/turbofile.css @@ -0,0 +1,6493 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +@font-face { + font-family: "Archivo"; + src: url("./fonts/Archivo-Regular.ttf") format("truetype"); + font-weight: 400; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: "Archivo"; + src: url("./fonts/Archivo-Medium.ttf") format("truetype"); + font-weight: 500; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: "Archivo"; + src: url("./fonts/Archivo-SemiBold.ttf") format("truetype"); + font-weight: 600; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: "Archivo"; + src: url("./fonts/Archivo-Bold.ttf") format("truetype"); + font-weight: 700; + font-style: normal; + font-display: swap; +} + +.turbofile-workbench { + --tf-bg: var(--vscode-editor-background); + --tf-bg-secondary: var(--vscode-sideBar-background, var(--vscode-editor-background)); + --tf-surface: var(--vscode-sideBar-background, var(--vscode-editor-background)); + --tf-surface-muted: color-mix(in srgb, var(--vscode-foreground) 6%, var(--vscode-editor-background)); + --tf-text: var(--vscode-foreground); + --tf-text-muted: var(--vscode-descriptionForeground); + --tf-text-weak: color-mix(in srgb, var(--vscode-foreground) 52%, transparent); + --tf-brand-blue: var(--vscode-textLink-foreground, #5b8dff); + --tf-brand-blue-dark: var(--vscode-foreground); + --tf-accent-yellow: #e1ff25; + --tf-accent-pink: #ff64da; + --tf-success: #22c55e; + --tf-danger: var(--vscode-errorForeground, #ef4444); + --tf-border: color-mix(in srgb, var(--vscode-foreground) 14%, transparent); + --tf-card-radius: 8px; + --tf-control-radius: 6px; + --tf-card-shadow: none; + --tf-card-shadow-hover: 0 8px 24px rgba(0, 0, 0, 0.24); + --tf-brand-soft: color-mix(in srgb, var(--tf-brand-blue) 16%, transparent); + --tf-brand-soft-hover: color-mix(in srgb, var(--tf-brand-blue) 22%, transparent); + --tf-yellow-soft: rgba(225, 255, 37, 0.26); + --tf-pink-soft: rgba(255, 100, 218, 0.16); + --turbofile-unified-background: var(--vscode-activityBar-background, var(--vscode-sideBar-background, var(--vscode-editor-background))); + --turbofile-file-surface-final: var(--vscode-editor-background); + --tf-file-surface: var(--turbofile-file-surface-final); + --turbofile-pane-border: var(--tf-border); + position: relative; + height: 100%; + min-width: 620px; + display: block; + background: var(--turbofile-unified-background); + color: var(--vscode-foreground); + font-family: var(--turbofile-ui-font-family, "Archivo", "Microsoft YaHei UI", "Microsoft YaHei", "Segoe UI", sans-serif); + font-size: var(--turbofile-ui-font-size, 14px); + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; + overflow: hidden; +} + +.turbofile-workbench.is-turbofile-light { + --tf-bg: #ffffff; + --tf-bg-secondary: #f7f7f7; + --tf-surface: #ffffff; + --tf-surface-muted: #f7f7f7; + --tf-text: #1a1a1a; + --tf-text-muted: #616161; + --tf-text-weak: #8a8a8a; + --tf-brand-blue: #0078d4; + --tf-brand-blue-dark: #1a1a1a; + --tf-accent-yellow: #e1ff25; + --tf-accent-pink: #ff64da; + --tf-success: #22c55e; + --tf-danger: #ef4444; + --tf-border: #e5e5e5; + --tf-card-shadow: none; + --tf-card-shadow-hover: none; + --tf-brand-soft: #e5f3ff; + --tf-brand-soft-hover: #d7ebff; + --tf-windows-nav-bg: #f7f7f7; + --tf-windows-hover: #f0f0f0; + --tf-windows-selected: #e5f3ff; + --tf-windows-selected-border: #99d1ff; + --tf-windows-selected-neutral: #e5e5e5; + --tf-yellow-soft: rgba(225, 255, 37, 0.26); + --tf-pink-soft: rgba(255, 100, 218, 0.16); + --turbofile-unified-background: var(--tf-bg); + --turbofile-file-surface-final: #ffffff; + --tf-file-surface: var(--turbofile-file-surface-final); +} + +body.turbofile-theme-switching .monaco-workbench, +body.turbofile-theme-switching .monaco-workbench *, +body.turbofile-theme-switching .part.editor, +body.turbofile-theme-switching .part.sidebar, +body.turbofile-theme-switching .part.activitybar, +body.turbofile-theme-switching .part.titlebar, +body.turbofile-theme-switching .part.panel, +body.turbofile-theme-switching .part.statusbar, +body.turbofile-theme-switching .turbofile-workbench, +body.turbofile-theme-switching .turbofile-workbench * { + transition: none !important; +} + +.turbofile-editor-shell { + height: 100%; + min-height: 0; + display: block; + overflow: hidden; +} + +body.turbofile-shell { + /* Match the sidebar part's minimum width (sidebarPart.ts minimumWidth = 54). Requesting a + narrower sidebar gets clamped to 54, so pinning the collapsed nav/taskbar to that same width + keeps the nav, the expand/collapse toggle and the bottom taskbar aligned exactly with the + file-browser (editor) boundary. */ + --turbofile-page-nav-width: 54px; + --turbofile-taskbar-height: 24px; +} + +body.turbofile-shell:has(.turbofile-page-nav.is-nav-expanded) { + --turbofile-page-nav-width: 152px; +} + +.turbofile-editor-content { + height: 100%; + min-width: 0; + min-height: 0; + overflow: hidden; +} + +body.turbofile-shell.turbofile-main-editor-visible .monaco-workbench, +body.turbofile-shell.turbofile-main-editor-visible .part.editor, +body.turbofile-shell.turbofile-main-editor-visible .part.sidebar, +body.turbofile-shell.turbofile-main-editor-visible .part.panel, +body.turbofile-shell.turbofile-main-editor-visible .part.statusbar { + background-color: var(--vscode-activityBar-background) !important; +} + +/* The side bar always hosts the TurboFile navigation. Keep its TurboFile background even when a + document editor is active (which hides the main TurboFile editor pane and therefore drops the + `turbofile-main-editor-visible` class), so the left nav looks identical whether or not a + document is open. */ +body.turbofile-shell .part.sidebar { + background-color: var(--vscode-activityBar-background) !important; +} + +body.turbofile-shell.turbofile-main-editor-visible .monaco-workbench .part.panel, +body.turbofile-shell.turbofile-main-editor-visible .monaco-workbench .part.panel > .content, +body.turbofile-shell.turbofile-main-editor-visible .monaco-workbench .part.panel .composite.title, +body.turbofile-shell.turbofile-main-editor-visible .monaco-workbench .part.panel .terminal-wrapper, +body.turbofile-shell.turbofile-main-editor-visible .monaco-workbench .part.panel .terminal-outer-container, +body.turbofile-shell.turbofile-main-editor-visible .monaco-workbench .part.panel .terminal-tabs, +body.turbofile-shell.turbofile-main-editor-visible .monaco-workbench .part.panel .terminal-tab, +body.turbofile-shell.turbofile-main-editor-visible .monaco-workbench .part.panel .xterm { + background-color: var(--vscode-terminal-background, var(--vscode-editor-background)) !important; +} + +body.turbofile-shell.turbofile-main-editor-visible:has(.turbofile-taskbar) .monaco-workbench .part.panel.bottom .pane-body.integrated-terminal { + /* The fixed TurboFile taskbar sits outside VS Code's panel layout; shrink terminal content so + the last prompt line and horizontal scrollbar remain above the blue task/log bar. */ + box-sizing: border-box; + padding-bottom: var(--turbofile-taskbar-height); +} + +body.turbofile-shell.turbofile-main-editor-visible .monaco-workbench { + --turbofile-editor-unified-background: var(--vscode-activityBar-background, var(--vscode-sideBar-background, var(--vscode-editor-background))); + --turbofile-file-surface-final: var(--vscode-editor-background); + --turbofile-editor-file-surface: var(--turbofile-file-surface-final); +} + +body.turbofile-shell.turbofile-main-editor-visible.turbofile-editor-theme-dark .monaco-workbench, +body.turbofile-shell.turbofile-main-editor-visible .monaco-workbench.vs-dark, +body.turbofile-shell.turbofile-main-editor-visible .monaco-workbench.hc-black { + --turbofile-file-surface-final: color-mix(in srgb, var(--vscode-editor-background) 54%, var(--turbofile-editor-unified-background) 46%); + --turbofile-editor-file-surface: var(--turbofile-file-surface-final); +} + +body.turbofile-shell.turbofile-main-editor-visible.turbofile-editor-theme-light .monaco-workbench { + --turbofile-editor-unified-background: #ffffff; + --turbofile-file-surface-final: #ffffff; + --turbofile-editor-file-surface: var(--turbofile-file-surface-final); +} + +body.turbofile-shell.turbofile-main-editor-visible .monaco-workbench:has(.turbofile-workbench.is-turbofile-dark) { + --turbofile-file-surface-final: color-mix(in srgb, var(--vscode-editor-background) 54%, var(--turbofile-editor-unified-background) 46%); + --turbofile-editor-file-surface: var(--turbofile-file-surface-final); +} + +body.turbofile-shell.turbofile-main-editor-visible .monaco-workbench:has(.turbofile-workbench.is-turbofile-light) { + --turbofile-editor-unified-background: #ffffff; + --turbofile-file-surface-final: #ffffff; + --turbofile-editor-file-surface: var(--turbofile-file-surface-final); +} + +body.turbofile-shell.turbofile-main-editor-visible.turbofile-editor-theme-light .monaco-workbench .part.sidebar, +body.turbofile-shell.turbofile-main-editor-visible .monaco-workbench:has(.turbofile-workbench.is-turbofile-light) .part.sidebar { + background-color: #f7f7f7 !important; +} + +body.turbofile-shell.turbofile-main-editor-visible.turbofile-editor-theme-light .monaco-workbench .part.titlebar, +body.turbofile-shell.turbofile-main-editor-visible.turbofile-editor-theme-light .monaco-workbench .part.titlebar > .titlebar-container, +body.turbofile-shell.turbofile-main-editor-visible.turbofile-editor-theme-light .monaco-workbench .part.titlebar .titlebar-left, +body.turbofile-shell.turbofile-main-editor-visible.turbofile-editor-theme-light .monaco-workbench .part.titlebar .titlebar-center, +body.turbofile-shell.turbofile-main-editor-visible.turbofile-editor-theme-light .monaco-workbench .part.titlebar .titlebar-right, +body.turbofile-shell.turbofile-main-editor-visible.turbofile-editor-theme-light .monaco-workbench .part.titlebar .window-controls-container, +body.turbofile-shell.turbofile-main-editor-visible .monaco-workbench:has(.turbofile-workbench.is-turbofile-light) .part.titlebar, +body.turbofile-shell.turbofile-main-editor-visible .monaco-workbench:has(.turbofile-workbench.is-turbofile-light) .part.titlebar > .titlebar-container, +body.turbofile-shell.turbofile-main-editor-visible .monaco-workbench:has(.turbofile-workbench.is-turbofile-light) .part.titlebar .titlebar-left, +body.turbofile-shell.turbofile-main-editor-visible .monaco-workbench:has(.turbofile-workbench.is-turbofile-light) .part.titlebar .titlebar-center, +body.turbofile-shell.turbofile-main-editor-visible .monaco-workbench:has(.turbofile-workbench.is-turbofile-light) .part.titlebar .titlebar-right, +body.turbofile-shell.turbofile-main-editor-visible .monaco-workbench:has(.turbofile-workbench.is-turbofile-light) .part.titlebar .window-controls-container { + background-color: #ffffff !important; +} + +body.turbofile-shell.turbofile-main-editor-visible .monaco-workbench .part.editor, +body.turbofile-shell.turbofile-main-editor-visible .monaco-workbench .part.editor > .content, +body.turbofile-shell.turbofile-main-editor-visible .monaco-workbench .part.editor > .content .editor-group-container.turbofile-main-editor-group, +body.turbofile-shell.turbofile-main-editor-visible .monaco-workbench .part.editor > .content .editor-group-container.turbofile-main-editor-group > .title, +body.turbofile-shell.turbofile-main-editor-visible .monaco-workbench .part.editor > .content .editor-group-container.turbofile-main-editor-group > .title .tabs-and-actions-container, +body.turbofile-shell.turbofile-main-editor-visible .monaco-workbench .part.editor > .content .editor-group-container.turbofile-main-editor-group > .title .tabs-and-actions-container > .monaco-scrollable-element, +body.turbofile-shell.turbofile-main-editor-visible .monaco-workbench .part.editor > .content .editor-group-container.turbofile-main-editor-group > .title .tabs-container, +body.turbofile-shell.turbofile-main-editor-visible .monaco-workbench .part.editor > .content .editor-group-container.turbofile-main-editor-group > .title .editor-actions, +body.turbofile-shell.turbofile-main-editor-visible .monaco-workbench .part.editor > .content .editor-group-container.turbofile-main-editor-group > .title .editor-actions .monaco-action-bar, +body.turbofile-shell.turbofile-main-editor-visible .monaco-workbench .part.editor > .content .editor-group-container.turbofile-main-editor-group > .title .editor-actions .actions-container, +body.turbofile-shell.turbofile-main-editor-visible .monaco-workbench .part.editor > .content .editor-group-container.turbofile-main-editor-group > .title .editor-layout-actions, +body.turbofile-shell.turbofile-main-editor-visible .monaco-workbench .part.editor > .content .editor-group-container.turbofile-main-editor-group > .title .monaco-scrollable-element, +body.turbofile-shell.turbofile-main-editor-visible .monaco-workbench .part.editor > .content .editor-group-container.turbofile-main-editor-group > .title .tabs-container .tab, +body.turbofile-shell.turbofile-main-editor-visible .monaco-workbench .part.editor > .content .editor-group-container.turbofile-main-editor-group > .title .tabs-container .tab.active, +body.turbofile-shell.turbofile-main-editor-visible .monaco-workbench .part.editor > .content .editor-group-container.turbofile-main-editor-group > .title .tabs-container .tab:hover, +body.turbofile-shell.turbofile-main-editor-visible .monaco-workbench .part.editor > .content .editor-group-container.turbofile-main-editor-group > .title .tabs-container .tab .tab-label, +body.turbofile-shell.turbofile-main-editor-visible .monaco-workbench .part.editor > .content .editor-group-container.turbofile-main-editor-group > .title .tabs-container .tab .tab-label .monaco-icon-label, +body.turbofile-shell.turbofile-main-editor-visible .monaco-workbench .part.editor > .content .editor-group-container.turbofile-main-editor-group > .title .tabs-container .tab .tab-actions, +body.turbofile-shell.turbofile-main-editor-visible .monaco-workbench .part.editor > .content .editor-group-container.turbofile-main-editor-group > .title .tabs-container .tab .action-label { + background: var(--turbofile-file-surface-final, var(--turbofile-editor-file-surface, var(--vscode-editor-background))) !important; + background-color: var(--turbofile-file-surface-final, var(--turbofile-editor-file-surface, var(--vscode-editor-background))) !important; +} + +body.turbofile-shell.turbofile-main-editor-visible:not(.turbofile-transfer-tabs-visible) .monaco-workbench .part.editor > .content .editor-group-container.turbofile-main-editor-group.active > .title { + display: none !important; + height: 0 !important; + min-height: 0 !important; + border: 0 !important; +} + +body.turbofile-shell.turbofile-main-editor-visible:not(.turbofile-transfer-tabs-visible) .monaco-workbench .part.editor > .content .editor-group-container.turbofile-main-editor-group.active > .editor-container { + height: 100% !important; +} + +body.turbofile-shell .monaco-workbench .part.editor > .content .editor-group-container.turbofile-main-editor-group .tabs-container .tab[data-resource-name^="transfer-"] { + width: 64px !important; + min-width: 64px !important; + max-width: 64px !important; +} + +body.turbofile-shell .monaco-workbench .part.editor > .content .editor-group-container.turbofile-main-editor-group .tabs-container .tab[data-resource-name^="transfer-"] .label-name, +body.turbofile-shell .monaco-workbench .part.editor > .content .editor-group-container.turbofile-main-editor-group .tabs-container .tab[data-resource-name^="transfer-"] .label-description, +body.turbofile-shell .monaco-workbench .part.editor > .content .editor-group-container.turbofile-main-editor-group .tabs-container .tab[data-resource-name^="transfer-"] .label-separator { + display: none !important; +} + +body.turbofile-shell .monaco-workbench .part.editor > .content .editor-group-container.turbofile-main-editor-group .tabs-container .tab[data-resource-name^="transfer-"] .tab-label { + justify-content: center; + padding-left: 0 !important; + padding-right: 0 !important; +} + +body.turbofile-shell .monaco-workbench .part.editor > .content .editor-group-container.turbofile-main-editor-group .tabs-container .tab[data-resource-name^="transfer-"] .tab-label .codicon, +body.turbofile-shell .monaco-workbench .part.editor > .content .editor-group-container.turbofile-main-editor-group .tabs-container .tab[data-resource-name^="transfer-"] .monaco-icon-label::before { + font-size: 22px !important; + width: 22px !important; + height: 22px !important; + line-height: 22px !important; +} + +body.turbofile-shell.turbofile-main-editor-visible .monaco-workbench .part.titlebar, +body.turbofile-shell.turbofile-main-editor-visible .monaco-workbench .part.titlebar > .titlebar-container, +body.turbofile-shell.turbofile-main-editor-visible .monaco-workbench .part.titlebar .titlebar-left, +body.turbofile-shell.turbofile-main-editor-visible .monaco-workbench .part.titlebar .titlebar-center, +body.turbofile-shell.turbofile-main-editor-visible .monaco-workbench .part.titlebar .titlebar-right, +body.turbofile-shell.turbofile-main-editor-visible .monaco-workbench .part.titlebar .window-controls-container { + background-color: var(--turbofile-unified-background, var(--vscode-activityBar-background, var(--vscode-sideBar-background, var(--vscode-editor-background)))) !important; +} + +body.turbofile-shell.turbofile-main-editor-visible .monaco-workbench .part.titlebar .window-controls-container > .window-icon { + background-color: transparent !important; + transition: background 120ms ease, box-shadow 120ms ease, transform 120ms ease; +} + +body.turbofile-shell.turbofile-main-editor-visible .monaco-workbench .part.titlebar .window-controls-container > .window-icon:hover { + background: color-mix(in srgb, var(--vscode-titleBar-activeForeground, #111111) 8%, var(--vscode-titleBar-activeBackground, #ffffff)) !important; + box-shadow: 0 4px 14px color-mix(in srgb, var(--vscode-widget-shadow, #000000) 16%, transparent) !important; + transform: translateY(-1px); +} + +body.turbofile-shell.turbofile-main-editor-visible .monaco-workbench .part.titlebar.light .window-controls-container > .window-icon:hover { + background: color-mix(in srgb, var(--vscode-titleBar-activeForeground, #111111) 8%, var(--vscode-titleBar-activeBackground, #ffffff)) !important; + box-shadow: 0 4px 14px color-mix(in srgb, var(--vscode-widget-shadow, #000000) 16%, transparent) !important; + transform: translateY(-1px); +} + +body.turbofile-shell.turbofile-main-editor-visible .monaco-workbench .part.titlebar .window-controls-container > .window-icon.window-close:hover { + background: rgba(232, 17, 35, 0.9) !important; + box-shadow: 0 4px 14px color-mix(in srgb, var(--vscode-widget-shadow, #000000) 16%, transparent) !important; + transform: translateY(-1px); +} + +.turbofile-transfer-page { + height: 100%; + min-height: 0; + display: grid; + grid-template-rows: minmax(0, 1fr); + padding-bottom: 24px; + box-sizing: border-box; + overflow: hidden; +} + +.turbofile-transfer-page.has-live-transfer { + grid-template-rows: minmax(0, 1fr); +} + +.turbofile-transfer-page.is-terminal-focused { + grid-template-rows: minmax(0, 1fr); + padding-bottom: 0; +} + +.turbofile-page-nav { + min-width: 0; + min-height: 0; + box-sizing: border-box; + display: grid; + grid-template-rows: minmax(0, 1fr) 24px; + border-right: 0; + background: var(--turbofile-unified-background); + overflow: hidden; +} + +.turbofile-page-nav-list { + display: grid; + align-content: start; + gap: 0; + padding: 0; + overflow: hidden; +} + +.turbofile-page-nav-item, +.turbofile-page-nav-toggle { + width: 100%; + height: 48px; + box-sizing: border-box; + display: flex; + align-items: center; + gap: 10px; + border: 0; + border-radius: 0; + background: transparent; + color: var(--vscode-activityBar-inactiveForeground, var(--vscode-sideBar-foreground)); + cursor: pointer; + overflow: hidden; + position: relative; + opacity: 1; +} + +/* Suppress the default blue focus outline on the side-nav buttons (incl. the expand/collapse + toggle) so clicking them does not leave a blue ring around the button. */ +.turbofile-page-nav-item:focus, +.turbofile-page-nav-item:focus-visible, +.turbofile-page-nav-toggle:focus, +.turbofile-page-nav-toggle:focus-visible { + outline: none !important; +} + +.turbofile-page-nav-item { + justify-content: center; + padding: 0; +} + +.turbofile-editor-shell.is-nav-expanded .turbofile-page-nav-item { + justify-content: flex-start; + padding: 0 12px; + height: 48px; +} + +.turbofile-page-nav.is-nav-expanded .turbofile-page-nav-item { + justify-content: flex-start; + padding: 0 12px; + height: 48px; +} + +.turbofile-page-nav-item:hover, +.turbofile-page-nav-toggle:hover { + background: var(--vscode-list-hoverBackground); + color: color-mix(in srgb, var(--vscode-activityBar-inactiveForeground, var(--vscode-sideBar-foreground)) 88%, var(--vscode-activityBar-foreground, var(--vscode-foreground))); +} + +.turbofile-page-nav-item.is-active { + color: var(--vscode-activityBar-foreground, var(--vscode-list-activeSelectionForeground)); + box-shadow: inset 2px 0 0 var(--vscode-activityBar-activeBorder, var(--vscode-focusBorder)); +} + +.turbofile-page-nav.is-nav-expanded .turbofile-page-nav-item.is-active { + background: var(--vscode-list-hoverBackground); + color: var(--vscode-list-activeSelectionForeground); +} + +.turbofile-page-nav-item .codicon, +.turbofile-page-nav-toggle .codicon, +.turbofile-nav-sun-icon { + flex: 0 0 24px; + color: var(--vscode-icon-foreground, currentColor); + opacity: 0.72; + font-size: 24px !important; + line-height: 24px !important; + text-align: center; +} + +.turbofile-page-nav-item:hover .codicon, +.turbofile-page-nav-item:hover .turbofile-nav-sun-icon, +.turbofile-page-nav-toggle:hover .codicon { + opacity: 0.9; +} + +.turbofile-page-nav-item.is-active .codicon, +.turbofile-page-nav-item.is-active .turbofile-nav-sun-icon { + color: var(--vscode-activityBar-foreground, var(--vscode-list-activeSelectionForeground)); + opacity: 1; +} + +.turbofile-nav-sun-icon { + width: 24px; + height: 24px; + display: block; + fill: none; + stroke: currentColor; + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: 2; +} + +.turbofile-page-nav-label { + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + display: none; +} + +.turbofile-editor-shell.is-nav-expanded .turbofile-page-nav-label { + display: inline; +} + +.turbofile-page-nav.is-nav-expanded .turbofile-page-nav-label { + display: inline; +} + +.turbofile-page-nav-toggle { + justify-content: center; + height: 24px; + border-top: 1px solid var(--vscode-panel-border); + border-radius: 0; + padding: 0; +} + +.turbofile-editor-shell.is-nav-expanded .turbofile-page-nav-toggle { + justify-content: flex-start; + padding: 0 12px; + height: 24px; + color: var(--vscode-sideBar-foreground); +} + +.turbofile-page-nav.is-nav-expanded .turbofile-page-nav-toggle { + justify-content: flex-start; + padding: 0 12px; + height: 24px; + color: var(--vscode-sideBar-foreground); +} + +.turbofile-full-page { + height: 100%; + min-height: 0; + display: grid; + align-content: start; + gap: 18px; + padding: 18px 22px; + background: var(--turbofile-unified-background); + overflow: auto; +} + +.turbofile-page-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; +} + +.turbofile-page-header-copy { + min-width: 0; + display: grid; + gap: 6px; +} + +.turbofile-page-header h2 { + margin: 0; + display: flex; + align-items: center; + gap: 9px; + font-size: 18px; + font-weight: 700; +} + +.turbofile-page-header p { + margin: 0; + color: var(--vscode-descriptionForeground); + line-height: 1.45; +} + +.turbofile-appearance-page { + display: grid; + align-content: start; + gap: 12px; + padding: 16px 20px; + background: var(--turbofile-unified-background); +} + +.turbofile-appearance-shell { + width: 100%; + min-width: 0; + display: grid; + gap: 14px; +} + +.turbofile-appearance-title { + margin: 0; + color: var(--vscode-foreground); + font-size: calc(var(--turbofile-browser-font-size, 14px) * 1.18); + font-weight: 700; + letter-spacing: 0; +} + +.turbofile-appearance-card { + min-width: 0; + border: 1px solid var(--vscode-panel-border); + border-radius: 6px; + background: var(--vscode-sideBar-background); + overflow: hidden; +} + +.turbofile-appearance-card-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 12px 14px; + border-bottom: 1px solid var(--vscode-panel-border); +} + +.turbofile-appearance-card-copy { + display: grid; + gap: 4px; +} + +.turbofile-appearance-card-copy h2 { + margin: 0; + font-size: calc(var(--turbofile-browser-font-size, 14px) * 1.02); + font-weight: 700; + color: var(--vscode-foreground); +} + +.turbofile-appearance-card-copy p { + margin: 0; + color: var(--vscode-descriptionForeground); + font-size: calc(var(--turbofile-ui-font-size, 14px) * 0.92); + font-weight: 500; +} + +.turbofile-appearance-mode-group { + display: flex; + align-items: center; + gap: 4px; + padding: 2px; + border: 1px solid var(--vscode-panel-border); + border-radius: 999px; + background: var(--vscode-editor-background); +} + +.turbofile-appearance-mode-button { + height: 26px; + display: inline-flex; + align-items: center; + gap: 6px; + padding: 0 10px; + border: 0; + border-radius: 999px; + color: var(--vscode-descriptionForeground); + background: transparent; + font: inherit; + font-size: inherit; + font-weight: 600; + cursor: pointer; +} + +.turbofile-appearance-font-summary { + display: grid; + border-top: 1px solid var(--vscode-panel-border); +} + +.turbofile-appearance-mode-button:hover { + color: var(--vscode-foreground); + background: var(--vscode-list-hoverBackground); +} + +.turbofile-appearance-mode-button.is-active { + color: var(--vscode-foreground); + background: color-mix(in srgb, var(--vscode-editor-background), var(--vscode-foreground) 8%); +} + +.turbofile-appearance-icon { + width: 16px; + height: 16px; + flex: 0 0 16px; + fill: none; + stroke: currentColor; + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: 1.8; +} + +.turbofile-appearance-icon path[d^="M20.2"] { + fill: currentColor; + stroke: none; +} + +.turbofile-appearance-preview { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0; + margin: 10px; + border-radius: 5px; + background: var(--turbofile-appearance-preview-background, var(--vscode-editor-background)); + border: 1px solid var(--vscode-panel-border); + overflow: hidden; + font-family: var(--vscode-editor-font-family, var(--turbofile-browser-font-family, "Segoe UI", "Microsoft YaHei UI", sans-serif)); + font-size: calc(var(--turbofile-browser-font-size, 14px) * 0.95); + filter: contrast(var(--turbofile-appearance-contrast-filter, 105%)); +} + +.turbofile-appearance-preview-pane { + min-width: 0; + padding: 6px 0; + position: relative; +} + +.turbofile-appearance-preview-pane.is-left { + border-right: 2px solid var(--vscode-editorGutter-addedBackground, #2ea043); +} + +.turbofile-appearance-code-line { + height: 26px; + display: grid; + grid-template-columns: 54px minmax(0, 1fr); + align-items: center; + color: var(--turbofile-appearance-preview-foreground, var(--vscode-editor-foreground)); +} + +.turbofile-appearance-code-line.is-removed { + background: color-mix(in srgb, var(--vscode-editorGutter-deletedBackground, #f85149) 28%, transparent); +} + +.turbofile-appearance-code-line.is-added { + background: color-mix(in srgb, var(--vscode-editorGutter-addedBackground, #2ea043) 28%, transparent); +} + +.turbofile-appearance-line-number { + padding-right: 14px; + color: var(--vscode-editorLineNumber-foreground); + text-align: right; + user-select: none; +} + +.turbofile-appearance-code-line code { + min-width: 0; + color: var(--turbofile-appearance-preview-foreground, var(--vscode-editor-foreground)); + white-space: pre; + overflow: hidden; + text-overflow: ellipsis; +} + +.turbofile-appearance-code-line .is-keyword { + color: var(--turbofile-appearance-preview-accent, var(--vscode-textLink-foreground)); +} + +.turbofile-appearance-code-line .is-type { + color: var(--vscode-charts-green, #4ec9b0); +} + +.turbofile-appearance-code-line .is-string { + color: var(--vscode-debugTokenExpression-string, #ce9178); +} + +.turbofile-appearance-code-line .is-number { + color: var(--vscode-debugTokenExpression-number, #b5cea8); +} + +.turbofile-appearance-config { + margin: 0 10px 10px; + border: 1px solid var(--vscode-panel-border); + border-radius: 5px; + background: var(--vscode-editor-background); + overflow: hidden; +} + +.turbofile-appearance-config-header { + min-height: 46px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 8px 12px; + border-bottom: 1px solid var(--vscode-panel-border); +} + +.turbofile-appearance-config-header h3 { + margin: 0; + font-size: calc(var(--turbofile-browser-font-size, 14px) * 1.05); + font-weight: 700; +} + +.turbofile-appearance-config-actions { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; + color: var(--vscode-descriptionForeground); +} + +.turbofile-appearance-config-actions button { + min-height: 28px; + border: 1px solid transparent; + border-radius: 4px; + background: transparent; + color: inherit; + font: inherit; + font-size: inherit; + font-weight: 600; + cursor: pointer; +} + +.turbofile-appearance-config-actions button:hover { + background: var(--vscode-list-hoverBackground); + color: var(--vscode-foreground); +} + +.turbofile-appearance-theme-name { + max-width: min(420px, 42vw); + height: 30px; + display: flex; + align-items: center; + gap: 7px; + padding: 0 10px; + border: 1px solid var(--vscode-input-border); + border-radius: 5px; + background: var(--vscode-input-background); + color: var(--vscode-foreground); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.turbofile-appearance-aa { + width: 22px; + height: 22px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 5px; + background: var(--vscode-editor-background); + color: var(--vscode-textLink-foreground, #007acc); + font-size: 12px; + font-weight: 700; +} + +.turbofile-appearance-aa::before { + content: "Aa"; +} + +.turbofile-appearance-config-rows { + display: grid; +} + +.turbofile-appearance-setting-row { + min-height: 44px; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 12px; + padding: 7px 12px; + border-top: 1px solid var(--vscode-panel-border); + font-size: inherit; + font-weight: 600; +} + +.turbofile-appearance-color-pill, +.turbofile-appearance-value-pill, +.turbofile-appearance-font-size-value { + position: relative; + min-width: 172px; + height: 30px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + border: 1px solid var(--vscode-input-border); + border-radius: 5px; + padding: 0 10px; + font-weight: 600; + cursor: pointer; +} + +.turbofile-appearance-color-pill.is-dark { + color: #ffffff; +} + +.turbofile-appearance-color-pill.is-light { + color: #111827; +} + +.turbofile-appearance-color-dot { + width: 14px; + height: 14px; + border-radius: 999px; + border: 1px solid color-mix(in srgb, currentColor, transparent 65%); +} + +.turbofile-appearance-color-input { + position: absolute; + width: 1px; + height: 1px; + opacity: 0; + pointer-events: none; +} + +.turbofile-appearance-value-pill, +.turbofile-appearance-font-size-value { + color: var(--vscode-descriptionForeground); + background: var(--vscode-input-background); +} + +.turbofile-appearance-toggle { + width: 40px; + height: 22px; + padding: 2px; + border: 1px solid var(--vscode-input-border, var(--vscode-widget-border)); + border-radius: 999px; + background: color-mix(in srgb, var(--vscode-foreground) 8%, var(--vscode-input-background)); + box-sizing: border-box; + cursor: pointer; +} + +.turbofile-appearance-toggle.is-on { + background: var(--vscode-button-background); + border-color: var(--vscode-button-background); +} + +.turbofile-appearance-toggle-knob { + width: 18px; + height: 18px; + display: block; + border-radius: 999px; + background: var(--vscode-descriptionForeground); + transform: translateX(0); +} + +.turbofile-appearance-toggle.is-on .turbofile-appearance-toggle-knob { + transform: translateX(18px); + background: var(--vscode-button-foreground); +} + +.turbofile-appearance-slider-control { + min-width: 220px; + display: flex; + align-items: center; + gap: 10px; + color: var(--vscode-foreground); +} + +.turbofile-appearance-slider { + width: 180px; + accent-color: var(--vscode-button-background); +} + +.turbofile-appearance-wide-setting { + min-height: 62px; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 12px; + padding: 10px 12px; + border-top: 1px solid var(--vscode-panel-border); +} + +.turbofile-appearance-wide-copy { + display: grid; + gap: 4px; +} + +.turbofile-appearance-wide-copy strong { + font-size: inherit; + font-weight: 700; +} + +.turbofile-appearance-wide-copy span { + color: var(--vscode-descriptionForeground); + font-size: calc(var(--turbofile-ui-font-size, 14px) * 0.92); + font-weight: 500; +} + +.turbofile-appearance-font-size-value { + min-width: 92px; + color: var(--vscode-descriptionForeground); + font-size: inherit; + cursor: default; +} + +.turbofile-workbench.is-appearance-pointer button, +.turbofile-workbench.is-appearance-pointer input[type="range"], +.turbofile-workbench.is-appearance-pointer label { + cursor: pointer; +} + +body.turbofile-shell.turbofile-appearance-translucent .part.sidebar { + background-color: color-mix(in srgb, var(--vscode-activityBar-background), transparent 8%) !important; +} + +@media (max-width: 900px) { + .turbofile-appearance-card-header, + .turbofile-appearance-config-header { + align-items: stretch; + flex-direction: column; + } + + .turbofile-appearance-preview { + grid-template-columns: 1fr; + } + + .turbofile-appearance-preview-pane.is-left { + border-right: 0; + border-bottom: 2px solid var(--vscode-editorGutter-addedBackground, #2ea043); + } + + .turbofile-appearance-theme-name { + max-width: none; + width: 100%; + } +} + +.turbofile-page-header-actions, +.turbofile-page-card-actions, +.turbofile-settings-actions { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; +} + +.turbofile-settings-device-list { + display: grid; + gap: 8px; +} + +.turbofile-settings-device-row { + min-height: 44px; + display: grid; + grid-template-columns: minmax(180px, 1fr) auto; + align-items: center; + gap: 12px; + padding: 8px 10px; + border: 1px solid var(--vscode-panel-border); + border-radius: 5px; + background: var(--vscode-editor-background); +} + +.turbofile-settings-device-summary { + min-width: 0; + display: grid; + gap: 3px; +} + +.turbofile-settings-device-summary strong { + min-width: 0; + display: flex; + align-items: center; + gap: 7px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.turbofile-settings-device-summary span { + min-width: 0; + color: var(--vscode-descriptionForeground); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.turbofile-settings-device-row-actions { + display: flex; + align-items: center; + flex-wrap: wrap; + justify-content: flex-end; + gap: 7px; +} + +.turbofile-page-button { + height: 30px; + display: inline-flex; + align-items: center; + gap: 7px; + padding: 0 12px; + border: 1px solid var(--vscode-button-border, transparent); + border-radius: 5px; + color: var(--vscode-button-secondaryForeground); + background: var(--vscode-button-secondaryBackground); + cursor: pointer; +} + +.turbofile-page-button:hover { + background: var(--vscode-button-secondaryHoverBackground); +} + +.turbofile-page-button.is-primary { + color: var(--vscode-button-foreground); + background: var(--vscode-button-background); +} + +.turbofile-page-button.is-primary:hover { + background: var(--vscode-button-hoverBackground); +} + +.turbofile-device-page-grid, +.turbofile-task-summary-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 16px; +} + +.turbofile-page-card, +.turbofile-settings-section { + border: 1px solid var(--vscode-panel-border); + border-radius: 6px; + background: var(--vscode-sideBar-background); +} + +.turbofile-page-card { + display: grid; + gap: 12px; + padding: 16px; +} + +.turbofile-device-page-card { + position: relative; + min-height: 176px; + align-content: start; + border-radius: 10px; + background: + linear-gradient(135deg, color-mix(in srgb, var(--vscode-button-background) 8%, transparent), transparent 48%), + color-mix(in srgb, var(--vscode-editor-background) 78%, var(--vscode-sideBar-background)); + box-shadow: 0 8px 22px color-mix(in srgb, var(--vscode-widget-shadow) 32%, transparent); + overflow: hidden; +} + +.turbofile-device-page-card.is-tone-0 { + --turbofile-device-card-accent: #5b8dff; + --turbofile-device-card-accent-2: #44c7de; +} + +.turbofile-device-page-card.is-tone-1 { + --turbofile-device-card-accent: #16a085; + --turbofile-device-card-accent-2: #5ee0a0; +} + +.turbofile-device-page-card.is-tone-2 { + --turbofile-device-card-accent: #8b74ff; + --turbofile-device-card-accent-2: #57a7ff; +} + +.turbofile-device-page-card.is-tone-3 { + --turbofile-device-card-accent: #f0a545; + --turbofile-device-card-accent-2: #ffd166; +} + +.turbofile-device-page-card.is-tone-4 { + --turbofile-device-card-accent: #39b5d8; + --turbofile-device-card-accent-2: #8fd8ff; +} + +.turbofile-device-page-card.is-tone-5 { + --turbofile-device-card-accent: #d88545; + --turbofile-device-card-accent-2: #ffb26b; +} + +.turbofile-device-page-card::before { + display: none; +} + +.turbofile-device-page-card .turbofile-page-card-title { + min-height: 40px; + font-size: calc(var(--turbofile-ui-font-size, 14px) + 1px); + flex-wrap: wrap; +} + +.turbofile-device-page-card .turbofile-page-card-title .codicon, +.turbofile-device-page-icon { + width: 36px; + height: 36px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 9px; + color: #ffffff; + background: linear-gradient(135deg, var(--turbofile-device-card-accent, #6d78ff), var(--turbofile-device-card-accent-2, #3fb6d8)); + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.14); +} + +.turbofile-device-page-icon { + color: inherit; + padding: 5px; +} + +.turbofile-device-page-card .turbofile-page-card-actions { + margin-top: 6px; +} + +.turbofile-device-card-status { + flex-basis: 100%; + margin-left: 48px; + margin-top: -14px; + display: inline-flex; + align-items: center; + gap: 5px; + color: #4ade80; + font-size: 11px; + font-weight: 500; +} + +.turbofile-device-card-status::before { + content: ""; + width: 6px; + height: 6px; + border-radius: 999px; + background: currentColor; + box-shadow: 0 0 10px color-mix(in srgb, currentColor 60%, transparent); +} + +.turbofile-device-card-status.is-unknown { + color: var(--vscode-descriptionForeground); +} + +.turbofile-device-card-status.is-testing { + color: var(--vscode-descriptionForeground); +} + +.turbofile-device-card-status.is-offline { + color: var(--vscode-descriptionForeground); +} + +.turbofile-device-card-status.is-online { + color: var(--vscode-testing-iconPassed); +} + +.turbofile-device-page-card .turbofile-page-button { + border: 0; + border-radius: 6px; + background: color-mix(in srgb, var(--vscode-button-secondaryBackground) 88%, transparent); +} + +.turbofile-device-page-card .turbofile-page-button.is-primary { + color: var(--vscode-textLink-foreground); + background: color-mix(in srgb, var(--vscode-textLink-foreground) 16%, var(--vscode-button-secondaryBackground)); +} + +.turbofile-device-test-result { + min-height: 24px; + display: flex; + align-items: center; + padding: 5px 8px; + border: 1px solid var(--vscode-panel-border); + border-radius: 4px; + background: color-mix(in srgb, var(--vscode-editor-background), var(--vscode-foreground) 3%); + color: var(--vscode-descriptionForeground); +} + +.turbofile-device-test-result.is-testing { + color: var(--vscode-textLink-foreground); +} + +.turbofile-device-test-result.is-success { + color: var(--vscode-testing-iconPassed); +} + +.turbofile-device-test-result.is-error { + color: var(--vscode-testing-iconFailed); +} + +.turbofile-dialog-overlay { + position: absolute; + inset: 0; + z-index: 30; + display: flex; + align-items: center; + justify-content: center; + padding: 22px; + background: color-mix(in srgb, var(--vscode-editor-background) 42%, transparent); + backdrop-filter: blur(7px); +} + +.turbofile-dialog-panel { + width: min(420px, 94vw); + display: grid; + gap: 14px; + padding: 18px; + border: 1px solid var(--vscode-panel-border); + border-radius: 10px; + background: color-mix(in srgb, var(--vscode-editor-background) 92%, var(--vscode-sideBar-background)); + color: var(--vscode-foreground); + box-shadow: 0 16px 44px color-mix(in srgb, var(--vscode-widget-shadow) 60%, transparent); +} + +.turbofile-dialog-header { + display: grid; + grid-template-columns: 38px minmax(0, 1fr); + gap: 12px; + align-items: start; +} + +.turbofile-dialog-icon { + width: 38px; + height: 38px; + border-radius: 10px; + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--vscode-button-foreground); + background: var(--vscode-button-background); +} + +.turbofile-dialog-panel.is-danger .turbofile-dialog-icon { + background: color-mix(in srgb, var(--vscode-errorForeground) 82%, #111827); +} + +.turbofile-dialog-copy { + min-width: 0; + display: grid; + gap: 4px; +} + +.turbofile-dialog-copy h3 { + margin: 0; + font-size: calc(var(--turbofile-ui-font-size, 14px) + 1px); + line-height: 1.35; +} + +.turbofile-dialog-copy p { + margin: 0; + color: var(--vscode-descriptionForeground); + line-height: 1.45; +} + +.turbofile-dialog-detail { + margin-top: 6px; + padding: 8px 10px; + border: 1px solid var(--vscode-panel-border); + border-radius: 6px; + background: color-mix(in srgb, var(--vscode-editor-background) 84%, var(--vscode-foreground) 4%); + color: var(--vscode-descriptionForeground); + line-height: 1.45; +} + +.turbofile-dialog-input { + width: 100%; + min-height: 36px; + box-sizing: border-box; + padding: 6px 10px 8px; + border: 1px solid var(--vscode-input-border); + border-radius: 6px; + background: var(--vscode-input-background); + color: var(--vscode-input-foreground); + font-family: var(--vscode-editor-font-family, "Cascadia Code", Consolas, "Microsoft YaHei UI", monospace); + font-size: var(--turbofile-ui-font-size, 14px); + line-height: 1.35; + outline: none; +} + +.turbofile-dialog-input:focus { + border-color: var(--vscode-focusBorder); +} + +.turbofile-dialog-actions { + display: flex; + justify-content: flex-end; + gap: 8px; +} + +.turbofile-dialog-button { + height: 30px; + padding: 0 14px; + border: 1px solid var(--vscode-button-border, transparent); + border-radius: 6px; + font: inherit; + cursor: pointer; +} + +.turbofile-dialog-button-secondary { + color: var(--vscode-button-secondaryForeground); + background: var(--vscode-button-secondaryBackground); +} + +.turbofile-dialog-button-primary { + color: var(--vscode-button-foreground); + background: var(--vscode-button-background); +} + +.turbofile-dialog-button-primary.is-danger { + background: color-mix(in srgb, var(--vscode-errorForeground) 82%, #111827); +} + +.turbofile-page-card-title, +.turbofile-section-title { + display: flex; + align-items: center; + gap: 8px; + font-weight: 700; +} + +.turbofile-device-page-meta { + display: grid; + gap: 8px; +} + +.turbofile-info-line { + display: grid; + grid-template-columns: 72px minmax(0, 1fr); + gap: 10px; + align-items: center; + color: var(--vscode-descriptionForeground); +} + +.turbofile-info-line strong { + min-width: 0; + color: var(--vscode-foreground); + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.turbofile-settings-section { + display: grid; + gap: 14px; + padding: 16px; +} + +.turbofile-editor-settings { + display: grid; + gap: 10px; +} + +.turbofile-editor-setting-row { + min-height: 62px; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 16px; + padding: 12px; + border: 1px solid var(--vscode-panel-border); + border-radius: 5px; + background: var(--vscode-editor-background); +} + +.turbofile-editor-setting-copy { + min-width: 0; + display: grid; + gap: 4px; +} + +.turbofile-editor-setting-copy label { + color: var(--vscode-foreground); + font-weight: 600; +} + +.turbofile-editor-setting-copy span { + color: var(--vscode-descriptionForeground); + line-height: 1.4; +} + +.turbofile-editor-setting-controls { + display: flex; + align-items: center; + gap: 8px; +} + +.turbofile-editor-font-size-input { + box-sizing: border-box; + width: 86px; + height: 30px; + padding: 0 8px; + border: 1px solid var(--vscode-input-border); + border-radius: 4px; + color: var(--vscode-input-foreground); + background: var(--vscode-input-background); + appearance: textfield; + -moz-appearance: textfield; +} + +.turbofile-editor-font-size-input::-webkit-outer-spin-button, +.turbofile-editor-font-size-input::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; +} + +.turbofile-browser-select { + box-sizing: border-box; + min-width: 220px; + height: 30px; + padding: 0 8px; + border: 1px solid var(--vscode-input-border); + border-radius: 4px; + color: var(--vscode-input-foreground); + background: var(--vscode-input-background); +} + +.turbofile-browser-color-input { + box-sizing: border-box; + width: 48px; + height: 30px; + padding: 2px; + border: 1px solid var(--vscode-input-border); + border-radius: 4px; + background: var(--vscode-input-background); +} + +.turbofile-device-form.turbofile-settings-form { + width: 100%; + box-shadow: none; +} + +.turbofile-summary-card-top { + display: flex; + align-items: center; + justify-content: space-between; + color: var(--vscode-descriptionForeground); +} + +.turbofile-summary-card strong { + font-size: 24px; +} + +.turbofile-theme-current-card { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + align-items: center; + gap: 12px; + padding: 12px; + border: 1px solid var(--vscode-panel-border); + border-radius: 5px; + background: var(--vscode-editor-background); +} + +.turbofile-theme-section { + align-content: start; +} + +.turbofile-theme-group { + display: grid; + gap: 10px; +} + +.turbofile-theme-group h3 { + margin: 0; + color: var(--vscode-descriptionForeground); + font-size: 12px; + font-weight: 600; +} + +.turbofile-theme-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + gap: 10px; +} + +.turbofile-theme-option { + min-width: 0; + min-height: 64px; + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: 12px; + padding: 10px 12px; + border: 1px solid var(--vscode-panel-border); + border-radius: 5px; + color: var(--vscode-foreground); + background: var(--vscode-editor-background); + cursor: pointer; + text-align: left; +} + +.turbofile-theme-option:hover { + border-color: var(--vscode-focusBorder); + background: var(--vscode-list-hoverBackground); +} + +.turbofile-theme-option.is-active { + border-color: var(--vscode-focusBorder); + background: var(--vscode-list-activeSelectionBackground); + color: var(--vscode-list-activeSelectionForeground); +} + +.turbofile-theme-swatches { + width: 48px; + height: 36px; + display: grid; + grid-template-columns: repeat(2, 1fr); + overflow: hidden; + border: 1px solid var(--vscode-panel-border); + border-radius: 4px; +} + +.turbofile-theme-swatch { + min-width: 0; + min-height: 0; +} + +.turbofile-theme-copy { + min-width: 0; + display: grid; + gap: 4px; +} + +.turbofile-theme-copy strong, +.turbofile-theme-copy span { + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.turbofile-theme-copy span { + color: var(--vscode-descriptionForeground); +} + +.turbofile-theme-option.is-active .turbofile-theme-copy span { + color: inherit; +} + +.turbofile-theme-indicator { + display: inline-flex; + color: var(--vscode-descriptionForeground); +} + +.turbofile-theme-option.is-active .turbofile-theme-indicator { + color: var(--vscode-list-activeSelectionForeground); +} + +.turbofile-task-log-list.is-page-list { + border: 1px solid var(--vscode-panel-border); + border-radius: 6px; + background: var(--vscode-sideBar-background); +} + +.turbofile-route-placeholder { + padding: 12px; + color: var(--vscode-descriptionForeground); +} + +body.turbofile-shell .part.sidebar .composite.title, +body.turbofile-shell .part.sidebar .pane-header { + display: none !important; +} + +body.turbofile-shell .part.sidebar { + position: relative; + border-right: 0 !important; + box-shadow: none !important; + overflow: hidden; +} + +body.turbofile-shell.turbofile-main-editor-visible .monaco-workbench .monaco-sash.vertical { + display: none !important; +} + +body.turbofile-shell .part.sidebar .content, +body.turbofile-shell .part.sidebar .composite, +body.turbofile-shell .part.sidebar .split-view-view, +body.turbofile-shell .part.sidebar .pane, +body.turbofile-shell .part.sidebar .pane-body { + min-height: 0; + height: 100% !important; +} + +body.turbofile-shell .part.sidebar .content .turbofile-page-nav { + display: none; +} + +body.turbofile-shell .part.sidebar > .turbofile-page-nav { + position: fixed; + left: 0; + top: 35px; + bottom: 0; + width: var(--turbofile-page-nav-width); + z-index: 20; + height: auto; +} + +body.turbofile-shell .part.sidebar > .turbofile-page-nav.is-nav-expanded { + width: var(--turbofile-page-nav-width); +} + +.turbofile-route-nav { + box-sizing: border-box; + height: 100%; + display: grid; + grid-template-rows: minmax(0, 1fr) 42px; + color: var(--vscode-sideBar-foreground); + overflow: hidden; +} + +.turbofile-route-nav-list { + display: grid; + align-content: start; + gap: 4px; + padding: 10px 8px; + overflow: hidden; +} + +.turbofile-route-nav-item, +.turbofile-route-collapse { + width: 100%; + height: 34px; + display: flex; + align-items: center; + gap: 9px; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--vscode-sideBar-foreground); + cursor: pointer; + overflow: hidden; +} + +.turbofile-route-nav-item { + justify-content: flex-start; + padding: 0 10px; +} + +.turbofile-route-collapse { + justify-content: flex-start; + padding: 0 12px; + border-top: 1px solid var(--vscode-panel-border); + border-radius: 0; +} + +.turbofile-route-nav-item:hover, +.turbofile-route-collapse:hover { + background: var(--vscode-list-hoverBackground); +} + +.turbofile-route-nav-item .codicon, +.turbofile-route-collapse .codicon { + flex: 0 0 18px; + font-size: 17px; +} + +.turbofile-route-nav-item span, +.turbofile-route-collapse span { + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.turbofile-main { + min-height: 0; + display: grid; + grid-template-columns: minmax(320px, 1fr) minmax(320px, 1fr); + gap: 1px; + background: var(--turbofile-unified-background); + padding: 0; + overflow: hidden; +} + +.turbofile-transfer-page.is-pane-maximized .turbofile-main { + grid-template-columns: minmax(0, 1fr); +} + +.turbofile-pane.is-hidden-by-maximize { + display: none; +} + +.turbofile-pane, +.turbofile-transfer-rail, +.turbofile-taskbar { + background: var(--turbofile-unified-background); +} + +.turbofile-pane { + min-height: 0; + min-width: 0; + display: grid; + grid-template-rows: auto auto minmax(0, 1fr); + border: 1px solid var(--turbofile-pane-border); + border-radius: 4px 4px 0 0; + overflow: hidden; + font-family: var(--turbofile-browser-font-family, "Segoe UI", "Microsoft YaHei UI", sans-serif); + font-size: var(--turbofile-browser-font-size, 14px); + color: var(--turbofile-browser-font-color, var(--vscode-foreground)); +} + +.turbofile-pane.turbofile-pane-image-grid { + grid-template-rows: minmax(0, 1fr); +} + +.turbofile-pane-image-grid .turbofile-image-page { + min-height: 0; + min-width: 0; +} + +.turbofile-pane:focus, +.turbofile-pane:focus-visible, +.turbofile-pane:focus-within { + border-color: var(--turbofile-pane-border) !important; + outline: none !important; + box-shadow: none !important; +} + +.turbofile-pane-header { + height: 42px; + padding: 6px 8px; + display: grid; + grid-template-rows: 30px; + gap: 0; + border-top: 1px solid var(--turbofile-pane-border); + border-bottom: 1px solid var(--turbofile-pane-border); + position: relative; + z-index: 10; + overflow: visible; +} + +.turbofile-pane-title { + display: flex; + align-items: center; + gap: 5px; + font-size: calc(var(--turbofile-browser-font-size, 14px) * 0.95); + font-weight: 700; + color: var(--turbofile-browser-font-color, var(--vscode-foreground)); + line-height: 1.25; +} + +.turbofile-device-card, +.turbofile-device-select { + height: 30px; + display: flex; + align-items: center; + gap: 7px; + padding: 0 8px; + border: 1px solid var(--vscode-input-border); + border-radius: 4px; + background: var(--vscode-input-background); + color: var(--turbofile-browser-font-color, var(--vscode-input-foreground)); + font-size: calc(var(--turbofile-browser-font-size, 14px) * 0.95); + line-height: 1.25; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + cursor: pointer; +} + +.turbofile-device-card:hover, +.turbofile-device-card:focus { + border-color: var(--vscode-focusBorder); + outline: none; +} + +.turbofile-device-select { + width: 100%; + display: block; + position: relative; + padding: 0; + border: 0; + background: transparent; + font-family: var(--turbofile-browser-font-family, "Segoe UI", "Microsoft YaHei UI", sans-serif); + overflow: visible; +} + +.turbofile-device-select-button { + width: 100%; + height: 30px; + display: flex; + align-items: center; + gap: 7px; + padding: 0 8px; + border: 1px solid var(--vscode-input-border); + border-radius: 4px; + background: var(--vscode-input-background); + color: var(--turbofile-browser-font-color, var(--vscode-input-foreground)); + font-family: inherit; + font-size: calc(var(--turbofile-browser-font-size, 14px) * 0.95); + line-height: 1.25; + text-align: left; + cursor: pointer; +} + +.turbofile-device-select-button:hover, +.turbofile-device-select-button:focus, +.turbofile-device-select.is-open .turbofile-device-select-button { + border-color: var(--vscode-focusBorder); + outline: none; +} + +.turbofile-device-card-label, +.turbofile-device-select-label { + min-width: 0; + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.turbofile-device-select-icon { + width: 18px; + height: 18px; + flex: 0 0 auto; + line-height: 1; + display: inline-flex; + align-items: center; + justify-content: center; + filter: drop-shadow(0 0 4px rgba(46, 140, 255, 0.22)); + forced-color-adjust: none; +} + +.turbofile-device-monitor-svg { + width: 100%; + height: 100%; + display: block; + flex: 0 0 auto; +} + +.turbofile-device-card > .codicon-chevron-down, +.turbofile-device-select-button > .codicon-chevron-down { + flex: 0 0 auto; + color: var(--vscode-descriptionForeground); +} + +.turbofile-device-select-menu { + position: absolute; + z-index: 50; + top: calc(100% + 3px); + left: 0; + right: 0; + display: none; + max-height: 280px; + padding: 4px 0; + border: 1px solid var(--vscode-dropdown-border, var(--vscode-panel-border)); + border-radius: 4px; + background: var(--vscode-dropdown-background, var(--vscode-input-background)); + color: var(--vscode-dropdown-foreground, var(--vscode-input-foreground)); + box-shadow: 0 8px 18px color-mix(in srgb, var(--vscode-widget-shadow, #000) 18%, transparent); + overflow-y: auto; +} + +.turbofile-device-select.is-open .turbofile-device-select-menu { + display: block; +} + +.turbofile-device-select-option { + width: 100%; + min-height: 30px; + display: flex; + align-items: center; + gap: 8px; + padding: 4px 9px; + border: 0; + background: transparent; + color: inherit; + font-family: inherit; + font-size: calc(var(--turbofile-browser-font-size, 14px) * 0.95); + line-height: 1.25; + text-align: left; + cursor: pointer; +} + +.turbofile-device-select-option:hover, +.turbofile-device-select-option:focus { + background: var(--vscode-list-hoverBackground); + outline: none; +} + +.turbofile-device-select-option.is-selected, +.turbofile-device-select-option.is-selected:hover, +.turbofile-device-select-option.is-selected:focus, +.turbofile-device-select-option.is-selected:focus-visible { + background: transparent; + color: inherit; + outline: none; + box-shadow: none; + border-color: transparent; +} + +.turbofile-browser-header { + height: 66px; + display: grid; + grid-template-rows: 32px 34px; + border-bottom: 1px solid var(--turbofile-pane-border); +} + +.turbofile-browser-toolbar { + display: flex; + align-items: center; + gap: 6px; + padding: 0 8px; + border-bottom: 1px solid var(--turbofile-pane-border); +} + +.turbofile-browser-toolbar-title { + display: inline-flex; + align-items: center; + gap: 5px; + color: var(--turbofile-browser-font-color, var(--vscode-foreground)); + font-size: calc(var(--turbofile-browser-font-size, 14px) * 1.03); + font-weight: 600; + line-height: 1.25; + margin-right: auto; +} + +.turbofile-browser-item-count { + flex: 0 0 auto; + margin-left: 4px; + padding-left: 9px; + border-left: 1px solid color-mix(in srgb, var(--turbofile-browser-font-color, var(--vscode-foreground)) 18%, transparent); + color: var(--vscode-descriptionForeground); + font-size: calc(var(--turbofile-browser-font-size, 14px) * 0.92); + font-weight: 500; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.turbofile-toolbar-button, +.turbofile-rail-button, +.turbofile-primary-button, +.turbofile-secondary-button { + border: 0; + color: var(--vscode-button-secondaryForeground); + background: var(--vscode-button-secondaryBackground); + cursor: pointer; +} + +.turbofile-toolbar-button { + width: 24px; + height: 24px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 3px; + background: transparent; + box-shadow: none; + text-shadow: none; + filter: none; + appearance: none; + -webkit-appearance: none; +} + +.turbofile-toolbar-button .codicon, +.turbofile-toolbar-button .turbofile-bi-icon { + box-shadow: none; + text-shadow: none; + filter: none; +} + +.turbofile-bi-icon { + width: 16px; + height: 16px; + flex: 0 0 16px; + display: inline-block; + vertical-align: text-bottom; +} + +.turbofile-text-primary { + color: #3b82f6; +} + +.turbofile-text-success { + color: #2fae7b; +} + +.turbofile-text-warning { + color: #f2b91b; +} + +.turbofile-text-info { + color: #22c7dd; +} + +.turbofile-text-danger { + color: #ef6670; +} + +.turbofile-text-secondary { + color: #7b8794; +} + +.turbofile-toolbar-button:hover { + background: transparent; +} + +.turbofile-secondary-button:hover { + background: var(--vscode-button-secondaryHoverBackground); +} + +.turbofile-rail-button:hover { + background: var(--vscode-button-secondaryHoverBackground); +} + +.turbofile-pathbar { + --turbofile-path-color: var(--vscode-descriptionForeground); + display: flex; + align-items: center; + gap: 4px; + padding: 0 6px; + color: var(--turbofile-path-color); + font-size: calc(var(--turbofile-browser-font-size, 14px) * 0.95); + line-height: 1.25; + white-space: nowrap; + overflow: hidden; + background: var(--tf-file-surface, var(--vscode-editor-background)); +} + +.turbofile-pathbar-primary { + --turbofile-path-color: #60a5fa; +} + +.turbofile-pathbar-success { + --turbofile-path-color: #4fc391; +} + +.turbofile-location-menu-button { + height: 28px; + display: inline-flex; + align-items: center; + gap: 6px; + flex: 0 0 auto; + padding: 0 8px; + border: 1px solid color-mix(in srgb, var(--vscode-foreground) 8%, transparent); + border-radius: 8px; + background: color-mix(in srgb, var(--vscode-foreground) 6%, var(--tf-file-surface, var(--vscode-editor-background))); + color: var(--turbofile-browser-font-color, var(--vscode-input-foreground)); + font-family: inherit; + font-size: inherit; + line-height: 1.25; + cursor: pointer; +} + +.turbofile-location-menu-button:hover, +.turbofile-location-menu-button:focus { + border-color: color-mix(in srgb, #2e8cff 55%, transparent); + background: color-mix(in srgb, #2e8cff 14%, var(--vscode-editor-background)); + outline: none; +} + +.turbofile-location-menu-button-label { + max-width: 48px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-weight: 600; +} + +.turbofile-location-menu-button > .codicon-chevron-down { + color: var(--vscode-descriptionForeground); +} + +.turbofile-location-icon { + position: relative; + display: inline-block; + width: 18px; + height: 18px; + flex: 0 0 18px; + background-position: center; + background-repeat: no-repeat; + background-size: contain; +} + +.turbofile-location-icon.turbofile-location-icon-windows { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%231687ff' d='M2 4.2 11 3v8.1H2V4.2zM12.3 2.8 22 1.5v9.6h-9.7V2.8zM2 12.4h9v8.1l-9-1.2v-6.9zM12.3 12.4H22V22l-9.7-1.3v-8.3z'/%3E%3C/svg%3E"); + filter: drop-shadow(0 0 5px rgba(46, 140, 255, 0.24)); +} + +.turbofile-location-icon.turbofile-location-icon-desktop { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Crect x='3' y='4' width='18' height='12' rx='1.8' fill='%232e8cff'/%3E%3Cpath fill='%232e8cff' d='M10 17h4v2h4v2H6v-2h4v-2z'/%3E%3C/svg%3E"); + filter: drop-shadow(0 0 5px rgba(46, 140, 255, 0.24)); +} + +.turbofile-location-icon.turbofile-location-icon-drive { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%2367ef67' d='M5 3h14l2 14H3L5 3z'/%3E%3Cpath fill='%234bdc4f' d='M3 17h18v3c0 .9-.7 1.6-1.6 1.6H4.6C3.7 21.6 3 20.9 3 20v-3z'/%3E%3Cpath fill='%23182b19' d='M9.2 7.2h2v5h-2v-5zM13.6 7.2h2v5h-2v-5z'/%3E%3Ccircle fill='%23182b19' cx='8.3' cy='19.2' r='1.1'/%3E%3Ccircle fill='%23182b19' cx='12.2' cy='19.2' r='1.1'/%3E%3C/svg%3E"); + filter: drop-shadow(0 0 4px rgba(103, 239, 103, 0.18)); +} + +.context-view.monaco-menu-container:has(.turbofile-windows-location-action) { + border: 1px solid rgba(129, 144, 160, 0.28); + border-radius: 14px; + background: #11151a; + box-shadow: 0 14px 32px rgba(0, 0, 0, 0.34); + padding: 8px 0; +} + +.context-view.monaco-menu-container:has(.turbofile-windows-location-action) .monaco-menu, +.context-view.monaco-menu-container:has(.turbofile-windows-location-action) .monaco-action-bar { + background: transparent; + color: #f4f7fb; +} + +.context-view.monaco-menu-container:has(.turbofile-windows-location-action) .monaco-action-bar.vertical .action-menu-item { + height: 44px; + margin: 3px 10px; + border-radius: 8px; + color: #f4f7fb !important; + background: transparent !important; +} + +.context-view.monaco-menu-container:has(.turbofile-windows-location-action) .monaco-action-bar.vertical .action-label.turbofile-windows-location-action { + height: 44px; + margin: 3px 10px; + border-radius: 8px; + color: #f4f7fb !important; + background: transparent !important; +} + +.context-view.monaco-menu-container:has(.turbofile-windows-location-action) .monaco-action-bar.vertical .action-item.focused .action-menu-item, +.context-view.monaco-menu-container:has(.turbofile-windows-location-action) .monaco-action-bar.vertical .action-menu-item:hover { + background: rgba(255, 255, 255, 0.08) !important; +} + +.context-view.monaco-menu-container:has(.turbofile-windows-location-action) .monaco-action-bar.vertical .action-item.focused .action-label.turbofile-windows-location-action, +.context-view.monaco-menu-container:has(.turbofile-windows-location-action) .monaco-action-bar.vertical .action-label.turbofile-windows-location-action:hover { + background: rgba(255, 255, 255, 0.08) !important; +} + +.context-view.monaco-menu-container:has(.turbofile-windows-location-action) .monaco-action-bar.vertical .action-label:not(.separator) { + font-size: 15px; + font-weight: 600; +} + +.context-view.monaco-menu-container:has(.turbofile-windows-location-action) .monaco-action-bar.vertical .action-label.separator { + border-bottom-color: rgba(255, 255, 255, 0.16); + margin: 7px 18px !important; +} + +.context-view.monaco-menu-container:has(.turbofile-windows-location-action) .monaco-action-bar.vertical .menu-item-check { + left: auto; + right: 14px; + width: 20px; + color: #2e8cff !important; +} + +.monaco-menu .monaco-action-bar.vertical .action-label.turbofile-windows-location-action { + display: inline-flex; + align-items: center; + gap: 14px; + padding-left: 20px; + padding-right: 44px; + font-family: var(--vscode-font-family); +} + +.monaco-menu .monaco-action-bar.vertical .action-label.turbofile-windows-location-action::before { + content: ""; + display: inline-block; + width: 24px; + height: 24px; + flex: 0 0 24px; + background-position: center; + background-repeat: no-repeat; + background-size: contain; +} + +.monaco-menu .monaco-action-bar.vertical .action-label.turbofile-windows-location-action.checked::after { + content: ""; + display: inline-block; + width: 10px; + height: 18px; + margin-left: auto; + margin-right: 2px; + border-right: 2px solid #2e8cff; + border-bottom: 2px solid #2e8cff; + transform: rotate(45deg) translateY(-2px); +} + +.monaco-menu .monaco-action-bar.vertical .action-label.turbofile-windows-location-action.turbofile-location-icon-windows::before { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%231687ff' d='M2 4.2 11 3v8.1H2V4.2zM12.3 2.8 22 1.5v9.6h-9.7V2.8zM2 12.4h9v8.1l-9-1.2v-6.9zM12.3 12.4H22V22l-9.7-1.3v-8.3z'/%3E%3C/svg%3E"); +} + +.monaco-menu .monaco-action-bar.vertical .action-label.turbofile-windows-location-action.turbofile-location-icon-desktop::before { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Crect x='3' y='4' width='18' height='12' rx='1.8' fill='%232e8cff'/%3E%3Cpath fill='%232e8cff' d='M10 17h4v2h4v2H6v-2h4v-2z'/%3E%3C/svg%3E"); +} + +.monaco-menu .monaco-action-bar.vertical .action-label.turbofile-windows-location-action.turbofile-location-icon-drive::before { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%2367ef67' d='M5 3h14l2 14H3L5 3z'/%3E%3Cpath fill='%234bdc4f' d='M3 17h18v3c0 .9-.7 1.6-1.6 1.6H4.6C3.7 21.6 3 20.9 3 20v-3z'/%3E%3Cpath fill='%23182b19' d='M9.2 7.2h2v5h-2v-5zM13.6 7.2h2v5h-2v-5z'/%3E%3Ccircle fill='%23182b19' cx='8.3' cy='19.2' r='1.1'/%3E%3Ccircle fill='%23182b19' cx='12.2' cy='19.2' r='1.1'/%3E%3C/svg%3E"); +} + +.context-view.monaco-menu-container:has(.turbofile-sort-action) { + border: 1px solid rgba(129, 144, 160, 0.28); + border-radius: 10px; + background: #11151a; + box-shadow: 0 14px 32px rgba(0, 0, 0, 0.34); + padding: 4px 0; +} + +.context-view.monaco-menu-container:has(.turbofile-sort-action) .monaco-menu, +.context-view.monaco-menu-container:has(.turbofile-sort-action) .monaco-action-bar { + background: transparent; + color: #f4f7fb; +} + +.context-view.monaco-menu-container:has(.turbofile-sort-action) .monaco-action-bar.vertical .action-label.turbofile-sort-action { + height: 34px; + margin: 2px 6px; + border-radius: 6px; + color: #f4f7fb !important; + background: transparent !important; +} + +.context-view.monaco-menu-container:has(.turbofile-sort-action) .monaco-action-bar.vertical .action-item.focused .action-label.turbofile-sort-action, +.context-view.monaco-menu-container:has(.turbofile-sort-action) .monaco-action-bar.vertical .action-label.turbofile-sort-action:hover { + background: rgba(255, 255, 255, 0.08) !important; +} + +.context-view.monaco-menu-container:has(.turbofile-sort-action) .monaco-action-bar.vertical .action-label.separator { + border-bottom-color: rgba(255, 255, 255, 0.16); + margin: 5px 12px !important; +} + +.context-view.monaco-menu-container:has(.turbofile-sort-action) .monaco-action-bar.vertical .menu-item-check { + display: none; +} + +.monaco-menu .monaco-action-bar.vertical .action-label.turbofile-sort-action { + display: inline-flex; + align-items: center; + min-width: 136px; + padding-left: 24px; + padding-right: 42px; + font-family: var(--vscode-font-family); + font-size: 14px; + font-weight: 500; +} + +.monaco-menu .monaco-action-bar.vertical .action-label.turbofile-sort-action.checked::after { + content: ""; + display: inline-block; + width: 5px; + height: 10px; + margin-left: auto; + margin-right: 3px; + border-right: 2px solid #8a8f98; + border-bottom: 2px solid #8a8f98; + transform: rotate(45deg) translateY(-1px); +} + +.turbofile-pathbar strong, +.turbofile-path-display { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + color: var(--turbofile-path-color); + font-weight: 500; +} + +.turbofile-path-display { + flex: 1 1 auto; + display: flex; + align-items: center; + gap: 0; + min-height: 24px; + font-family: "Cascadia Code", Consolas, "Microsoft YaHei UI", monospace; + font-size: calc(var(--turbofile-browser-font-size, 14px) * 0.88); + font-weight: 500; + cursor: text; +} + +.turbofile-path-display.is-error { + color: var(--vscode-errorForeground); +} + +.turbofile-path-segment { + min-width: 0; + border: 0; + border-radius: 4px; + padding: 1px; + background: transparent; + color: inherit; + font: inherit; + font-weight: 500; + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + cursor: pointer; +} + +.turbofile-path-segment:hover, +.turbofile-path-segment:focus { + background: color-mix(in srgb, var(--turbofile-path-color) 13%, transparent); + outline: none; +} + +.turbofile-path-separator { + color: inherit; + font-weight: 400; + margin: 0 1px; +} + +.turbofile-path-inline-editor { + box-sizing: border-box; + width: 100%; + height: 24px; + border: 1px solid var(--vscode-focusBorder); + border-radius: 4px; + padding: 1px 6px; + background: var(--vscode-input-background); + color: var(--vscode-input-foreground); + font: inherit; + outline: none; +} + +.turbofile-selection-count { + flex: 0 0 auto; + margin-left: auto; + padding: 1px 6px; + border-radius: 999px; + background: var(--vscode-badge-background); + color: var(--vscode-badge-foreground); + font-size: 11px; + line-height: 16px; +} + +.turbofile-file-list { + min-height: 0; + overflow: auto; + overflow-x: hidden; + background: var(--turbofile-file-surface-final, var(--tf-file-surface, var(--vscode-editor-background))); + font-family: var(--turbofile-browser-font-family, "Segoe UI", "Microsoft YaHei UI", sans-serif); + font-size: var(--turbofile-browser-font-size, 14px); + color: var(--turbofile-browser-font-color, var(--vscode-foreground)); + outline: none; + border: 0; + box-shadow: none; + scrollbar-width: thin; + scrollbar-color: color-mix(in srgb, var(--vscode-foreground) 30%, var(--vscode-editor-background)) var(--vscode-editor-background); +} + +.turbofile-file-list:focus, +.turbofile-file-list:focus-visible, +.turbofile-file-list:focus-within { + outline: none !important; + border-color: transparent !important; + box-shadow: none !important; +} + +.turbofile-file-list.is-external-drop-target { + background: + linear-gradient(0deg, color-mix(in srgb, var(--tf-brand-blue, #042aff) 7%, transparent), color-mix(in srgb, var(--tf-brand-blue, #042aff) 7%, transparent)), + var(--vscode-editor-background); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--tf-brand-blue, #042aff) 30%, transparent); +} + +.turbofile-workbench .turbofile-file-list::-webkit-scrollbar { + width: 6px; + height: 6px; + background: var(--vscode-editor-background); +} + +.turbofile-workbench .turbofile-file-list::-webkit-scrollbar-track, +.turbofile-workbench .turbofile-file-list::-webkit-scrollbar-corner, +.turbofile-workbench .turbofile-file-list::-webkit-scrollbar-button { + width: 0; + height: 0; + display: none; + background: var(--vscode-editor-background); +} + +.turbofile-workbench .turbofile-file-list::-webkit-scrollbar-thumb { + min-height: 28px; + border: 1px solid var(--vscode-editor-background); + border-radius: 999px; + background: color-mix(in srgb, var(--vscode-foreground) 30%, var(--vscode-editor-background)); +} + +.turbofile-workbench .turbofile-file-list::-webkit-scrollbar-thumb:hover { + background: color-mix(in srgb, var(--vscode-foreground) 42%, var(--vscode-editor-background)); +} + +.turbofile-file-list-spacer { + width: 1px; + pointer-events: none; +} + +.turbofile-file-row { + height: max(30px, calc(var(--turbofile-browser-font-size, 14px) + 16px)); + display: grid; + grid-template-columns: minmax(120px, 1fr) 92px 136px; + align-items: center; + gap: 10px; + padding: 0 10px; + border: 0; + color: var(--turbofile-browser-font-color, var(--vscode-foreground)); +} + +.turbofile-file-row:hover { + background: var(--vscode-list-hoverBackground); +} + +.turbofile-file-row.is-clickable:active { + background: var(--vscode-list-activeSelectionBackground); +} + +.turbofile-file-row.is-selected { + background: var(--vscode-list-activeSelectionBackground); + color: var(--vscode-list-activeSelectionForeground); +} + +.turbofile-file-row.is-internal-drop-target { + background: color-mix(in srgb, var(--tf-brand-blue, #042aff) 16%, transparent); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--tf-brand-blue, #042aff) 55%, transparent); + border-radius: 4px; +} + +.turbofile-file-row.is-renaming { + --turbofile-inline-rename-height: max(36px, calc(var(--turbofile-browser-font-size, 14px) + 22px)); + height: var(--turbofile-inline-rename-height); + grid-template-columns: minmax(220px, 1fr); +} + +.turbofile-file-row.is-renaming .turbofile-file-meta { + display: none; +} + +.turbofile-file-name { + min-width: 0; + display: flex; + align-items: center; + gap: 6px; + line-height: calc(var(--turbofile-browser-font-size, 14px) + 8px); + padding-bottom: 2px; + box-sizing: border-box; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.turbofile-file-icon-slot { + flex: 0 0 18px; + width: 18px; + height: 22px; + display: inline-flex; + align-items: center; + justify-content: center; + overflow: hidden; +} + +.turbofile-file-icon-slot .turbofile-bi-icon { + width: 16px; + height: 16px; + flex: 0 0 16px; +} + +.turbofile-file-icon-slot .monaco-icon-label { + width: 18px; + height: 22px; + flex: 0 0 18px; + overflow: visible; +} + +.turbofile-file-icon-slot .monaco-icon-label::before { + width: 16px; + height: 22px; + padding-right: 0; +} + +.turbofile-file-name.is-renaming { + overflow: visible; +} + +.turbofile-inline-rename { + min-width: 80px; + flex: 1 1 auto; +} + +.turbofile-inline-rename-editor { + width: 100%; + height: var(--turbofile-inline-rename-height); + min-height: var(--turbofile-inline-rename-height); + box-sizing: border-box; + /* Center the single editable line by making line-height fill the content box (height minus the 2px + border, no vertical padding). This vertically centers the text AND gives descenders like "_"/g/y + plenty of half-leading below the baseline, so they are never clipped — while keeping a plain block + layout so the caret still scrolls horizontally for long names. */ + padding: 0 6px; + border: 1px solid var(--vscode-input-border, var(--vscode-focusBorder)); + border-radius: 3px; + background: var(--vscode-input-background); + color: var(--vscode-input-foreground); + font-family: var(--turbofile-browser-font-family, "Segoe UI", "Microsoft YaHei UI", sans-serif); + font-size: var(--turbofile-browser-font-size, 14px); + line-height: calc(var(--turbofile-inline-rename-height) - 2px); + white-space: pre; + overflow: hidden; + outline: none; + cursor: text; + user-select: text; + -webkit-user-select: text; +} + +.turbofile-inline-rename-editor:focus { + border-color: var(--vscode-focusBorder, var(--vscode-inputOption-activeBorder)); +} + +.turbofile-inline-rename-editor.is-invalid { + border-color: var(--vscode-inputValidation-errorBorder, #be1100); +} + +.turbofile-file-meta { + color: var(--turbofile-browser-font-color, var(--vscode-descriptionForeground)); + text-align: right; + white-space: nowrap; + font-size: calc(var(--turbofile-browser-font-size, 14px) - 1px); +} + +.turbofile-transfer-rail { + min-height: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 10px; + padding: 10px; + border-top: 1px solid var(--vscode-focusBorder); + overflow: hidden; +} + +.turbofile-rail-button { + width: 44px; + height: 34px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 4px; +} + +.turbofile-primary-button, +.turbofile-secondary-button { + height: 26px; + padding: 0 10px; + border-radius: 4px; +} + +.turbofile-primary-button { + color: var(--vscode-button-foreground); + background: var(--vscode-button-background); +} + +.turbofile-primary-button:hover { + background: var(--vscode-button-hoverBackground); +} + +.turbofile-primary-button:disabled, +.turbofile-secondary-button:disabled { + opacity: 0.55; + cursor: default; +} + +.turbofile-taskbar { + position: fixed; + left: var(--turbofile-page-nav-width); + right: 0; + bottom: 0; + z-index: 40; + display: grid; + grid-template-rows: minmax(0, 1fr); + box-sizing: border-box; + min-height: var(--turbofile-taskbar-height); + height: var(--turbofile-taskbar-height); + padding: 0; + border-top: 1px solid var(--vscode-statusBar-border, var(--vscode-panel-border)); + color: var(--vscode-statusBar-foreground); + background: var(--vscode-statusBar-background); + cursor: pointer; + overflow: hidden; + user-select: none; + font-family: inherit; + font-size: 11px; + font-weight: 400; +} + +.turbofile-taskbar.has-live-transfer { + background: var(--vscode-statusBar-background); +} + +.turbofile-taskbar:focus-visible { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: -1px; +} + +.turbofile-statusbar-progress { + display: none; +} + +.turbofile-statusbar-progress-value { + width: 0; + height: 100%; + background: var(--vscode-progressBar-background); + transition: width 140ms ease-out; +} + +.turbofile-statusbar-progress.is-running .turbofile-statusbar-progress-value { + animation: turbofile-statusbar-progress-glow 1.2s ease-in-out infinite; +} + +.turbofile-statusbar-content { + min-width: 0; + min-height: 0; + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + gap: 12px; + align-items: center; + padding: 0 8px; +} + +.turbofile-statusbar-section { + min-width: 0; + display: inline-flex; + align-items: center; + gap: 10px; + white-space: nowrap; +} + +.turbofile-statusbar-section.is-right { + justify-content: flex-end; +} + +.turbofile-statusbar-item { + min-width: 0; + display: inline-flex; + align-items: center; + gap: 5px; + font-family: inherit; + font-size: inherit; + font-weight: inherit; + line-height: 20px; + color: inherit; + opacity: 0.94; +} + +.turbofile-statusbar-item span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} + +.turbofile-statusbar-message { + min-width: 0; + display: inline-flex; + align-items: center; + gap: 8px; + white-space: nowrap; + overflow: hidden; +} + +.turbofile-statusbar-message-title, +.turbofile-statusbar-message-detail { + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.turbofile-statusbar-message-title { + font-family: inherit; + font-size: inherit; + font-weight: inherit; + line-height: 20px; + color: inherit; +} + +.turbofile-statusbar-message-detail { + font-family: inherit; + font-size: inherit; + font-weight: inherit; + line-height: 20px; + color: color-mix(in srgb, var(--vscode-statusBar-foreground) 76%, transparent); +} + +.turbofile-statusbar-cancel { + width: 20px; + height: 20px; + border: 0; + border-radius: 4px; + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--vscode-statusBar-foreground); + background: transparent; + cursor: pointer; +} + +.turbofile-statusbar-cancel:hover { + background: var(--vscode-toolbar-hoverBackground, color-mix(in srgb, var(--vscode-statusBar-foreground) 12%, transparent)); +} + +.turbofile-statusbar-cancel:disabled { + opacity: 0.45; + cursor: default; +} + +@media (max-width: 980px) { + .turbofile-statusbar-content { + grid-template-columns: auto minmax(0, 1fr); + } + + .turbofile-statusbar-section.is-right { + display: none; + } +} + +@keyframes turbofile-statusbar-progress-glow { + 0%, + 100% { + opacity: 0.76; + } + 50% { + opacity: 1; + } +} + +.turbofile-live-transfer-panel { + --turbofile-live-accent: #1473ff; + --turbofile-live-success: #16c995; + --turbofile-live-card: color-mix(in srgb, var(--vscode-editor-background) 94%, white 6%); + --turbofile-live-card-border: color-mix(in srgb, var(--vscode-panel-border) 78%, transparent); + --turbofile-live-muted: var(--vscode-descriptionForeground); + width: 100%; + box-sizing: border-box; + border: 1px solid var(--turbofile-live-card-border); + border-radius: 10px; + background: + linear-gradient(180deg, color-mix(in srgb, var(--vscode-editor-background) 96%, white 4%), var(--vscode-editor-background)); + box-shadow: 0 8px 24px color-mix(in srgb, #000 14%, transparent); + color: var(--vscode-foreground); + overflow: hidden; +} + +.turbofile-live-transfer-panel.is-single { + padding: 12px 16px; +} + +.turbofile-live-transfer-panel.is-multi { + padding: 12px; + max-height: min(44vh, 460px); + overflow: auto; +} + +body.vs-dark .turbofile-live-transfer-panel, +body.hc-black .turbofile-live-transfer-panel { + --turbofile-live-card: color-mix(in srgb, var(--vscode-editor-background) 72%, #0b1726 28%); + --turbofile-live-card-border: color-mix(in srgb, var(--vscode-panel-border) 68%, #2f4864 32%); + background: + linear-gradient(135deg, color-mix(in srgb, var(--vscode-editor-background) 76%, #0d1b2d 24%), color-mix(in srgb, var(--vscode-editor-background) 92%, #07111d 8%)); + box-shadow: 0 10px 28px color-mix(in srgb, #000 36%, transparent); +} + +.turbofile-live-transfer-header { + display: grid; + grid-template-columns: minmax(160px, 1fr) auto; + gap: 16px; + align-items: center; + padding: 4px 4px 12px; + border-bottom: 1px solid var(--turbofile-live-card-border); +} + +.turbofile-live-transfer-title { + display: inline-flex; + align-items: center; + gap: 8px; + font-weight: 700; + font-size: 15px; + color: var(--vscode-foreground); +} + +.turbofile-live-transfer-count { + min-width: 24px; + height: 24px; + padding: 0 7px; + border-radius: 999px; + display: inline-flex; + align-items: center; + justify-content: center; + background: color-mix(in srgb, var(--turbofile-live-accent) 16%, transparent); + color: var(--vscode-foreground); + font-size: 12px; + font-weight: 700; +} + +.turbofile-live-transfer-summary { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 18px; + min-width: 0; +} + +.turbofile-live-transfer-list { + display: grid; + gap: 8px; + padding-top: 10px; +} + +.turbofile-live-transfer-card { + display: grid; + gap: 9px; + padding: 12px 14px; + border: 1px solid var(--turbofile-live-card-border); + border-radius: 8px; + background: var(--turbofile-live-card); + box-shadow: 0 3px 12px color-mix(in srgb, #000 9%, transparent); +} + +.turbofile-live-transfer-card-top { + display: grid; + grid-template-columns: 38px minmax(0, 1fr) auto auto; + gap: 12px; + align-items: center; +} + +.turbofile-live-transfer-identity { + min-width: 0; + display: flex; + align-items: baseline; + gap: 14px; +} + +.turbofile-live-transfer-name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-weight: 700; + font-size: 14px; + color: var(--vscode-foreground); +} + +.turbofile-live-transfer-size { + flex: 0 0 auto; + font-size: 12px; + color: var(--turbofile-live-muted); +} + +.turbofile-live-transfer-percent { + min-width: 58px; + text-align: right; + font-weight: 700; + font-size: 14px; + color: var(--vscode-foreground); +} + +.turbofile-live-transfer-file-icon { + width: 38px; + height: 38px; + border-radius: 7px; + display: inline-flex; + align-items: center; + justify-content: center; + color: #ffffff; + background: linear-gradient(135deg, #1884ff, #1463f3); +} + +.turbofile-live-transfer-file-icon.is-archive { + background: linear-gradient(135deg, #1884ff, #1463f3); +} + +.turbofile-live-transfer-file-icon.is-image { + background: linear-gradient(135deg, #ffb84d, #ff8a34); +} + +.turbofile-live-transfer-file-icon.is-file { + background: linear-gradient(135deg, #7c6bff, #5a50e8); +} + +.turbofile-live-transfer-file-icon .turbofile-bi-icon { + width: 20px; + height: 20px; + color: currentColor; +} + +.turbofile-live-transfer-progress { + height: 6px; + border-radius: 999px; + overflow: hidden; + background: color-mix(in srgb, var(--vscode-foreground) 10%, transparent); +} + +.turbofile-live-transfer-progress-value { + height: 100%; + border-radius: inherit; + background: linear-gradient(90deg, var(--turbofile-live-accent), #24c7f1, var(--turbofile-live-success)); + transition: width 120ms linear; +} + +.turbofile-live-transfer-metrics { + display: grid; + grid-template-columns: repeat(5, minmax(110px, 1fr)) minmax(150px, 0.9fr); + gap: 8px; + align-items: center; +} + +.turbofile-live-transfer-metric { + min-width: 0; + display: inline-flex; + align-items: center; + gap: 8px; + padding-right: 12px; + border-right: 1px solid color-mix(in srgb, var(--vscode-panel-border) 55%, transparent); +} + +.turbofile-live-transfer-metric .turbofile-bi-icon { + width: 15px; + height: 15px; + flex: 0 0 auto; +} + +.turbofile-live-transfer-metric-text { + min-width: 0; + display: grid; + gap: 1px; +} + +.turbofile-live-transfer-metric-value { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 13px; + font-weight: 700; + color: var(--vscode-foreground); +} + +.turbofile-live-transfer-metric-label, +.turbofile-live-transfer-wave-label { + font-size: 11px; + color: var(--turbofile-live-muted); +} + +.turbofile-live-transfer-wave { + min-width: 0; + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; +} + +.turbofile-live-transfer-sparkline { + width: 120px; + height: 30px; + overflow: visible; +} + +.turbofile-live-transfer-sparkline path { + fill: none; + stroke: var(--turbofile-live-success); + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; +} + +.turbofile-live-transfer-cancel, +.turbofile-live-transfer-cancel-all { + border: 1px solid color-mix(in srgb, #ff5d5d 36%, var(--vscode-panel-border)); + border-radius: 999px; + display: inline-flex; + align-items: center; + justify-content: center; + color: #ff5d5d; + background: color-mix(in srgb, var(--vscode-editor-background) 92%, transparent); + cursor: pointer; +} + +.turbofile-live-transfer-cancel { + width: 34px; + height: 34px; +} + +.turbofile-live-transfer-cancel-all { + height: 34px; + padding: 0 12px; + gap: 6px; + font-weight: 600; +} + +.turbofile-live-transfer-cancel:hover, +.turbofile-live-transfer-cancel-all:hover { + background: color-mix(in srgb, #ff5d5d 12%, var(--vscode-editor-background)); +} + +.turbofile-live-transfer-cancel:disabled { + opacity: 0.45; + cursor: default; +} + +.turbofile-live-transfer-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 10px 4px 0; + color: var(--turbofile-live-muted); + font-size: 12px; +} + +.turbofile-live-transfer-total { + margin-left: auto; +} + +@media (max-width: 980px) { + .turbofile-live-transfer-header, + .turbofile-live-transfer-card-top { + grid-template-columns: 38px minmax(0, 1fr) auto; + } + + .turbofile-live-transfer-header { + grid-template-columns: minmax(0, 1fr); + } + + .turbofile-live-transfer-summary { + justify-content: flex-start; + flex-wrap: wrap; + } + + .turbofile-live-transfer-metrics { + grid-template-columns: repeat(2, minmax(120px, 1fr)); + } + + .turbofile-live-transfer-wave { + justify-content: flex-start; + } +} + +.turbofile-bottom-status { + min-width: 0; + display: grid; + grid-template-rows: auto auto; + gap: 1px; +} + +.turbofile-task-title { + font-weight: 600; + font-size: 12px; + line-height: 15px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.turbofile-progress { + height: 6px; + overflow: hidden; + border-radius: 999px; + background: color-mix(in srgb, var(--vscode-progressBar-background) 18%, transparent); +} + +.turbofile-progress-value { + width: 34%; + height: 100%; + background: var(--vscode-button-background); + transition: width 160ms ease-out; +} + +.turbofile-progress.is-running .turbofile-progress-value { + background: var(--vscode-progressBar-background); + animation: turbofile-progress-pulse 1s ease-in-out infinite; +} + +.turbofile-task-meta { + color: var(--vscode-descriptionForeground); + font-size: 11px; + line-height: 14px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.turbofile-task-list { + min-height: 0; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 4px 10px; + margin-top: 8px; + overflow: hidden; +} + +.turbofile-task-row { + height: 24px; + min-width: 0; + display: grid; + grid-template-columns: minmax(96px, 0.9fr) minmax(120px, 1.3fr) auto 28px; + gap: 8px; + align-items: center; + padding: 0 8px; + border: 1px solid var(--vscode-panel-border); + border-radius: 4px; + background: var(--vscode-editor-background); +} + +.turbofile-task-row-name, +.turbofile-task-row-detail, +.turbofile-task-row-metrics, +.turbofile-task-row-actions { + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.turbofile-task-row-name { + display: flex; + align-items: center; + gap: 6px; + font-weight: 600; +} + +.turbofile-task-row-detail, +.turbofile-task-row-metrics { + color: var(--vscode-descriptionForeground); +} + +.turbofile-task-row-metrics { + text-align: right; +} + +.turbofile-task-row.is-success .turbofile-task-row-name { + color: var(--vscode-testing-iconPassed); +} + +.turbofile-task-row.is-error .turbofile-task-row-name { + color: var(--vscode-testing-iconFailed); +} + +.turbofile-task-row.is-canceled .turbofile-task-row-name { + color: var(--vscode-disabledForeground); +} + +.turbofile-task-row.is-running .turbofile-task-row-name .codicon { + animation: turbofile-task-spin 1.2s linear infinite; +} + +.turbofile-task-row-actions { + display: flex; + align-items: center; + justify-content: flex-end; +} + +.turbofile-task-row-button { + width: 22px; + height: 22px; + border: 0; + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--vscode-button-secondaryForeground); + background: transparent; + cursor: pointer; +} + +.turbofile-task-row-button:hover { + background: var(--vscode-toolbar-hoverBackground); +} + +.turbofile-task-row-button:disabled { + opacity: 0.5; + cursor: default; +} + +.turbofile-task-log-overlay { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + background: color-mix(in srgb, var(--vscode-editor-background), transparent 20%); + z-index: 9; +} + +.turbofile-task-log-panel { + width: min(840px, 92vw); + max-height: min(640px, 82vh); + display: grid; + grid-template-rows: auto minmax(0, 1fr); + border: 1px solid var(--vscode-panel-border); + border-radius: 6px; + background: var(--vscode-sideBar-background); + box-shadow: 0 10px 28px var(--vscode-widget-shadow); + overflow: hidden; +} + +.turbofile-task-log-header { + height: 42px; + display: flex; + align-items: center; + gap: 10px; + padding: 0 12px 0 16px; + border-bottom: 1px solid var(--vscode-panel-border); +} + +.turbofile-task-log-header h2 { + flex: 1; + margin: 0; + font-size: 14px; + font-weight: 600; +} + +.turbofile-task-log-list { + min-height: 0; + display: grid; + align-content: start; + gap: 6px; + padding: 10px; + overflow: auto; +} + +.turbofile-task-log-empty { + padding: 18px; + color: var(--vscode-descriptionForeground); + text-align: center; +} + +.turbofile-bottom-actions { + display: flex; + align-items: center; + justify-content: center; + flex-wrap: wrap; + gap: 10px; + min-width: 0; +} + +.turbofile-bottom-spacer { + min-width: 0; +} + +.turbofile-bottom-action { + height: 38px; + min-width: 112px; + padding: 0 20px; + border: 1px solid rgba(15, 23, 42, 0.08); + border-radius: 999px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + color: #111827; + background: linear-gradient(180deg, #f3f4f6 0%, #e5e7eb 100%); + box-shadow: 0 10px 22px rgba(15, 23, 42, 0.10); + cursor: pointer; + font-family: "Inter", "Segoe UI", "Microsoft YaHei UI", sans-serif; + font-weight: 600; + font-size: 13px; + letter-spacing: 0; + transition: transform 120ms ease, box-shadow 120ms ease, border-color 120ms ease, background 120ms ease; +} + +.turbofile-bottom-action:hover { + border-color: rgba(15, 23, 42, 0.14); + background: linear-gradient(180deg, #f9fafb 0%, #e5e7eb 100%); + box-shadow: 0 12px 24px rgba(15, 23, 42, 0.14); + transform: translateY(-1px); +} + +.turbofile-bottom-action.is-primary { + color: #111827; + background: linear-gradient(180deg, #f3f4f6 0%, #e5e7eb 100%); +} + +.turbofile-bottom-action.is-primary:hover { + background: linear-gradient(180deg, #f9fafb 0%, #e5e7eb 100%); +} + +.turbofile-bottom-action.is-mode.is-selected { + color: #f8fafc; + border-color: rgba(17, 17, 17, 0.72); + background: linear-gradient(180deg, #2b2b2b 0%, #1f1f1f 100%); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06), 0 12px 24px rgba(0, 0, 0, 0.16); +} + +.turbofile-bottom-action.is-mode.is-selected:hover { + background: linear-gradient(180deg, #303030 0%, #202020 100%); +} + +.turbofile-bottom-action.is-mode.is-selected .turbofile-bi-icon { + color: #ffffff; +} + +.turbofile-bottom-action.is-start { + min-width: 128px; +} + +.turbofile-bottom-action.is-start .turbofile-bi-icon { + color: #ffc107; +} + +.turbofile-bottom-action:disabled { + opacity: 0.5; + cursor: default; +} + +@keyframes turbofile-task-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +@keyframes turbofile-progress-pulse { + 0% { + opacity: 0.6; + } + 50% { + opacity: 1; + } + 100% { + opacity: 0.6; + } +} + +.turbofile-device-overlay { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + background: color-mix(in srgb, var(--vscode-editor-background), transparent 18%); + z-index: 10; +} + +.turbofile-device-form { + width: min(560px, 100%); + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; + padding: 18px; + border: 1px solid var(--vscode-panel-border); + border-radius: 6px; + background: var(--vscode-sideBar-background); + box-shadow: 0 8px 24px var(--vscode-widget-shadow); +} + +.turbofile-device-form-title { + grid-column: 1 / -1; + font-size: 14px; + font-weight: 600; +} + +.turbofile-device-field { + display: grid; + gap: 5px; +} + +.turbofile-device-field label { + color: var(--vscode-descriptionForeground); +} + +.turbofile-device-field input, +.turbofile-device-field select { + height: 28px; + padding: 0 8px; + border: 1px solid var(--vscode-input-border); + border-radius: 3px; + background: var(--vscode-input-background); + color: var(--vscode-input-foreground); + pointer-events: auto; +} + +.turbofile-device-field input { + cursor: text; + user-select: text; + -webkit-user-select: text; +} + +.turbofile-device-field select { + cursor: pointer; + user-select: auto; + -webkit-user-select: auto; +} + +.turbofile-device-field.is-wide { + grid-column: 1 / -1; +} + +.turbofile-device-form-message { + grid-column: 1 / -1; + min-height: 18px; + color: var(--vscode-descriptionForeground); +} + +.turbofile-device-form-actions { + grid-column: 1 / -1; + display: flex; + justify-content: flex-end; + gap: 8px; +} + +.turbofile-image-page { + height: 100%; + display: grid; + grid-template-rows: 38px minmax(0, 1fr); + background: var(--vscode-editor-background); + color: var(--vscode-foreground); +} + +.turbofile-image-page-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 0 12px; + border-bottom: 0; + background: var(--vscode-editor-background); + position: relative; +} + +.turbofile-image-page-header-left { + display: none; + align-items: center; + gap: 14px; + min-width: 0; +} + +.turbofile-image-page-title { + display: flex; + align-items: center; + gap: 8px; + font-size: 14px; + font-weight: 600; + white-space: nowrap; +} + +.turbofile-image-page-columns { + display: flex; + align-items: center; + gap: 8px; + padding: 4px 10px; + border-radius: 10px; + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(148, 163, 184, 0.15); +} + +.turbofile-image-page-columns-label { + color: #9ca3af; + font-size: 12px; + font-weight: 600; +} + +.turbofile-image-page-columns-button { + border: none; + background: transparent; + color: #cbd5e1; + padding: 2px 4px; + border-radius: 6px; + font-size: 12px; + font-weight: 600; + cursor: pointer; +} + +.turbofile-image-page-columns-button:hover { + background: rgba(255, 255, 255, 0.06); + color: #f8fafc; +} + +.turbofile-image-page-columns-button.is-active { + background: rgba(99, 102, 241, 0.24); + color: #dbeafe; +} + +.turbofile-image-page-actions { + display: flex; + align-items: center; + gap: 6px; + position: absolute; + top: 5px; + right: 12px; + z-index: 10; + background: transparent; + pointer-events: auto; +} + +.turbofile-image-page-actions > .turbofile-secondary-button { + display: none; +} + +.turbofile-image-page-action { + width: 28px; + height: 28px; + border: 0; + border-radius: 7px; + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; + background: var(--tf-image-control-bg); + color: var(--tf-image-control-text); + box-shadow: var(--tf-image-control-shadow); + cursor: pointer; + transition: background 160ms ease, color 160ms ease, opacity 160ms ease, transform 160ms ease; +} + +.turbofile-image-page-action:hover { + background: var(--tf-image-control-hover); +} + +.turbofile-image-page-action:disabled { + opacity: 0.45; + cursor: default; +} + +.turbofile-image-page-action:disabled:hover { + background: var(--tf-image-control-bg); +} + +.turbofile-image-page-action.is-active { + color: var(--tf-image-control-text); + background: var(--tf-image-control-bg); +} + +.turbofile-image-page-action.is-danger { + color: var(--vscode-errorForeground, #f87171); +} + +.turbofile-image-page-action.is-danger:not(:disabled):hover { + background: color-mix(in srgb, var(--vscode-errorForeground, #f87171) 12%, var(--tf-image-control-hover)); +} + +.turbofile-image-page-action .codicon { + font-size: 14px; +} + +.turbofile-image-page-sort { + position: relative; + display: inline-flex; + pointer-events: auto; +} + +.turbofile-image-page-scale { + position: relative; + display: inline-flex; + pointer-events: auto; +} + +.turbofile-image-page-sort-button { + width: 112px; + height: 28px; + border: 0; + border-radius: 7px; + padding: 0 6px; + display: inline-flex; + align-items: center; + gap: 4px; + background: var(--tf-image-control-bg); + color: var(--tf-image-control-text); + box-shadow: var(--tf-image-control-shadow); + cursor: pointer; + font-size: 12px; + font-weight: 600; + transition: background 160ms ease, color 160ms ease; +} + +.turbofile-image-page-scale-button { + width: 88px; + height: 28px; + border: 0; + border-radius: 7px; + padding: 0 6px; + display: inline-flex; + align-items: center; + gap: 4px; + background: var(--tf-image-control-bg); + color: var(--tf-image-control-text); + box-shadow: var(--tf-image-control-shadow); + cursor: pointer; + font-size: 12px; + font-weight: 600; + transition: background 160ms ease, color 160ms ease; +} + +.turbofile-image-page-sort-button:hover, +.turbofile-image-page-sort.is-open .turbofile-image-page-sort-button, +.turbofile-image-page-scale-button:hover, +.turbofile-image-page-scale.is-open .turbofile-image-page-scale-button { + background: var(--tf-image-control-hover); +} + +.turbofile-image-page-sort-button .codicon { + font-size: 14px; + flex: 0 0 auto; +} + +.turbofile-image-page-scale-button .codicon { + font-size: 14px; + flex: 0 0 auto; +} + +.turbofile-image-page-sort-button .codicon-chevron-down { + font-size: 12px; + opacity: 0.7; +} + +.turbofile-image-page-scale-button .codicon-chevron-down { + font-size: 12px; + opacity: 0.7; +} + +.turbofile-image-page-sort-label { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.turbofile-image-page-scale-label { + flex: 1 1 auto; + min-width: 20px; + white-space: nowrap; + text-align: center; +} + +.turbofile-image-page-sort-menu { + position: absolute; + z-index: 50; + top: calc(100% + 4px); + right: 0; + min-width: 128px; + display: none; + padding: 4px 0; + border: 1px solid var(--tf-image-border); + border-radius: 8px; + background: var(--tf-image-surface, var(--tf-image-control-bg)); + color: var(--tf-image-text, var(--tf-image-control-text)); + box-shadow: var(--tf-image-control-shadow); +} + +.turbofile-image-page-scale-menu { + position: absolute; + z-index: 50; + top: calc(100% + 4px); + right: 0; + min-width: 84px; + display: none; + padding: 4px 0; + border: 1px solid var(--tf-image-border); + border-radius: 8px; + background: var(--tf-image-surface, var(--tf-image-control-bg)); + color: var(--tf-image-text, var(--tf-image-control-text)); + box-shadow: var(--tf-image-control-shadow); +} + +.turbofile-image-page-sort.is-open .turbofile-image-page-sort-menu { + display: block; +} + +.turbofile-image-page-scale.is-open .turbofile-image-page-scale-menu { + display: block; +} + +.turbofile-image-page-sort-option { + width: 100%; + min-height: 30px; + display: grid; + grid-template-columns: 18px minmax(0, 1fr); + align-items: center; + gap: 6px; + padding: 4px 8px; + border: 0; + background: transparent; + color: inherit; + font-size: 12px; + font-weight: 600; + text-align: left; + cursor: pointer; +} + +.turbofile-image-page-sort-divider { + height: 1px; + margin: 4px 0; + background: var(--tf-image-border); +} + +.turbofile-image-page-scale-option { + width: 100%; + min-height: 30px; + display: flex; + align-items: center; + gap: 6px; + padding: 4px 8px; + border: 0; + background: transparent; + color: inherit; + font-size: 12px; + font-weight: 600; + text-align: left; + cursor: pointer; +} + +.turbofile-image-page-sort-option:hover, +.turbofile-image-page-sort-option:focus, +.turbofile-image-page-scale-option:hover, +.turbofile-image-page-scale-option:focus { + background: var(--tf-image-control-hover); + outline: none; +} + +.turbofile-image-page-sort-check { + width: 18px; + flex: 0 0 auto; + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--tf-image-brand); +} + +.turbofile-image-page-scale-check { + width: 12px; + flex: 0 0 auto; + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--tf-image-brand); +} + +.turbofile-image-page-sort-check .codicon { + font-size: 13px; +} + +.turbofile-image-page-scale-check .codicon { + font-size: 13px; +} + +.turbofile-image-page-sort-option-label { + flex: 1; + min-width: 0; + white-space: nowrap; +} + +.turbofile-image-page-scale-option-label { + flex: 1; + white-space: nowrap; +} + +.turbofile-image-page-sort-option .codicon-arrow-up, +.turbofile-image-page-sort-option .codicon-arrow-down { + font-size: 12px; + flex: 0 0 auto; + color: var(--tf-image-brand); +} + +.turbofile-image-page-body { + min-height: 0; + overflow-x: auto; + overflow-y: auto; + padding: 10px; + outline: none; +} + +.turbofile-image-page-body.is-external-drop-target { + background: + linear-gradient(0deg, color-mix(in srgb, var(--tf-brand-blue, #042aff) 7%, transparent), color-mix(in srgb, var(--tf-brand-blue, #042aff) 7%, transparent)), + var(--vscode-editor-background); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--tf-brand-blue, #042aff) 30%, transparent); +} + +.turbofile-image-page-grid { + display: grid; + grid-template-columns: repeat(var(--turbofile-image-grid-columns, 8), minmax(0, 1fr)); + gap: 10px; + justify-items: center; + align-items: start; +} + +.turbofile-image-page-spacer { + grid-column: 1 / -1; + min-height: 0; + pointer-events: none; +} + +.turbofile-image-page-card { + position: relative; + display: flex; + flex-direction: column; + gap: 0; + padding: 8px; + border: 1px solid color-mix(in srgb, var(--vscode-panel-border) 80%, transparent); + border-radius: 8px; + background: color-mix(in srgb, var(--vscode-editor-background) 96%, var(--vscode-foreground) 3%); + color: var(--vscode-foreground); + cursor: pointer; + text-align: left; + user-select: none; + width: fit-content; + max-width: none; + transition: border-color 120ms ease, box-shadow 120ms ease, transform 90ms ease; + content-visibility: auto; + contain: layout paint style; + contain-intrinsic-size: 220px 220px; +} + +.turbofile-image-page-card:hover { + border-color: color-mix(in srgb, #22c55e 50%, var(--vscode-panel-border)); + box-shadow: 0 4px 12px color-mix(in srgb, var(--vscode-widget-shadow) 18%, transparent); +} + +.turbofile-image-page-card:active { + transform: scale(1.01); +} + +.turbofile-image-page-card.is-selected { + border-color: #22c55e; + box-shadow: 0 0 0 2px color-mix(in srgb, #22c55e 25%, transparent); +} + +.turbofile-image-page-card.is-focused { + outline: 1px solid color-mix(in srgb, #22c55e 45%, transparent); + outline-offset: 2px; +} + +.turbofile-image-page-card:focus-visible { + outline: 2px solid color-mix(in srgb, #22c55e 62%, transparent); + outline-offset: 2px; +} + +.turbofile-image-page-card::after { + content: ""; + position: absolute; + top: 10px; + right: 10px; + width: 13px; + height: 13px; + border-radius: 999px; + background: #22c55e; + box-shadow: 0 0 0 2px var(--vscode-editor-background); +} + +.turbofile-image-page-card-preview { + aspect-ratio: 1 / 1; + border-radius: 6px; + background: color-mix(in srgb, var(--vscode-editor-background) 86%, var(--vscode-foreground) 5%); + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + flex: 0 0 auto; +} + +.turbofile-image-page-card-preview img { + width: 100%; + height: 100%; + max-width: none; + max-height: none; + /* Fill the (aspect-clamped) preview box; a very wide/tall image shows a centered crop instead of + shrinking to a sliver. */ + object-fit: cover; + display: block; +} + +.turbofile-image-page-card-placeholder { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + color: rgba(226, 232, 240, 0.7); +} + +.turbofile-image-page-card-name { + display: block; + padding: 8px 10px 10px; + color: color-mix(in srgb, var(--vscode-foreground) 84%, transparent); + line-height: 1.25; + text-align: center; + overflow-wrap: anywhere; + white-space: normal; + word-break: break-word; + cursor: text; + user-select: text; +} + +.turbofile-image-preview-overlay { + position: absolute; + inset: 0; + background: color-mix(in srgb, var(--vscode-editor-background) 72%, transparent); + backdrop-filter: blur(12px); + z-index: 11; +} + +.turbofile-image-preview-close { + position: absolute; + top: 22px; + right: 22px; + width: 36px; + height: 36px; + padding: 0; + border-radius: 10px; + z-index: 2; + box-shadow: 0 8px 18px color-mix(in srgb, var(--vscode-widget-shadow) 26%, transparent); +} + +.turbofile-image-preview-stage { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + padding: 42px 25vw 118px; +} + +.turbofile-image-preview-image { + display: block; + max-width: min(58vw, 1120px); + max-height: min(82vh, 920px); + object-fit: contain; +} + +.turbofile-image-preview-image.is-thumbnail { + filter: saturate(0.9); + opacity: 0.92; +} + +.turbofile-image-preview-placeholder { + display: flex; + align-items: center; + gap: 8px; + color: #e2e8f0; + font-size: 14px; +} + +.turbofile-image-preview-caption { + position: absolute; + left: 50%; + bottom: 44px; + transform: translateX(-50%); + padding: 6px 12px; + border-radius: 10px; + background: color-mix(in srgb, var(--vscode-editor-background) 92%, transparent); + color: var(--vscode-foreground); + font-size: 14px; + white-space: nowrap; + box-shadow: 0 8px 18px color-mix(in srgb, var(--vscode-widget-shadow) 22%, transparent); +} + +.turbofile-image-preview-navigation { + position: absolute; + left: 50%; + bottom: 18px; + transform: translateX(-50%); + display: flex; + align-items: center; + gap: 100px; +} + +.turbofile-image-preview-nav-button { + width: 52px; + height: 52px; + border: none; + border-radius: 999px; + background: rgba(255, 255, 255, 0.18); + color: #fff; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + box-shadow: 0 8px 20px rgba(0, 0, 0, 0.3); +} + +.turbofile-image-preview-nav-button:hover { + background: rgba(255, 255, 255, 0.28); +} + +.turbofile-image-preview-side { + position: absolute; + bottom: 42px; + width: 88px; + height: 88px; + border: 0; + border-radius: 10px; + padding: 6px; + background: color-mix(in srgb, var(--vscode-editor-background) 76%, transparent); + box-shadow: 0 12px 26px color-mix(in srgb, var(--vscode-widget-shadow) 35%, transparent); + cursor: pointer; + z-index: 2; + overflow: hidden; +} + +.turbofile-image-preview-side.is-left { + left: 18px; +} + +.turbofile-image-preview-side.is-right { + right: 18px; +} + +.turbofile-image-preview-side:disabled { + opacity: 0; + pointer-events: none; +} + +.turbofile-image-preview-side img { + width: 100%; + height: 100%; + display: block; + border-radius: 7px; + object-fit: cover; + filter: saturate(0.92); +} + +.turbofile-image-preview-side-icon { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + border-radius: inherit; + color: #ffffff; + background: rgba(0, 0, 0, 0.34); +} + +.turbofile-image-grid-overlay { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + background: color-mix(in srgb, var(--vscode-editor-background), transparent 10%); + z-index: 10; +} + +.turbofile-image-grid-panel { + width: min(1180px, 96%); + height: min(820px, 92%); + min-height: 420px; + display: grid; + grid-template-rows: 44px minmax(0, 1fr) 180px; + border: 1px solid var(--vscode-panel-border); + border-radius: 6px; + background: var(--vscode-editor-background); + box-shadow: 0 8px 24px var(--vscode-widget-shadow); + overflow: hidden; +} + +.turbofile-image-grid-header { + display: flex; + align-items: center; + gap: 12px; + padding: 0 12px; + border-bottom: 1px solid var(--vscode-panel-border); + background: var(--vscode-sideBar-background); +} + +.turbofile-image-grid-title { + min-width: 0; + display: flex; + align-items: center; + gap: 8px; + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.turbofile-image-grid-actions { + margin-left: auto; + display: flex; + align-items: center; + gap: 8px; +} + +.turbofile-image-grid-preview { + position: relative; + min-height: 0; + display: flex; + align-items: center; + justify-content: center; + padding: 16px; + background: var(--vscode-editor-background); + overflow: hidden; +} + +.turbofile-image-grid-preview-image { + display: block; + max-width: 100%; + max-height: 100%; + object-fit: contain; +} + +.turbofile-image-grid-placeholder { + display: flex; + align-items: center; + gap: 8px; + color: var(--vscode-descriptionForeground); +} + +.turbofile-image-grid-caption { + position: absolute; + left: 16px; + right: 16px; + bottom: 12px; + padding: 6px 10px; + border-radius: 4px; + background: color-mix(in srgb, var(--vscode-editor-background), transparent 18%); + color: var(--vscode-foreground); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.turbofile-image-grid-strip { + min-height: 0; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(118px, 1fr)); + gap: 8px; + padding: 10px; + border-top: 1px solid var(--vscode-panel-border); + background: var(--vscode-sideBar-background); + overflow: auto; +} + +.turbofile-image-grid-card { + height: 152px; + padding: 0; + border: 1px solid var(--vscode-panel-border); + border-radius: 4px; + background: var(--vscode-editor-background); + color: var(--vscode-foreground); + overflow: hidden; + cursor: pointer; +} + +.turbofile-image-grid-card:hover { + background: var(--vscode-list-hoverBackground); +} + +.turbofile-image-grid-card.is-selected { + border-color: var(--vscode-focusBorder); + outline: 1px solid var(--vscode-focusBorder); +} + +.turbofile-image-grid-card img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.turbofile-image-grid-card-placeholder { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; + padding: 10px; + color: var(--vscode-descriptionForeground); +} + +.turbofile-image-grid-card-placeholder span { + max-width: 100%; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.turbofile-sync-overlay { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + background: color-mix(in srgb, var(--vscode-editor-background), transparent 14%); + z-index: 11; +} + +.turbofile-sync-panel { + width: min(1040px, 96%); + height: min(760px, 92%); + min-height: 360px; + display: grid; + grid-template-rows: 42px auto auto minmax(0, 1fr); + border: 1px solid var(--vscode-panel-border); + border-radius: 6px; + background: var(--vscode-editor-background); + box-shadow: 0 8px 24px var(--vscode-widget-shadow); + overflow: hidden; +} + +.turbofile-sync-header { + display: flex; + align-items: center; + gap: 12px; + padding: 0 12px; + border-bottom: 1px solid var(--vscode-panel-border); + background: var(--vscode-sideBar-background); +} + +.turbofile-sync-title { + min-width: 0; + display: flex; + align-items: center; + gap: 8px; + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.turbofile-sync-header-actions { + margin-left: auto; + display: flex; + align-items: center; +} + +.turbofile-sync-summary { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(86px, 1fr)); + gap: 1px; + border-bottom: 1px solid var(--vscode-panel-border); + background: var(--vscode-panel-border); +} + +.turbofile-sync-summary-metric { + min-width: 0; + display: grid; + gap: 3px; + padding: 8px 10px; + background: var(--vscode-sideBar-background); +} + +.turbofile-sync-summary-metric span { + color: var(--vscode-descriptionForeground); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.turbofile-sync-summary-metric strong { + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.turbofile-sync-toolbar { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; + padding: 8px 10px; + border-bottom: 1px solid var(--vscode-panel-border); + background: var(--vscode-editor-background); +} + +.turbofile-sync-direction-actions { + display: inline-flex; + align-items: center; + gap: 6px; + padding-right: 8px; + border-right: 1px solid color-mix(in srgb, var(--vscode-panel-border), transparent 45%); +} + +.turbofile-sync-hint { + min-width: 0; + margin-right: auto; + color: var(--vscode-descriptionForeground); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.turbofile-sync-list { + min-height: 0; + overflow: auto; + background: var(--vscode-editor-background); +} + +.turbofile-sync-row { + height: 30px; + display: grid; + grid-template-columns: 28px minmax(96px, 112px) 72px minmax(160px, 1fr) 180px; + align-items: center; + gap: 8px; + padding: 0 10px; + border-bottom: 1px solid color-mix(in srgb, var(--vscode-panel-border), transparent 55%); + color: var(--vscode-foreground); + cursor: pointer; +} + +.turbofile-sync-row:hover { + background: var(--vscode-list-hoverBackground); +} + +.turbofile-sync-row.is-skipped { + color: var(--vscode-descriptionForeground); +} + +.turbofile-sync-checkbox { + justify-self: center; +} + +.turbofile-sync-direction, +.turbofile-sync-reason, +.turbofile-sync-meta { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.turbofile-sync-direction { + height: 22px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 4px; + min-width: 0; + padding: 0 6px; + border: 1px solid transparent; + border-radius: 4px; + background: transparent; + color: var(--vscode-textLink-foreground); + font-weight: 600; + font: inherit; +} + +.turbofile-sync-direction:not(:disabled) { + cursor: pointer; + border-color: color-mix(in srgb, var(--vscode-textLink-foreground), transparent 55%); +} + +.turbofile-sync-direction:not(:disabled):hover { + background: var(--vscode-toolbar-hoverBackground); +} + +.turbofile-sync-direction:disabled { + opacity: 0.78; +} + +.turbofile-sync-direction.is-reversed { + color: var(--vscode-editorWarning-foreground); + border-color: color-mix(in srgb, var(--vscode-editorWarning-foreground), transparent 50%); +} + +.turbofile-sync-direction .codicon { + font-size: 12px; +} + +.turbofile-sync-reason { + justify-self: start; + padding: 2px 6px; + border-radius: 3px; + background: var(--vscode-badge-background); + color: var(--vscode-badge-foreground); +} + +.turbofile-sync-reason.is-newer { + background: var(--vscode-testing-iconPassed); + color: var(--vscode-editor-background); +} + +.turbofile-sync-reason.is-size { + background: var(--vscode-testing-iconQueued); + color: var(--vscode-editor-background); +} + +.turbofile-sync-path { + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.turbofile-sync-meta { + color: var(--vscode-descriptionForeground); + text-align: right; +} + +.turbofile-sidebar-view { + box-sizing: border-box; + height: 100%; + padding: 10px; + overflow: auto; + color: var(--vscode-sideBar-foreground); +} + +.turbofile-sidebar-actions { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 10px; +} + +.turbofile-sidebar-button, +.turbofile-sidebar-device, +.turbofile-sidebar-row { + border: 0; + color: var(--vscode-button-secondaryForeground); + background: var(--vscode-button-secondaryBackground); + cursor: pointer; +} + +.turbofile-sidebar-button { + width: 28px; + height: 28px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 4px; +} + +.turbofile-sidebar-button:hover, +.turbofile-sidebar-device:hover, +.turbofile-sidebar-row:hover { + background: var(--vscode-button-secondaryHoverBackground); +} + +.turbofile-sidebar-device { + width: 100%; + min-height: 46px; + display: grid; + grid-template-columns: 18px minmax(0, 1fr); + align-items: center; + gap: 8px; + margin-bottom: 6px; + padding: 7px 8px; + border-radius: 4px; + text-align: left; +} + +.turbofile-sidebar-device-text { + min-width: 0; + display: grid; + gap: 2px; +} + +.turbofile-sidebar-device-text strong, +.turbofile-sidebar-device-text span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.turbofile-sidebar-device-text span, +.turbofile-sidebar-empty { + color: var(--vscode-descriptionForeground); + font-size: 11px; +} + +.turbofile-sidebar-empty { + padding: 8px 2px; +} + +.turbofile-sidebar-device-form { + display: grid; + gap: 8px; + margin-bottom: 12px; + padding: 10px; + border: 1px solid var(--vscode-panel-border); + border-radius: 4px; + background: var(--vscode-editor-background); +} + +.turbofile-sidebar-form-title { + font-size: 12px; +} + +.turbofile-sidebar-field { + display: grid; + gap: 3px; +} + +.turbofile-sidebar-field label { + color: var(--vscode-descriptionForeground); + font-size: 11px; +} + +.turbofile-sidebar-field input, +.turbofile-sidebar-field select { + box-sizing: border-box; + width: 100%; + height: 26px; + border: 1px solid var(--vscode-input-border); + border-radius: 3px; + padding: 0 7px; + color: var(--vscode-input-foreground); + background: var(--vscode-input-background); +} + +.turbofile-sidebar-form-message { + min-height: 16px; + color: var(--vscode-descriptionForeground); + font-size: 11px; +} + +.turbofile-sidebar-form-actions { + display: flex; + justify-content: flex-end; + gap: 6px; +} + +.turbofile-sidebar-text-button { + height: 26px; + border: 0; + border-radius: 3px; + padding: 0 9px; + color: var(--vscode-button-secondaryForeground); + background: var(--vscode-button-secondaryBackground); + cursor: pointer; +} + +.turbofile-sidebar-text-button:hover { + background: var(--vscode-button-secondaryHoverBackground); +} + +.turbofile-sidebar-text-button.is-primary { + color: var(--vscode-button-foreground); + background: var(--vscode-button-background); +} + +.turbofile-sidebar-text-button.is-primary:hover { + background: var(--vscode-button-hoverBackground); +} + +.turbofile-sidebar-settings { + display: grid; + gap: 6px; +} + +.turbofile-sidebar-row { + height: 32px; + display: flex; + align-items: center; + gap: 8px; + padding: 0 8px; + border-radius: 4px; + text-align: left; +} + +.turbofile-workbench.is-turbofile-dark, +.vs-dark .turbofile-workbench, +.hc-black .turbofile-workbench { + --tf-bg: var(--vscode-editor-background); + --turbofile-file-surface-final: color-mix(in srgb, var(--vscode-editor-background) 54%, var(--turbofile-unified-background) 46%); + --tf-file-surface: var(--turbofile-file-surface-final); + --tf-bg-secondary: color-mix(in srgb, var(--turbofile-file-surface-final) 92%, var(--vscode-foreground) 4%); + --tf-surface: var(--turbofile-file-surface-final); + --tf-surface-muted: color-mix(in srgb, var(--vscode-foreground) 6%, var(--tf-file-surface)); + --tf-text: var(--vscode-foreground); + --tf-text-muted: var(--vscode-descriptionForeground); + --tf-text-weak: color-mix(in srgb, var(--vscode-foreground) 52%, transparent); + --tf-brand-blue: var(--vscode-textLink-foreground, #5b8dff); + --tf-brand-blue-dark: var(--vscode-foreground); + --tf-accent-yellow: #e1ff25; + --tf-accent-pink: #ff64da; + --tf-success: #22c55e; + --tf-danger: var(--vscode-errorForeground, #ef4444); + --tf-border: color-mix(in srgb, var(--vscode-foreground) 14%, transparent); + --tf-card-shadow: none; + --tf-card-shadow-hover: 0 8px 24px rgba(0, 0, 0, 0.24); + --tf-brand-soft: color-mix(in srgb, var(--tf-brand-blue) 16%, transparent); + --tf-brand-soft-hover: color-mix(in srgb, var(--tf-brand-blue) 22%, transparent); + --turbofile-unified-background: var(--vscode-activityBar-background, var(--vscode-sideBar-background, var(--vscode-editor-background))); + --turbofile-pane-border: var(--tf-border); +} + +.turbofile-workbench.is-turbofile-dark .turbofile-page-card, +.turbofile-workbench.is-turbofile-dark .turbofile-settings-section, +.turbofile-workbench.is-turbofile-dark .turbofile-appearance-card, +.turbofile-workbench.is-turbofile-dark .turbofile-dialog-panel, +.turbofile-workbench.is-turbofile-dark .turbofile-task-log-panel, +.turbofile-workbench.is-turbofile-dark .turbofile-sync-panel, +.turbofile-workbench.is-turbofile-dark .turbofile-image-grid-panel, +.turbofile-workbench.is-turbofile-dark .turbofile-live-transfer-panel, +.turbofile-workbench.is-turbofile-dark .turbofile-pane, +.turbofile-workbench.is-turbofile-dark .turbofile-task-row, +.turbofile-workbench.is-turbofile-dark .turbofile-live-transfer-card, +.turbofile-workbench.is-turbofile-dark .turbofile-sidebar-device-form, +.vs-dark .turbofile-workbench .turbofile-page-card, +.vs-dark .turbofile-workbench .turbofile-settings-section, +.vs-dark .turbofile-workbench .turbofile-appearance-card, +.vs-dark .turbofile-workbench .turbofile-dialog-panel, +.vs-dark .turbofile-workbench .turbofile-task-log-panel, +.vs-dark .turbofile-workbench .turbofile-sync-panel, +.vs-dark .turbofile-workbench .turbofile-image-grid-panel, +.vs-dark .turbofile-workbench .turbofile-live-transfer-panel, +.vs-dark .turbofile-workbench .turbofile-pane, +.vs-dark .turbofile-workbench .turbofile-task-row, +.vs-dark .turbofile-workbench .turbofile-live-transfer-card, +.vs-dark .turbofile-workbench .turbofile-sidebar-device-form, +.hc-black .turbofile-workbench .turbofile-page-card, +.hc-black .turbofile-workbench .turbofile-settings-section, +.hc-black .turbofile-workbench .turbofile-appearance-card, +.hc-black .turbofile-workbench .turbofile-dialog-panel, +.hc-black .turbofile-workbench .turbofile-task-log-panel, +.hc-black .turbofile-workbench .turbofile-sync-panel, +.hc-black .turbofile-workbench .turbofile-image-grid-panel, +.hc-black .turbofile-workbench .turbofile-live-transfer-panel, +.hc-black .turbofile-workbench .turbofile-pane, +.hc-black .turbofile-workbench .turbofile-task-row, +.hc-black .turbofile-workbench .turbofile-live-transfer-card, +.hc-black .turbofile-workbench .turbofile-sidebar-device-form { + background: var(--tf-file-surface); +} + +.turbofile-workbench.is-turbofile-dark .turbofile-pathbar, +.turbofile-workbench.is-turbofile-dark .turbofile-browser-header, +.turbofile-workbench.is-turbofile-dark .turbofile-browser-toolbar, +.vs-dark .turbofile-workbench .turbofile-pathbar, +.vs-dark .turbofile-workbench .turbofile-browser-header, +.vs-dark .turbofile-workbench .turbofile-browser-toolbar, +.hc-black .turbofile-workbench .turbofile-pathbar, +.hc-black .turbofile-workbench .turbofile-browser-header, +.hc-black .turbofile-workbench .turbofile-browser-toolbar { + background: var(--tf-file-surface); +} + +.turbofile-workbench.is-turbofile-dark .turbofile-location-menu-button, +.vs-dark .turbofile-workbench .turbofile-location-menu-button, +.hc-black .turbofile-workbench .turbofile-location-menu-button { + background: var(--tf-file-surface); +} + +.turbofile-workbench.is-turbofile-dark .turbofile-device-card, +.turbofile-workbench.is-turbofile-dark .turbofile-device-select-button, +.turbofile-workbench.is-turbofile-dark .turbofile-device-select-menu, +.vs-dark .turbofile-workbench .turbofile-device-card, +.vs-dark .turbofile-workbench .turbofile-device-select-button, +.vs-dark .turbofile-workbench .turbofile-device-select-menu, +.hc-black .turbofile-workbench .turbofile-device-card, +.hc-black .turbofile-workbench .turbofile-device-select-button, +.hc-black .turbofile-workbench .turbofile-device-select-menu { + border: 1px solid var(--turbofile-pane-border); + background: var(--tf-file-surface); +} + +.turbofile-workbench.is-turbofile-dark .turbofile-device-select-menu, +.vs-dark .turbofile-workbench .turbofile-device-select-menu, +.hc-black .turbofile-workbench .turbofile-device-select-menu { + background: var(--turbofile-unified-background); +} + +.turbofile-workbench.is-turbofile-dark .turbofile-device-card-status, +.vs-dark .turbofile-workbench .turbofile-device-card-status, +.hc-black .turbofile-workbench .turbofile-device-card-status { + color: var(--tf-success); +} + +.turbofile-workbench.is-turbofile-dark .turbofile-device-card-status.is-unknown, +.turbofile-workbench.is-turbofile-dark .turbofile-device-card-status.is-testing, +.turbofile-workbench.is-turbofile-dark .turbofile-device-card-status.is-offline, +.vs-dark .turbofile-workbench .turbofile-device-card-status.is-unknown, +.vs-dark .turbofile-workbench .turbofile-device-card-status.is-testing, +.vs-dark .turbofile-workbench .turbofile-device-card-status.is-offline, +.hc-black .turbofile-workbench .turbofile-device-card-status.is-unknown, +.hc-black .turbofile-workbench .turbofile-device-card-status.is-testing, +.hc-black .turbofile-workbench .turbofile-device-card-status.is-offline { + color: var(--vscode-descriptionForeground); +} + +.turbofile-workbench.is-turbofile-dark .turbofile-device-card-status.is-online, +.vs-dark .turbofile-workbench .turbofile-device-card-status.is-online, +.hc-black .turbofile-workbench .turbofile-device-card-status.is-online { + color: var(--tf-success); +} + +.turbofile-workbench.is-turbofile-dark .turbofile-location-menu-button:hover, +.turbofile-workbench.is-turbofile-dark .turbofile-location-menu-button:focus, +.vs-dark .turbofile-workbench .turbofile-location-menu-button:hover, +.vs-dark .turbofile-workbench .turbofile-location-menu-button:focus, +.hc-black .turbofile-workbench .turbofile-location-menu-button:hover, +.hc-black .turbofile-workbench .turbofile-location-menu-button:focus { + background: color-mix(in srgb, var(--vscode-foreground) 4%, var(--tf-file-surface)); +} + +.turbofile-theme-toggle { + width: 34px; + height: 34px; + display: none; + flex: 0 0 auto; + align-items: center; + justify-content: center; + margin: 0 0 0 4px; + border: 1px solid transparent; + border-radius: 999px; + color: var(--vscode-titleBar-activeForeground, #111111); + background: transparent; + box-shadow: none; + cursor: pointer; + -webkit-app-region: no-drag; + position: relative; + z-index: 2500; + outline: none; + transition: background 120ms ease, border-color 120ms ease, color 120ms ease, box-shadow 120ms ease, transform 120ms ease; +} + +body.turbofile-shell .turbofile-theme-toggle { + display: inline-flex; +} + +.turbofile-editor-shell .turbofile-theme-toggle { + display: none !important; +} + +.turbofile-theme-toggle:hover { + border-color: color-mix(in srgb, var(--vscode-titleBar-activeForeground, #111111) 24%, transparent); + background: color-mix(in srgb, var(--vscode-titleBar-activeForeground, #111111) 8%, var(--vscode-titleBar-activeBackground, #ffffff)); + box-shadow: 0 4px 14px color-mix(in srgb, var(--vscode-widget-shadow, #000000) 16%, transparent); + transform: translateY(-1px); +} + +.turbofile-theme-toggle:focus-visible { + border-color: color-mix(in srgb, var(--vscode-titleBar-activeForeground, #111111) 24%, transparent); + background: color-mix(in srgb, var(--vscode-titleBar-activeForeground, #111111) 8%, var(--vscode-titleBar-activeBackground, #ffffff)); + box-shadow: 0 4px 14px color-mix(in srgb, var(--vscode-widget-shadow, #000000) 16%, transparent); +} + +.turbofile-theme-toggle .turbofile-appearance-icon { + width: 17px; + height: 17px; +} + +.turbofile-titlebar-menu { + display: inline-flex; + align-items: center; + gap: 6px; + height: 100%; + margin-left: 8px; + -webkit-app-region: no-drag; + position: relative; + z-index: 2500; + order: 2; +} + +.turbofile-titlebar-menu-button, +.turbofile-titlebar-update-button { + height: 28px; + border: 0; + border-radius: 6px; + color: var(--vscode-titleBar-activeForeground, #cccccc); + background: transparent; + cursor: pointer; + -webkit-app-region: no-drag; + outline: none; +} + +.turbofile-titlebar-menu-button { + padding: 0 9px; + font-size: 14px; + line-height: 28px; +} + +.turbofile-titlebar-update-button { + height: 20px; + min-width: 42px; + padding: 0 9px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 3px; + border-radius: 999px; + color: #ffffff; + background: #168bd8; +} + +.turbofile-titlebar-update-button .codicon { + font-size: 12px; + line-height: 1; +} + +.turbofile-titlebar-update-button-checking, +.turbofile-titlebar-update-button-downloading, +.turbofile-titlebar-update-button-installing { + width: auto; + min-width: 42px; + padding: 0 9px; + cursor: default; +} + +.turbofile-titlebar-update-label { + font-size: 12px; + font-weight: 600; + line-height: 20px; + white-space: nowrap; +} + +.turbofile-titlebar-menu-button:hover, +.turbofile-titlebar-menu-button:focus-visible { + background: color-mix(in srgb, var(--vscode-titleBar-activeForeground, #ffffff) 10%, transparent); + color: var(--vscode-titleBar-activeForeground, #ffffff); +} + +.turbofile-titlebar-update-button:hover, +.turbofile-titlebar-update-button:focus-visible { + background: #1f9ff2; + box-shadow: none; +} + +.turbofile-titlebar-update-button:disabled, +.turbofile-titlebar-update-button:disabled:hover, +.turbofile-titlebar-update-button:disabled:focus-visible { + background: #168bd8; + box-shadow: none; + color: #ffffff; + opacity: 0.88; +} + +.turbofile-about-overlay { + position: fixed; + inset: 0; + z-index: 10000; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.24); + -webkit-app-region: no-drag; +} + +.turbofile-about-dialog { + width: min(572px, calc(100vw - 32px)); + border: 1px solid var(--vscode-window-activeBorder, var(--vscode-widget-border, #3c3c3c)); + border-radius: 8px; + overflow: hidden; + color: var(--vscode-foreground, #cccccc); + background: var(--vscode-editor-background, #1f1f1f); + box-shadow: 0 18px 54px rgba(0, 0, 0, 0.38); +} + +.turbofile-about-header { + height: 44px; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 12px 0 16px; + box-sizing: border-box; +} + +.turbofile-about-header-title { + font-size: 15px; + font-weight: 500; +} + +.turbofile-about-close { + width: 32px; + height: 32px; + display: inline-flex; + align-items: center; + justify-content: center; + border: 0; + border-radius: 4px; + color: var(--vscode-foreground, #cccccc); + background: transparent; + cursor: pointer; +} + +.turbofile-about-close:hover, +.turbofile-about-close:focus-visible { + background: color-mix(in srgb, var(--vscode-foreground, #ffffff) 12%, transparent); +} + +.turbofile-about-content { + min-height: 292px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 16px; + padding: 22px 32px 30px; + box-sizing: border-box; + text-align: center; +} + +.turbofile-about-app-icon { + width: 92px; + height: 92px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 28px; + color: #ffffff; + background: linear-gradient(135deg, #36a3ff, #7d5cff 72%); + box-shadow: 0 10px 26px color-mix(in srgb, #2f80ff 34%, transparent); +} + +.turbofile-about-app-icon .turbofile-bi-icon { + width: 46px; + height: 46px; +} + +.turbofile-about-name { + font-size: 26px; + font-weight: 700; +} + +.turbofile-about-version, +.turbofile-about-copyright { + font-size: 16px; + color: color-mix(in srgb, var(--vscode-foreground, #cccccc) 84%, transparent); +} + +.turbofile-about-footer { + display: flex; + justify-content: flex-end; + padding: 16px 18px 22px; + background: color-mix(in srgb, var(--vscode-editor-background, #1f1f1f) 88%, var(--vscode-foreground, #cccccc)); +} + +.turbofile-about-ok { + min-width: 120px; + height: 46px; + border: 1px solid var(--vscode-focusBorder, #0078d4); + border-radius: 6px; + color: var(--vscode-button-foreground, #ffffff); + background: var(--vscode-button-background, #0e639c); + cursor: pointer; +} + +.turbofile-about-ok:hover, +.turbofile-about-ok:focus-visible { + background: var(--vscode-button-hoverBackground, #1177bb); +} + +.turbofile-appearance-page .turbofile-appearance-card:not(.turbofile-appearance-font-card) { + display: none !important; +} + +.turbofile-appearance-page .turbofile-appearance-mode-group { + display: none !important; +} + +.turbofile-workbench.is-turbofile-dark .turbofile-file-list, +.vs-dark .turbofile-workbench .turbofile-file-list, +.hc-black .turbofile-workbench .turbofile-file-list { + background: var(--tf-file-surface); + scrollbar-color: color-mix(in srgb, var(--vscode-foreground) 30%, var(--tf-file-surface)) var(--tf-file-surface); +} + +.turbofile-workbench.is-turbofile-dark .turbofile-file-list::-webkit-scrollbar, +.turbofile-workbench.is-turbofile-dark .turbofile-file-list::-webkit-scrollbar-track, +.turbofile-workbench.is-turbofile-dark .turbofile-file-list::-webkit-scrollbar-corner, +.turbofile-workbench.is-turbofile-dark .turbofile-file-list::-webkit-scrollbar-button, +.vs-dark .turbofile-workbench .turbofile-file-list::-webkit-scrollbar, +.vs-dark .turbofile-workbench .turbofile-file-list::-webkit-scrollbar-track, +.vs-dark .turbofile-workbench .turbofile-file-list::-webkit-scrollbar-corner, +.vs-dark .turbofile-workbench .turbofile-file-list::-webkit-scrollbar-button, +.hc-black .turbofile-workbench .turbofile-file-list::-webkit-scrollbar, +.hc-black .turbofile-workbench .turbofile-file-list::-webkit-scrollbar-track, +.hc-black .turbofile-workbench .turbofile-file-list::-webkit-scrollbar-corner, +.hc-black .turbofile-workbench .turbofile-file-list::-webkit-scrollbar-button { + background: var(--tf-file-surface); +} + +.turbofile-workbench.is-turbofile-dark .turbofile-file-list::-webkit-scrollbar-thumb, +.vs-dark .turbofile-workbench .turbofile-file-list::-webkit-scrollbar-thumb, +.hc-black .turbofile-workbench .turbofile-file-list::-webkit-scrollbar-thumb { + border-color: var(--tf-file-surface); + background: color-mix(in srgb, var(--vscode-foreground) 30%, var(--tf-file-surface)); +} + +.turbofile-workbench.is-turbofile-dark .turbofile-file-list::-webkit-scrollbar-thumb:hover, +.vs-dark .turbofile-workbench .turbofile-file-list::-webkit-scrollbar-thumb:hover, +.hc-black .turbofile-workbench .turbofile-file-list::-webkit-scrollbar-thumb:hover { + background: color-mix(in srgb, var(--vscode-foreground) 42%, var(--tf-file-surface)); +} + +.vs .turbofile-workbench, +.vs .turbofile-workbench * { + scrollbar-color: #c8c8c8 #ffffff; +} + +.vs .turbofile-workbench, +.vs .turbofile-workbench .turbofile-transfer-page, +.vs .turbofile-workbench .turbofile-full-page, +.vs .turbofile-workbench .turbofile-appearance-page, +.vs .turbofile-workbench .turbofile-main, +.vs .turbofile-workbench .turbofile-image-page, +.vs .turbofile-workbench .turbofile-image-page-body { + background: var(--tf-bg); + color: var(--tf-text); +} + +.vs .turbofile-page-nav, +.vs .turbofile-route-nav { + border-color: var(--tf-border); + background: var(--tf-windows-nav-bg, var(--tf-bg-secondary)); + color: var(--tf-text); +} + +.vs .turbofile-page-nav-item, +.vs .turbofile-page-nav-toggle, +.vs .turbofile-route-nav-item, +.vs .turbofile-route-collapse { + color: var(--tf-text-muted); +} + +.vs .turbofile-page-nav-item:hover, +.vs .turbofile-page-nav-toggle:hover, +.vs .turbofile-route-nav-item:hover, +.vs .turbofile-route-collapse:hover { + color: var(--tf-brand-blue-dark); + background: var(--tf-windows-hover, var(--tf-bg-secondary)); +} + +.vs .turbofile-page-nav-item.is-active, +.vs .turbofile-route-nav-item.is-active { + color: var(--tf-brand-blue-dark); + background: var(--tf-windows-selected-neutral, var(--tf-bg-secondary)); +} + +.vs .turbofile-page-nav-item.is-active::before { + background: var(--tf-brand-blue); +} + +.vs .turbofile-page-header h2, +.vs .turbofile-page-card-title, +.vs .turbofile-section-title, +.vs .turbofile-appearance-title, +.vs .turbofile-image-page-title, +.vs .turbofile-image-grid-title, +.vs .turbofile-task-title, +.vs .turbofile-live-transfer-title, +.vs .turbofile-sync-title { + color: var(--tf-text); + letter-spacing: -0.01em; +} + +.vs .turbofile-page-header h2 .codicon, +.vs .turbofile-section-title .codicon, +.vs .turbofile-image-page-title .codicon, +.vs .turbofile-image-grid-title .codicon { + color: var(--tf-brand-blue); +} + +.vs .turbofile-page-header p, +.vs .turbofile-appearance-card-copy p, +.vs .turbofile-info-line span, +.vs .turbofile-task-meta, +.vs .turbofile-task-row-detail, +.vs .turbofile-task-row-metrics, +.vs .turbofile-image-page-columns-label, +.vs .turbofile-image-page-card-name, +.vs .turbofile-live-transfer-size, +.vs .turbofile-live-transfer-metric-label, +.vs .turbofile-sync-hint, +.vs .turbofile-sync-meta { + color: var(--tf-text-muted); +} + +.vs .turbofile-page-card, +.vs .turbofile-settings-section, +.vs .turbofile-appearance-card, +.vs .turbofile-dialog-panel, +.vs .turbofile-task-log-panel, +.vs .turbofile-sync-panel, +.vs .turbofile-image-grid-panel, +.vs .turbofile-live-transfer-panel, +.vs .turbofile-pane, +.vs .turbofile-task-row, +.vs .turbofile-live-transfer-card, +.vs .turbofile-sidebar-device-form { + border: 1px solid var(--tf-border); + border-radius: var(--tf-card-radius); + background: var(--tf-surface); + box-shadow: var(--tf-card-shadow); +} + +.vs .turbofile-page-card:hover, +.vs .turbofile-device-page-card:hover, +.vs .turbofile-image-page-card:hover, +.vs .turbofile-image-grid-card:hover, +.vs .turbofile-task-row:hover, +.vs .turbofile-live-transfer-card:hover { + box-shadow: var(--tf-card-shadow-hover); +} + +.vs .turbofile-device-page-card { + border-radius: var(--tf-card-radius); + background: var(--tf-surface); + box-shadow: var(--tf-card-shadow); +} + +.vs .turbofile-device-page-card::before { + top: 18px; + bottom: 18px; + width: 3px; + background: var(--tf-brand-blue); +} + +.vs .turbofile-device-page-card .turbofile-page-card-title .codicon { + color: var(--tf-brand-blue); + background: var(--tf-brand-soft); + box-shadow: none; +} + +.vs .turbofile-device-card-status { + color: var(--tf-success); +} + +.vs .turbofile-device-card-status.is-unknown { + color: var(--vscode-descriptionForeground); +} + +.vs .turbofile-device-card-status.is-testing { + color: var(--vscode-descriptionForeground); +} + +.vs .turbofile-device-card-status.is-offline { + color: var(--vscode-descriptionForeground); +} + +.vs .turbofile-device-card-status.is-online { + color: var(--tf-success); +} + +.vs .turbofile-page-button, +.vs .turbofile-dialog-button, +.vs .turbofile-task-row-button, +.vs .turbofile-sidebar-text-button, +.vs .turbofile-image-page-columns-button, +.vs .turbofile-sync-direction { + border: 1px solid var(--tf-border); + border-radius: var(--tf-control-radius); + color: var(--tf-brand-blue-dark); + background: var(--tf-surface); + box-shadow: none; +} + +.vs .turbofile-page-button:hover, +.vs .turbofile-dialog-button:hover, +.vs .turbofile-task-row-button:hover, +.vs .turbofile-sidebar-text-button:hover, +.vs .turbofile-image-page-columns-button:hover, +.vs .turbofile-sync-direction:not(:disabled):hover { + color: var(--tf-brand-blue-dark); + border-color: var(--tf-border); + background: var(--tf-windows-hover, var(--tf-bg-secondary)); +} + +.vs .turbofile-page-button.is-primary, +.vs .turbofile-dialog-button-primary, +.vs .turbofile-sidebar-text-button.is-primary, +.vs .turbofile-image-page-columns-button.is-active, +.vs .turbofile-sync-direction.is-reversed { + border-color: var(--tf-brand-blue); + color: #ffffff; + background: var(--tf-brand-blue); +} + +.vs .turbofile-page-button.is-primary:hover, +.vs .turbofile-dialog-button-primary:hover, +.vs .turbofile-sidebar-text-button.is-primary:hover { + border-color: var(--tf-brand-blue-dark); + background: var(--tf-brand-blue-dark); +} + +.vs .turbofile-dialog-button-primary.is-danger, +.vs .turbofile-dialog-panel.is-danger .turbofile-dialog-icon { + border-color: var(--tf-danger); + background: var(--tf-danger); + color: #ffffff; +} + +.vs .turbofile-toolbar-button, +.vs .turbofile-rail-button, +.vs .turbofile-primary-button, +.vs .turbofile-secondary-button, +.vs .turbofile-image-preview-close, +.vs .turbofile-image-preview-nav-button { + border: 1px solid transparent; + border-radius: var(--tf-control-radius); + color: var(--tf-text-muted); + background: transparent; +} + +.vs .turbofile-toolbar-button:hover, +.vs .turbofile-rail-button:hover, +.vs .turbofile-secondary-button:hover, +.vs .turbofile-image-preview-close:hover, +.vs .turbofile-image-preview-nav-button:hover { + color: var(--tf-brand-blue-dark); + border-color: transparent; + background: var(--tf-windows-hover, var(--tf-bg-secondary)); +} + +.vs .turbofile-primary-button { + color: #ffffff; + background: var(--tf-brand-blue); +} + +.vs .turbofile-appearance-card-header, +.vs .turbofile-browser-toolbar, +.vs .turbofile-browser-pathbar, +.vs .turbofile-pane-header, +.vs .turbofile-live-transfer-header, +.vs .turbofile-task-log-header, +.vs .turbofile-sync-header, +.vs .turbofile-sync-toolbar, +.vs .turbofile-image-grid-header { + border-color: var(--tf-border); + background: var(--tf-surface); +} + +.vs .turbofile-appearance-mode-button { + border: 1px solid var(--tf-border); + color: var(--tf-text-muted); + background: var(--tf-surface); +} + +.vs .turbofile-appearance-mode-button:hover { + color: var(--tf-brand-blue); + background: var(--tf-bg-secondary); +} + +.vs .turbofile-appearance-mode-button.is-active { + border-color: var(--tf-brand-blue); + color: var(--tf-brand-blue); + background: var(--tf-brand-soft); +} + +.vs .turbofile-editor-setting-select, +.vs .turbofile-editor-font-size-input, +.vs .turbofile-dialog-input, +.vs .turbofile-sidebar-field input, +.vs .turbofile-sidebar-field select, +.vs .turbofile-device-select-button, +.vs .turbofile-pathbar-input { + border: 1px solid var(--tf-border); + border-radius: var(--tf-control-radius); + color: var(--tf-text); + background: var(--tf-surface); +} + +.vs .turbofile-editor-setting-select:focus, +.vs .turbofile-editor-font-size-input:focus, +.vs .turbofile-dialog-input:focus, +.vs .turbofile-sidebar-field input:focus, +.vs .turbofile-sidebar-field select:focus, +.vs .turbofile-device-select-button:focus, +.vs .turbofile-pathbar-input:focus { + border-color: var(--tf-brand-blue); + outline: 1px solid color-mix(in srgb, var(--tf-brand-blue) 42%, transparent); + outline-offset: 1px; +} + +.vs .turbofile-pane { + border-radius: var(--tf-card-radius) var(--tf-card-radius) 0 0; + font-family: var(--turbofile-browser-font-family, "Archivo", "Microsoft YaHei UI", "Microsoft YaHei", "Segoe UI", sans-serif); +} + +.turbofile-transfer-page .turbofile-pane { + border-radius: 0; +} + +.turbofile-transfer-page .turbofile-pane[data-turbofile-side="left"] { + border-left: 0; +} + +.vs .turbofile-browser-header, +.vs .turbofile-file-list, +.vs .turbofile-task-list, +.vs .turbofile-image-page-grid, +.vs .turbofile-image-grid-strip, +.vs .turbofile-sync-list { + background: var(--tf-surface); +} + +.vs .turbofile-file-row, +.vs .turbofile-device-select-option, +.vs .turbofile-sync-row { + color: var(--tf-text); + border-color: transparent; +} + +.vs .turbofile-file-row:hover, +.vs .turbofile-device-select-option:hover, +.vs .turbofile-sync-row:hover { + background: var(--tf-windows-hover, var(--tf-bg-secondary)); +} + +.vs .turbofile-file-row.is-selected, +.vs .turbofile-sync-row.is-reversed { + color: var(--tf-brand-blue-dark); + background: var(--tf-windows-selected, var(--tf-brand-soft)); +} + +.vs .turbofile-device-select-option.is-selected, +.vs .turbofile-device-select-option.is-selected:hover, +.vs .turbofile-device-select-option.is-selected:focus, +.vs .turbofile-device-select-option.is-selected:focus-visible { + color: var(--tf-text); + background: transparent; + outline: none; + box-shadow: none; + border-color: transparent; +} + +.vs .turbofile-file-row-name, +.vs .turbofile-info-line strong, +.vs .turbofile-summary-card strong, +.vs .turbofile-live-transfer-name, +.vs .turbofile-live-transfer-percent, +.vs .turbofile-sync-path { + color: var(--tf-text); +} + +.vs .turbofile-file-row-meta, +.vs .turbofile-file-row-size, +.vs .turbofile-file-row-modified, +.vs .turbofile-sidebar-device-text span, +.vs .turbofile-sidebar-empty { + color: var(--tf-text-muted); +} + +.vs .turbofile-device-test-result, +.vs .turbofile-dialog-detail, +.vs .turbofile-sync-summary-metric, +.vs .turbofile-live-transfer-metric, +.vs .turbofile-task-log-empty { + border-color: var(--tf-border); + background: var(--tf-bg-secondary); + color: var(--tf-text-muted); +} + +.vs .turbofile-device-test-result.is-success, +.vs .turbofile-task-row.is-success .turbofile-task-row-name, +.vs .turbofile-sync-reason.is-newer { + color: var(--tf-success); +} + +.vs .turbofile-device-test-result.is-testing, +.vs .turbofile-task-row.is-running .turbofile-task-row-name, +.vs .turbofile-sync-reason { + color: var(--tf-brand-blue); +} + +.vs .turbofile-device-test-result.is-error, +.vs .turbofile-task-row.is-error .turbofile-task-row-name { + color: var(--tf-danger); +} + +.vs .turbofile-summary-card .codicon, +.vs .turbofile-live-transfer-file-icon, +.vs .turbofile-dialog-icon { + color: var(--tf-brand-blue); + background: var(--tf-brand-soft); +} + +.vs .turbofile-live-transfer-progress { + background: var(--tf-bg-secondary); +} + +.vs .turbofile-live-transfer-progress-value { + background: linear-gradient(90deg, var(--tf-brand-blue), var(--tf-success)); +} + +.vs .turbofile-taskbar { + border-top: 1px solid var(--vscode-statusBar-border, var(--tf-border)); + color: var(--vscode-statusBar-foreground); + background: var(--vscode-statusBar-background); +} + +.vs .turbofile-live-transfer-panel { + background: var(--tf-surface); +} + +.vs .turbofile-image-page-header, +.vs .turbofile-image-page-actions { + background: var(--tf-bg); +} + +.vs .turbofile-image-page-card, +.vs .turbofile-image-grid-card { + border: 1px solid var(--tf-border); + border-radius: var(--tf-card-radius); + background: var(--tf-surface); + box-shadow: var(--tf-card-shadow); +} + +.vs .turbofile-image-page-card.is-selected, +.vs .turbofile-image-grid-card.is-selected { + border-color: var(--tf-brand-blue); + outline: 1px solid var(--tf-brand-blue); + box-shadow: var(--tf-card-shadow-hover); +} + +.vs .turbofile-image-page-card-placeholder, +.vs .turbofile-image-grid-card-placeholder, +.vs .turbofile-image-grid-placeholder, +.vs .turbofile-image-preview-placeholder { + background: var(--tf-bg-secondary); + color: var(--tf-text-muted); +} + +.vs .turbofile-image-preview-overlay, +.vs .turbofile-image-grid-overlay, +.vs .turbofile-dialog-overlay, +.vs .turbofile-task-log-overlay, +.vs .turbofile-sync-overlay { + background: rgba(255, 255, 255, 0.82); + backdrop-filter: blur(14px); +} + +.vs .turbofile-image-preview-image, +.vs .turbofile-image-grid-preview-image { + border-radius: var(--tf-card-radius); + box-shadow: 0 18px 44px rgba(17, 31, 104, 0.18); +} + +.vs .turbofile-image-preview-caption, +.vs .turbofile-image-grid-caption { + border: 1px solid rgba(229, 231, 235, 0.86); + color: var(--tf-text); + background: rgba(255, 255, 255, 0.9); + box-shadow: var(--tf-card-shadow); +} + +.vs .turbofile-image-preview-side { + border: 1px solid var(--tf-border); + border-radius: var(--tf-card-radius); + background: var(--tf-surface); + box-shadow: var(--tf-card-shadow); +} + +.vs .turbofile-image-preview-side-icon { + color: var(--tf-brand-blue); + background: rgba(255, 255, 255, 0.62); +} + +.vs .turbofile-sync-row.is-skipped { + opacity: 0.58; + background: var(--tf-bg-secondary); +} + +.vs .turbofile-sync-reason.is-size { + color: var(--tf-accent-pink); +} + +/* Ultralytics-style image explorer and preview. Scoped to image surfaces only. */ +.vs .turbofile-workbench, +.turbofile-workbench.is-turbofile-light { + --tf-image-bg: #f1f1f1; + --tf-image-surface: #ffffff; + --tf-image-surface-muted: #f7f7f7; + --tf-image-border: #e5e5e5; + --tf-image-text: #1a1a1a; + --tf-image-muted: #616161; + --tf-image-subtle: #8a8a8a; + --tf-image-brand: #0078d4; + --tf-image-hover-bg: #f0f0f0; + --tf-image-selected-bg: #e5f3ff; + --tf-image-selected-ring: #99d1ff; + --tf-image-hover-shadow: none; + --tf-image-card-shadow: none; + --tf-image-overlay: rgba(255, 255, 255, 0.86); + --tf-image-control-bg: rgba(255, 255, 255, 0.96); + --tf-image-control-hover: #f0f0f0; + --tf-image-control-text: #1a1a1a; + --tf-image-control-shadow: none; + --tf-image-frame-bg: #f7f7f7; + --tf-image-info-bg: linear-gradient(180deg, rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.62)); + --tf-image-info-text: #ffffff; +} + +.vs-dark .turbofile-workbench, +.hc-black .turbofile-workbench, +.turbofile-workbench.is-turbofile-dark { + --tf-image-bg: #202020; + --tf-image-surface: #141414; + --tf-image-surface-muted: #1f1f1f; + --tf-image-border: rgba(255, 255, 255, 0.1); + --tf-image-text: #f5f5f5; + --tf-image-muted: #a3a3a3; + --tf-image-subtle: #737373; + --tf-image-brand: #7aa2ff; + --tf-image-hover-bg: color-mix(in srgb, var(--tf-image-brand) 7%, transparent); + --tf-image-selected-bg: color-mix(in srgb, var(--tf-image-brand) 10%, transparent); + --tf-image-selected-ring: color-mix(in srgb, var(--tf-image-brand) 24%, transparent); + --tf-image-hover-shadow: 0 12px 28px rgba(0, 0, 0, 0.34); + --tf-image-card-shadow: none; + --tf-image-overlay: rgba(5, 5, 5, 0.84); + --tf-image-control-bg: rgba(20, 20, 20, 0.92); + --tf-image-control-hover: #262626; + --tf-image-control-text: #f5f5f5; + --tf-image-control-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.28), 0 4px 6px -4px rgba(0, 0, 0, 0.32); + --tf-image-frame-bg: #101114; + --tf-image-info-bg: linear-gradient(180deg, rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.68)); + --tf-image-info-text: #ffffff; +} + +.vs .turbofile-workbench .turbofile-image-page, +.vs-dark .turbofile-workbench .turbofile-image-page, +.hc-black .turbofile-workbench .turbofile-image-page, +.turbofile-workbench.is-turbofile-light .turbofile-image-page, +.turbofile-workbench.is-turbofile-dark .turbofile-image-page { + grid-template-rows: 38px minmax(0, 1fr); + background: var(--tf-preview-backdrop-bg, var(--tf-image-bg)); + color: var(--tf-image-text); +} + +.vs .turbofile-workbench .turbofile-image-page-header, +.vs-dark .turbofile-workbench .turbofile-image-page-header, +.hc-black .turbofile-workbench .turbofile-image-page-header, +.turbofile-workbench.is-turbofile-light .turbofile-image-page-header, +.turbofile-workbench.is-turbofile-dark .turbofile-image-page-header { + padding: 0 16px; + border-bottom: 0; + background: var(--tf-preview-backdrop-bg, var(--tf-image-bg)); +} + +.vs .turbofile-workbench .turbofile-image-page-actions, +.vs-dark .turbofile-workbench .turbofile-image-page-actions, +.hc-black .turbofile-workbench .turbofile-image-page-actions, +.turbofile-workbench.is-turbofile-light .turbofile-image-page-actions, +.turbofile-workbench.is-turbofile-dark .turbofile-image-page-actions { + top: 5px; + right: 12px; + background: transparent; +} + +.vs .turbofile-workbench .turbofile-image-page-title, +.vs-dark .turbofile-workbench .turbofile-image-page-title, +.hc-black .turbofile-workbench .turbofile-image-page-title, +.turbofile-workbench.is-turbofile-light .turbofile-image-page-title, +.turbofile-workbench.is-turbofile-dark .turbofile-image-page-title { + color: var(--tf-image-text); + font-size: 16px; + font-weight: 700; + letter-spacing: -0.01em; +} + +.vs .turbofile-workbench .turbofile-image-page-title .codicon, +.vs-dark .turbofile-workbench .turbofile-image-page-title .codicon, +.hc-black .turbofile-workbench .turbofile-image-page-title .codicon, +.turbofile-workbench.is-turbofile-light .turbofile-image-page-title .codicon, +.turbofile-workbench.is-turbofile-dark .turbofile-image-page-title .codicon { + color: var(--tf-image-brand); +} + +.vs .turbofile-workbench .turbofile-image-page-columns, +.vs-dark .turbofile-workbench .turbofile-image-page-columns, +.hc-black .turbofile-workbench .turbofile-image-page-columns, +.turbofile-workbench.is-turbofile-light .turbofile-image-page-columns, +.turbofile-workbench.is-turbofile-dark .turbofile-image-page-columns { + gap: 4px; + padding: 4px; + border: 1px solid var(--tf-image-border); + border-radius: 10px; + background: var(--tf-image-surface-muted); +} + +.vs .turbofile-workbench .turbofile-image-page-columns-label, +.vs-dark .turbofile-workbench .turbofile-image-page-columns-label, +.hc-black .turbofile-workbench .turbofile-image-page-columns-label, +.turbofile-workbench.is-turbofile-light .turbofile-image-page-columns-label, +.turbofile-workbench.is-turbofile-dark .turbofile-image-page-columns-label { + padding: 0 6px; + color: var(--tf-image-muted); + font-weight: 600; +} + +.vs .turbofile-workbench .turbofile-image-page-columns-button, +.vs-dark .turbofile-workbench .turbofile-image-page-columns-button, +.hc-black .turbofile-workbench .turbofile-image-page-columns-button, +.turbofile-workbench.is-turbofile-light .turbofile-image-page-columns-button, +.turbofile-workbench.is-turbofile-dark .turbofile-image-page-columns-button { + min-width: 28px; + padding: 4px 8px; + border-radius: 8px; + color: var(--tf-image-muted); + background: transparent; + transition: background 150ms ease, color 150ms ease, box-shadow 150ms ease; +} + +.vs .turbofile-workbench .turbofile-image-page-columns-button:hover, +.vs-dark .turbofile-workbench .turbofile-image-page-columns-button:hover, +.hc-black .turbofile-workbench .turbofile-image-page-columns-button:hover, +.turbofile-workbench.is-turbofile-light .turbofile-image-page-columns-button:hover, +.turbofile-workbench.is-turbofile-dark .turbofile-image-page-columns-button:hover { + background: var(--tf-image-control-hover); + color: var(--tf-image-text); +} + +.vs .turbofile-workbench .turbofile-image-page-columns-button.is-active, +.vs-dark .turbofile-workbench .turbofile-image-page-columns-button.is-active, +.hc-black .turbofile-workbench .turbofile-image-page-columns-button.is-active, +.turbofile-workbench.is-turbofile-light .turbofile-image-page-columns-button.is-active, +.turbofile-workbench.is-turbofile-dark .turbofile-image-page-columns-button.is-active { + background: var(--tf-image-surface); + color: var(--tf-image-text); + box-shadow: var(--tf-image-card-shadow); +} + +.vs .turbofile-workbench .turbofile-image-page-body, +.vs-dark .turbofile-workbench .turbofile-image-page-body, +.hc-black .turbofile-workbench .turbofile-image-page-body, +.turbofile-workbench.is-turbofile-light .turbofile-image-page-body, +.turbofile-workbench.is-turbofile-dark .turbofile-image-page-body { + padding: 8px 16px 24px; + background: var(--tf-preview-backdrop-bg, var(--tf-image-bg)); +} + +.vs .turbofile-workbench .turbofile-image-page-grid, +.vs-dark .turbofile-workbench .turbofile-image-page-grid, +.hc-black .turbofile-workbench .turbofile-image-page-grid, +.turbofile-workbench.is-turbofile-light .turbofile-image-page-grid, +.turbofile-workbench.is-turbofile-dark .turbofile-image-page-grid { + gap: 16px; + background: transparent; +} + +.vs .turbofile-workbench .turbofile-image-page-card, +.vs-dark .turbofile-workbench .turbofile-image-page-card, +.hc-black .turbofile-workbench .turbofile-image-page-card, +.turbofile-workbench.is-turbofile-light .turbofile-image-page-card, +.turbofile-workbench.is-turbofile-dark .turbofile-image-page-card { + padding: 4px; + border: 1px solid transparent; + border-radius: 6px; + background: transparent; + color: var(--tf-image-text); + box-shadow: none; + overflow: visible; + transition: background 120ms ease, border-color 120ms ease, box-shadow 120ms ease; + contain-intrinsic-size: 220px 218px; +} + +.vs .turbofile-workbench .turbofile-image-page-card:hover, +.vs-dark .turbofile-workbench .turbofile-image-page-card:hover, +.hc-black .turbofile-workbench .turbofile-image-page-card:hover, +.turbofile-workbench.is-turbofile-light .turbofile-image-page-card:hover, +.turbofile-workbench.is-turbofile-dark .turbofile-image-page-card:hover { + border-color: transparent; + background: var(--tf-image-hover-bg, color-mix(in srgb, var(--tf-image-brand) 7%, transparent)); + box-shadow: none; + transform: none; +} + +.vs .turbofile-workbench .turbofile-image-page-card:active, +.vs-dark .turbofile-workbench .turbofile-image-page-card:active, +.hc-black .turbofile-workbench .turbofile-image-page-card:active, +.turbofile-workbench.is-turbofile-light .turbofile-image-page-card:active, +.turbofile-workbench.is-turbofile-dark .turbofile-image-page-card:active { + transform: translateY(0); +} + +.vs .turbofile-workbench .turbofile-image-page-card.is-selected, +.vs-dark .turbofile-workbench .turbofile-image-page-card.is-selected, +.hc-black .turbofile-workbench .turbofile-image-page-card.is-selected, +.turbofile-workbench.is-turbofile-light .turbofile-image-page-card.is-selected, +.turbofile-workbench.is-turbofile-dark .turbofile-image-page-card.is-selected { + border-color: var(--tf-image-brand); + outline: none; + background: var(--tf-image-selected-bg, color-mix(in srgb, var(--tf-image-brand) 10%, transparent)); + box-shadow: 0 0 0 1px var(--tf-image-selected-ring, color-mix(in srgb, var(--tf-image-brand) 24%, transparent)); +} + +.vs .turbofile-workbench .turbofile-image-page-card.is-focused, +.vs-dark .turbofile-workbench .turbofile-image-page-card.is-focused, +.hc-black .turbofile-workbench .turbofile-image-page-card.is-focused, +.turbofile-workbench.is-turbofile-light .turbofile-image-page-card.is-focused, +.turbofile-workbench.is-turbofile-dark .turbofile-image-page-card.is-focused { + outline: 1px solid color-mix(in srgb, var(--tf-image-brand) 52%, transparent); + outline-offset: 2px; +} + +.vs .turbofile-workbench .turbofile-image-page-card::after, +.vs-dark .turbofile-workbench .turbofile-image-page-card::after, +.hc-black .turbofile-workbench .turbofile-image-page-card::after, +.turbofile-workbench.is-turbofile-light .turbofile-image-page-card::after, +.turbofile-workbench.is-turbofile-dark .turbofile-image-page-card::after { + display: none; +} + +.vs .turbofile-workbench .turbofile-image-page-card-preview, +.vs-dark .turbofile-workbench .turbofile-image-page-card-preview, +.hc-black .turbofile-workbench .turbofile-image-page-card-preview, +.turbofile-workbench.is-turbofile-light .turbofile-image-page-card-preview, +.turbofile-workbench.is-turbofile-dark .turbofile-image-page-card-preview { + position: relative; + aspect-ratio: 220 / 150; + border-radius: 0; + padding: 0; + box-sizing: border-box; + background: transparent; +} + +.vs .turbofile-workbench .turbofile-image-page-card-preview img, +.vs-dark .turbofile-workbench .turbofile-image-page-card-preview img, +.hc-black .turbofile-workbench .turbofile-image-page-card-preview img, +.turbofile-workbench.is-turbofile-light .turbofile-image-page-card-preview img, +.turbofile-workbench.is-turbofile-dark .turbofile-image-page-card-preview img { + width: auto; + height: auto; + max-width: none; + max-height: none; + object-fit: contain; + image-rendering: auto; + border-radius: 0; + background: transparent; + transition: opacity 200ms ease; +} + +.turbofile-image-page-card-info { + position: absolute; + left: 0; + right: 0; + bottom: 0; + display: grid; + grid-template-columns: minmax(22px, max-content) minmax(0, 1fr); + align-items: center; + gap: 8px; + min-height: 34px; + padding: 18px 10px 8px; + box-sizing: border-box; + border-radius: 0 0 10px 10px; + background: var(--tf-image-info-bg); + color: var(--tf-image-info-text); + font-family: var(--vscode-font-family, "Segoe UI", "Microsoft YaHei UI", sans-serif); + font-size: 12px; + font-weight: 600; + line-height: 1.2; + z-index: 2; + opacity: 0; + pointer-events: none; + transform: translateY(4px); + transition: opacity 160ms ease, transform 160ms ease; +} + +.turbofile-image-page-card:hover .turbofile-image-page-card-info, +.turbofile-image-page-card:focus-visible .turbofile-image-page-card-info { + opacity: 1; + transform: none; +} + +.turbofile-image-page-card-index, +.turbofile-image-page-card-dimensions { + color: var(--tf-image-info-text); + white-space: nowrap; +} + +.turbofile-image-page-card-dimensions { + justify-self: end; +} + +.vs .turbofile-workbench .turbofile-image-page-card-name, +.vs-dark .turbofile-workbench .turbofile-image-page-card-name, +.hc-black .turbofile-workbench .turbofile-image-page-card-name, +.turbofile-workbench.is-turbofile-light .turbofile-image-page-card-name, +.turbofile-workbench.is-turbofile-dark .turbofile-image-page-card-name { + display: block; + padding: 8px 10px 10px; + color: var(--tf-image-text); + line-height: 1.25; + text-align: center; + overflow-wrap: anywhere; + white-space: normal; + word-break: break-word; + cursor: text; + user-select: text; +} + +.turbofile-image-page-card-filename { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + text-align: center; + color: var(--tf-image-info-text); + text-shadow: 0 1px 3px rgba(0, 0, 0, 0.55); +} + +.turbofile-image-page-card-dimensions.is-empty { + visibility: hidden; +} + +.vs .turbofile-workbench .turbofile-image-page-card-placeholder, +.vs-dark .turbofile-workbench .turbofile-image-page-card-placeholder, +.hc-black .turbofile-workbench .turbofile-image-page-card-placeholder, +.turbofile-workbench.is-turbofile-light .turbofile-image-page-card-placeholder, +.turbofile-workbench.is-turbofile-dark .turbofile-image-page-card-placeholder { + background: transparent; + color: var(--tf-image-muted); +} + +.vs .turbofile-workbench .turbofile-image-preview-overlay, +.vs-dark .turbofile-workbench .turbofile-image-preview-overlay, +.hc-black .turbofile-workbench .turbofile-image-preview-overlay, +.turbofile-workbench.is-turbofile-light .turbofile-image-preview-overlay, +.turbofile-workbench.is-turbofile-dark .turbofile-image-preview-overlay { + background: var(--tf-image-overlay); + backdrop-filter: blur(18px); + outline: none; +} + +.turbofile-image-preview-toolbar { + position: absolute; + top: 18px; + left: 18px; + right: 18px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + z-index: 4; + pointer-events: none; +} + +.turbofile-image-preview-meta { + min-width: 0; + max-width: min(720px, calc(100vw - 132px)); + display: inline-flex; + align-items: center; + gap: 8px; + padding: 9px 12px; + border: 1px solid var(--tf-image-border); + border-radius: 12px; + background: var(--tf-image-control-bg); + color: var(--tf-image-control-text); + box-shadow: var(--tf-image-card-shadow); + pointer-events: auto; + backdrop-filter: blur(14px); +} + +.turbofile-image-preview-meta .codicon { + color: var(--tf-image-brand); +} + +.turbofile-image-preview-meta-text { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 13px; + font-weight: 700; + letter-spacing: -0.01em; +} + +.vs .turbofile-workbench .turbofile-image-preview-close, +.vs-dark .turbofile-workbench .turbofile-image-preview-close, +.hc-black .turbofile-workbench .turbofile-image-preview-close, +.turbofile-workbench.is-turbofile-light .turbofile-image-preview-close, +.turbofile-workbench.is-turbofile-dark .turbofile-image-preview-close { + position: static; + width: 40px; + height: 40px; + padding: 0; + border: 1px solid var(--tf-image-border); + border-radius: 12px; + background: var(--tf-image-control-bg); + color: var(--tf-image-control-text); + box-shadow: var(--tf-image-card-shadow); + pointer-events: auto; + backdrop-filter: blur(14px); + transition: background 150ms ease, box-shadow 150ms ease, transform 150ms ease; +} + +.vs .turbofile-workbench .turbofile-image-preview-close:hover, +.vs-dark .turbofile-workbench .turbofile-image-preview-close:hover, +.hc-black .turbofile-workbench .turbofile-image-preview-close:hover, +.turbofile-workbench.is-turbofile-light .turbofile-image-preview-close:hover, +.turbofile-workbench.is-turbofile-dark .turbofile-image-preview-close:hover { + background: var(--tf-image-control-hover); + box-shadow: var(--tf-image-hover-shadow); + transform: translateY(-1px); +} + +.vs .turbofile-workbench .turbofile-image-preview-stage, +.vs-dark .turbofile-workbench .turbofile-image-preview-stage, +.hc-black .turbofile-workbench .turbofile-image-preview-stage, +.turbofile-workbench.is-turbofile-light .turbofile-image-preview-stage, +.turbofile-workbench.is-turbofile-dark .turbofile-image-preview-stage { + padding: 84px 132px 86px; +} + +.vs .turbofile-workbench .turbofile-image-preview-image, +.vs-dark .turbofile-workbench .turbofile-image-preview-image, +.hc-black .turbofile-workbench .turbofile-image-preview-image, +.turbofile-workbench.is-turbofile-light .turbofile-image-preview-image, +.turbofile-workbench.is-turbofile-dark .turbofile-image-preview-image { + max-width: min(86vw, 1500px); + max-height: min(82vh, 980px); + border-radius: 14px; + background: var(--tf-image-surface); + box-shadow: 0 24px 70px color-mix(in srgb, var(--tf-image-text) 18%, transparent); +} + +.vs .turbofile-workbench .turbofile-image-preview-placeholder, +.vs-dark .turbofile-workbench .turbofile-image-preview-placeholder, +.hc-black .turbofile-workbench .turbofile-image-preview-placeholder, +.turbofile-workbench.is-turbofile-light .turbofile-image-preview-placeholder, +.turbofile-workbench.is-turbofile-dark .turbofile-image-preview-placeholder { + padding: 16px 20px; + border: 1px solid var(--tf-image-border); + border-radius: 14px; + background: var(--tf-image-control-bg); + color: var(--tf-image-muted); + box-shadow: var(--tf-image-card-shadow); +} + +.vs .turbofile-workbench .turbofile-image-preview-caption, +.vs-dark .turbofile-workbench .turbofile-image-preview-caption, +.hc-black .turbofile-workbench .turbofile-image-preview-caption, +.turbofile-workbench.is-turbofile-light .turbofile-image-preview-caption, +.turbofile-workbench.is-turbofile-dark .turbofile-image-preview-caption { + bottom: 28px; + max-width: min(820px, calc(100vw - 240px)); + padding: 8px 12px; + border: 1px solid var(--tf-image-border); + border-radius: 12px; + background: var(--tf-image-control-bg); + color: var(--tf-image-muted); + overflow: hidden; + text-overflow: ellipsis; + box-shadow: var(--tf-image-card-shadow); + backdrop-filter: blur(14px); +} + +.vs .turbofile-workbench .turbofile-image-preview-navigation, +.vs-dark .turbofile-workbench .turbofile-image-preview-navigation, +.hc-black .turbofile-workbench .turbofile-image-preview-navigation, +.turbofile-workbench.is-turbofile-light .turbofile-image-preview-navigation, +.turbofile-workbench.is-turbofile-dark .turbofile-image-preview-navigation { + display: none; +} + +.vs .turbofile-workbench .turbofile-image-preview-side, +.vs-dark .turbofile-workbench .turbofile-image-preview-side, +.hc-black .turbofile-workbench .turbofile-image-preview-side, +.turbofile-workbench.is-turbofile-light .turbofile-image-preview-side, +.turbofile-workbench.is-turbofile-dark .turbofile-image-preview-side { + top: 50%; + bottom: auto; + width: 96px; + height: 118px; + padding: 6px; + border: 1px solid var(--tf-image-border); + border-radius: 14px; + background: var(--tf-image-control-bg); + box-shadow: var(--tf-image-card-shadow); + transform: translateY(-50%); + backdrop-filter: blur(14px); + transition: box-shadow 150ms ease, transform 150ms ease, background 150ms ease; +} + +.vs .turbofile-workbench .turbofile-image-preview-side.is-left, +.vs-dark .turbofile-workbench .turbofile-image-preview-side.is-left, +.hc-black .turbofile-workbench .turbofile-image-preview-side.is-left, +.turbofile-workbench.is-turbofile-light .turbofile-image-preview-side.is-left, +.turbofile-workbench.is-turbofile-dark .turbofile-image-preview-side.is-left { + left: 18px; +} + +.vs .turbofile-workbench .turbofile-image-preview-side.is-right, +.vs-dark .turbofile-workbench .turbofile-image-preview-side.is-right, +.hc-black .turbofile-workbench .turbofile-image-preview-side.is-right, +.turbofile-workbench.is-turbofile-light .turbofile-image-preview-side.is-right, +.turbofile-workbench.is-turbofile-dark .turbofile-image-preview-side.is-right { + right: 18px; +} + +.vs .turbofile-workbench .turbofile-image-preview-side:hover, +.vs-dark .turbofile-workbench .turbofile-image-preview-side:hover, +.hc-black .turbofile-workbench .turbofile-image-preview-side:hover, +.turbofile-workbench.is-turbofile-light .turbofile-image-preview-side:hover, +.turbofile-workbench.is-turbofile-dark .turbofile-image-preview-side:hover { + background: var(--tf-image-control-hover); + box-shadow: var(--tf-image-hover-shadow); + transform: translateY(-50%) scale(1.02); +} + +.vs .turbofile-workbench .turbofile-image-preview-side img, +.vs-dark .turbofile-workbench .turbofile-image-preview-side img, +.hc-black .turbofile-workbench .turbofile-image-preview-side img, +.turbofile-workbench.is-turbofile-light .turbofile-image-preview-side img, +.turbofile-workbench.is-turbofile-dark .turbofile-image-preview-side img { + border-radius: 10px; +} + +.vs .turbofile-workbench .turbofile-image-preview-side-icon, +.vs-dark .turbofile-workbench .turbofile-image-preview-side-icon, +.hc-black .turbofile-workbench .turbofile-image-preview-side-icon, +.turbofile-workbench.is-turbofile-light .turbofile-image-preview-side-icon, +.turbofile-workbench.is-turbofile-dark .turbofile-image-preview-side-icon { + color: #ffffff; + background: linear-gradient(180deg, rgba(0, 0, 0, 0.08), rgba(0, 0, 0, 0.5)); +} + +@media (max-width: 900px) { + .vs .turbofile-workbench .turbofile-image-preview-stage, + .vs-dark .turbofile-workbench .turbofile-image-preview-stage, + .hc-black .turbofile-workbench .turbofile-image-preview-stage, + .turbofile-workbench.is-turbofile-light .turbofile-image-preview-stage, + .turbofile-workbench.is-turbofile-dark .turbofile-image-preview-stage { + padding: 78px 18px 94px; + } + + .vs .turbofile-workbench .turbofile-image-preview-side, + .vs-dark .turbofile-workbench .turbofile-image-preview-side, + .hc-black .turbofile-workbench .turbofile-image-preview-side, + .turbofile-workbench.is-turbofile-light .turbofile-image-preview-side, + .turbofile-workbench.is-turbofile-dark .turbofile-image-preview-side { + top: auto; + bottom: 18px; + width: 76px; + height: 76px; + transform: none; + } + + .vs .turbofile-workbench .turbofile-image-preview-side:hover, + .vs-dark .turbofile-workbench .turbofile-image-preview-side:hover, + .hc-black .turbofile-workbench .turbofile-image-preview-side:hover, + .turbofile-workbench.is-turbofile-light .turbofile-image-preview-side:hover, + .turbofile-workbench.is-turbofile-dark .turbofile-image-preview-side:hover { + transform: translateY(-1px); + } +} + +/* Platform-accurate single image preview layer. */ +.vs .turbofile-workbench, +.turbofile-workbench.is-turbofile-light { + --tf-preview-backdrop-bg: #f1f1f1; + --tf-preview-control-bg: rgba(255, 255, 255, 0.9); + --tf-preview-control-hover: rgba(243, 244, 246, 0.95); + --tf-preview-control-text: #111111; + --tf-preview-muted-text: #6b7280; + --tf-preview-border-ring: rgba(229, 231, 235, 0.5); + --tf-preview-elevated-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.10), 0 4px 6px -4px rgba(0, 0, 0, 0.10); +} + +.vs-dark .turbofile-workbench, +.hc-black .turbofile-workbench, +.turbofile-workbench.is-turbofile-dark { + --tf-preview-backdrop-bg: #202020; + --tf-preview-control-bg: rgba(20, 20, 20, 0.9); + --tf-preview-control-hover: rgba(38, 38, 38, 0.95); + --tf-preview-control-text: #f5f5f5; + --tf-preview-muted-text: #a3a3a3; + --tf-preview-border-ring: rgba(255, 255, 255, 0.1); + --tf-preview-elevated-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.10), 0 4px 6px -4px rgba(0, 0, 0, 0.10); +} + +.vs .turbofile-workbench, +.turbofile-workbench.is-turbofile-light { + --tf-preview-side-fill: rgba(255, 255, 255, 0.94); + --tf-preview-side-hover: #ffffff; + --tf-preview-side-image-bg: #f3f4f6; + --tf-preview-side-icon-bg: rgba(17, 24, 39, 0.68); + --tf-preview-side-icon-hover: rgba(17, 24, 39, 0.82); +} + +.vs-dark .turbofile-workbench, +.hc-black .turbofile-workbench, +.turbofile-workbench.is-turbofile-dark { + --tf-preview-side-fill: rgba(15, 16, 19, 0.94); + --tf-preview-side-hover: rgba(21, 23, 27, 0.96); + --tf-preview-side-image-bg: #0b0c0f; + --tf-preview-side-icon-bg: rgba(0, 0, 0, 0.56); + --tf-preview-side-icon-hover: rgba(0, 0, 0, 0.7); +} + +.vs .turbofile-workbench .turbofile-image-preview-overlay, +.vs-dark .turbofile-workbench .turbofile-image-preview-overlay, +.hc-black .turbofile-workbench .turbofile-image-preview-overlay, +.turbofile-workbench.is-turbofile-light .turbofile-image-preview-overlay, +.turbofile-workbench.is-turbofile-dark .turbofile-image-preview-overlay { + background: var(--tf-preview-backdrop-bg); + backdrop-filter: none; + outline: none; + overflow: hidden; +} + +.turbofile-image-preview-dismiss { + position: absolute; + inset: 0; + z-index: 10; + border: 0; + padding: 0; + margin: 0; + background: transparent; + cursor: pointer; +} + +.vs .turbofile-workbench .turbofile-image-preview-stage, +.vs-dark .turbofile-workbench .turbofile-image-preview-stage, +.hc-black .turbofile-workbench .turbofile-image-preview-stage, +.turbofile-workbench.is-turbofile-light .turbofile-image-preview-stage, +.turbofile-workbench.is-turbofile-dark .turbofile-image-preview-stage { + z-index: 20; + padding: 12px 8px; + overflow: hidden; + pointer-events: none; + touch-action: none; +} + +.turbofile-image-preview-figure { + position: relative; + z-index: 20; + display: block; + width: 100%; + height: 100%; + max-width: 100%; + max-height: 100%; + pointer-events: auto; + user-select: none; + touch-action: none; +} + +.turbofile-image-preview-figure.is-pixel-inspector { + cursor: crosshair; +} + +/* Interim thumbnail shown instantly while the full-resolution original loads/decodes. */ +.turbofile-image-preview-interim { + position: absolute; + inset: 0; + display: block; + width: 100%; + height: 100%; + object-fit: contain; + user-select: none; + -webkit-user-drag: none; + pointer-events: none; +} + +.vs .turbofile-workbench .turbofile-image-preview-canvas, +.vs-dark .turbofile-workbench .turbofile-image-preview-canvas, +.hc-black .turbofile-workbench .turbofile-image-preview-canvas, +.turbofile-workbench.is-turbofile-light .turbofile-image-preview-canvas, +.turbofile-workbench.is-turbofile-dark .turbofile-image-preview-canvas { + position: absolute; + inset: 0; + display: block; + width: 100%; + height: 100%; + border-radius: 0; + background: transparent; + box-shadow: none; + touch-action: none; +} + +.turbofile-image-preview-toolbar { + top: 16px; + right: 16px; + left: auto; + z-index: 30; + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + width: auto; + pointer-events: auto; +} + +.turbofile-image-preview-action, +.turbofile-image-preview-zoom-controls .turbofile-image-preview-action, +.turbofile-image-preview-zoom-value { + width: 32px; + height: 32px; + border: 0; + border-radius: 8px; + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; + background: var(--tf-preview-control-bg); + color: var(--tf-preview-control-text); + box-shadow: var(--tf-preview-elevated-shadow); + cursor: pointer; + transition: background 160ms ease, opacity 160ms ease, transform 160ms ease; +} + +.turbofile-image-preview-action:hover, +.turbofile-image-preview-zoom-controls .turbofile-image-preview-action:hover { + background: var(--tf-preview-control-hover); +} + +.turbofile-image-preview-action.is-active { + color: #042aff; + background: rgba(225, 255, 37, 0.92); +} + +.turbofile-image-preview-action:disabled, +.turbofile-image-preview-zoom-value:disabled { + cursor: default; + opacity: 0.5; +} + +.turbofile-image-preview-action .codicon, +.turbofile-image-preview-zoom-value { + font-size: 14px; +} + +.turbofile-image-preview-footer { + position: absolute; + left: 16px; + right: 16px; + bottom: 14px; + z-index: 30; + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + pointer-events: none; +} + +.turbofile-image-preview-zoom-controls { + display: flex; + align-items: center; + justify-content: center; + gap: 2px; + pointer-events: auto; +} + +.turbofile-image-preview-zoom-controls .is-transient, +.turbofile-image-preview-zoom-controls .is-reset { + opacity: 0; + transition-delay: 300ms; +} + +.turbofile-image-preview-zoom-controls:hover .is-transient, +.turbofile-image-preview-zoom-controls:hover .is-reset { + opacity: 1; + transition-delay: 0ms; +} + +.turbofile-image-preview-zoom-value { + width: auto; + min-width: 55px; + padding: 0 12px; + font-size: 13px; + font-weight: 600; +} + +.vs .turbofile-workbench .turbofile-image-preview-caption, +.vs-dark .turbofile-workbench .turbofile-image-preview-caption, +.hc-black .turbofile-workbench .turbofile-image-preview-caption, +.turbofile-workbench.is-turbofile-light .turbofile-image-preview-caption, +.turbofile-workbench.is-turbofile-dark .turbofile-image-preview-caption { + position: static; + left: auto; + bottom: auto; + transform: none; + max-width: min(720px, calc(100vw - 180px)); + min-height: 32px; + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: center; + gap: 4px 12px; + padding: 6px 12px; + border: 0; + border-radius: 10px; + background: var(--tf-preview-control-bg); + color: var(--tf-preview-control-text); + font-family: var(--vscode-font-family, "Segoe UI", "Microsoft YaHei UI", sans-serif); + font-size: 13px; + font-weight: 400; + box-shadow: var(--tf-preview-elevated-shadow); + backdrop-filter: none; + pointer-events: auto; +} + +.turbofile-image-preview-info-index, +.turbofile-image-preview-info-dimensions { + color: var(--tf-preview-muted-text); + white-space: nowrap; +} + +.turbofile-image-preview-info-name { + max-width: min(360px, 42vw); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--tf-preview-control-text); +} + +.turbofile-image-preview-pixel-tooltip { + position: absolute; + z-index: 36; + display: none; + padding: 4px 6px; + border: 1px solid rgba(0, 0, 0, 0.18); + background: rgba(255, 255, 255, 0.96); + color: #111111; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12); + font: 11px/1.35 Consolas, "Courier New", monospace; + white-space: pre; + pointer-events: none; +} + +.turbofile-image-preview-pixel-tooltip.is-visible { + display: block; +} + +.turbofile-image-preview-histogram { + position: absolute; + z-index: 35; + width: 264px; + border-radius: 4px; + background: #808080; + box-shadow: 0 12px 28px rgba(0, 0, 0, 0.25); + overflow: hidden; + user-select: none; +} + +.turbofile-image-preview-histogram-header { + height: 24px; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 6px 0 8px; + background: rgba(0, 0, 0, 0.18); + color: #ffffff; + font-size: 12px; +} + +.turbofile-image-preview-histogram-close { + width: 18px; + height: 18px; + border: 0; + border-radius: 4px; + display: inline-flex; + align-items: center; + justify-content: center; + background: transparent; + color: #ffffff; + cursor: pointer; +} + +.turbofile-image-preview-histogram-close:hover { + background: rgba(255, 255, 255, 0.18); +} + +.turbofile-image-preview-histogram-canvas { + display: block; + width: 260px; + height: 130px; + margin: 2px; + border-radius: 2px; + background: #808080; +} + +.vs .turbofile-workbench .turbofile-image-preview-side, +.vs-dark .turbofile-workbench .turbofile-image-preview-side, +.hc-black .turbofile-workbench .turbofile-image-preview-side, +.turbofile-workbench.is-turbofile-light .turbofile-image-preview-side, +.turbofile-workbench.is-turbofile-dark .turbofile-image-preview-side { + top: auto; + bottom: 28px; + width: 156px; + height: 96px; + padding: 10px; + border: 1px solid var(--tf-preview-border-ring); + border-radius: 13px; + background: var(--tf-preview-side-fill); + box-shadow: var(--tf-preview-elevated-shadow); + transform: none; + overflow: hidden; + z-index: 30; + backdrop-filter: blur(10px) saturate(1.05); + transition: transform 200ms ease, box-shadow 200ms ease, background 200ms ease; +} + +.vs .turbofile-workbench .turbofile-image-preview-side.is-left, +.vs-dark .turbofile-workbench .turbofile-image-preview-side.is-left, +.hc-black .turbofile-workbench .turbofile-image-preview-side.is-left, +.turbofile-workbench.is-turbofile-light .turbofile-image-preview-side.is-left, +.turbofile-workbench.is-turbofile-dark .turbofile-image-preview-side.is-left { + left: 12px; +} + +.vs .turbofile-workbench .turbofile-image-preview-side.is-right, +.vs-dark .turbofile-workbench .turbofile-image-preview-side.is-right, +.hc-black .turbofile-workbench .turbofile-image-preview-side.is-right, +.turbofile-workbench.is-turbofile-light .turbofile-image-preview-side.is-right, +.turbofile-workbench.is-turbofile-dark .turbofile-image-preview-side.is-right { + right: 12px; +} + +.vs .turbofile-workbench .turbofile-image-preview-side:hover, +.vs-dark .turbofile-workbench .turbofile-image-preview-side:hover, +.hc-black .turbofile-workbench .turbofile-image-preview-side:hover, +.turbofile-workbench.is-turbofile-light .turbofile-image-preview-side:hover, +.turbofile-workbench.is-turbofile-dark .turbofile-image-preview-side:hover { + background: var(--tf-preview-side-hover); + box-shadow: var(--tf-preview-elevated-shadow), 0 0 0 1px var(--tf-preview-border-ring); + transform: translateY(-1px); +} + +.vs .turbofile-workbench .turbofile-image-preview-side img, +.vs-dark .turbofile-workbench .turbofile-image-preview-side img, +.hc-black .turbofile-workbench .turbofile-image-preview-side img, +.turbofile-workbench.is-turbofile-light .turbofile-image-preview-side img, +.turbofile-workbench.is-turbofile-dark .turbofile-image-preview-side img { + width: 100%; + height: 100%; + border-radius: 7px; + object-fit: contain; + background: var(--tf-preview-side-image-bg); + box-sizing: border-box; +} + +.vs .turbofile-workbench .turbofile-image-preview-side-icon, +.vs-dark .turbofile-workbench .turbofile-image-preview-side-icon, +.hc-black .turbofile-workbench .turbofile-image-preview-side-icon, +.turbofile-workbench.is-turbofile-light .turbofile-image-preview-side-icon, +.turbofile-workbench.is-turbofile-dark .turbofile-image-preview-side-icon { + inset: auto; + top: 50%; + left: 50%; + width: 34px; + height: 34px; + border-radius: 999px; + color: #ffffff; + background: var(--tf-preview-side-icon-bg); + transform: translate(-50%, -50%); + transition: background 200ms ease; +} + +.vs .turbofile-workbench .turbofile-image-preview-side:hover .turbofile-image-preview-side-icon, +.vs-dark .turbofile-workbench .turbofile-image-preview-side:hover .turbofile-image-preview-side-icon, +.hc-black .turbofile-workbench .turbofile-image-preview-side:hover .turbofile-image-preview-side-icon, +.turbofile-workbench.is-turbofile-light .turbofile-image-preview-side:hover .turbofile-image-preview-side-icon, +.turbofile-workbench.is-turbofile-dark .turbofile-image-preview-side:hover .turbofile-image-preview-side-icon { + background: var(--tf-preview-side-icon-hover); +} + +.vs .turbofile-workbench .turbofile-image-preview-navigation, +.vs-dark .turbofile-workbench .turbofile-image-preview-navigation, +.hc-black .turbofile-workbench .turbofile-image-preview-navigation, +.turbofile-workbench.is-turbofile-light .turbofile-image-preview-navigation, +.turbofile-workbench.is-turbofile-dark .turbofile-image-preview-navigation { + display: none; +} + +@media (max-width: 900px) { + .vs .turbofile-workbench .turbofile-image-preview-stage, + .vs-dark .turbofile-workbench .turbofile-image-preview-stage, + .hc-black .turbofile-workbench .turbofile-image-preview-stage, + .turbofile-workbench.is-turbofile-light .turbofile-image-preview-stage, + .turbofile-workbench.is-turbofile-dark .turbofile-image-preview-stage { + padding: 12px 8px 108px; + } + + .vs .turbofile-workbench .turbofile-image-preview-side, + .vs-dark .turbofile-workbench .turbofile-image-preview-side, + .hc-black .turbofile-workbench .turbofile-image-preview-side, + .turbofile-workbench.is-turbofile-light .turbofile-image-preview-side, + .turbofile-workbench.is-turbofile-dark .turbofile-image-preview-side { + bottom: 18px; + width: 76px; + height: 76px; + } +} diff --git a/out/vs/workbench/contrib/turbofile/browser/turbofileEditor.js b/out/vs/workbench/contrib/turbofile/browser/turbofileEditor.js new file mode 100644 index 00000000..33c8643f --- /dev/null +++ b/out/vs/workbench/contrib/turbofile/browser/turbofileEditor.js @@ -0,0 +1,13260 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) { + var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d; + if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc); + else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r; + return c > 3 && r && Object.defineProperty(target, key, r), r; +}; +var __param = (this && this.__param) || function (paramIndex, decorator) { + return function (target, key) { decorator(target, key, paramIndex); } +}; +var TurboFileEditor_1; +import './media/turbofile.css'; +import { DataTransfers } from '../../../../base/browser/dnd.js'; +import { $, clearNode, getWindow } from '../../../../base/browser/dom.js'; +import { StandardMouseEvent } from '../../../../base/browser/mouseEvent.js'; +import { ActionViewItem } from '../../../../base/browser/ui/actionbar/actionViewItems.js'; +import { decodeBase64, encodeBase64, VSBuffer } from '../../../../base/common/buffer.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; +import { Emitter, Event } from '../../../../base/common/event.js'; +import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; +import { URI } from '../../../../base/common/uri.js'; +import { joinPath } from '../../../../base/common/resources.js'; +import { Separator, toAction } from '../../../../base/common/actions.js'; +import { getIconClasses } from '../../../../editor/common/services/getIconClasses.js'; +import { ILanguageService } from '../../../../editor/common/languages/language.js'; +import { IModelService } from '../../../../editor/common/services/model.js'; +import { ITurboFileSshService } from '../../../../platform/turbofile/common/turbofile.js'; +import { withTurboFileTimeout } from '../../../../platform/turbofile/common/turbofileTimeout.js'; +import { resolveTransferPlan } from '../../../../platform/turbofile/common/turbofileTransferPlan.js'; +import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; +import { createFileSystemProviderError, FileKind, FileSystemProviderErrorCode, FileType, IFileService } from '../../../../platform/files/common/files.js'; +import { IStorageService } from '../../../../platform/storage/common/storage.js'; +import { INativeEnvironmentService } from '../../../../platform/environment/common/environment.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { CodeDataTransfers, getPathForFile } from '../../../../platform/dnd/browser/dnd.js'; +import { TerminalLocation } from '../../../../platform/terminal/common/terminal.js'; +import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; +import { ColorScheme } from '../../../../platform/theme/common/theme.js'; +import { IQuickInputService } from '../../../../platform/quickinput/common/quickInput.js'; +import { IClipboardService } from '../../../../platform/clipboard/common/clipboardService.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { IProductService } from '../../../../platform/product/common/productService.js'; +import { IUpdateService } from '../../../../platform/update/common/update.js'; +import { EditorPane } from '../../../browser/parts/editor/editorPane.js'; +import { DEFAULT_EDITOR_ASSOCIATION } from '../../../common/editor.js'; +import { IEditorService } from '../../../services/editor/common/editorService.js'; +import { ITextEditorService } from '../../../services/textfile/common/textEditorService.js'; +import { IWorkbenchLayoutService } from '../../../services/layout/browser/layoutService.js'; +import { ITerminalGroupService, ITerminalService } from '../../terminal/browser/terminal.js'; +import { BasePty } from '../../terminal/common/basePty.js'; +import { ITerminalProfileService } from '../../terminal/common/terminal.js'; +import { ColorThemeData } from '../../../services/themes/common/colorThemeData.js'; +import { COLOR_THEME_DARK_INITIAL_COLORS, COLOR_THEME_LIGHT_INITIAL_COLORS, IWorkbenchThemeService, ThemeSettings, ThemeSettingDefaults } from '../../../services/themes/common/workbenchThemeService.js'; +import { defaultTurboFileTransferProtocol } from '../common/turbofileTypes.js'; +import { ILabelService } from '../../../../platform/label/common/label.js'; +import { TurboFileEditorInput } from '../common/turbofileEditorInput.js'; +import { TurboFileDeviceStore } from './turbofileDeviceStore.js'; +import { turboFileWorkbenchBridge } from './turbofileWorkbenchBridge.js'; +import { TurboFileDirectoryInflightTracker } from '../common/turbofileDirectoryInflight.js'; +import { TurboFileDirectoryPrefetchQueue } from '../common/turbofileDirectoryPrefetchQueue.js'; +import { getTurboFileDirectoryMoveRefreshPaths, getTurboFileRowDragEffectAllowed, shouldHandleTurboFilePaneDropAtPage } from '../common/turbofileDragDrop.js'; +import { getTurboFileImageGridPasteTargetSide } from '../common/turbofileImageGridClipboard.js'; +import { formatTurboFileImageDimensions } from '../common/turbofileImageDimensions.js'; +import { getTurboFileImageGridCardSize, getTurboFileImageGridEffectiveColumns } from '../common/turbofileImageGridSizing.js'; +import { createTurboFileImagePreviewReadOptions } from '../common/turbofileImagePreview.js'; +import { createTurboFileImageGridResourceOptions, createTurboFileImagePreviewResourceOptions } from '../common/turbofileImageResource.js'; +import { sortTurboFileItems } from '../common/turbofileFileSort.js'; +import { getTurboFileTitlebarUpdateActionKind } from '../common/turbofileTitlebar.js'; +import { chooseDisplayTransferSpeed, getVisibleTransferStartedAt, isTurboFileRsyncStrategy, shouldStartTransferTiming, TURBOFILE_RSYNC_PROGRESS_RENDER_INTERVAL_MS, TURBOFILE_RSYNC_STARTUP_TICK_INTERVAL_MS, TURBOFILE_TRANSFER_PROGRESS_RENDER_INTERVAL_MS } from '../common/turbofileTransferDisplay.js'; +import { createTurboFilePostTransferRefreshPlan } from '../common/turbofileTransferRefresh.js'; +import { shouldRetargetWindowsDesktopPane } from '../common/turbofileWindowsLocation.js'; +const turboFileTerminalPageByTerminalId = new Map(); +const turboFileLastActiveTerminalByPageId = new Map(); +const turboFileTerminalPanelHiddenByPageId = new Set(); +let turboFileTerminalPanelVisibilityInProgress = false; +let turboFileTerminalPanelVisibilityChangeDepth = 0; +let turboFileTerminalPanelVisibilityResetHandle; +function isTurboFileTerminalPanelVisibilityInProgress() { + return turboFileTerminalPanelVisibilityInProgress; +} +// Per-page (editor input) left/right device + path + sort selection. Held at module scope so it +// survives an editor PANE instance being disposed and reused for another page - which is exactly +// what merging two editor groups does. Without this, a merged page would render with the receiving +// group's previous page selection (e.g. its server), instead of its own. +const turboFilePaneSelectionByPageId = new Map(); +const LOCAL_WINDOWS_DEVICE_ID = 'local-windows'; +const LOCAL_WINDOWS_FALLBACK_PATH = 'C:\\'; +const SYNC_RECURSIVE_SCAN_LIMIT = 5000; +const TURBOFILE_REMOTE_DOCUMENT_SCHEME = 'turbofile-remote'; +const FILE_LIST_ROW_HEIGHT = 30; +const FILE_LIST_OVERSCAN_ROWS = 24; +const FILE_LIST_DEFAULT_VIEWPORT_HEIGHT = 720; +// Lazy size/date enrichment of remote rows is best-effort over SSH; a single stat round trip can fail +// transiently (connection contention, a momentary hiccup) and leave the visible rows blank. Retry a +// few times with backoff so size/date reliably appear instead of staying empty until the user scrolls. +const REMOTE_ENRICH_RETRY_BASE_MS = 350; +const REMOTE_ENRICH_MAX_RETRIES = 6; +const PANE_DIRECTORY_CACHE_TTL_MS = 120000; +const PANE_DIRECTORY_CACHE_MAX_ENTRIES = 256; +const PANE_DIRECTORY_PREFETCH_LIMIT = 12; +// Directory name listings are cheap (a single `find -printf` round trip), so a few can run in +// parallel even on weak SSH boards. Draining the neighbour-prefetch backlog faster makes the +// directory the user is about to open far more likely to be cached by double-click time. +const PANE_DIRECTORY_PREFETCH_CONCURRENCY = 3; +const PANE_DIRECTORY_VISIBLE_PREFETCH_LIMIT = 8; +const PANE_DEVICE_SNAPSHOT_TTL_MS = 300000; +const PANE_FAST_DOUBLE_CLICK_MS = 300; +const TRANSFER_PROGRESS_RENDER_INTERVAL_MS = TURBOFILE_TRANSFER_PROGRESS_RENDER_INTERVAL_MS; +const RSYNC_PROGRESS_RENDER_INTERVAL_MS = TURBOFILE_RSYNC_PROGRESS_RENDER_INTERVAL_MS; +const TRANSFER_SPEED_SAMPLE_MIN_INTERVAL_MS = 350; +const TRANSFER_SPEED_SAMPLE_MAX_COUNT = 8; +const TURBOFILE_BROWSER_FONT_SIZE_STORAGE_KEY = 'turbofile.browser.fontSize'; +const TURBOFILE_BROWSER_FONT_FAMILY_STORAGE_KEY = 'turbofile.browser.fontFamily'; +const TURBOFILE_UI_FONT_SIZE_STORAGE_KEY = 'turbofile.ui.fontSize'; +const TURBOFILE_UI_FONT_FAMILY_STORAGE_KEY = 'turbofile.ui.fontFamily'; +const TURBOFILE_LOCAL_WINDOWS_DEFAULT_PATH_STORAGE_KEY = 'turbofile.localWindows.defaultPath'; +const TURBOFILE_LOCAL_WINDOWS_TRANSFER_PROTOCOL_STORAGE_KEY = 'turbofile.localWindows.preferredTransferProtocol'; +const TURBOFILE_UI_DEFAULT_FONT_SIZE = 14; +const TURBOFILE_DEFAULT_FONT_FAMILY = '"Archivo", "Microsoft YaHei UI", "Microsoft YaHei", "Segoe UI", sans-serif'; +const TURBOFILE_UI_DEFAULT_FONT_FAMILY = TURBOFILE_DEFAULT_FONT_FAMILY; +const TURBOFILE_BROWSER_DEFAULT_FONT_SIZE = 14; +const TURBOFILE_BROWSER_DEFAULT_FONT_FAMILY = TURBOFILE_DEFAULT_FONT_FAMILY; +const TURBOFILE_UI_FONT_FAMILIES = [ + [TURBOFILE_DEFAULT_FONT_FAMILY, 'Archivo / Microsoft YaHei'], + ['"Microsoft YaHei UI", "Microsoft YaHei", "Segoe UI", sans-serif', 'Microsoft YaHei UI'], + ['"Microsoft YaHei", "Microsoft YaHei UI", "Segoe UI", sans-serif', '微软雅黑'], + ['"Cascadia Code", Consolas, "Microsoft YaHei UI", monospace', 'Cascadia Code'], + ['Consolas, "Microsoft YaHei UI", "Microsoft YaHei", monospace', 'Consolas'], + ['Arial, "Microsoft YaHei UI", "Microsoft YaHei", sans-serif', 'Arial'] +]; +const TURBOFILE_BROWSER_FONT_FAMILIES = TURBOFILE_UI_FONT_FAMILIES; +const PANE_SORT_OPTIONS = [ + { key: 'name', label: '名称' }, + { key: 'modified', label: '修改日期' }, + { key: 'type', label: '类型' }, + { key: 'size', label: '大小' } +]; +const PANE_SORT_DIRECTION_OPTIONS = [ + { direction: 'ascending', label: '递增' }, + { direction: 'descending', label: '递减' } +]; +const IMAGE_GRID_THUMBNAIL_SCALE_OPTIONS = [1, 2, 3, 4, 5, 6]; +const IMAGE_GRID_SORT_OPTIONS = [ + { key: 'name', label: '名称' }, + { key: 'date', label: '修改日期' }, + { key: 'type', label: '类型' }, + { key: 'size', label: '大小' } +]; +const IMAGE_GRID_SORT_DIRECTION_OPTIONS = [ + { descending: false, label: '\u9012\u589e' }, + { descending: true, label: '\u9012\u51cf' } +]; +const IMAGE_GRID_SORT_COLLATOR = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }); +const IMAGE_GRID_COLUMN_MIN = 4; +// Default number of columns when the image grid opens (clamped down only when the body is too narrow +// to fit that many thumbnails) and the hard upper bound when zooming columns in. +const IMAGE_GRID_COLUMN_DEFAULT = 14; +const IMAGE_GRID_COLUMN_MAX = 17; +const IMAGE_GRID_NEIGHBOR_LOAD_COUNT = 2; +const IMAGE_GRID_OVERSCAN_ROWS = 1; +const IMAGE_GRID_CARD_GAP = 10; +const IMAGE_GRID_CARD_VERTICAL_CHROME = 58; +const IMAGE_GRID_CARD_INLINE_CHROME = 16; +const IMAGE_GRID_THUMB_MIN = 96; +const IMAGE_GRID_THUMB_MAX = 640; +const IMAGE_GRID_THUMB_STEP = 64; +const IMAGE_GRID_ORIGINAL_CARD_DIMENSION_THRESHOLD = 100; +const IMAGE_GRID_CTRL_WHEEL_STEP_THRESHOLD = 32; +const IMAGE_PREVIEW_ZOOM_MIN = 0.01; +const IMAGE_PREVIEW_ZOOM_MAX = 40; +const IMAGE_PREVIEW_BUTTON_ZOOM_DELTA = 3.5; +const IMAGE_PREVIEW_WHEEL_DIVISOR = 10; +const IMAGE_PREVIEW_WHEEL_DELTA_LIMIT = 5; +const IMAGE_PREVIEW_ZOOM_RATIO = 0.1; +const IMAGE_GRID_THUMB_QUALITY = 72; +const IMAGE_GRID_LOCAL_PARALLEL_LIMIT = 16; +const IMAGE_GRID_REMOTE_PARALLEL_LIMIT = 8; +const IMAGE_GRID_REMOTE_WINDOWS_PARALLEL_LIMIT = 6; +// The visible page is split into small chunks that are fetched in parallel (each chunk is one SSH +// exec). Running several at once lets the remote device downscale multiple images on different cores +// simultaneously instead of one sequential queue — the main reason large-image pages were slow. +// Concurrency stays under the SSH MaxSessions ceiling so channels are never exhausted. +const IMAGE_GRID_BATCH_CHUNK_COUNT = 8; +const IMAGE_GRID_BATCH_CONCURRENCY = 5; +// If a batched thumbnail comes back larger than this, the remote did not downscale it (no image tool; +// it streamed the raw original), so the client downscales it before showing — keeps tool-less boards +// (e.g. uncompressed .bmp) fast and low-memory. Smaller payloads render via a native directly. +const IMAGE_GRID_CLIENT_DOWNSCALE_BYTES = 320 * 1024; +// Preview reads the true original (cat, no SFTP/tool) up to this size; larger originals are decoded +// to a display-sized JPEG remotely instead, so a huge file never spikes client memory or fails to +// decode in the . +const IMAGE_PREVIEW_RAW_MAX_BYTES = 10 * 1024 * 1024; +const IMAGE_PREVIEW_ELEMENT_LOAD_TIMEOUT_MS = 8000; +const IMAGE_GRID_TRANSIENT_RETRY_DELAY_MS = 650; +const IMAGE_GRID_DECODE_RETRY_LIMIT = 2; +// Wait this long after the last scroll movement before loading the visible page, so a fast drag/jump +// through many pages doesn't fire (and then have to abandon) a load for every page it passes. +const IMAGE_GRID_VISIBLE_LOAD_DEBOUNCE_MS = 110; +const IMAGE_GRID_PREVIEW_NEIGHBOR_COUNT = 2; +const IMAGE_GRID_PREVIEW_PREFETCH_DELAY_MS = 80; +const IMAGE_DPR_CAP = 2; +const IMAGE_PREVIEW_MIN_DIM = 320; +const IMAGE_PREVIEW_MAX_DIM = 2200; +const IMAGE_PREVIEW_QUALITY = 82; +const IMAGE_PREVIEW_DIM_STEP = 128; +const IMAGE_READ_CACHE_MAX_ENTRIES = 512; +const IMAGE_READ_CACHE_MAX_BYTES = 8 * 1024 * 1024; +const TURBOFILE_APPEARANCE_DARK_THEME_LABEL = '2017 Dark (Visual Studio - C/C++)'; +const TURBOFILE_APPEARANCE_LIGHT_THEME_LABEL = 'TurboFile Light'; +const TURBOFILE_LIGHT_THEME_ID = ThemeSettingDefaults.COLOR_THEME_LIGHT; +const TURBOFILE_LIGHT_THEME_CLASS_ID = 'vs turbofile-light-2026'; +const TURBOFILE_LIGHT_THEME_COLORS = { + 'focusBorder': '#042AFF', + 'foreground': '#111111', + 'descriptionForeground': '#6B7280', + 'disabledForeground': '#9E9E9E', + 'errorForeground': '#ef4444', + 'selection.background': '#DDE6FF', + 'icon.foreground': '#6B7280', + 'activityBar.background': '#ffffff', + 'activityBar.foreground': '#111111', + 'activityBar.inactiveForeground': '#6B7280', + 'activityBar.activeBorder': '#042AFF', + 'activityBar.activeBackground': '#F3F3F3', + 'activityBar.border': '#E5E7EB', + 'activityBarBadge.background': '#042AFF', + 'activityBarBadge.foreground': '#ffffff', + 'sideBar.background': '#ffffff', + 'sideBar.foreground': '#111111', + 'sideBar.border': '#E5E7EB', + 'sideBarTitle.foreground': '#111111', + 'sideBarSectionHeader.background': '#F3F3F3', + 'sideBarSectionHeader.foreground': '#111111', + 'sideBarSectionHeader.border': '#E5E7EB', + 'editor.background': '#ffffff', + 'editor.foreground': '#111111', + 'editorLineNumber.foreground': '#9E9E9E', + 'editorLineNumber.activeForeground': '#042AFF', + 'editorCursor.foreground': '#111111', + 'editor.selectionBackground': '#DDE6FF', + 'editor.inactiveSelectionBackground': '#EDF2FF', + 'editorIndentGuide.background1': '#E5E7EB', + 'editorIndentGuide.activeBackground1': '#9E9E9E', + 'editor.findMatchBackground': '#E1FF25', + 'editor.findMatchHighlightBackground': '#E1FF2580', + 'editorGroupHeader.tabsBackground': '#F3F3F3', + 'editorGroupHeader.tabsBorder': '#E5E7EB', + 'editorGroup.border': '#E5E7EB', + 'tab.activeBackground': '#ffffff', + 'tab.activeForeground': '#111111', + 'tab.activeBorder': '#042AFF', + 'tab.inactiveBackground': '#F3F3F3', + 'tab.inactiveForeground': '#6B7280', + 'tab.border': '#E5E7EB', + 'panel.background': '#ffffff', + 'panel.border': '#E5E7EB', + 'panelTitle.activeForeground': '#111111', + 'panelTitle.inactiveForeground': '#6B7280', + 'statusBar.background': '#111F68', + 'statusBar.foreground': '#ffffff', + 'statusBar.border': '#0B164B', + 'statusBar.noFolderBackground': '#111F68', + 'titleBar.activeBackground': '#ffffff', + 'titleBar.activeForeground': '#111111', + 'titleBar.inactiveBackground': '#ffffff', + 'titleBar.inactiveForeground': '#6B7280', + 'titleBar.border': '#E5E7EB', + 'list.activeSelectionBackground': '#EDF2FF', + 'list.activeSelectionForeground': '#111111', + 'list.inactiveSelectionBackground': '#F3F3F3', + 'list.inactiveSelectionForeground': '#111111', + 'list.hoverBackground': '#F3F3F3', + 'list.focusBackground': '#EDF2FF', + 'list.focusOutline': '#042AFF', + 'list.highlightForeground': '#042AFF', + 'input.background': '#ffffff', + 'input.foreground': '#111111', + 'input.border': '#E5E7EB', + 'input.placeholderForeground': '#9E9E9E', + 'dropdown.background': '#ffffff', + 'dropdown.foreground': '#111111', + 'dropdown.border': '#E5E7EB', + 'inputOption.activeBackground': '#EDF2FF', + 'inputOption.activeBorder': '#042AFF', + 'inputOption.activeForeground': '#042AFF', + 'button.background': '#042AFF', + 'button.foreground': '#ffffff', + 'button.hoverBackground': '#111F68', + 'button.secondaryBackground': '#ffffff', + 'button.secondaryForeground': '#111111', + 'button.secondaryHoverBackground': '#F3F3F3', + 'badge.background': '#EDF2FF', + 'badge.foreground': '#042AFF', + 'progressBar.background': '#22C55E', + 'breadcrumb.background': '#ffffff', + 'breadcrumb.foreground': '#6B7280', + 'breadcrumb.focusForeground': '#042AFF', + 'breadcrumb.activeSelectionForeground': '#042AFF', + 'scrollbar.shadow': '#00000000', + 'scrollbarSlider.background': '#D1D5DB80', + 'scrollbarSlider.hoverBackground': '#9E9E9E80', + 'scrollbarSlider.activeBackground': '#6B728080', + 'menu.background': '#ffffff', + 'menu.foreground': '#111111', + 'menu.selectionBackground': '#EDF2FF', + 'menu.selectionForeground': '#111111', + 'menu.border': '#E5E7EB', + 'quickInput.background': '#ffffff', + 'quickInput.foreground': '#111111', + 'quickInputList.focusBackground': '#EDF2FF', + 'editorWidget.background': '#ffffff', + 'editorWidget.foreground': '#111111', + 'editorWidget.border': '#E5E7EB', + 'widget.shadow': '#111F6814', + 'textLink.foreground': '#042AFF', + 'textLink.activeForeground': '#111111', + 'toolbar.hoverBackground': '#F3F3F3', + 'toolbar.activeBackground': '#EDF2FF', + 'notifications.background': '#ffffff', + 'notifications.foreground': '#111111', + 'notifications.border': '#E5E7EB', + 'terminal.background': '#ffffff', + 'terminal.foreground': '#111111', + 'gitDecoration.addedResourceForeground': '#22C55E', + 'gitDecoration.modifiedResourceForeground': '#042AFF', + 'gitDecoration.deletedResourceForeground': '#FF64DA', + 'editorGutter.addedBackground': '#22C55E', + 'editorGutter.modifiedBackground': '#042AFF', + 'editorGutter.deletedBackground': '#FF64DA', + 'testing.iconPassed': '#22C55E', + 'testing.iconFailed': '#ef4444', + 'testing.iconQueued': '#042AFF', + 'charts.blue': '#042AFF', + 'charts.green': '#22C55E', + 'charts.yellow': '#E1FF25', + 'charts.purple': '#FF64DA' +}; +let TurboFileEditor = class TurboFileEditor extends EditorPane { + static { TurboFileEditor_1 = this; } + static { this.ID = TurboFileEditorInput.EDITOR_ID; } + constructor(group, telemetryService, workbenchThemeService, storageService, environmentService, sshService, contextMenuService, fileService, modelService, languageService, instantiationService, editorService, textEditorService, terminalService, terminalGroupService, terminalProfileService, quickInputService, clipboardService, configurationService, layoutService, updateService, productService, labelService) { + super(new.target.ID, group, telemetryService, workbenchThemeService, storageService); + this.workbenchThemeService = workbenchThemeService; + this.storageService = storageService; + this.environmentService = environmentService; + this.sshService = sshService; + this.contextMenuService = contextMenuService; + this.fileService = fileService; + this.modelService = modelService; + this.languageService = languageService; + this.instantiationService = instantiationService; + this.editorService = editorService; + this.textEditorService = textEditorService; + this.terminalService = terminalService; + this.terminalGroupService = terminalGroupService; + this.terminalProfileService = terminalProfileService; + this.quickInputService = quickInputService; + this.clipboardService = clipboardService; + this.configurationService = configurationService; + this.layoutService = layoutService; + this.updateService = updateService; + this.productService = productService; + this.labelService = labelService; + this.renderDisposables = this._register(new DisposableStore()); + this.devices = []; + this.paneImageGridStates = { left: undefined, right: undefined }; + this.imageGridSortKey = 'name'; + this.imageGridSortDescending = true; + this.defaultImagePreviewSamplingMode = 'smooth'; + // Thumbnails load only after scrolling settles, and only for the page in view; bumping the token + // invalidates any batch still loading a page the user has scrolled away from (fast jumps). + this.imageGridVisibleLoadTimers = new Map(); + this.imageGridThumbnailQueue = []; + this.imageGridThumbnailQueued = new Set(); + this.imageGridThumbnailLoadingCount = 0; + // Memoized scroll-body width. Measuring body.clientWidth forces a synchronous layout; reading it + // once per card during a slice rebuild caused dozens of forced reflows per scroll frame, which + // both janked scrolling and made the grid's scrollHeight momentarily collapse (the browser then + // clamped scrollTop, producing the post-scroll "jump/jitter"). The body width only changes on + // window/pane resize, so cache it and invalidate on layout() and full image-page renders. + this.imageGridPreviewRequestSeq = 0; + this.imageGridCtrlWheelDeltaRemainder = 0; + this.imagePreviewRenderSeq = 0; + this.imageReadCache = new Map(); + this.fileListScrollRaf = { left: 0, right: 0 }; + this.fileListRestoreRaf = { left: 0, right: 0 }; + this.fileListRenderedRange = { left: undefined, right: undefined }; + // Lazy size/date enrichment of names-only listings: serialize per side and remember if more + // rows scrolled into view while a batch was in flight so they get picked up right after. + this.attrsEnrichInFlight = { left: false, right: false }; + this.attrsEnrichAgain = { left: false, right: false }; + this.attrsEnrichRetryHandle = { left: undefined, right: undefined }; + this.attrsEnrichRetryState = { left: undefined, right: undefined }; + this.paneDirectoryCache = new Map(); + this.paneDirectoryInflight = new TurboFileDirectoryInflightTracker(); + this.paneDirectoryPrefetchQueue = new TurboFileDirectoryPrefetchQueue(PANE_DIRECTORY_PREFETCH_CONCURRENCY); + this.paneDeviceSnapshots = new Map(); + this.activePage = 'transfer'; + this.activeFilePaneSide = 'left'; + this.isTaskLogVisible = false; + this.paneRequestSeq = { left: 0, right: 0 }; + this.paneDoubleClickState = { + left: { lastAt: 0 }, + right: { lastAt: 0 } + }; + this.isDeviceFormVisible = false; + this.hasAppliedInitialRightPaneDefault = false; + this.deviceFormMessage = ''; + this.pendingRenderAfterDeviceSelectClose = false; + this.deviceConnectionMessages = new Map(); + this.deviceRuntimeStatuses = new Map(); + this.availableColorThemes = []; + this.isLoadingColorThemes = false; + this.hasLoadedColorThemes = false; + this.taskbarHiddenForTerminal = false; + this.terminalRevealInProgress = false; + this.hasStartedNativeWindowsDrag = false; + this.windowsClipboardPasteInFlight = new Set(); + this.availableWindowsDriveRoots = []; + this.statusMessage = '极速传已启动:双栏浏览、设备管理、传输、预览和终端入口已接入。'; + this.taskSequence = 1; + this.tasks = []; + this.isEditorVisible = false; + this.remoteDocumentLabelFormatterRegistered = false; + this.localWindowsDevice = this.createLocalWindowsDevice(); + const localWindowsDefaultPath = this.getLocalWindowsDeviceDefaultPath(); + this.paneStates = { + left: { + deviceId: LOCAL_WINDOWS_DEVICE_ID, + path: localWindowsDefaultPath, + items: [], + loading: false, + selectedPaths: new Set(), + scrollTop: 0, + viewportHeight: 0, + sortKey: 'name', + sortDirection: 'descending' + }, + right: { + deviceId: LOCAL_WINDOWS_DEVICE_ID, + path: localWindowsDefaultPath, + items: [], + loading: false, + selectedPaths: new Set(), + scrollTop: 0, + viewportHeight: 0, + sortKey: 'name', + sortDirection: 'descending' + } + }; + } + createEditor(parent) { + this.editorGroupContainer = parent.closest('.editor-group-container'); + this.editorGroupContainer?.classList.add('turbofile-main-editor-group'); + this.container = $('.turbofile-workbench'); + parent.appendChild(this.container); + this.deviceStore = this.instantiationService.createInstance(TurboFileDeviceStore); + this.devices = this.deviceStore.loadDevices(); + void this.ensureDefaultSshDevices().catch(error => this.handleDefaultSshDevicesError(error)); + void this.ensureTurboFileChrome(); + this.ensureRemoteFileSystemProvider(); + this._register(turboFileWorkbenchBridge.onDidRunCommand(command => this.handleWorkbenchCommand(command))); + this._register(this.terminalService.onDidDisposeInstance(instance => this.clearTerminalBindingForInstance(instance.instanceId))); + this._register(this.layoutService.onDidChangePartVisibility(event => { + if (event.partId === "workbench.parts.panel" /* Parts.PANEL_PART */ && !event.visible && !this.terminalRevealInProgress && !isTurboFileTerminalPanelVisibilityInProgress()) { + this.rememberTerminalPanelHiddenForCurrentPage(); + this.restoreTaskbarAfterTerminal(); + } + })); + this._register(this.workbenchThemeService.onDidColorThemeChange(() => { + this.applyTurboFileThemeState(); + this.ensureTitlebarChrome(); + if (this.activePage === 'themes') { + this.render(); + } + })); + this._register(this.updateService.onStateChange(() => this.ensureTitlebarAppMenu())); + void this.syncNativeEditorDisplaySettings(); + void this.loadColorThemes(); + void this.refreshWindowsDriveRoots(); + void this.refreshWindowsDesktopPath(); + this.ensureTitlebarChrome(); + this.ensurePaneDeviceSelection({ preferFirstSshOnRight: true }); + this.render(); + void this.refreshPane('left'); + void this.refreshPane('right'); + void this.prefetchDefaultDeviceDirectories(); + } + async ensureTurboFileChrome() { + // Keep native editor chrome controlled by layout state instead of permanently mutating user settings. + } + async ensureDefaultSshDevices() { + if (!this.deviceStore) { + return; + } + const result = await this.deviceStore.ensureDefaultDevices(); + if (!result.added && !result.updated && !result.removed) { + return; + } + this.devices = this.deviceStore.loadDevices(); + this.ensurePaneDeviceSelection({ preferFirstSshOnRight: result.added > 0 }); + this.statusMessage = result.removed + ? `已清理内置 SSH 设备 ${result.removed} 台。` + : `已加载默认 SSH 设备:新增 ${result.added} 台,更新 ${result.updated} 台。`; + this.render(); + void this.refreshPane('left'); + void this.refreshPane('right'); + void this.prefetchDefaultDeviceDirectories(); + } + handleDefaultSshDevicesError(error) { + const message = error instanceof Error ? error.message : String(error); + this.statusMessage = `Default SSH devices failed to load: ${message}`; + this.render(); + } + async refreshDevicesFromStore() { + try { + await this.ensureDefaultSshDevices(); + } + catch (error) { + this.handleDefaultSshDevicesError(error); + return; + } + this.devices = this.deviceStore?.loadDevices() ?? []; + this.ensurePaneDeviceSelection(); + this.statusMessage = `Device list refreshed: ${this.devices.length} SSH devices.`; + this.render(); + } + async setInput(input, options, context, token) { + const previousInput = this.currentInput; + const isPageSwitch = !!previousInput && previousInput.instanceId !== input.instanceId; + if (isPageSwitch) { + // Save the page we are leaving before its selection is overwritten, then give the incoming + // page its own selection back. This is what makes a page keep its server/path/sort after a + // group merge or tab switch instead of showing the previous page's. + this.capturePaneSelectionForCurrentPage(); + } + this.currentInput = input; + if (isPageSwitch) { + if (!this.restorePaneSelectionForPage(input.instanceId)) { + this.resetPaneSelectionToDefault(); + } + this.refreshBothPanesAfterPageRestore(); + } + // Record the incoming page's current selection so even an untouched page can be restored later. + this.capturePaneSelectionForCurrentPage(); + input.setCancelRunningTasksForClose(() => this.cancelRunningTasksForClose()); + input.setCleanupForClose(() => this.disposeBoundTerminalsForPage(input.instanceId)); + this.updateInputRunningTaskCount(); + await super.setInput(input, options, context, token); + if (!token.isCancellationRequested) { + void this.syncActiveTerminalForCurrentPage(); + this.notifyActivePageChanged(); + this.render(); + } + } + refreshBothPanesAfterPageRestore() { + for (const side of ['left', 'right']) { + const state = this.paneStates[side]; + ++this.paneRequestSeq[side]; + state.items = []; + state.error = undefined; + this.resetPaneScroll(side); + this.clearPaneSelection(side); + void this.refreshPane(side, { force: false, silent: true }); + } + } + layout(_dimension, _position) { + // CSS grid owns the fast-transfer two-pane layout. + // The image-grid body width is derived from the viewport, so drop its cache on resize. + this.invalidateAllImageGridLayoutCaches(); + } + setVisible(visible) { + super.setVisible(visible); + this.isEditorVisible = visible; + document.body.classList.toggle('turbofile-main-editor-visible', visible); + this.updateTransferTabsVisibility(); + if (visible) { + void this.syncActiveTerminalForCurrentPage(); + this.applyTurboFileThemeState(); + this.ensureTitlebarChrome(); + } + } + focus() { + this.container?.focus(); + } + dispose() { + document.body.classList.remove('turbofile-main-editor-visible'); + document.body.classList.remove('turbofile-transfer-tabs-visible'); + document.body.classList.remove('turbofile-editor-theme-light', 'turbofile-editor-theme-dark', 'turbofile-theme-switching'); + if (this.appearanceTransitionTimer !== undefined) { + getWindow(this.container).clearTimeout(this.appearanceTransitionTimer); + this.appearanceTransitionTimer = undefined; + } + this.editorGroupContainer?.classList.remove('turbofile-main-editor-group'); + this.closeAboutDialog(); + this.removeTitlebarAppMenu(); + this.removeTitlebarThemeToggle(); + this.clearLiveTransferTicker(); + this.clearFileListScrollRaf('left'); + this.clearFileListScrollRaf('right'); + this.clearFileListRestoreRaf('left'); + this.clearFileListRestoreRaf('right'); + for (const side of ['left', 'right']) { + if (this.attrsEnrichRetryHandle[side] !== undefined) { + getWindow(this.container).clearTimeout(this.attrsEnrichRetryHandle[side]); + this.attrsEnrichRetryHandle[side] = undefined; + } + } + this.releaseAllImageGridStates(); + this.disposeImageGridObserver(); + this.clearImageGridScrollTimer(); + this.clearImageGridScrollRaf(); + this.clearImageGridThumbnailQueue(); + this.clearImageGridPreviewPrefetchTimer(); + this.imagePreviewAnalysis = undefined; + this.clearImagePreviewRender(); + super.dispose(); + } + render() { + if (!this.container) { + return; + } + if (this.shouldDeferRenderForOpenDeviceSelect()) { + this.pendingRenderAfterDeviceSelectClose = true; + return; + } + this.pendingRenderAfterDeviceSelectClose = false; + this.renderDisposables.clear(); + clearNode(this.container); + this.ensurePaneDeviceSelection(); + this.applyBrowserAppearanceSettings(); + this.applyTurboFileThemeState(); + void this.renderLegacyImageGridOverlay; + const shell = $('.turbofile-editor-shell'); + const content = $('.turbofile-editor-content'); + content.appendChild(this.renderActivePage()); + shell.appendChild(content); + this.container.appendChild(shell); + this.registerWorkbenchKeyboardShortcuts(); + this.registerWindowFileDropInterceptor(); + this.ensureTitlebarChrome(); + const previewState = this.getActiveImageGridPreviewState(); + if (previewState) { + this.setActiveImageGridState(previewState); + this.container.appendChild(this.renderImagePreviewOverlay(previewState)); + } + if (this.syncPreviewState) { + this.container.appendChild(this.renderSyncPreviewOverlay()); + } + if (this.isTaskLogVisible) { + this.container.appendChild(this.renderTaskLogOverlay()); + } + if (this.isDeviceFormVisible) { + this.container.appendChild(this.renderDeviceForm()); + } + if (this.dialogState) { + this.container.appendChild(this.renderDialogOverlay()); + } + this.bindImageGridObserver(); + } + renderActivePage() { + if (this.activePage === 'devices') { + return this.renderDevicesPage(); + } + if (this.activePage === 'settings') { + return this.renderSettingsPage(); + } + if (this.activePage === 'themes') { + return this.renderThemesPage(); + } + if (this.activePage === 'tasks') { + return this.renderTasksPage(); + } + if (this.activePage === 'images') { + return this.renderImageGridPage(); + } + return this.renderTransferPage(); + } + registerWorkbenchKeyboardShortcuts() { + if (!this.container) { + return; + } + const handleKeydown = (event) => this.handleWorkbenchKeydown(event); + this.container.addEventListener('keydown', handleKeydown); + const ownerWindow = getWindow(this.container); + ownerWindow.addEventListener('keydown', handleKeydown, true); + this.renderDisposables.add({ dispose: () => this.container?.removeEventListener('keydown', handleKeydown) }); + this.renderDisposables.add({ dispose: () => ownerWindow.removeEventListener('keydown', handleKeydown, true) }); + } + /** + * Intercept EXTERNAL (OS) file drags at the window in the capture phase — i.e. before the VS Code + * workbench's own editor drop target, which would otherwise open the dropped file as an editor + * instead of transferring it. We only take over external file drops; internal pane-to-pane drags + * keep flowing to the existing pane/list handlers. This guarantees dropping (or pasting) a local + * file/folder onto the app always transfers and never opens the file. + */ + registerWindowFileDropInterceptor() { + if (!this.container) { + return; + } + const ownerWindow = getWindow(this.container); + const onDragOver = (event) => this.handleWindowFileDragOver(event); + const onDrop = (event) => this.handleWindowFileDrop(event); + ownerWindow.addEventListener('dragover', onDragOver, true); + ownerWindow.addEventListener('drop', onDrop, true); + this.renderDisposables.add({ dispose: () => ownerWindow.removeEventListener('dragover', onDragOver, true) }); + this.renderDisposables.add({ dispose: () => ownerWindow.removeEventListener('drop', onDrop, true) }); + } + shouldInterceptExternalFileDrop(event) { + return this.isEditorVisible + && (this.activePage === 'transfer' || this.activePage === 'images') + && !this.dialogState && !this.isDeviceFormVisible && !this.isTaskLogVisible + && !this.getTurboFileDragSourceSide(event) + && this.hasExternalDropData(event); + } + handleWindowFileDragOver(event) { + if (!this.shouldInterceptExternalFileDrop(event)) { + return; + } + event.preventDefault(); + event.stopPropagation(); + if (event.dataTransfer) { + event.dataTransfer.dropEffect = 'copy'; + } + if (this.activePage === 'transfer') { + this.setExternalDropTargetSide(this.getExternalDropSideFromEvent(event)); + } + } + handleWindowFileDrop(event) { + if (!this.shouldInterceptExternalFileDrop(event)) { + return; + } + event.preventDefault(); + event.stopPropagation(); + const initiatedAt = Date.now(); + const side = this.activePage === 'images' + ? (this.imageGridState?.side ?? this.activeFilePaneSide) + : this.getExternalDropSideFromEvent(event); + this.setExternalDropTargetSide(undefined); + const externalPaths = this.extractExternalDropPaths(event); + this.clearCurrentFileDragState(); + if (externalPaths.length) { + this.deferAfterDrag(() => void this.transferExternalPaths(side, externalPaths, 'copy', initiatedAt)); + } + } + /** + * Resolve which pane an external drop lands on. Prefer the pane under the drag target; fall back to + * the pointer X position relative to the pane rectangles so a drop on a gap/header still routes to + * the pane the user is clearly over (rather than defaulting to a stale "active" side). + */ + getExternalDropSideFromEvent(event) { + const bySide = this.getPaneSideFromElement(event.target instanceof HTMLElement ? event.target : undefined); + if (bySide) { + this.activeFilePaneSide = bySide; + return bySide; + } + const x = event.clientX; + const rightPane = this.container?.querySelector('.turbofile-pane[data-turbofile-side="right"]'); + if (rightPane) { + const rect = rightPane.getBoundingClientRect(); + if (rect.width > 0 && x >= rect.left) { + this.activeFilePaneSide = 'right'; + return 'right'; + } + } + const leftPane = this.container?.querySelector('.turbofile-pane[data-turbofile-side="left"]'); + if (leftPane) { + const rect = leftPane.getBoundingClientRect(); + if (rect.width > 0 && x <= rect.right) { + this.activeFilePaneSide = 'left'; + return 'left'; + } + } + return this.activeFilePaneSide; + } + handleWorkbenchKeydown(event) { + if (event.defaultPrevented) { + return; + } + if (this.handleImageGridKeyboardShortcut(event)) { + return; + } + if (this.handlePaneKeyboardShortcut(event)) { + return; + } + if ((event.key !== 'Delete' && event.code !== 'Delete') || event.ctrlKey || event.metaKey || event.altKey) { + return; + } + const side = this.getDeleteKeyboardTargetSide(event); + if (!side) { + return; + } + event.preventDefault(); + void this.deleteSelected(side); + } + handlePaneKeyboardShortcut(event) { + if (this.activePage !== 'transfer' || this.dialogState || this.getActiveImageGridPreviewState()?.previewVisible || this.isDeviceFormVisible || this.isTaskLogVisible) { + return false; + } + if (!(event.ctrlKey || event.metaKey) || event.altKey) { + return false; + } + const key = event.key.toLowerCase(); + if (key !== 'a' && key !== 'c' && key !== 'x' && key !== 'v') { + return false; + } + const target = event.target; + if (!this.isKeyboardEventFromTurboFileSurface(target) || this.isEditableKeyboardTarget(target)) { + return false; + } + const targetElement = target instanceof HTMLElement ? target : undefined; + if (targetElement?.closest('.turbofile-inline-rename, .turbofile-dialog, .turbofile-device-form, .turbofile-device-select.is-open, .turbofile-image-page')) { + return false; + } + const side = this.getPaneSideFromElement(targetElement) ?? this.activeFilePaneSide; + if (!side) { + return false; + } + event.preventDefault(); + event.stopPropagation(); + this.setActiveFilePaneSide(side); + if (key === 'a') { + this.selectAllPaneItems(side); + return true; + } + if (key === 'c' || key === 'x') { + this.copySelectedToInternalClipboard(side, key === 'x' ? 'move' : 'copy', false); + return true; + } + void this.pasteIntoPane(side); + return true; + } + handleImageGridKeyboardShortcut(event) { + const state = this.getActiveImageGridPreviewState() ?? this.imageGridState; + if (!state || !this.isImageGridSurfaceActive(state) || this.dialogState || this.isDeviceFormVisible || this.isTaskLogVisible) { + return false; + } + // While the single-image preview is open, Left/Right always step between images. Handle it here + // in the capture-phase workbench handler — not only on the overlay's own keydown — so it works + // no matter which element holds focus (the overlay easily loses focus to a button, the canvas, or + // the file list behind it, which is why the arrows previously appeared to do nothing). + if (state.previewVisible && !this.isEditableKeyboardTarget(event.target) && (event.key === 'ArrowLeft' || event.key === 'ArrowRight')) { + event.preventDefault(); + event.stopPropagation(); + this.moveImageGridPreview(event.key === 'ArrowLeft' ? -1 : 1, state); + return true; + } + const target = event.target; + if (!this.isKeyboardEventFromTurboFileSurface(target) || this.isEditableKeyboardTarget(target)) { + return false; + } + const targetElement = target instanceof HTMLElement ? target : undefined; + if (state.displayMode === 'pane' && !state.previewVisible && !targetElement?.closest('.turbofile-pane-image-grid')) { + return false; + } + if (targetElement?.closest('.turbofile-dialog, .turbofile-device-form, .turbofile-image-page-sort.is-open, .turbofile-image-page-scale.is-open')) { + return false; + } + if ((event.key === 'Delete' || event.code === 'Delete') && !event.ctrlKey && !event.metaKey && !event.altKey) { + event.preventDefault(); + void this.deleteSelectedImageGridItems(state); + return true; + } + if (!state.previewVisible && (event.ctrlKey || event.metaKey) && !event.altKey) { + const key = event.key.toLowerCase(); + if (key === 'a') { + event.preventDefault(); + this.selectAllImageGridItems(state); + return true; + } + if (key === 'c' || key === 'x') { + event.preventDefault(); + this.copyImageGridSelectionToInternalClipboard(state, key === 'x' ? 'move' : 'copy'); + return true; + } + if (key === 'v') { + event.preventDefault(); + void this.pasteIntoPane(getTurboFileImageGridPasteTargetSide(state.side, this.internalTransferClipboard?.sourceSide)); + return true; + } + } + return false; + } + isImageGridSurfaceActive(state) { + if (state.displayMode === 'page') { + return this.activePage === 'images' && this.imageGridState === state; + } + return this.activePage === 'transfer' && this.paneImageGridStates[state.side] === state; + } + setActiveImageGridState(state) { + this.imageGridState = state; + this.setActiveFilePaneSide(state.side); + } + getActiveImageGridPreviewState() { + const activeState = this.imageGridState; + if (activeState?.previewVisible && this.isImageGridSurfaceActive(activeState)) { + return activeState; + } + for (const state of Object.values(this.paneImageGridStates)) { + if (state?.previewVisible && this.isImageGridSurfaceActive(state)) { + return state; + } + } + return undefined; + } + getVisibleImageGridStates() { + const states = []; + if (this.activePage === 'images' && this.imageGridState?.displayMode === 'page') { + states.push(this.imageGridState); + } + if (this.activePage === 'transfer') { + for (const state of Object.values(this.paneImageGridStates)) { + if (state) { + states.push(state); + } + } + } + return states; + } + releaseImageGridState(state) { + if (!state) { + return; + } + this.releaseImageGridStateImages(state); + this.clearImageGridVisibleLoadTimer(state); + this.clearImageGridStateScrollRaf(state); + this.clearImageGridLoadedImageIndexes(state); + this.clearImageGridLayoutMetrics(state); + state.renderedRange = undefined; + state.bodyWidthCache = undefined; + if (this.imageGridState === state) { + this.imageGridState = undefined; + } + if (state.displayMode === 'pane' && this.paneImageGridStates[state.side] === state) { + this.paneImageGridStates[state.side] = undefined; + } + } + releaseAllImageGridStates() { + const seen = new Set(); + for (const state of [this.imageGridState, ...Object.values(this.paneImageGridStates)]) { + if (state && !seen.has(state)) { + seen.add(state); + this.releaseImageGridState(state); + } + } + this.imageGridState = undefined; + this.paneImageGridStates.left = undefined; + this.paneImageGridStates.right = undefined; + } + getDeleteKeyboardTargetSide(event) { + if (this.activePage !== 'transfer' || this.dialogState || this.getActiveImageGridPreviewState()?.previewVisible || this.isDeviceFormVisible || this.isTaskLogVisible) { + return undefined; + } + const target = event.target; + if (!this.isKeyboardEventFromTurboFileSurface(target)) { + return undefined; + } + if (this.isEditableKeyboardTarget(target)) { + return undefined; + } + const targetElement = target instanceof HTMLElement ? target : undefined; + if (targetElement?.closest('.turbofile-inline-rename, .turbofile-dialog, .turbofile-device-form, .turbofile-device-select.is-open')) { + return undefined; + } + const targetSide = this.getPaneSideFromElement(targetElement); + if (targetSide) { + this.activeFilePaneSide = targetSide; + return targetSide; + } + const leftSelected = this.getSelectedTransferItems('left').length > 0; + const rightSelected = this.getSelectedTransferItems('right').length > 0; + if (leftSelected && rightSelected) { + return this.activeFilePaneSide; + } + if (leftSelected) { + return 'left'; + } + if (rightSelected) { + return 'right'; + } + return undefined; + } + isKeyboardEventFromTurboFileSurface(target) { + if (!this.container) { + return false; + } + if (target instanceof Node && this.container.contains(target)) { + return true; + } + const ownerDocument = this.container.ownerDocument; + return target === ownerDocument.body || target === ownerDocument.documentElement; + } + getPaneSideFromElement(element) { + const side = element?.closest('.turbofile-pane[data-turbofile-side]')?.dataset.turbofileSide; + return side === 'left' || side === 'right' ? side : undefined; + } + setActiveFilePaneSide(side) { + this.activeFilePaneSide = side; + } + /** + * Resets the drag-state flags after an internal pane drag finishes (drop or dragend) or after + * we've handed control over to the Windows native drag pipeline. Safe to call multiple times. + */ + clearCurrentFileDragState() { + this.currentFileDragSide = undefined; + this.hasStartedNativeWindowsDrag = false; + this.setExternalDropTargetSide(undefined); + this.clearInternalDropTargetHighlight(); + } + /** + * Defers work triggered by a drop until after the current drop event returns. Re-rendering the + * file list synchronously inside the drop handler removes the dragged source row before the + * browser has finished tearing down the drag, which can leave the drag image (the lingering + * "shadow") floating on screen. Running on the next timer tick lets that teardown complete first. + */ + deferAfterDrag(action) { + const ownerWindow = getWindow(this.container); + ownerWindow.setTimeout(() => { + ownerWindow.requestAnimationFrame(() => { + ownerWindow.requestAnimationFrame(action); + }); + }, 0); + } + clearInternalDropTargetHighlight() { + for (const row of this.container?.querySelectorAll('.turbofile-file-row.is-internal-drop-target') ?? []) { + row.classList.remove('is-internal-drop-target'); + } + } + /** + * dragstart handler for a file-list row. Marks the drag as TurboFile-originated (so internal + * drop handlers can detect it via the side data) and remembers the source side so a later + * dragleave from the window can hand off to the Windows native drag pipeline. Same-pane folder + * row drops move items, while pane-to-pane and external drops keep the existing copy behavior. + * + * Selection rule: if the dragged row is part of the current multi-selection, drag the whole + * selection; otherwise drag just that single row (without disturbing the previous selection + * in a way that would surprise the user — we keep the row's behavior consistent with single + * left-click). + */ + beginFileRowDrag(side, item, event) { + if (!event.dataTransfer || !item.path || item.parent) { + return; + } + const state = this.paneStates[side]; + if (!state.selectedPaths.has(item.path)) { + state.selectedPaths.clear(); + state.selectedPaths.add(item.path); + this.updateRenderedSelection(side); + } + this.setActiveFilePaneSide(side); + this.currentFileDragSide = side; + this.hasStartedNativeWindowsDrag = false; + event.dataTransfer.effectAllowed = getTurboFileRowDragEffectAllowed(); + event.dataTransfer.setData('application/x-turbofile-side', side); + event.dataTransfer.setData('application/x-turbofile-operation', 'copy'); + const items = this.getSelectedTransferItems(side); + const labels = items.length ? items.map(i => i.name) : [item.name]; + event.dataTransfer.setData('text/plain', labels.join('\n')); + // Keep the browser drag alive for pane-to-pane drops. If the pointer leaves the window, + // dragleave/drag will hand local Windows files to the native drag pipeline. + this.tryStartImmediateLocalWindowsDrag(side, event); + } + tryStartImmediateLocalWindowsDrag(side, event) { + if (!event.altKey) { + return; + } + const device = this.getPaneDevice(side); + if (device?.id !== LOCAL_WINDOWS_DEVICE_ID) { + return; + } + const dragItems = this.getSelectedTransferItems(side) + .filter((candidate) => !!candidate.path && !candidate.parent) + .map(candidate => ({ + sourcePath: candidate.path, + name: candidate.name, + type: candidate.type, + size: candidate.directory ? undefined : candidate.rawSize + })); + if (!dragItems.length) { + return; + } + event.preventDefault(); + this.hasStartedNativeWindowsDrag = true; + void this.sshService.startWindowsFileDrag({ + source: { + kind: 'local', + path: this.paneStates[side].path, + label: device.name + }, + items: dragItems + }).then(result => { + if (!result.ok) { + this.statusMessage = `拖出到 Windows 失败:${result.message}`; + this.render(); + } + }).catch(error => { + const message = error instanceof Error ? error.message : String(error); + this.statusMessage = `拖出到 Windows 失败:${message}`; + this.render(); + }); + } + /** + * Called when the user drags a TurboFile file-row out of the window. Local files are handed to + * Windows directly; remote files first capture Explorer/Desktop's real drop directory through + * the native CopyHook helper, then use the normal transfer pipeline to download there. + */ + async startWindowsFileDragFromPane(side) { + const device = this.getPaneDevice(side); + if (!device) { + return; + } + if (device.id !== LOCAL_WINDOWS_DEVICE_ID) { + try { + const sourceItems = this.getSelectedTransferItems(side) + .filter((candidate) => !!candidate.path && !candidate.parent); + const dragItems = sourceItems + .map(candidate => ({ + sourcePath: candidate.path, + name: candidate.name, + type: candidate.type, + size: candidate.directory ? undefined : candidate.rawSize + })); + if (!dragItems.length) { + return; + } + this.statusMessage = '拖到 Windows 后将开始下载...'; + this.render(); + const result = await this.sshService.startWindowsFileDrag({ + source: await this.toTransferEndpoint(device, this.paneStates[side].path), + items: dragItems + }); + if (!result.ok) { + this.statusMessage = result.canceled ? '已取消拖到 Windows。' : `拖到 Windows 失败:${result.message}`; + this.render(); + return; + } + if (!result.targetDirectory) { + this.statusMessage = '拖到 Windows 失败:没有捕获到目标目录。'; + this.render(); + return; + } + await this.transferItemsToWindowsDirectory(side, device, sourceItems, result.targetDirectory, Date.now()); + } + catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.statusMessage = `拖到 Windows 失败:${message}`; + this.render(); + } + return; + } + // Defensive fallback for a local drag that didn't start natively at dragstart: start the + // native drag now with the real Windows paths (no staging, no transfer task). + try { + const dragItems = this.getSelectedTransferItems(side) + .filter((candidate) => !!candidate.path && !candidate.parent) + .map(candidate => ({ + sourcePath: candidate.path, + name: candidate.name, + type: candidate.type, + size: candidate.directory ? undefined : candidate.rawSize + })); + if (!dragItems.length) { + return; + } + const result = await this.sshService.startWindowsFileDrag({ + source: { kind: 'local', path: this.paneStates[side].path, label: device.name }, + items: dragItems + }); + if (!result.ok) { + this.statusMessage = `拖出到 Windows 失败:${result.message}`; + this.render(); + } + } + catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.statusMessage = `拖出到 Windows 失败:${message}`; + this.render(); + } + } + maybeStartWindowsFileDragFromPointer(side, event) { + if (this.currentFileDragSide !== side || this.hasStartedNativeWindowsDrag) { + return; + } + if (event.clientX === 0 && event.clientY === 0 && event.screenX === 0 && event.screenY === 0) { + return; + } + const ownerWindow = getWindow(this.container); + const outsideWindow = event.clientX <= 0 + || event.clientY <= 0 + || event.clientX >= ownerWindow.innerWidth - 1 + || event.clientY >= ownerWindow.innerHeight - 1; + if (!outsideWindow) { + return; + } + this.hasStartedNativeWindowsDrag = true; + void this.startWindowsFileDragFromPane(side); + } + renderTransferPage() { + const page = $('.turbofile-transfer-page'); + this.registerTransferPageDragAndDrop(page); + const hasLiveTransfer = this.tasks.some(task => task.kind === 'transfer' && task.status === 'running'); + if (hasLiveTransfer) { + page.classList.add('has-live-transfer'); + } + const terminalPanelVisible = this.isTerminalPanelVisibleForCurrentPage(); + const shouldRenderTaskbar = this.shouldRenderTaskbar(); + if (terminalPanelVisible) { + page.classList.add('is-terminal-focused'); + } + if (this.maximizedPane) { + page.classList.add('is-pane-maximized'); + page.dataset.maximizedPane = this.maximizedPane; + } + const main = $('.turbofile-main'); + main.appendChild(this.renderPane('left')); + main.appendChild(this.renderPane('right')); + page.appendChild(main); + if (shouldRenderTaskbar) { + page.appendChild(this.renderTaskbar()); + } + return page; + } + registerTransferPageDragAndDrop(page) { + const handleDragEnter = (event) => this.handleTransferPageDragOver(event); + const handleDragOver = (event) => this.handleTransferPageDragOver(event); + const handleDrop = (event) => this.handleTransferPageDrop(event); + const handleDragLeave = (event) => this.handleTransferPageDragLeave(event); + page.addEventListener('dragenter', handleDragEnter, true); + page.addEventListener('dragover', handleDragOver, true); + page.addEventListener('drop', handleDrop, true); + page.addEventListener('dragleave', handleDragLeave, true); + this.renderDisposables.add({ + dispose: () => { + page.removeEventListener('dragenter', handleDragEnter, true); + page.removeEventListener('dragover', handleDragOver, true); + page.removeEventListener('drop', handleDrop, true); + page.removeEventListener('dragleave', handleDragLeave, true); + } + }); + } + handleTransferPageDragOver(event) { + if (!this.isTransferPageFileDrag(event)) { + return; + } + if (this.shouldLetConcreteDropTargetHandle(event)) { + return; + } + const targetSide = this.getTransferDropTargetSide(event); + const sourceSide = this.getTurboFileDragSourceSide(event) ?? this.currentFileDragSide; + if (!shouldHandleTurboFilePaneDropAtPage(sourceSide, targetSide)) { + this.setExternalDropTargetSide(undefined); + return; + } + this.consumeTurboFileDropEvent(event); + if (event.dataTransfer) { + event.dataTransfer.dropEffect = 'copy'; + } + this.setExternalDropTargetSide(this.hasExternalDropData(event) ? targetSide : undefined); + } + handleTransferPageDrop(event) { + if (!this.isTransferPageFileDrag(event)) { + return; + } + if (this.shouldLetConcreteDropTargetHandle(event)) { + return; + } + const initiatedAt = Date.now(); + const targetSide = this.getTransferDropTargetSide(event); + const sourceSide = this.getTurboFileDragSourceSide(event) ?? this.currentFileDragSide; + if (!shouldHandleTurboFilePaneDropAtPage(sourceSide, targetSide)) { + this.setExternalDropTargetSide(undefined); + return; + } + this.consumeTurboFileDropEvent(event); + this.setExternalDropTargetSide(undefined); + if (sourceSide) { + this.clearCurrentFileDragState(); + if (sourceSide !== targetSide) { + this.deferAfterDrag(() => void this.transferSelected(sourceSide, targetSide, initiatedAt)); + } + return; + } + const externalPaths = this.extractExternalDropPaths(event); + this.clearCurrentFileDragState(); + if (externalPaths.length) { + this.deferAfterDrag(() => void this.transferExternalPaths(targetSide, externalPaths, 'copy', initiatedAt)); + } + } + consumeTurboFileDropEvent(event) { + event.preventDefault(); + event.stopPropagation(); + } + shouldLetConcreteDropTargetHandle(event) { + const target = event.target instanceof HTMLElement ? event.target : undefined; + return !!target?.closest('.turbofile-file-list, .turbofile-image-page-body'); + } + handleTransferPageDragLeave(event) { + const relatedTarget = event.relatedTarget; + if (relatedTarget instanceof Node && event.currentTarget instanceof Node && event.currentTarget.contains(relatedTarget)) { + return; + } + this.setExternalDropTargetSide(undefined); + if (this.currentFileDragSide && !this.hasStartedNativeWindowsDrag) { + this.hasStartedNativeWindowsDrag = true; + void this.startWindowsFileDragFromPane(this.currentFileDragSide); + } + } + isTransferPageFileDrag(event) { + return !!this.getTurboFileDragSourceSide(event) || this.hasExternalDropData(event); + } + getTransferDropTargetSide(event) { + const target = event.target instanceof HTMLElement ? event.target : undefined; + const targetSide = this.getPaneSideFromElement(target); + if (targetSide) { + this.activeFilePaneSide = targetSide; + return targetSide; + } + return this.activeFilePaneSide; + } + getTurboFileDragSourceSide(event) { + const sourceSide = event.dataTransfer?.getData('application/x-turbofile-side'); + return sourceSide === 'left' || sourceSide === 'right' ? sourceSide : undefined; + } + renderDevicesPage() { + const page = $('.turbofile-full-page'); + page.appendChild(this.renderPageHeader(Codicon.server, '设备管理', '管理已保存的 SSH 设备,可在这里编辑、删除和测试连接。', [ + this.createPageButton(Codicon.refresh, '刷新设备', () => void this.refreshDevicesFromStore()), + this.createPageButton(Codicon.add, '添加 SSH 设备', () => this.showAddDeviceForm()) + ])); + const grid = $('.turbofile-device-page-grid'); + for (const device of this.getAllDevices()) { + grid.appendChild(this.renderDevicePageCard(device)); + } + page.appendChild(grid); + return page; + } + ensureTitlebarChrome() { + this.ensureTitlebarAppMenu(); + this.ensureTitlebarThemeToggle(); + } + ensureTitlebarAppMenu(retry = 0) { + if (!document.body.classList.contains('turbofile-shell')) { + return; + } + this.removeTitlebarAppMenu(); + const titlebarLeft = document.querySelector('.monaco-workbench .part.titlebar .titlebar-container > .titlebar-left'); + if (!titlebarLeft) { + if (retry < 10) { + setTimeout(() => this.ensureTitlebarAppMenu(retry + 1), 100); + } + return; + } + const menu = this.createTitlebarAppMenu(); + const appIcon = titlebarLeft.querySelector('.window-appicon'); + const nativeMenubar = titlebarLeft.querySelector('.menubar'); + if (nativeMenubar) { + titlebarLeft.insertBefore(menu, nativeMenubar); + } + else if (appIcon?.nextSibling) { + titlebarLeft.insertBefore(menu, appIcon.nextSibling); + } + else { + titlebarLeft.appendChild(menu); + } + } + removeTitlebarAppMenu() { + for (const menu of Array.from(document.querySelectorAll('.turbofile-titlebar-menu'))) { + menu.remove(); + } + } + createTitlebarAppMenu() { + const menu = $('.turbofile-titlebar-menu'); + const updateActionKind = getTurboFileTitlebarUpdateActionKind(this.updateService.state); + menu.appendChild(this.createTitlebarMenuButton('设置', button => this.showTitlebarMenu(button, '设置', []))); + menu.appendChild(this.createTitlebarMenuButton('视图', button => this.showTitlebarMenu(button, '视图', []))); + menu.appendChild(this.createTitlebarMenuButton('帮助', button => this.showTitlebarMenu(button, '帮助', [ + toAction({ id: 'turbofile.help.about', label: '关于极速传', enabled: true, run: () => this.showAboutDialog() }) + ]))); + if (updateActionKind !== 'hidden') { + menu.appendChild(this.createTitlebarUpdateButton(updateActionKind)); + } + return menu; + } + createTitlebarUpdateButton(kind) { + const button = $('button.turbofile-titlebar-update-button'); + button.type = 'button'; + button.classList.add(`turbofile-titlebar-update-button-${kind}`); + const title = this.getTitlebarUpdateTitle(kind); + button.title = title; + button.setAttribute('aria-label', title); + button.disabled = !this.canRunTitlebarUpdateAction(kind); + const label = this.getTitlebarUpdateLabel(kind); + button.appendChild($('span.turbofile-titlebar-update-label', undefined, label)); + button.addEventListener('click', event => { + event.preventDefault(); + event.stopPropagation(); + if (this.canRunTitlebarUpdateAction(kind)) { + void this.runTitlebarUpdateAction(kind); + } + }); + return button; + } + getTitlebarUpdateTitle(kind) { + switch (kind) { + case 'checking': + return 'Checking for updates'; + case 'download': + return 'Download update'; + case 'downloading': + return 'Downloading update'; + case 'install': + return 'Install update'; + case 'installing': + return 'Installing update'; + case 'restart': + return 'Restart to update'; + default: + return 'Update'; + } + } + getTitlebarUpdateLabel(kind) { + const version = this.getTitlebarUpdateVersionLabel(); + switch (kind) { + case 'checking': + return '检查'; + case 'downloading': + return version ? `更新 ${version} ${this.getTitlebarDownloadProgressLabel()}` : `更新 ${this.getTitlebarDownloadProgressLabel()}`; + case 'installing': + return version ? `更新 ${version} ${this.getTitlebarInstallProgressLabel()}` : `更新 ${this.getTitlebarInstallProgressLabel()}`; + default: + return version ? `更新 ${version}` : '更新'; + } + } + getTitlebarUpdateVersionLabel() { + const state = this.updateService.state; + const update = state.update; + switch (state.type) { + case "available for download" /* StateType.AvailableForDownload */: + case "downloading" /* StateType.Downloading */: + case "downloaded" /* StateType.Downloaded */: + case "updating" /* StateType.Updating */: + case "ready" /* StateType.Ready */: + return update?.productVersion ?? update?.version ?? ''; + default: + return ''; + } + } + getTitlebarDownloadProgressLabel() { + const state = this.updateService.state; + if (state.type === "downloading" /* StateType.Downloading */ && typeof state.downloadedBytes === 'number' && typeof state.totalBytes === 'number' && state.totalBytes > 0) { + return `${Math.max(0, Math.min(100, Math.floor((state.downloadedBytes / state.totalBytes) * 100)))}%`; + } + return '...'; + } + getTitlebarInstallProgressLabel() { + const state = this.updateService.state; + if (state.type === "updating" /* StateType.Updating */ && typeof state.currentProgress === 'number' && typeof state.maxProgress === 'number' && state.maxProgress > 0) { + return `${Math.max(0, Math.min(100, Math.floor((state.currentProgress / state.maxProgress) * 100)))}%`; + } + return '...'; + } + canRunTitlebarUpdateAction(kind) { + return kind === 'download' || kind === 'install' || kind === 'restart'; + } + createTitlebarMenuButton(label, onClick) { + const button = $('button.turbofile-titlebar-menu-button'); + button.type = 'button'; + button.textContent = label; + button.addEventListener('click', event => { + event.preventDefault(); + event.stopPropagation(); + onClick(button); + }); + return button; + } + showTitlebarMenu(anchor, title, actions) { + const fallbackAction = toAction({ id: `turbofile.titlebar.${title}.empty`, label: '暂无内容', enabled: false, run: () => { } }); + this.contextMenuService.showContextMenu({ + getAnchor: () => anchor, + getActions: () => actions.length ? actions : [fallbackAction] + }); + } + async runTitlebarUpdateAction(kind) { + if (kind === 'download') { + await this.updateService.downloadUpdate(true); + } + else if (kind === 'install') { + await this.updateService.applyUpdate(); + } + else if (kind === 'restart') { + await this.updateService.quitAndInstall(); + } + } + ensureTitlebarThemeToggle(retry = 0) { + if (!document.body.classList.contains('turbofile-shell')) { + return; + } + this.removeTitlebarThemeToggle(); + const titlebarRight = document.querySelector('.monaco-workbench .part.titlebar .titlebar-container > .titlebar-right'); + const controls = titlebarRight?.querySelector('.window-controls-container'); + if (!titlebarRight || !controls) { + if (retry < 10) { + setTimeout(() => this.ensureTitlebarThemeToggle(retry + 1), 100); + } + return; + } + titlebarRight.insertBefore(this.createThemeToggleButton(), controls); + } + removeTitlebarThemeToggle() { + for (const button of Array.from(document.querySelectorAll('.turbofile-theme-toggle'))) { + button.remove(); + } + } + createThemeToggleButton() { + const effectiveMode = this.getEffectiveAppearanceMode(this.getAppearanceMode()); + const targetMode = effectiveMode === 'light' ? 'dark' : 'light'; + const button = $('button.turbofile-theme-toggle'); + button.type = 'button'; + button.title = effectiveMode === 'light' ? '切换到深色模式' : '切换到浅色模式'; + button.setAttribute('aria-label', button.title); + button.appendChild(renderTurboFileAppearanceIcon(effectiveMode === 'light' ? 'moon' : 'sun')); + button.addEventListener('click', event => { + event.preventDefault(); + event.stopPropagation(); + void this.applyAppearanceMode(targetMode); + }); + return button; + } + showAboutDialog() { + this.closeAboutDialog(); + const overlay = $('.turbofile-about-overlay'); + overlay.setAttribute('role', 'presentation'); + const dialog = $('.turbofile-about-dialog'); + dialog.setAttribute('role', 'dialog'); + dialog.setAttribute('aria-modal', 'true'); + dialog.setAttribute('aria-labelledby', 'turbofile-about-title'); + const header = $('.turbofile-about-header'); + const headerTitle = $('div#turbofile-about-title.turbofile-about-header-title'); + headerTitle.textContent = '关于极速传'; + const close = $('button.turbofile-about-close'); + close.type = 'button'; + close.title = '关闭'; + close.setAttribute('aria-label', '关闭'); + close.appendChild(renderIcon(Codicon.close)); + close.addEventListener('click', () => this.closeAboutDialog()); + header.append(headerTitle, close); + const content = $('.turbofile-about-content'); + const icon = $('.turbofile-about-app-icon'); + icon.appendChild(renderTurboFileIcon('lightning-charge-fill')); + const name = $('div.turbofile-about-name'); + name.textContent = this.productService.nameShort || '极速传'; + const version = $('div.turbofile-about-version'); + version.textContent = `版本 ${this.productService.version}`; + const copyright = $('div.turbofile-about-copyright'); + copyright.textContent = '© 极速传'; + content.append(icon, name, version, copyright); + const footer = $('.turbofile-about-footer'); + const ok = $('button.turbofile-about-ok'); + ok.type = 'button'; + ok.textContent = '确定'; + ok.addEventListener('click', () => this.closeAboutDialog()); + footer.appendChild(ok); + dialog.append(header, content, footer); + overlay.appendChild(dialog); + overlay.addEventListener('click', event => { + if (event.target === overlay) { + this.closeAboutDialog(); + } + }); + overlay.addEventListener('keydown', event => { + if (event.key === 'Escape') { + event.preventDefault(); + this.closeAboutDialog(); + } + }); + this.aboutDialogElement = overlay; + document.body.appendChild(overlay); + getWindow(overlay).requestAnimationFrame(() => ok.focus({ preventScroll: true })); + } + closeAboutDialog() { + this.aboutDialogElement?.remove(); + this.aboutDialogElement = undefined; + } + renderDevicePageCard(device) { + const card = $('.turbofile-page-card.turbofile-device-page-card'); + card.classList.add(`is-tone-${this.getDeviceCardTone(device)}`); + const title = $('.turbofile-page-card-title'); + title.appendChild(this.renderDevicePageIcon(device)); + const titleText = $('span'); + titleText.textContent = device.name; + title.appendChild(titleText); + const status = $('span.turbofile-device-card-status'); + const runtimeStatus = this.getDeviceRuntimeStatus(device); + status.classList.add(`is-${runtimeStatus}`); + status.textContent = this.getDeviceRuntimeStatusLabel(runtimeStatus); + title.appendChild(status); + card.appendChild(title); + const meta = $('.turbofile-device-page-meta'); + meta.appendChild(this.renderInfoLine('地址', device.id === LOCAL_WINDOWS_DEVICE_ID ? device.host : `${device.host}:${device.port || 22}`)); + meta.appendChild(this.renderInfoLine('用户', device.username || '本机')); + meta.appendChild(this.renderInfoLine('默认目录', device.defaultPath || (device.id === LOCAL_WINDOWS_DEVICE_ID ? this.getLocalWindowsDefaultPath() : '/'))); + meta.appendChild(this.renderInfoLine('传输模式', this.getDeviceTransferProtocolLabel(device))); + card.appendChild(meta); + const actions = $('.turbofile-page-card-actions'); + actions.appendChild(this.createPageButton(Codicon.send, '连接', () => { void this.testDeviceConnection(device); })); + actions.appendChild(this.createPageButton(Codicon.edit, '编辑', () => this.showEditDeviceForm(device))); + if (device.id !== LOCAL_WINDOWS_DEVICE_ID) { + actions.appendChild(this.createPageButton(Codicon.trash, '删除', () => { void this.deleteDeviceFromPage(device); })); + } + card.appendChild(actions); + const result = this.deviceConnectionMessages.get(device.id); + if (result) { + const message = $('.turbofile-device-test-result'); + message.classList.add(`is-${result.status}`); + message.textContent = result.message; + card.appendChild(message); + } + return card; + } + getDeviceRuntimeStatus(device) { + if (device.id === LOCAL_WINDOWS_DEVICE_ID) { + return 'online'; + } + return this.deviceRuntimeStatuses.get(device.id) ?? 'unknown'; + } + setDeviceRuntimeStatus(device, status) { + const deviceId = typeof device === 'string' ? device : device.id; + this.deviceRuntimeStatuses.set(deviceId, deviceId === LOCAL_WINDOWS_DEVICE_ID ? 'online' : status); + } + getDeviceRuntimeStatusLabel(status) { + switch (status) { + case 'online': + return '在线'; + default: + return '离线'; + } + } + getDeviceCardTone(device) { + const seed = `${device.host}:${device.port || 22}:${device.name}`; + let hash = 0; + for (let i = 0; i < seed.length; i++) { + hash = ((hash << 5) - hash + seed.charCodeAt(i)) | 0; + } + return Math.abs(hash) % 6; + } + getDeviceTransferProtocolLabel(device) { + switch (device.preferredTransferProtocol) { + case 'auto-fast': + case 'tar': + return device.capabilities.tar ? '智能极速(固定策略)' : '智能极速(tar 未检测到)'; + case 'rsync': + return device.capabilities.rsync ? 'rsync 增量(可用)' : 'rsync 增量(未通过检测)'; + case 'scp': + return device.capabilities.scp ? 'SCP 文件(可用)' : 'SCP 文件(未通过检测)'; + case 'sftp': + default: + return 'SFTP 稳定模式'; + } + } + renderSettingsPage() { + return this.renderTransferPage(); + /* + const page = $('.turbofile-full-page'); + page.appendChild(this.renderPageHeader(Codicon.settingsGear, '系统设置', '管理 极速传 的本地行为、字体和快捷操作。')); + + const actionsSection = $('.turbofile-settings-section'); + const actionsTitle = $('.turbofile-section-title'); + actionsTitle.appendChild(renderIcon(Codicon.tools)); + actionsTitle.append('快捷操作'); + actionsSection.appendChild(actionsTitle); + const quickActions = $('.turbofile-settings-actions'); + quickActions.appendChild(this.createPageButton(Codicon.plug, '测试当前 SSH 设备', () => { void this.testCurrentSshConnection(); })); + quickActions.appendChild(this.createPageButton(Codicon.terminal, '打开左侧终端', () => { void this.openTerminal('left'); })); + quickActions.appendChild(this.createPageButton(Codicon.terminal, '打开右侧终端', () => { void this.openTerminal('right'); })); + quickActions.appendChild(this.createPageButton(Codicon.sync, '同步文件夹', () => { void this.syncSelectedDirectories(); }, true)); + actionsSection.appendChild(quickActions); + page.appendChild(actionsSection); + return page; + */ + } + renderAppearanceFontSettingsCard() { + const card = $('.turbofile-appearance-card.turbofile-appearance-font-card'); + const header = $('.turbofile-appearance-card-header'); + const copy = $('.turbofile-appearance-card-copy'); + const title = $('h2'); + title.textContent = '字体'; + const description = $('p'); + description.textContent = '调整极速传界面和文件浏览区的显示字体。'; + copy.appendChild(title); + copy.appendChild(description); + header.appendChild(copy); + card.appendChild(header); + const rows = $('.turbofile-appearance-font-summary'); + rows.appendChild(this.renderEditorFontSizeSetting()); + rows.appendChild(this.renderUiFontSizeSetting()); + rows.appendChild(this.renderUiFontFamilySetting()); + rows.appendChild(this.renderBrowserFontSizeSetting()); + rows.appendChild(this.renderBrowserFontFamilySetting()); + card.appendChild(rows); + return card; + } + renderEditorFontSizeSetting() { + return this.renderFontSizeSetting('文档字体大小', '调整当前打开文档的字号,修改后立即生效。', 'editor.fontSize', this.getEditorFontSize(), 8, 40, value => this.updateEditorFontSize(value)); + } + renderUiFontSizeSetting() { + return this.renderFontSizeSetting('UI 字号', '统一调整极速传界面的字体大小,不影响 VS Code 原生文档编辑器。', 'turbofile-ui-font-size', this.getUiFontSize(), 11, 24, value => this.updateUiFontSize(value)); + } + renderBrowserFontSizeSetting() { + return this.renderFontSizeSetting('文件浏览区字体大小', '只控制左右文件浏览区域,修改后立即生效。', 'turbofile-browser-font-size', this.getBrowserFontSize(), 11, 20, value => this.updateBrowserFontSize(value)); + } + renderUiFontFamilySetting() { + return this.renderFontFamilySetting('UI 字体', '选择极速传界面使用的字体。', 'turbofile-ui-font-family', this.getUiFontFamily(), TURBOFILE_UI_FONT_FAMILIES, value => this.updateUiFontFamily(value)); + } + renderBrowserFontFamilySetting() { + return this.renderFontFamilySetting('文件浏览区字体', '选择文件名、大小和时间的显示字体。', 'turbofile-browser-font-family', this.getBrowserFontFamily(), TURBOFILE_BROWSER_FONT_FAMILIES, value => this.updateBrowserFontFamily(value)); + } + renderFontSizeSetting(labelText, descriptionText, id, currentFontSize, min, max, update) { + const row = $('.turbofile-editor-setting-row.turbofile-appearance-wide-setting'); + const copy = $('.turbofile-editor-setting-copy'); + const label = $('label'); + label.textContent = labelText; + label.htmlFor = id; + const description = $('span'); + description.textContent = descriptionText; + copy.appendChild(label); + copy.appendChild(description); + const controls = $('.turbofile-editor-setting-controls'); + const input = $('input.turbofile-editor-font-size-input'); + input.id = id; + input.type = 'number'; + input.min = String(min); + input.max = String(max); + input.step = '1'; + input.value = String(currentFontSize); + input.addEventListener('change', () => update(input.valueAsNumber)); + input.addEventListener('keydown', event => { + if (event.key === 'Enter') { + event.preventDefault(); + update(input.valueAsNumber); + } + }); + controls.appendChild(input); + row.appendChild(copy); + row.appendChild(controls); + return row; + } + renderFontFamilySetting(labelText, descriptionText, id, currentFontFamily, options, update) { + const row = $('.turbofile-editor-setting-row.turbofile-appearance-wide-setting'); + const copy = $('.turbofile-editor-setting-copy'); + const label = $('label'); + label.textContent = labelText; + label.htmlFor = id; + const description = $('span'); + description.textContent = descriptionText; + copy.appendChild(label); + copy.appendChild(description); + const controls = $('.turbofile-editor-setting-controls'); + const select = $('select.turbofile-browser-select'); + select.id = id; + for (const [value, optionLabel] of options) { + const option = $('option'); + option.value = value; + option.textContent = optionLabel; + option.selected = value === currentFontFamily; + select.appendChild(option); + } + select.addEventListener('change', () => update(select.value)); + controls.appendChild(select); + row.appendChild(copy); + row.appendChild(controls); + return row; + } + getUiFontSize() { + const stored = Number(this.storageService.get(TURBOFILE_UI_FONT_SIZE_STORAGE_KEY, 0 /* StorageScope.PROFILE */, String(TURBOFILE_UI_DEFAULT_FONT_SIZE))); + return this.normalizeUiFontSize(stored, TURBOFILE_UI_DEFAULT_FONT_SIZE); + } + getEditorFontSize() { + const value = this.configurationService.getValue('editor.fontSize'); + return this.normalizeEditorFontSize(typeof value === 'number' ? value : TURBOFILE_UI_DEFAULT_FONT_SIZE, TURBOFILE_UI_DEFAULT_FONT_SIZE); + } + normalizeUiFontSize(value, fallback) { + if (!Number.isFinite(value)) { + return fallback; + } + return Math.min(24, Math.max(11, Math.round(value))); + } + normalizeEditorFontSize(value, fallback) { + if (!Number.isFinite(value)) { + return fallback; + } + return Math.min(40, Math.max(8, Math.round(value))); + } + updateUiFontSize(value) { + const fontSize = this.normalizeUiFontSize(value, this.getUiFontSize()); + this.storageService.store(TURBOFILE_UI_FONT_SIZE_STORAGE_KEY, String(fontSize), 0 /* StorageScope.PROFILE */, 0 /* StorageTarget.USER */); + this.statusMessage = `UI 字号已设置为 ${fontSize}px。`; + this.render(); + } + updateEditorFontSize(value) { + const fontSize = this.normalizeEditorFontSize(value, this.getEditorFontSize()); + void this.configurationService.updateValue('editor.fontSize', fontSize, 2 /* ConfigurationTarget.USER */); + this.statusMessage = `文档字体大小已设置为 ${fontSize}px。`; + this.render(); + } + getUiFontFamily() { + const stored = this.storageService.get(TURBOFILE_UI_FONT_FAMILY_STORAGE_KEY, 0 /* StorageScope.PROFILE */, TURBOFILE_UI_DEFAULT_FONT_FAMILY); + return TURBOFILE_UI_FONT_FAMILIES.some(([value]) => value === stored) ? stored : TURBOFILE_UI_DEFAULT_FONT_FAMILY; + } + updateUiFontFamily(value) { + const fontFamily = TURBOFILE_UI_FONT_FAMILIES.some(([option]) => option === value) ? value : TURBOFILE_UI_DEFAULT_FONT_FAMILY; + this.storageService.store(TURBOFILE_UI_FONT_FAMILY_STORAGE_KEY, fontFamily, 0 /* StorageScope.PROFILE */, 0 /* StorageTarget.USER */); + this.statusMessage = 'UI 字体已更新。'; + this.render(); + } + getBrowserFontSize() { + const stored = Number(this.storageService.get(TURBOFILE_BROWSER_FONT_SIZE_STORAGE_KEY, 0 /* StorageScope.PROFILE */, String(TURBOFILE_BROWSER_DEFAULT_FONT_SIZE))); + return this.normalizeBrowserFontSize(stored, TURBOFILE_BROWSER_DEFAULT_FONT_SIZE); + } + normalizeBrowserFontSize(value, fallback) { + if (!Number.isFinite(value)) { + return fallback; + } + return Math.min(20, Math.max(11, Math.round(value))); + } + updateBrowserFontSize(value) { + const fontSize = this.normalizeBrowserFontSize(value, this.getBrowserFontSize()); + this.storageService.store(TURBOFILE_BROWSER_FONT_SIZE_STORAGE_KEY, String(fontSize), 0 /* StorageScope.PROFILE */, 0 /* StorageTarget.USER */); + this.statusMessage = `文件浏览区字体大小已设置为 ${fontSize}px。`; + this.render(); + } + getBrowserFontFamily() { + const stored = this.storageService.get(TURBOFILE_BROWSER_FONT_FAMILY_STORAGE_KEY, 0 /* StorageScope.PROFILE */, TURBOFILE_BROWSER_DEFAULT_FONT_FAMILY); + return TURBOFILE_BROWSER_FONT_FAMILIES.some(([value]) => value === stored) ? stored : TURBOFILE_BROWSER_DEFAULT_FONT_FAMILY; + } + updateBrowserFontFamily(value) { + const fontFamily = TURBOFILE_BROWSER_FONT_FAMILIES.some(([option]) => option === value) ? value : TURBOFILE_BROWSER_DEFAULT_FONT_FAMILY; + this.storageService.store(TURBOFILE_BROWSER_FONT_FAMILY_STORAGE_KEY, fontFamily, 0 /* StorageScope.PROFILE */, 0 /* StorageTarget.USER */); + this.statusMessage = '文件浏览区字体已更新。'; + this.render(); + } + applyBrowserAppearanceSettings() { + if (!this.container) { + return; + } + this.container.style.setProperty('--turbofile-ui-font-size', `${this.getUiFontSize()}px`); + this.container.style.setProperty('--turbofile-ui-font-family', this.getUiFontFamily()); + this.container.style.setProperty('--turbofile-browser-font-size', `${this.getBrowserFontSize()}px`); + this.container.style.setProperty('--turbofile-browser-font-family', this.getBrowserFontFamily()); + this.container.style.removeProperty('--turbofile-appearance-contrast-filter'); + this.container.classList.remove('is-appearance-pointer', 'is-appearance-translucent-sidebar'); + document.body.classList.remove('turbofile-appearance-pointer', 'turbofile-appearance-translucent'); + this.container.style.removeProperty('--turbofile-browser-font-color'); + } + renderThemesPage() { + const page = $('.turbofile-full-page.turbofile-appearance-page'); + const shell = $('.turbofile-appearance-shell'); + const title = $('h1.turbofile-appearance-title'); + title.textContent = '外观'; + shell.appendChild(title); + shell.appendChild(this.renderAppearanceFontSettingsCard()); + page.appendChild(shell); + return page; + } + applyTurboFileThemeState() { + if (!this.container) { + return; + } + const effectiveMode = this.pendingAppearanceMode ?? this.getEffectiveAppearanceMode(this.getAppearanceMode()); + this.applyTurboFileThemeClasses(effectiveMode); + } + applyTurboFileThemeClasses(mode) { + this.container?.classList.toggle('is-turbofile-light', mode === 'light'); + this.container?.classList.toggle('is-turbofile-dark', mode === 'dark'); + document.body.classList.toggle('turbofile-editor-theme-light', mode === 'light'); + document.body.classList.toggle('turbofile-editor-theme-dark', mode === 'dark'); + } + beginAppearanceTransition(mode) { + if (this.appearanceTransitionTimer !== undefined) { + getWindow(this.container).clearTimeout(this.appearanceTransitionTimer); + this.appearanceTransitionTimer = undefined; + } + this.pendingAppearanceMode = mode; + document.body.classList.add('turbofile-theme-switching'); + this.container?.classList.add('is-theme-switching'); + this.applyTurboFileThemeClasses(mode); + this.ensureTitlebarThemeToggle(); + } + endAppearanceTransition() { + if (!this.container) { + this.pendingAppearanceMode = undefined; + document.body.classList.remove('turbofile-theme-switching'); + return; + } + const targetWindow = getWindow(this.container); + if (this.appearanceTransitionTimer !== undefined) { + targetWindow.clearTimeout(this.appearanceTransitionTimer); + } + this.appearanceTransitionTimer = targetWindow.setTimeout(() => { + this.appearanceTransitionTimer = undefined; + this.pendingAppearanceMode = undefined; + document.body.classList.remove('turbofile-theme-switching'); + this.container?.classList.remove('is-theme-switching'); + this.applyTurboFileThemeState(); + this.ensureTitlebarThemeToggle(); + }, 180); + } + getAppearanceMode() { + if (this.configurationService.getValue(ThemeSettings.DETECT_COLOR_SCHEME)) { + return 'system'; + } + return this.getEffectiveAppearanceMode(); + } + getEffectiveAppearanceMode(mode) { + if (mode === 'system') { + const preferred = this.workbenchThemeService.getPreferredColorScheme(); + if (preferred === ColorScheme.LIGHT || preferred === ColorScheme.HIGH_CONTRAST_LIGHT) { + return 'light'; + } + } + if (!mode || mode === 'system') { + const currentType = this.workbenchThemeService.getColorTheme().type; + return currentType === ColorScheme.LIGHT || currentType === ColorScheme.HIGH_CONTRAST_LIGHT ? 'light' : 'dark'; + } + return mode; + } + getAppearanceTheme(mode) { + const label = mode === 'dark' ? TURBOFILE_APPEARANCE_DARK_THEME_LABEL : TURBOFILE_APPEARANCE_LIGHT_THEME_LABEL; + const settingsId = mode === 'dark' ? ThemeSettingDefaults.COLOR_THEME_DARK : ThemeSettingDefaults.COLOR_THEME_LIGHT; + const themeId = mode === 'light' ? TURBOFILE_LIGHT_THEME_CLASS_ID : 'vs-dark turbofile-2017-dark-cpp'; + const realTheme = this.availableColorThemes.find(theme => theme.settingsId === settingsId) + ?? this.availableColorThemes.find(theme => theme.label === label || theme.id === themeId); + if (realTheme) { + return realTheme; + } + const builtinTheme = this.getBuiltinColorThemes().find(theme => theme.label === label || theme.id === themeId || theme.settingsId === settingsId); + return builtinTheme + ?? this.createBuiltinColorTheme(mode === 'dark' ? 'vs-dark turbofile-2017-dark-cpp' : TURBOFILE_LIGHT_THEME_CLASS_ID, label, settingsId, mode === 'dark' ? COLOR_THEME_DARK_INITIAL_COLORS : TURBOFILE_LIGHT_THEME_COLORS); + } + async applyAppearanceMode(mode) { + await this.loadColorThemes(true); + const darkTheme = this.getAppearanceTheme('dark'); + const lightTheme = this.getAppearanceTheme('light'); + const preferred = this.workbenchThemeService.getPreferredColorScheme(); + const targetMode = mode === 'system' + ? preferred === ColorScheme.LIGHT || preferred === ColorScheme.HIGH_CONTRAST_LIGHT ? 'light' : 'dark' + : mode; + this.beginAppearanceTransition(targetMode); + try { + if (mode === 'system') { + await this.configurationService.updateValue(ThemeSettings.PREFERRED_DARK_THEME, darkTheme.settingsId, 2 /* ConfigurationTarget.USER */); + await this.configurationService.updateValue(ThemeSettings.PREFERRED_LIGHT_THEME, lightTheme.settingsId, 2 /* ConfigurationTarget.USER */); + await this.configurationService.updateValue(ThemeSettings.DETECT_COLOR_SCHEME, true, 2 /* ConfigurationTarget.USER */); + await this.setColorThemeBySettingsId(preferred === ColorScheme.LIGHT ? lightTheme : darkTheme); + this.statusMessage = '已设置为跟随系统主题。'; + } + else { + await this.configurationService.updateValue(ThemeSettings.DETECT_COLOR_SCHEME, false, 2 /* ConfigurationTarget.USER */); + await this.applyColorTheme(mode === 'light' ? lightTheme : darkTheme); + this.endAppearanceTransition(); + return; + } + this.endAppearanceTransition(); + this.render(); + } + catch (error) { + this.pendingAppearanceMode = undefined; + document.body.classList.remove('turbofile-theme-switching'); + this.container?.classList.remove('is-theme-switching'); + const message = error instanceof Error ? error.message : String(error); + this.statusMessage = `切换外观失败:${message}`; + this.render(); + } + } + async applyColorTheme(theme) { + try { + await this.setColorThemeBySettingsId(theme); + this.statusMessage = `已切换主题:${this.getThemeLabel(theme)}`; + this.render(); + } + catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.statusMessage = `切换主题失败:${message}`; + this.render(); + } + } + /** + * Applies a theme by its settingsId string so the workbench theme service resolves the *real* + * registered theme (which lazily loads its TextMate tokenColors). Passing the in-memory + * placeholder object instead would apply a theme without tokenColors and flatten document syntax + * highlighting to a single color. Falls back to the object only if the id cannot be resolved. + */ + async setColorThemeBySettingsId(theme) { + if (theme.settingsId) { + const applied = await this.workbenchThemeService.setColorTheme(theme.settingsId, 'auto'); + if (applied) { + return; + } + } + await this.workbenchThemeService.setColorTheme(theme, 'auto'); + } + async loadColorThemes(force = false) { + if (this.isLoadingColorThemes || (!force && this.hasLoadedColorThemes)) { + return; + } + this.isLoadingColorThemes = true; + try { + const themes = await this.workbenchThemeService.getColorThemes(); + const colorThemes = themes.slice(); + for (const builtinTheme of this.getBuiltinColorThemes()) { + this.appendColorThemeIfMissing(colorThemes, builtinTheme); + } + this.availableColorThemes = colorThemes.slice().sort((a, b) => { + const typeDelta = this.getThemeTypeOrder(a.type) - this.getThemeTypeOrder(b.type); + if (typeDelta !== 0) { + return typeDelta; + } + return a.label.localeCompare(b.label, 'zh-Hans'); + }); + } + catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.statusMessage = `加载主题失败:${message}`; + } + finally { + this.isLoadingColorThemes = false; + this.hasLoadedColorThemes = true; + if (this.activePage === 'themes') { + this.render(); + } + } + } + async syncNativeEditorDisplaySettings() { + const settingsHome = this.environmentService.parentAppUserRoamingDataHome; + if (!settingsHome) { + return; + } + const settingsUri = joinPath(settingsHome, 'settings.json'); + try { + const settingsText = (await this.fileService.readFile(settingsUri)).value.toString(); + const settings = JSON.parse(settingsText); + const updates = []; + const applyIfUnset = (key, value) => { + const inspect = this.configurationService.inspect(key); + if (inspect.userValue !== undefined || inspect.userLocalValue !== undefined || inspect.userRemoteValue !== undefined || inspect.workspaceValue !== undefined || inspect.workspaceFolderValue !== undefined || inspect.memoryValue !== undefined) { + return; + } + updates.push(this.configurationService.updateValue(key, value, 8 /* ConfigurationTarget.MEMORY */)); + }; + const editorFontSize = settings['editor.fontSize']; + if (typeof editorFontSize === 'number' && Number.isFinite(editorFontSize)) { + applyIfUnset('editor.fontSize', editorFontSize); + } + const editorFontFamily = settings['editor.fontFamily']; + if (typeof editorFontFamily === 'string' && editorFontFamily.length) { + applyIfUnset('editor.fontFamily', editorFontFamily); + } + const editorLineHeight = settings['editor.lineHeight']; + if (typeof editorLineHeight === 'number' && Number.isFinite(editorLineHeight)) { + applyIfUnset('editor.lineHeight', editorLineHeight); + } + const editorFontLigatures = settings['editor.fontLigatures']; + if (typeof editorFontLigatures === 'boolean' || typeof editorFontLigatures === 'string') { + applyIfUnset('editor.fontLigatures', editorFontLigatures); + } + const tokenColorCustomizations = settings['editor.tokenColorCustomizations']; + if (tokenColorCustomizations && typeof tokenColorCustomizations === 'object') { + applyIfUnset('editor.tokenColorCustomizations', tokenColorCustomizations); + } + const semanticTokenColorCustomizations = settings['editor.semanticTokenColorCustomizations']; + if (semanticTokenColorCustomizations && typeof semanticTokenColorCustomizations === 'object') { + applyIfUnset('editor.semanticTokenColorCustomizations', semanticTokenColorCustomizations); + } + const semanticHighlighting = settings['editor.semanticHighlighting.enabled']; + if (typeof semanticHighlighting === 'boolean' || typeof semanticHighlighting === 'string') { + applyIfUnset('editor.semanticHighlighting.enabled', semanticHighlighting); + } + await Promise.all(updates); + } + catch { + // If the host VS Code settings file is unavailable, keep the current editor configuration. + } + } + getBuiltinColorThemes() { + return [ + this.createBuiltinColorTheme('vs-dark turbofile-dark-2026', '深色 2026', 'Dark 2026', COLOR_THEME_DARK_INITIAL_COLORS), + this.createBuiltinColorTheme('vs-dark turbofile-dark-plus', 'Dark+', 'Dark+', COLOR_THEME_DARK_INITIAL_COLORS), + this.createBuiltinColorTheme('vs-dark turbofile-dark-modern', 'Dark Modern', 'Dark Modern', COLOR_THEME_DARK_INITIAL_COLORS), + this.createBuiltinColorTheme('vs-dark turbofile-vs-dark', 'Dark (Visual Studio)', 'Visual Studio Dark', COLOR_THEME_DARK_INITIAL_COLORS), + this.createBuiltinColorTheme('vs-dark turbofile-cpp-dark', 'Dark (Visual Studio - C/C++)', 'Visual Studio Dark - C++', COLOR_THEME_DARK_INITIAL_COLORS), + this.createBuiltinColorTheme('vs-dark turbofile-2017-dark-cpp', TURBOFILE_APPEARANCE_DARK_THEME_LABEL, ThemeSettingDefaults.COLOR_THEME_DARK, COLOR_THEME_DARK_INITIAL_COLORS), + this.createBuiltinColorTheme('vs turbofile-light-plus', 'Light+', 'Light+', COLOR_THEME_LIGHT_INITIAL_COLORS), + this.createBuiltinColorTheme('vs turbofile-light-modern', 'Light Modern', 'Light Modern', COLOR_THEME_LIGHT_INITIAL_COLORS), + this.createBuiltinColorTheme('vs turbofile-vs-light', 'Light (Visual Studio)', 'Visual Studio Light', COLOR_THEME_LIGHT_INITIAL_COLORS), + this.createBuiltinColorTheme('vs turbofile-cpp-light', 'Light (Visual Studio - C/C++)', 'Visual Studio Light - C++', COLOR_THEME_LIGHT_INITIAL_COLORS), + this.createBuiltinColorTheme('vs turbofile-2017-light-cpp', '2017 Light (Visual Studio - C/C++)', 'Visual Studio 2017 Light - C++', COLOR_THEME_LIGHT_INITIAL_COLORS), + this.createTurboFileLightColorTheme(), + this.createBuiltinColorTheme('hc-black', '高对比深色', ThemeSettingDefaults.COLOR_THEME_HC_DARK, { + 'activityBar.background': '#000000', + 'activityBar.activeBorder': '#f38518', + 'activityBar.foreground': '#ffffff', + 'sideBar.background': '#000000', + 'sideBar.border': '#6fc3df', + 'editor.background': '#000000', + 'editor.foreground': '#ffffff', + 'panel.background': '#000000', + 'focusBorder': '#f38518', + 'button.background': '#0f4a85', + 'button.foreground': '#ffffff' + }), + this.createBuiltinColorTheme('hc-light', '高对比浅色', ThemeSettingDefaults.COLOR_THEME_HC_LIGHT, { + 'activityBar.background': '#ffffff', + 'activityBar.activeBorder': '#0f4a85', + 'activityBar.foreground': '#000000', + 'sideBar.background': '#ffffff', + 'sideBar.border': '#0f4a85', + 'editor.background': '#ffffff', + 'editor.foreground': '#000000', + 'panel.background': '#ffffff', + 'focusBorder': '#0f4a85', + 'button.background': '#0f4a85', + 'button.foreground': '#ffffff' + }) + ]; + } + appendColorThemeIfMissing(themes, theme) { + if (!themes.some(candidate => candidate.id === theme.id || candidate.settingsId === theme.settingsId || candidate.label === theme.label)) { + themes.push(theme); + } + } + createTurboFileLightColorTheme() { + return this.createBuiltinColorTheme(TURBOFILE_LIGHT_THEME_CLASS_ID, TURBOFILE_APPEARANCE_LIGHT_THEME_LABEL, TURBOFILE_LIGHT_THEME_ID, TURBOFILE_LIGHT_THEME_COLORS); + } + createBuiltinColorTheme(id, label, settingsId, colors) { + const theme = ColorThemeData.createUnloadedTheme(id, colors); + theme.label = label; + theme.settingsId = settingsId; + theme.isLoaded = true; + return theme; + } + getThemeLabel(theme) { + return theme.label || theme.settingsId || this.getThemeTypeLabel(theme.type); + } + getThemeTypeLabel(type) { + switch (type) { + case ColorScheme.LIGHT: + return '浅色'; + case ColorScheme.HIGH_CONTRAST_DARK: + return '高对比深色'; + case ColorScheme.HIGH_CONTRAST_LIGHT: + return '高对比浅色'; + default: + return '深色'; + } + } + getThemeTypeOrder(type) { + switch (type) { + case ColorScheme.DARK: + return 0; + case ColorScheme.LIGHT: + return 1; + case ColorScheme.HIGH_CONTRAST_DARK: + return 2; + case ColorScheme.HIGH_CONTRAST_LIGHT: + return 3; + } + } + renderTasksPage() { + const page = $('.turbofile-full-page'); + const stats = this.getTaskStats(); + page.appendChild(this.renderPageHeader(Codicon.history, '任务日志', '查看传输、删除、压缩、解压、终端和同步任务的执行记录。', [ + this.createPageButton(Codicon.trash, '清空已完成', () => this.clearFinishedTasks()), + this.createPageButton(Codicon.remoteExplorer, '返回传输页', () => this.setActivePage('transfer'), true) + ])); + const summary = $('.turbofile-task-summary-grid'); + summary.appendChild(this.renderSummaryCard('运行中', String(stats.running), Codicon.sync)); + summary.appendChild(this.renderSummaryCard('成功', String(stats.success), Codicon.pass)); + summary.appendChild(this.renderSummaryCard('失败', String(stats.error), Codicon.error)); + summary.appendChild(this.renderSummaryCard('取消', String(stats.canceled), Codicon.circleSlash)); + page.appendChild(summary); + const list = $('.turbofile-task-log-list.is-page-list'); + if (!this.tasks.length) { + const empty = $('.turbofile-task-log-empty'); + empty.textContent = '暂无任务记录'; + list.appendChild(empty); + } + else { + for (const task of this.tasks) { + list.appendChild(this.renderTaskRow(task)); + } + } + page.appendChild(list); + return page; + } + renderPageHeader(icon, title, subtitle, actions = []) { + const header = $('.turbofile-page-header'); + const copy = $('.turbofile-page-header-copy'); + const heading = $('h2'); + heading.appendChild(renderIcon(icon)); + heading.append(title); + const detail = $('p'); + detail.textContent = subtitle; + copy.appendChild(heading); + copy.appendChild(detail); + header.appendChild(copy); + if (actions.length) { + const actionBar = $('.turbofile-page-header-actions'); + for (const action of actions) { + actionBar.appendChild(action); + } + header.appendChild(actionBar); + } + return header; + } + renderSummaryCard(label, value, icon) { + const card = $('.turbofile-page-card.turbofile-summary-card'); + const top = $('.turbofile-summary-card-top'); + top.append(label); + top.appendChild(renderIcon(icon)); + const number = $('strong'); + number.textContent = value; + card.appendChild(top); + card.appendChild(number); + return card; + } + renderInfoLine(label, value) { + const line = $('.turbofile-info-line'); + const key = $('span'); + key.textContent = label; + const content = $('strong'); + content.textContent = value; + line.appendChild(key); + line.appendChild(content); + return line; + } + createPageButton(icon, label, handler, primary = false) { + const button = $('button.turbofile-page-button'); + button.type = 'button'; + if (primary) { + button.classList.add('is-primary'); + } + button.appendChild(renderIcon(icon)); + button.append(label); + button.addEventListener('click', handler); + return button; + } + showAddDeviceForm() { + this.closeDeviceSelect(undefined, false); + this.activePage = 'devices'; + this.updateTransferTabsVisibility(); + this.notifyActivePageChanged(); + this.isDeviceFormVisible = true; + this.editingDeviceId = undefined; + this.deviceFormMessage = ''; + this.statusMessage = '请填写 SSH 设备信息,保存后会出现在设备下拉列表中。'; + this.render(); + } + showEditDeviceForm(device) { + this.closeDeviceSelect(undefined, false); + this.activePage = 'devices'; + this.updateTransferTabsVisibility(); + this.notifyActivePageChanged(); + this.isDeviceFormVisible = true; + this.editingDeviceId = device.id; + this.deviceFormMessage = `正在编辑设备:${device.name}`; + this.statusMessage = this.deviceFormMessage; + this.render(); + } + setActivePage(page) { + if (page !== this.activePage) { + this.closeDeviceSelect(undefined, false); + } + if (page !== 'images' && page !== 'transfer' && (this.imageGridState || this.paneImageGridStates.left || this.paneImageGridStates.right)) { + this.releaseAllImageGridStates(); + this.disposeImageGridObserver(); + this.clearImageGridScrollTimer(); + this.clearImageGridScrollRaf(); + this.clearImageGridThumbnailQueue(); + this.clearImageGridPreviewPrefetchTimer(); + this.imagePreviewAnalysis = undefined; + this.clearImagePreviewRender(); + } + this.activePage = page; + if (page !== 'transfer') { + this.taskbarHiddenForTerminal = false; + } + this.isTaskLogVisible = false; + this.isDeviceFormVisible = false; + this.editingDeviceId = undefined; + this.updateTransferTabsVisibility(); + this.notifyActivePageChanged(); + this.render(); + } + updateTransferTabsVisibility() { + document.body.classList.toggle('turbofile-transfer-tabs-visible', this.isEditorVisible && this.activePage === 'transfer'); + } + notifyActivePageChanged() { + if (!this.currentInput) { + return; + } + turboFileWorkbenchBridge.reportActivePage(this.currentInput.instanceId, this.activePage); + } + async deleteDeviceFromPage(device) { + if (!this.deviceStore || device.id === LOCAL_WINDOWS_DEVICE_ID) { + return; + } + const confirmed = getWindow(this.container).confirm(`确定删除设备“${device.name}”吗?`); + if (!confirmed) { + return; + } + const affectedSides = ['left', 'right'].filter(side => this.paneStates[side].deviceId === device.id); + await this.deviceStore.deleteDevice(device.id); + this.deviceConnectionMessages.delete(device.id); + this.deviceRuntimeStatuses.delete(device.id); + this.devices = this.deviceStore.loadDevices(); + this.ensurePaneDeviceSelection(); + for (const side of affectedSides) { + const state = this.paneStates[side]; + state.items = []; + state.loading = false; + state.error = undefined; + state.selectedPath = undefined; + state.selectedPaths.clear(); + state.selectionAnchorPath = undefined; + } + this.statusMessage = `已删除设备:${device.name}`; + this.render(); + for (const side of affectedSides) { + void this.refreshPane(side); + } + } + async testDeviceConnection(device) { + if (device.id === LOCAL_WINDOWS_DEVICE_ID) { + this.statusMessage = '本机 Windows 设备不需要 SSH 测试。'; + this.render(); + return; + } + this.deviceConnectionMessages.set(device.id, { status: 'testing', message: '正在测试 SSH 连接...' }); + this.setDeviceRuntimeStatus(device, 'testing'); + this.render(); + const task = this.beginTask('ssh-test', `测试 ${device.name}`, `${device.username}@${device.host}:${device.port || 22}`); + const result = await this.sshService.testConnection(await this.getSshConnectionOptions(device)); + if (result.ok) { + this.finishTask(task, 'success', `SSH 连接成功:${device.name}`); + this.deviceConnectionMessages.set(device.id, { status: 'success', message: `SSH 连接成功:${device.name}` }); + this.setDeviceRuntimeStatus(device, 'online'); + this.statusMessage = `SSH 连接成功:${device.name}`; + } + else { + this.finishTask(task, 'error', `SSH 连接失败:${result.message}`, { error: result.message }); + this.deviceConnectionMessages.set(device.id, { status: 'error', message: `SSH 连接失败:${result.message}` }); + this.setDeviceRuntimeStatus(device, 'offline'); + this.statusMessage = `SSH 连接失败:${result.message}`; + } + this.render(); + } + async detectAndStoreDeviceCapabilities(device) { + if (!this.deviceStore || device.id === LOCAL_WINDOWS_DEVICE_ID || device.preferredTransferProtocol === 'sftp') { + return undefined; + } + const result = await this.sshService.detectCapabilities(await this.getSshConnectionOptions(device)); + if (!result.ok || !result.capabilities) { + return { status: 'error', message: `设备能力检测失败,将按 SFTP 兜底:${result.message}` }; + } + const capabilities = this.toDeviceCapabilities(result.capabilities); + this.deviceStore.updateDeviceCapabilities(device.id, capabilities); + return this.getProtocolCheckResult(this.normalizeTransferProtocol(device.preferredTransferProtocol ?? defaultTurboFileTransferProtocol), result.capabilities); + } + async detectDraftProtocolCapabilities(draft, editingDevice) { + if (draft.preferredTransferProtocol === 'sftp') { + return { status: 'success', message: 'SFTP 稳定模式,无需额外检测。', missingDependencies: [] }; + } + const result = await this.sshService.detectCapabilities(await this.getSshConnectionOptionsFromDraft(draft, editingDevice)); + if (!result.ok || !result.capabilities) { + return { status: 'error', message: `设备能力检测失败:${result.message}`, missingDependencies: [] }; + } + return this.getProtocolCheckResult(this.normalizeTransferProtocol(draft.preferredTransferProtocol ?? defaultTurboFileTransferProtocol), result.capabilities); + } + getProtocolCheckResult(protocol, capabilities) { + if (protocol === 'auto-fast') { + if (capabilities.tar && capabilities.scp) { + return { status: 'success', message: '智能极速可用:单文件固定使用 SCP,多文件和文件夹固定使用 tar。', capabilities, missingDependencies: [] }; + } + if (capabilities.scp) { + return { status: 'error', message: '智能极速需要 tar 支持:单文件可用 SCP,多文件和文件夹需要 tar。', capabilities, missingDependencies: [] }; + } + return { status: 'error', message: '未检测到可用传输能力,请检查 SSH/SFTP/SCP。', capabilities, missingDependencies: [] }; + } + if (protocol === 'rsync') { + const missingDependencies = this.getMissingProtocolDependencies(protocol, capabilities); + if (capabilities.rsync) { + const message = capabilities.sshpass + ? 'rsync 可用,sshpass 可用。重复同步会使用 rsync 增量模式。' + : 'rsync 可用。本地与 SSH 互传可直接使用;SSH 设备互传密码路线建议安装 sshpass。'; + return { status: 'success', message, capabilities, missingDependencies }; + } + return { status: 'error', message: `缺少 ${missingDependencies.join('、')},rsync 增量不可用。`, capabilities, missingDependencies }; + } + if (protocol === 'tar') { + return this.getProtocolCheckResult('auto-fast', capabilities); + } + if (protocol === 'scp') { + return capabilities.scp + ? { status: 'success', message: 'SCP 可用,兼容模式可使用。', capabilities, missingDependencies: [] } + : { status: 'error', message: 'SCP 不可用,将按 SFTP 兜底。', capabilities, missingDependencies: [] }; + } + return undefined; + } + getMissingProtocolDependencies(protocol, capabilities) { + if (protocol !== 'rsync') { + return []; + } + const missing = []; + if (!capabilities.rsync) { + missing.push('rsync'); + } + if (!capabilities.sshpass) { + missing.push('sshpass'); + } + return missing; + } + toDeviceCapabilities(capabilities) { + return { + ssh: capabilities.ssh, + sftp: capabilities.sftp, + scp: capabilities.scp, + rsync: capabilities.rsync, + tar: capabilities.tar ?? capabilities.archive, + shell: capabilities.shell, + archive: capabilities.archive + }; + } + handleWorkbenchCommand(command) { + if (!this.deviceStore) { + return; + } + if (command.targetInstanceId && command.targetInstanceId !== this.currentInput?.instanceId) { + return; + } + if (command.type === 'showPage') { + this.setActivePage(command.page); + return; + } + if (command.type === 'addDevice') { + this.showAddDeviceForm(); + return; + } + if (command.type === 'refreshDevices') { + void this.refreshDevicesFromStore(); + return; + } + if (command.type === 'selectDevice') { + const device = this.getAllDevices().find(candidate => candidate.id === command.deviceId); + if (!device) { + this.statusMessage = '未找到要打开的设备。'; + this.render(); + return; + } + const targetSide = this.paneStates.right.deviceId === device.id ? 'left' : 'right'; + if (targetSide === 'right') { + this.hasAppliedInitialRightPaneDefault = true; + } + const state = this.paneStates[targetSide]; + state.deviceId = device.id; + state.path = this.getDefaultPaneDevicePath(device); + state.items = []; + state.error = undefined; + this.resetPaneScroll(targetSide); + this.clearPaneSelection(targetSide); + this.statusMessage = `已在${this.getSideLabel(targetSide)}打开设备:${device.name}`; + this.render(); + void this.refreshPane(targetSide); + return; + } + if (command.type === 'showSettings') { + this.setActivePage('transfer'); + return; + } + if (command.type === 'showTaskLog') { + this.setActivePage('tasks'); + return; + } + if (command.type === 'clearTasks') { + this.clearFinishedTasks(); + return; + } + if (command.type === 'testConnection') { + void this.testCurrentSshConnection(); + return; + } + if (command.type === 'sync') { + void this.syncSelectedDirectories(); + return; + } + if (command.type === 'openTerminal') { + void this.openTerminal(command.side); + } + } + renderPane(side) { + const state = this.paneStates[side]; + const device = this.getPaneDevice(side); + const pane = $('.turbofile-pane'); + pane.dataset.turbofileSide = side; + pane.classList.toggle('is-maximized', this.maximizedPane === side); + pane.classList.toggle('is-hidden-by-maximize', !!this.maximizedPane && this.maximizedPane !== side); + pane.addEventListener('pointerdown', () => this.setActiveFilePaneSide(side)); + pane.addEventListener('focusin', () => this.setActiveFilePaneSide(side)); + const paneImageGridState = this.activePage === 'transfer' ? this.paneImageGridStates[side] : undefined; + if (paneImageGridState) { + pane.classList.add('turbofile-pane-image-grid'); + pane.appendChild(this.renderImageGridPage(paneImageGridState)); + return pane; + } + const browserTitle = side === 'left' ? '左侧文件浏览' : '右侧文件浏览'; + const header = $('.turbofile-pane-header'); + header.appendChild(this.renderDeviceSelect(side, device)); + const browserHeader = $('.turbofile-browser-header'); + const toolbar = $('.turbofile-browser-toolbar'); + const toolbarTitle = $('.turbofile-browser-toolbar-title'); + toolbarTitle.appendChild(renderTurboFileIcon(side === 'left' ? 'files' : 'folder-check', side === 'left' ? 'info' : 'success')); + toolbarTitle.append(browserTitle); + const itemCount = $('.turbofile-browser-item-count'); + this.updatePaneItemCountElement(side, itemCount); + toolbarTitle.appendChild(itemCount); + toolbar.appendChild(toolbarTitle); + const toolbarAccent = side === 'left' ? 'primary' : 'success'; + const gridAccent = side === 'left' ? 'info' : 'success'; + this.appendPaneSortButton(toolbar, side, toolbarAccent); + this.appendToolbarButton(toolbar, Codicon.refresh, '刷新', () => this.refreshPane(side, { force: true }), 'arrow-clockwise', toolbarAccent); + this.appendToolbarButton(toolbar, Codicon.search, '搜索', () => this.searchInPane(side), 'search', toolbarAccent); + this.appendToolbarButton(toolbar, Codicon.trash, '删除选中项', () => this.deleteSelected(side), 'trash', 'danger'); + this.appendToolbarButton(toolbar, Codicon.copy, '复制当前路径', () => { void this.copyPanePath(side); }, 'files', 'secondary'); + this.appendToolbarButton(toolbar, Codicon.fileMedia, '展开图片', () => this.openImageGrid(side), 'grid-3x3-gap', gridAccent); + this.appendToolbarButton(toolbar, side === 'left' ? Codicon.layoutSidebarLeft : Codicon.layoutSidebarRight, '在当前侧展开图片', () => this.openImageGrid(side, undefined, false, 'grid', 'pane')); + this.appendToolbarButton(toolbar, Codicon.terminal, '打开终端', () => this.openTerminal(side), 'terminal', toolbarAccent); + this.appendToolbarButton(toolbar, this.maximizedPane === side ? Codicon.screenNormal : Codicon.screenFull, this.maximizedPane === side ? '恢复双栏' : '单栏显示', () => this.togglePaneMaximized(side)); + const pathbar = this.renderPanePathbar(side, device, state, toolbarAccent); + const selectedCount = this.getSelectedTransferItems(side).length; + if (selectedCount > 1) { + const selectionCount = $('.turbofile-selection-count'); + selectionCount.textContent = `已选 ${selectedCount} 项`; + pathbar.appendChild(selectionCount); + } + browserHeader.appendChild(toolbar); + browserHeader.appendChild(pathbar); + const list = $('.turbofile-file-list'); + list.classList.toggle('is-external-drop-target', this.externalDropTargetSide === side); + list.classList.add('show-file-icons'); + list.tabIndex = 0; + list.addEventListener('pointerdown', event => { + this.setActiveFilePaneSide(side); + if (event.target?.closest('.turbofile-inline-rename')) { + return; + } + list.focus({ preventScroll: true }); + }); + list.addEventListener('click', event => { + const target = event.target; + if (!target?.closest('.turbofile-file-row') && this.paneStates[side].selectedPaths.size) { + this.clearPaneSelection(side); + this.updateRenderedSelection(side); + } + }); + list.addEventListener('keydown', event => { + if (event.key === 'F2') { + event.preventDefault(); + this.setActiveFilePaneSide(side); + void this.renameSelected(side); + return; + } + if (event.key === 'Delete') { + event.preventDefault(); + this.setActiveFilePaneSide(side); + void this.deleteSelected(side); + return; + } + if ((event.ctrlKey || event.metaKey) && !event.altKey) { + if (this.isEditableKeyboardTarget(event.target)) { + return; + } + const key = event.key.toLowerCase(); + if (key === 'a') { + event.preventDefault(); + this.setActiveFilePaneSide(side); + this.selectAllPaneItems(side); + return; + } + if (key === 'c' || key === 'x') { + event.preventDefault(); + this.setActiveFilePaneSide(side); + this.copySelectedToInternalClipboard(side, key === 'x' ? 'move' : 'copy', false); + return; + } + } + }); + list.addEventListener('paste', event => { + event.preventDefault(); + void this.pasteIntoPane(side); + }); + list.addEventListener('dragover', event => { + const sourceSide = event.dataTransfer?.getData('application/x-turbofile-side'); + if (sourceSide && sourceSide !== side) { + this.consumeTurboFileDropEvent(event); + if (event.dataTransfer) { + event.dataTransfer.dropEffect = 'copy'; + } + this.setExternalDropTargetSide(undefined); + return; + } + if (this.hasExternalDropData(event)) { + this.consumeTurboFileDropEvent(event); + if (event.dataTransfer) { + event.dataTransfer.dropEffect = 'copy'; + } + this.setExternalDropTargetSide(side); + return; + } + this.setExternalDropTargetSide(undefined); + }); + list.addEventListener('dragleave', event => { + const relatedTarget = event.relatedTarget; + if (relatedTarget instanceof Node && list.contains(relatedTarget)) { + return; + } + this.setExternalDropTargetSide(undefined); + }); + list.addEventListener('drop', event => { + const initiatedAt = Date.now(); + this.consumeTurboFileDropEvent(event); + this.setExternalDropTargetSide(undefined); + const sourceSide = event.dataTransfer?.getData('application/x-turbofile-side'); + if (sourceSide && sourceSide !== side) { + this.clearCurrentFileDragState(); + this.deferAfterDrag(() => void this.transferSelected(sourceSide, side, initiatedAt)); + return; + } + // Read the external paths synchronously (the dataTransfer is only valid inside the handler), + // then defer the transfer so the drag image is cleared before the list re-renders. + const externalPaths = this.extractExternalDropPaths(event); + this.clearCurrentFileDragState(); + if (externalPaths.length) { + this.deferAfterDrag(() => void this.transferExternalPaths(side, externalPaths, 'copy', initiatedAt)); + } + }); + list.addEventListener('contextmenu', event => this.showPaneContextMenu(side, event)); + list.addEventListener('scroll', () => this.handleFileListScroll(side, list)); + this.renderFileList(side, list); + pane.appendChild(header); + pane.appendChild(browserHeader); + pane.appendChild(list); + return pane; + } + renderDeviceSelect(side, currentDevice) { + const select = $('.turbofile-device-select'); + const button = $('button.turbofile-device-select-button'); + const menu = $('.turbofile-device-select-menu'); + const devices = this.getAllDevices(); + const isOpen = this.openDeviceSelectSide === side; + select.dataset.turbofileSide = side; + select.classList.toggle('is-open', isOpen); + button.type = 'button'; + button.title = '选择设备'; + button.setAttribute('aria-haspopup', 'menu'); + button.setAttribute('aria-expanded', String(isOpen)); + button.setAttribute('aria-label', `${this.getSideLabel(side)}设备选择`); + button.appendChild(this.renderDeviceSelectIcon(currentDevice)); + button.appendChild($('span.turbofile-device-select-label', undefined, currentDevice ? this.getDeviceDisplayLabel(currentDevice) : 'Windows')); + button.appendChild(renderIcon(Codicon.chevronDown)); + menu.setAttribute('role', 'menu'); + menu.setAttribute('aria-label', `${this.getSideLabel(side)}设备列表`); + for (const device of devices) { + const option = $('button.turbofile-device-select-option'); + option.type = 'button'; + option.setAttribute('role', 'option'); + option.setAttribute('aria-selected', device.id === currentDevice?.id ? 'true' : 'false'); + option.dataset.deviceId = device.id; + if (device.id === currentDevice?.id) { + option.classList.add('is-selected'); + } + option.appendChild(this.renderDeviceSelectIcon(device)); + option.appendChild($('span.turbofile-device-select-label', undefined, this.getDeviceDisplayLabel(device))); + option.addEventListener('pointerenter', () => this.prefetchPaneDeviceDefaultDirectory(side, device)); + option.addEventListener('focus', () => this.prefetchPaneDeviceDefaultDirectory(side, device)); + option.addEventListener('click', event => { + event.preventDefault(); + event.stopPropagation(); + this.closeDeviceSelect(side, false); + this.setPaneDevice(side, device); + }); + menu.appendChild(option); + } + const toggleMenu = (event) => { + event.preventDefault(); + event.stopPropagation(); + const nextOpen = this.openDeviceSelectSide !== side || !select.classList.contains('is-open'); + this.closeDeviceSelect(undefined, false); + this.openDeviceSelectSide = nextOpen ? side : undefined; + select.classList.toggle('is-open', nextOpen); + button.setAttribute('aria-expanded', String(nextOpen)); + if (nextOpen) { + this.pendingRenderAfterDeviceSelectClose = false; + const selected = menu.querySelector('.turbofile-device-select-option.is-selected') ?? menu.querySelector('.turbofile-device-select-option'); + selected?.focus(); + } + else { + this.flushPendingRenderAfterDeviceSelectClose(); + } + }; + button.addEventListener('click', toggleMenu); + button.addEventListener('keydown', event => { + if (event.key === 'Enter' || event.key === ' ' || event.key === 'ArrowDown') { + toggleMenu(event); + } + }); + select.addEventListener('keydown', event => { + if (event.key === 'Escape') { + event.preventDefault(); + this.closeDeviceSelect(side); + button.focus(); + } + }); + const window = getWindow(select); + const handleWindowClick = (event) => { + if (!select.contains(event.target)) { + this.closeDeviceSelect(side); + } + }; + const handleWindowKeydown = (event) => { + if (event.key === 'Escape' && select.classList.contains('is-open')) { + event.preventDefault(); + this.closeDeviceSelect(side); + button.focus(); + } + }; + window.addEventListener('click', handleWindowClick); + window.addEventListener('keydown', handleWindowKeydown); + this.renderDisposables.add({ dispose: () => window.removeEventListener('click', handleWindowClick) }); + this.renderDisposables.add({ dispose: () => window.removeEventListener('keydown', handleWindowKeydown) }); + select.appendChild(button); + select.appendChild(menu); + return select; + } + shouldDeferRenderForOpenDeviceSelect() { + if (this.activePage !== 'transfer' || !this.openDeviceSelectSide || !this.container) { + return false; + } + return !!this.container.querySelector(`.turbofile-device-select[data-turbofile-side="${this.openDeviceSelectSide}"].is-open`); + } + closeDeviceSelect(side, flushPendingRender = true) { + if (side && this.openDeviceSelectSide !== side) { + return; + } + this.openDeviceSelectSide = undefined; + for (const opened of Array.from(this.container?.querySelectorAll('.turbofile-device-select.is-open') ?? [])) { + opened.classList.remove('is-open'); + opened.querySelector('.turbofile-device-select-button')?.setAttribute('aria-expanded', 'false'); + } + if (flushPendingRender) { + this.flushPendingRenderAfterDeviceSelectClose(); + } + } + flushPendingRenderAfterDeviceSelectClose() { + if (!this.pendingRenderAfterDeviceSelectClose) { + return; + } + this.pendingRenderAfterDeviceSelectClose = false; + this.render(); + } + renderPanePathbar(side, device, state, toolbarAccent) { + const pathbar = $('.turbofile-pathbar'); + pathbar.classList.add(`turbofile-pathbar-${toolbarAccent}`); + pathbar.appendChild(renderTurboFileIcon('geo-alt-fill', toolbarAccent)); + if (device?.id === LOCAL_WINDOWS_DEVICE_ID) { + const locationButton = $('button.turbofile-location-menu-button'); + locationButton.type = 'button'; + locationButton.title = '快速切换 Windows 位置'; + locationButton.setAttribute('aria-haspopup', 'menu'); + locationButton.setAttribute('aria-label', `${this.getSideLabel(side)}快速切换 Windows 位置`); + locationButton.appendChild(this.renderWindowsLocationIcon(state.path)); + const locationLabel = $('span.turbofile-location-menu-button-label'); + locationLabel.textContent = this.getWindowsLocationLabel(state.path); + locationButton.appendChild(locationLabel); + locationButton.appendChild(renderIcon(Codicon.chevronDown)); + locationButton.addEventListener('click', event => this.showWindowsLocationMenu(side, event)); + locationButton.addEventListener('keydown', event => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + this.showWindowsLocationMenu(side, event); + } + }); + pathbar.appendChild(locationButton); + } + const display = $('span.turbofile-path-display'); + display.tabIndex = 0; + display.title = state.error ? state.error : this.formatPanePathForDisplay(state.path, !!device && this.isWindowsDevice(device)); + this.renderPathDisplayContent(display, side, device, state); + display.addEventListener('click', event => this.maybeStartPathInlineEdit(event, side, display)); + display.addEventListener('dblclick', event => { + event.preventDefault(); + this.startPathInlineEdit(side, display); + }); + display.addEventListener('keydown', event => { + if (event.key === 'Enter') { + event.preventDefault(); + this.startPathInlineEdit(side, display); + } + }); + pathbar.appendChild(display); + return pathbar; + } + renderWindowsLocationIcon(path) { + const icon = $('span.turbofile-location-icon'); + icon.classList.add(this.getWindowsLocationIconClass(path)); + icon.setAttribute('aria-hidden', 'true'); + return icon; + } + renderPathDisplayContent(display, side, device, state) { + clearNode(display); + if (state.error) { + display.classList.add('is-error'); + display.textContent = state.error; + return; + } + display.classList.remove('is-error'); + const windowsPath = !!device && this.isWindowsDevice(device); + const segments = this.getPathSegments(state.path, windowsPath); + if (!segments.length) { + const root = $('button.turbofile-path-segment'); + root.type = 'button'; + root.textContent = windowsPath ? this.formatPanePathForDisplay(state.path, true) : '根目录'; + root.addEventListener('click', event => { + event.stopPropagation(); + void this.openPanePath(side, state.path); + }); + display.appendChild(root); + return; + } + segments.forEach((segment, index) => { + if (index > 0) { + const separator = $('span.turbofile-path-separator'); + separator.textContent = windowsPath ? '\\' : '/'; + display.appendChild(separator); + } + const button = $('button.turbofile-path-segment'); + button.type = 'button'; + button.textContent = segment.label; + button.title = this.formatPanePathForDisplay(segment.path, windowsPath); + button.addEventListener('click', event => { + event.stopPropagation(); + void this.openPanePath(side, segment.path); + }); + display.appendChild(button); + }); + } + getPathSegments(path, windowsPath) { + if (!path) { + return []; + } + if (!windowsPath) { + const parts = path.split('/').filter(Boolean); + const segments = [{ label: '根目录', path: '/' }]; + let current = ''; + for (const part of parts) { + current = `${current}/${part}`; + segments.push({ label: part, path: current }); + } + return segments; + } + const normalized = this.formatPanePathForDisplay(path, true); + const match = /^([A-Za-z]:)(?:\\(.*))?$/.exec(normalized); + if (!match) { + return [{ label: normalized, path: normalized }]; + } + const segments = [{ label: match[1], path: `${match[1]}\\` }]; + const rest = match[2] ?? ''; + let current = `${match[1]}\\`; + for (const part of rest.split('\\').filter(Boolean)) { + current = current.endsWith('\\') ? `${current}${part}` : `${current}\\${part}`; + segments.push({ label: part, path: current }); + } + return segments; + } + maybeStartPathInlineEdit(event, side, display) { + const target = event.target; + if (target?.closest('.turbofile-path-segment, .turbofile-path-separator')) { + return; + } + this.startPathInlineEdit(side, display); + } + startPathInlineEdit(side, display) { + const device = this.getPaneDevice(side); + const windowsPath = !!device && this.isWindowsDevice(device); + const currentPath = this.formatPanePathForDisplay(this.paneStates[side].path, windowsPath); + clearNode(display); + display.classList.add('is-editing'); + const input = $('input.turbofile-path-inline-editor'); + input.type = 'text'; + input.value = currentPath; + input.spellcheck = false; + display.appendChild(input); + let committed = false; + input.addEventListener('keydown', event => { + if (event.key === 'Enter') { + event.preventDefault(); + committed = true; + void this.commitPathInlineEdit(side, input.value); + } + else if (event.key === 'Escape') { + event.preventDefault(); + committed = true; + this.render(); + } + }); + input.addEventListener('blur', () => { + if (!committed && display.isConnected) { + this.render(); + } + }); + getWindow(display).setTimeout(() => { + input.focus(); + input.select(); + }, 0); + } + async commitPathInlineEdit(side, rawPath) { + const device = this.getPaneDevice(side); + if (!device) { + return; + } + const path = this.normalizePaneInputPath(rawPath, this.isWindowsDevice(device)); + if (!path) { + this.render(); + return; + } + await this.openPanePath(side, path); + } + async openPanePath(side, path) { + await this.refreshPane(side, { path, force: true }); + } + async navigatePaneDirectory(side, path) { + const device = this.getPaneDevice(side); + if (!device) { + return; + } + const state = this.paneStates[side]; + const requestId = ++this.paneRequestSeq[side]; + const needsAttributes = this.paneSortNeedsAttributes(side); + const cached = this.getCachedPaneDirectory(device, path); + state.path = path; + state.error = undefined; + state.loading = true; + this.resetPaneScroll(side); + this.clearPaneSelection(side); + // Only take the instant cached path when the cached listing can satisfy the current sort. A + // names-only listing cannot be shown stably under a size/modified sort. + if (cached && (!needsAttributes || this.cachedListingHasAttributes(cached))) { + this.applyPaneDirectoryEntry(side, device, cached); + state.loading = false; + this.render(); + return; + } + state.items = []; + try { + const listing = await this.loadPaneDirectory(device, path, false, 'foreground', needsAttributes); + if (this.paneRequestSeq[side] !== requestId || this.paneStates[side].path !== path) { + return; + } + this.applyPaneDirectoryEntry(side, device, listing); + } + catch (error) { + if (this.paneRequestSeq[side] === requestId) { + const message = error instanceof Error ? error.message : String(error); + state.error = message; + this.setDeviceRuntimeStatus(device, 'offline'); + this.statusMessage = `${device.name} 目录刷新失败:${message}`; + } + } + finally { + if (this.paneRequestSeq[side] === requestId) { + state.loading = false; + this.render(); + } + } + } + renderFileList(side, list) { + const state = this.paneStates[side]; + state.viewportHeight = list.clientHeight || state.viewportHeight || FILE_LIST_DEFAULT_VIEWPORT_HEIGHT; + this.clampFileListScrollTop(side); + if (state.error) { + this.fileListRenderedRange[side] = undefined; + list.appendChild(this.renderFileRow(side, { name: state.error, size: '', rawSize: 0, modified: '', rawModified: 0, type: 'unknown' }, false)); + return; + } + const range = this.getFileListRange(state); + this.renderFileListSlice(side, list, range); + list.scrollTop = state.scrollTop; + this.restoreRenderedFileListScroll(side, list); + this.scheduleEnrichVisibleRows(side); + } + renderFileListSlice(side, list, range) { + const state = this.paneStates[side]; + clearNode(list); + const visibleItems = this.getVisibleFileItems(state); + if (range.topSpacerHeight > 0) { + const topSpacer = $('.turbofile-file-list-spacer'); + topSpacer.style.height = `${range.topSpacerHeight}px`; + list.appendChild(topSpacer); + } + for (const item of visibleItems.slice(range.startIndex, range.endIndex)) { + list.appendChild(this.renderFileRow(side, item, this.isItemSelected(side, item))); + } + if (range.bottomSpacerHeight > 0) { + const bottomSpacer = $('.turbofile-file-list-spacer'); + bottomSpacer.style.height = `${range.bottomSpacerHeight}px`; + list.appendChild(bottomSpacer); + } + this.fileListRenderedRange[side] = { + startIndex: range.startIndex, + endIndex: range.endIndex, + total: visibleItems.length + }; + this.prefetchRenderedDirectoryRows(side, visibleItems, range); + } + prefetchRenderedDirectoryRows(side, items, range) { + let prefetched = 0; + for (const item of items.slice(range.startIndex, range.endIndex + PANE_DIRECTORY_VISIBLE_PREFETCH_LIMIT)) { + if (!item.path || item.parent || !item.directory) { + continue; + } + this.prefetchPaneDirectory(side, item.path); + if (++prefetched >= PANE_DIRECTORY_VISIBLE_PREFETCH_LIMIT) { + return; + } + } + } + getFileListRange(state) { + const total = this.getVisibleFileItems(state).length; + if (!total) { + return { startIndex: 0, endIndex: 0, topSpacerHeight: 0, bottomSpacerHeight: 0 }; + } + const viewportHeight = state.viewportHeight > 0 ? state.viewportHeight : FILE_LIST_DEFAULT_VIEWPORT_HEIGHT; + const firstVisibleRow = Math.max(0, Math.floor(state.scrollTop / FILE_LIST_ROW_HEIGHT)); + const startIndex = Math.max(0, firstVisibleRow - FILE_LIST_OVERSCAN_ROWS); + const visibleRows = Math.ceil(viewportHeight / FILE_LIST_ROW_HEIGHT) + FILE_LIST_OVERSCAN_ROWS * 2; + const endIndex = Math.min(total, startIndex + visibleRows); + return { + startIndex, + endIndex, + topSpacerHeight: startIndex * FILE_LIST_ROW_HEIGHT, + bottomSpacerHeight: Math.max(0, (total - endIndex) * FILE_LIST_ROW_HEIGHT) + }; + } + clampFileListScrollTop(side) { + const state = this.paneStates[side]; + const viewportHeight = state.viewportHeight > 0 ? state.viewportHeight : FILE_LIST_DEFAULT_VIEWPORT_HEIGHT; + const totalHeight = this.getVisibleFileItems(state).length * FILE_LIST_ROW_HEIGHT; + const maxScrollTop = Math.max(0, totalHeight - viewportHeight); + const nextScrollTop = Math.max(0, Math.min(state.scrollTop, maxScrollTop)); + if (nextScrollTop === state.scrollTop) { + return; + } + state.scrollTop = nextScrollTop; + this.fileListRenderedRange[side] = undefined; + } + getVisibleFileItems(state) { + return state.items; + } + sortPaneItems(side, items) { + const state = this.paneStates[side]; + return sortTurboFileItems(items, state.sortKey, state.sortDirection); + } + /** + * Sorting by size or modified time needs the per-file attributes a fast names-only listing + * does not carry. When the active sort needs them we must load the full listing up front so the + * directory opens already in the correct, stable order — otherwise the names-only listing renders + * in name order first and then visibly re-sorts as {@link enrichVisibleRows} fills in sizes. + */ + paneSortNeedsAttributes(side) { + const key = this.paneStates[side].sortKey; + return key === 'size' || key === 'modified'; + } + cachedListingHasAttributes(entry) { + return !entry.items.some(item => item.attrsPending); + } + resortPaneItems(side) { + const state = this.paneStates[side]; + state.items = this.sortPaneItems(side, state.items); + this.fileListRenderedRange[side] = undefined; + this.rememberPaneDeviceSnapshot(side); + this.render(); + } + getPaneSortLabel(key) { + return PANE_SORT_OPTIONS.find(option => option.key === key)?.label ?? '名称'; + } + getPaneSortDirectionLabel(direction) { + return PANE_SORT_DIRECTION_OPTIONS.find(option => option.direction === direction)?.label ?? '递增'; + } + handleFileListScroll(side, list) { + const state = this.paneStates[side]; + state.scrollTop = list.scrollTop; + state.viewportHeight = list.clientHeight || state.viewportHeight; + if (this.fileListScrollRaf[side] || !this.container) { + return; + } + this.fileListScrollRaf[side] = getWindow(this.container).requestAnimationFrame(() => { + this.fileListScrollRaf[side] = 0; + const currentList = this.container?.querySelector(`.turbofile-pane[data-turbofile-side="${side}"] .turbofile-file-list`); + if (!currentList || currentList !== list || state.error) { + return; + } + this.updateFileListVirtualSlice(side, currentList); + }); + } + updateFileListVirtualSlice(side, list) { + const state = this.paneStates[side]; + const range = this.getFileListRange(state); + const rendered = this.fileListRenderedRange[side]; + if (rendered && rendered.startIndex === range.startIndex && rendered.endIndex === range.endIndex && rendered.total === this.getVisibleFileItems(state).length) { + return; + } + const scrollTop = list.scrollTop; + this.renderFileListSlice(side, list, range); + list.scrollTop = scrollTop; + this.scheduleEnrichVisibleRows(side); + } + /** + * Re-renders just the visible slice on the existing file-list element (preserving the scroll + * container and its scrollTop), so an in-place data update — e.g. lazily filled size/date — never + * goes through the heavy full render that would interrupt an active scrollbar drag. + */ + rerenderFileListSliceInPlace(side) { + if (this.activePage !== 'transfer' || this.isTaskLogVisible) { + return; + } + const list = this.container?.querySelector(`.turbofile-pane[data-turbofile-side="${side}"] .turbofile-file-list`); + if (!list) { + return; + } + const state = this.paneStates[side]; + if (state.loading || state.error) { + return; + } + const scrollTop = list.scrollTop; + this.renderFileListSlice(side, list, this.getFileListRange(state)); + list.scrollTop = scrollTop; + } + /** + * Kicks off lazy size/date enrichment for the rows currently in view after a names-only listing. + * Serialized per side; if more rows scroll in while a batch is running, it re-runs once. + */ + scheduleEnrichVisibleRows(side) { + const device = this.getPaneDevice(side); + if (!device) { + return; + } + if (!this.paneStates[side].items.some(item => item.attrsPending)) { + return; + } + if (this.attrsEnrichInFlight[side]) { + this.attrsEnrichAgain[side] = true; + return; + } + void this.enrichVisibleRows(side); + } + async enrichVisibleRows(side) { + const device = this.getPaneDevice(side); + if (!device) { + return; + } + const state = this.paneStates[side]; + const path = state.path; + const range = this.getFileListRange(state); + const pending = this.getVisibleFileItems(state) + .slice(range.startIndex, range.endIndex) + .filter(item => item.attrsPending && !!item.path && !item.parent); + if (!pending.length) { + return; + } + this.attrsEnrichInFlight[side] = true; + try { + const names = pending.map(item => item.name); + const stats = device.id === LOCAL_WINDOWS_DEVICE_ID + ? await this.sshService.statLocalEntries(path, names) + : await this.sshService.statRemoteEntries(await this.getSshConnectionOptions(device), path, names); + if (this.paneStates[side].path !== path || this.getPaneDevice(side)?.id !== device.id) { + return; + } + const byName = new Map(stats.map(stat => [stat.name, stat])); + let changed = false; + for (const item of pending) { + const stat = byName.get(item.name); + if (!stat) { + continue; + } + item.rawSize = stat.size; + item.size = item.directory ? '' : formatSize(stat.size); + item.rawModified = stat.modified; + item.modified = stat.modified > 0 ? formatDate(stat.modified) : ''; + item.attrsPending = false; + changed = true; + } + if (changed) { + // Progress was made: give the directory a fresh retry budget for any rows still pending. + this.attrsEnrichRetryState[side] = undefined; + if (state.sortKey === 'modified' || state.sortKey === 'size') { + state.items = this.sortPaneItems(side, state.items); + this.fileListRenderedRange[side] = undefined; + } + // Update only the visible rows in place — never this.render(). A full render rebuilds the + // scroll-container element, which mid-drag destroys the element the browser is tracking and + // drops the scrollbar drag (the thumb jumps back and you must re-grab). Re-rendering just + // the slice on the existing list element keeps scrollTop/scrollHeight and the drag intact. + this.rerenderFileListSliceInPlace(side); + } + } + catch { + // Enrichment is best-effort; a transient failure is retried below instead of leaving the rows blank. + } + finally { + this.attrsEnrichInFlight[side] = false; + if (this.attrsEnrichAgain[side]) { + this.attrsEnrichAgain[side] = false; + this.scheduleEnrichVisibleRows(side); + } + else { + // If any visible row is still missing size/date (a failed or partial stat), retry with + // backoff so the columns reliably fill in rather than staying blank. + this.scheduleEnrichRetryIfNeeded(side, path); + } + } + } + scheduleEnrichRetryIfNeeded(side, path) { + const state = this.paneStates[side]; + const device = this.getPaneDevice(side); + if (state.path !== path || !device) { + this.attrsEnrichRetryState[side] = undefined; + return; + } + const range = this.getFileListRange(state); + const stillPending = this.getVisibleFileItems(state) + .slice(range.startIndex, range.endIndex) + .some(item => item.attrsPending && !!item.path && !item.parent); + if (!stillPending) { + this.attrsEnrichRetryState[side] = undefined; + return; + } + const current = this.attrsEnrichRetryState[side]; + const count = current && current.path === path ? current.count : 0; + if (count >= REMOTE_ENRICH_MAX_RETRIES) { + // Give up after a few attempts: the remote likely cannot stat these entries at all. + return; + } + this.attrsEnrichRetryState[side] = { path, count: count + 1 }; + const delay = REMOTE_ENRICH_RETRY_BASE_MS * Math.pow(2, Math.min(count, 4)); + const targetWindow = getWindow(this.container); + if (this.attrsEnrichRetryHandle[side] !== undefined) { + targetWindow.clearTimeout(this.attrsEnrichRetryHandle[side]); + } + this.attrsEnrichRetryHandle[side] = targetWindow.setTimeout(() => { + this.attrsEnrichRetryHandle[side] = undefined; + if (this.paneStates[side].path === path) { + this.scheduleEnrichVisibleRows(side); + } + }, delay); + } + resetPaneScroll(side) { + const state = this.paneStates[side]; + state.scrollTop = 0; + state.viewportHeight = 0; + this.fileListRenderedRange[side] = undefined; + this.clearFileListScrollRaf(side); + this.clearFileListRestoreRaf(side); + } + clearFileListScrollRaf(side) { + if (!this.fileListScrollRaf[side] || !this.container) { + this.fileListScrollRaf[side] = 0; + return; + } + getWindow(this.container).cancelAnimationFrame(this.fileListScrollRaf[side]); + this.fileListScrollRaf[side] = 0; + } + restoreRenderedFileListScroll(side, list) { + if (!this.container) { + return; + } + this.clearFileListRestoreRaf(side); + this.fileListRestoreRaf[side] = getWindow(this.container).requestAnimationFrame(() => { + this.fileListRestoreRaf[side] = 0; + const currentList = this.container?.querySelector(`.turbofile-pane[data-turbofile-side="${side}"] .turbofile-file-list`); + if (!currentList || currentList !== list || !list.isConnected) { + return; + } + const state = this.paneStates[side]; + state.viewportHeight = list.clientHeight || state.viewportHeight || FILE_LIST_DEFAULT_VIEWPORT_HEIGHT; + this.clampFileListScrollTop(side); + if (list.scrollTop !== state.scrollTop) { + list.scrollTop = state.scrollTop; + } + state.scrollTop = list.scrollTop; + const range = this.getFileListRange(state); + const rendered = this.fileListRenderedRange[side]; + if (!rendered || rendered.startIndex !== range.startIndex || rendered.endIndex !== range.endIndex || rendered.total !== this.getVisibleFileItems(state).length) { + this.renderFileListSlice(side, list, range); + list.scrollTop = state.scrollTop; + } + }); + } + clearFileListRestoreRaf(side) { + if (!this.fileListRestoreRaf[side] || !this.container) { + this.fileListRestoreRaf[side] = 0; + return; + } + getWindow(this.container).cancelAnimationFrame(this.fileListRestoreRaf[side]); + this.fileListRestoreRaf[side] = 0; + } + renderFileRow(side, item, selected) { + const row = $('.turbofile-file-row'); + const renaming = this.isInlineRenameTarget(side, item); + if (item.path) { + row.dataset.turbofilePath = item.path; + } + if (selected || renaming) { + row.classList.add('is-selected'); + } + if (renaming) { + row.classList.add('is-renaming'); + } + row.classList.add('is-clickable'); + if (item.path && !item.parent && !renaming) { + row.draggable = true; + row.addEventListener('dragstart', event => { + this.beginFileRowDrag(side, item, event); + }); + row.addEventListener('drag', event => { + this.maybeStartWindowsFileDragFromPointer(side, event); + }); + row.addEventListener('dragend', () => { + this.clearCurrentFileDragState(); + }); + } + row.addEventListener('click', event => { + this.setActiveFilePaneSide(side); + if (renaming && event.target?.closest('.turbofile-inline-rename')) { + return; + } + if (event.detail > 1) { + return; + } + this.container?.querySelector(`.turbofile-pane[data-turbofile-side="${side}"] .turbofile-file-list`)?.focus({ preventScroll: true }); + this.selectItem(side, item, event, false); + this.updateRenderedSelection(side); + }); + if ((item.directory || item.parent) && item.path) { + row.addEventListener('pointerenter', () => this.prefetchPaneDirectory(side, item.path, 'hover')); + row.addEventListener('pointerdown', event => { + this.prefetchPaneDirectory(side, item.path, 'commit'); + this.maybeOpenPaneDirectoryOnFastDoubleClick(side, item, event, renaming); + }); + // Drop a same-pane selection onto a folder row (or the ".." row) to move those items + // into that directory, like Windows Explorer / TurboFile-web. + const dropTargetPath = item.path; + row.addEventListener('dragover', event => { + if (this.currentFileDragSide !== side || !this.canMoveSelectionIntoDirectory(side, item, dropTargetPath)) { + return; + } + event.preventDefault(); + event.stopPropagation(); + if (event.dataTransfer) { + event.dataTransfer.dropEffect = 'move'; + } + row.classList.add('is-internal-drop-target'); + }); + row.addEventListener('dragleave', event => { + const relatedTarget = event.relatedTarget; + if (relatedTarget instanceof Node && row.contains(relatedTarget)) { + return; + } + row.classList.remove('is-internal-drop-target'); + }); + row.addEventListener('drop', event => { + if (this.currentFileDragSide !== side || !this.canMoveSelectionIntoDirectory(side, item, dropTargetPath)) { + return; + } + event.preventDefault(); + event.stopPropagation(); + row.classList.remove('is-internal-drop-target'); + const targetLabel = item.parent ? '上一级目录' : item.name; + this.clearCurrentFileDragState(); + this.deferAfterDrag(() => void this.moveSelectedItemsIntoDirectory(side, dropTargetPath, targetLabel)); + }); + } + row.addEventListener('dblclick', () => { + if (renaming) { + return; + } + if ((item.directory || item.parent) && this.consumeSuppressedNativeDirectoryDoubleClick(side, item.path)) { + return; + } + if (item.directory || item.parent) { + void this.openItem(side, item); + } + else { + void this.previewSelected(side, item); + } + }); + row.addEventListener('contextmenu', event => { + this.setActiveFilePaneSide(side); + this.showFileContextMenu(side, item, event); + }); + const name = $('.turbofile-file-name'); + if (renaming) { + name.classList.add('is-renaming'); + this.renderInlineRenameInput(side, item, name); + } + else { + name.appendChild(this.renderFileBrowserIcon(item)); + name.append(item.name); + } + const size = $('.turbofile-file-meta'); + size.textContent = item.size; + const modified = $('.turbofile-file-meta'); + modified.textContent = item.modified; + row.appendChild(name); + row.appendChild(size); + row.appendChild(modified); + return row; + } + maybeOpenPaneDirectoryOnFastDoubleClick(side, item, event, renaming) { + if (renaming || event.button !== 0 || !item.path || (!item.directory && !item.parent)) { + return; + } + const state = this.paneDoubleClickState[side]; + const now = Date.now(); + const isDoubleClick = state.lastPath === item.path && now - state.lastAt <= PANE_FAST_DOUBLE_CLICK_MS; + state.lastPath = item.path; + state.lastAt = now; + if (!isDoubleClick) { + return; + } + state.suppressNativePath = item.path; + void this.openItem(side, item); + } + consumeSuppressedNativeDirectoryDoubleClick(side, path) { + if (!path) { + return false; + } + const state = this.paneDoubleClickState[side]; + if (state.suppressNativePath !== path) { + return false; + } + state.suppressNativePath = undefined; + return true; + } + renderInlineRenameInput(side, item, container) { + let icon = this.renderFileBrowserIcon(item); + container.appendChild(icon); + const inputHost = $('span.turbofile-inline-rename'); + container.appendChild(inputHost); + const editor = $('.turbofile-inline-rename-editor'); + editor.contentEditable = 'plaintext-only'; + editor.spellcheck = false; + editor.setAttribute('role', 'textbox'); + editor.setAttribute('aria-label', '输入文件名。按 Enter 确认或 Escape 取消。'); + editor.textContent = item.name; + inputHost.appendChild(editor); + const disposables = new DisposableStore(); + this.renderDisposables.add(disposables); + let currentSelectionState = 'prefix'; + let finished = false; + const getValue = () => (editor.textContent ?? '').replace(/[\r\n]/g, ''); + const setValue = (value) => { + editor.textContent = value; + }; + const ensureSingleTextNode = () => { + const value = getValue(); + if (editor.childNodes.length !== 1 || editor.firstChild?.nodeType !== Node.TEXT_NODE) { + editor.textContent = value; + } + if (!editor.firstChild) { + editor.appendChild(editor.ownerDocument.createTextNode('')); + } + return editor.firstChild; + }; + const selectRange = (start, end) => { + const textNode = ensureSingleTextNode(); + const length = textNode.data.length; + const range = editor.ownerDocument.createRange(); + range.setStart(textNode, Math.max(0, Math.min(start, length))); + range.setEnd(textNode, Math.max(0, Math.min(end, length))); + const selection = editor.ownerDocument.getSelection(); + selection?.removeAllRanges(); + selection?.addRange(range); + }; + const updateValidationState = () => { + const message = this.validateRenameName(side, item, getValue()); + editor.classList.toggle('is-invalid', !!message); + editor.toggleAttribute('aria-invalid', !!message); + editor.title = message ?? ''; + return message; + }; + const updatePreviewState = () => { + const value = getValue(); + if (editor.textContent !== value) { + setValue(value); + selectRange(value.length, value.length); + } + const targetName = value || ' '; + const targetPath = item.path ? joinPanePath(this.paneStates[side].path, targetName, this.isWindowsDevice(this.getPaneDevice(side) ?? this.localWindowsDevice)) : undefined; + const nextIcon = this.renderFileBrowserIcon({ ...item, name: targetName, path: targetPath }); + icon.replaceWith(nextIcon); + icon = nextIcon; + updateValidationState(); + }; + const selectByState = () => { + editor.focus(); + const value = getValue(); + const dotIndex = value.lastIndexOf('.'); + if (item.directory || dotIndex <= 0) { + selectRange(0, value.length); + return; + } + if (currentSelectionState === 'prefix') { + selectRange(0, dotIndex); + } + else if (currentSelectionState === 'all') { + selectRange(0, value.length); + } + else { + selectRange(dotIndex + 1, value.length); + } + }; + const finish = (success) => { + if (finished) { + return; + } + finished = true; + const nextName = getValue(); + disposables.dispose(); + if (!this.isInlineRenameTarget(side, item)) { + return; + } + this.inlineRename = undefined; + this.render(); + if (success && nextName.trim() && nextName !== item.name && !this.validateRenameName(side, item, nextName)) { + void this.performRename(side, item, nextName); + } + }; + updateValidationState(); + const handleInput = () => updatePreviewState(); + editor.addEventListener('input', handleInput); + disposables.add({ dispose: () => editor.removeEventListener('input', handleInput) }); + const handleKeydown = (event) => { + if ((event.ctrlKey || event.metaKey) && !event.altKey) { + const key = event.key.toLowerCase(); + if (key === 'a' || key === 'c' || key === 'v' || key === 'x' || key === 'y' || key === 'z') { + event.stopPropagation(); + return; + } + } + if (event.key === 'Enter') { + event.preventDefault(); + event.stopPropagation(); + if (!updateValidationState()) { + finish(true); + } + return; + } + if (event.key === 'Escape') { + event.preventDefault(); + event.stopPropagation(); + finish(false); + return; + } + if (event.key === 'F2') { + event.preventDefault(); + event.stopPropagation(); + if (currentSelectionState === 'prefix') { + currentSelectionState = 'all'; + } + else if (currentSelectionState === 'all') { + currentSelectionState = 'suffix'; + } + else { + currentSelectionState = 'prefix'; + } + selectByState(); + } + }; + editor.addEventListener('keydown', handleKeydown); + disposables.add({ dispose: () => editor.removeEventListener('keydown', handleKeydown) }); + const handleBeforeInput = (event) => { + if (event.inputType === 'insertParagraph' || event.inputType === 'insertLineBreak') { + event.preventDefault(); + } + }; + editor.addEventListener('beforeinput', handleBeforeInput); + disposables.add({ dispose: () => editor.removeEventListener('beforeinput', handleBeforeInput) }); + const stopClipboardEventPropagation = (event) => { + event.stopPropagation(); + if (event.type === 'paste') { + const text = event.clipboardData?.getData('text/plain')?.replace(/[\r\n]/g, '') ?? ''; + if (text) { + event.preventDefault(); + editor.ownerDocument.execCommand('insertText', false, text); + updatePreviewState(); + } + } + }; + for (const eventType of ['copy', 'cut', 'paste']) { + editor.addEventListener(eventType, stopClipboardEventPropagation); + disposables.add({ dispose: () => editor.removeEventListener(eventType, stopClipboardEventPropagation) }); + } + const handleBlur = () => { + const window = getWindow(editor); + window.setTimeout(() => { + if (finished || window.document.activeElement === editor) { + return; + } + finish(!updateValidationState()); + }, 0); + }; + editor.addEventListener('blur', handleBlur); + disposables.add({ dispose: () => editor.removeEventListener('blur', handleBlur) }); + const window = getWindow(inputHost); + const frame = window.requestAnimationFrame(() => { + if (!finished && this.isInlineRenameTarget(side, item)) { + editor.focus(); + currentSelectionState = 'prefix'; + selectByState(); + } + }); + disposables.add({ dispose: () => window.cancelAnimationFrame(frame) }); + } + renderTaskbar() { + const taskbar = $('.turbofile-taskbar'); + const runningTransfers = this.tasks.filter(task => task.kind === 'transfer' && task.status === 'running'); + const runningTasks = this.tasks.filter(task => task.status === 'running'); + const latest = this.tasks[0]; + const stats = this.getTaskStats(); + if (runningTransfers.length) { + taskbar.classList.add('has-live-transfer'); + } + taskbar.tabIndex = 0; + taskbar.setAttribute('role', 'button'); + taskbar.title = '打开任务日志'; + taskbar.setAttribute('aria-label', '打开任务日志'); + taskbar.addEventListener('click', () => this.setActivePage('tasks')); + taskbar.addEventListener('keydown', event => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + this.setActivePage('tasks'); + } + }); + const progress = $('.turbofile-statusbar-progress'); + const progressValue = $('.turbofile-statusbar-progress-value'); + const liveSummary = runningTransfers.length ? this.getLiveTransferAggregate(runningTransfers) : undefined; + const livePercent = liveSummary && liveSummary.totalBytes > 0 + ? Math.max(0, Math.min(100, (liveSummary.transferredBytes / liveSummary.totalBytes) * 100)) + : runningTasks.length ? Math.max(...runningTasks.map(task => task.progress)) : 0; + progressValue.style.width = runningTasks.length ? `${livePercent.toFixed(1)}%` : '0%'; + progress.classList.toggle('is-running', runningTasks.length > 0); + progress.appendChild(progressValue); + taskbar.appendChild(progress); + const content = $('.turbofile-statusbar-content'); + const left = $('.turbofile-statusbar-section.is-left'); + left.appendChild(this.renderStatusbarItem(Codicon.history, `运行 ${stats.running} · 成功 ${stats.success} · 失败 ${stats.error} · 取消 ${stats.canceled}`)); + const message = $('.turbofile-statusbar-message'); + const title = $('.turbofile-statusbar-message-title'); + const meta = $('.turbofile-task-meta.turbofile-statusbar-message-detail'); + if (runningTransfers.length) { + title.textContent = this.getLiveTransferStatusTitle(runningTransfers); + meta.textContent = ''; + } + else if (latest) { + title.textContent = this.formatTaskLine(latest); + meta.textContent = ''; + } + else { + title.textContent = this.statusMessage; + meta.textContent = '暂无传输任务,最近日志会显示在这里。'; + } + message.appendChild(title); + if (meta.textContent) { + message.appendChild(meta); + } + const right = $('.turbofile-statusbar-section.is-right'); + if (runningTransfers.length && liveSummary) { + const isRsyncTransferRunning = runningTransfers.some(task => task.strategy?.startsWith('rsync')); + const transferSizeText = liveSummary.totalBytes > 0 + ? `${formatSize(liveSummary.transferredBytes)} / ${formatSize(liveSummary.totalBytes)}` + : liveSummary.transferredBytes > 0 || isRsyncTransferRunning ? formatSize(liveSummary.transferredBytes) : '准备传输'; + const showElapsedTime = liveSummary.remainingMs === undefined || liveSummary.remainingMs <= 0 || !Number.isFinite(liveSummary.remainingMs); + const timeText = showElapsedTime ? this.formatElapsed(liveSummary.startedAt) : this.formatRemaining(liveSummary.remainingMs); + const timeTitle = showElapsedTime ? '已耗时' : '剩余时间'; + right.appendChild(this.renderStatusbarItem(Codicon.arrowSwap, this.formatSpeed(liveSummary.speedBytesPerSecond), '当前速度')); + right.appendChild(this.renderStatusbarItem(Codicon.clock, timeText, timeTitle)); + right.appendChild(this.renderStatusbarItem(Codicon.archive, transferSizeText, '已传输')); + const protocol = runningTransfers.length === 1 ? this.getTransferProtocolLabel(runningTransfers[0].strategy) : undefined; + if (protocol) { + right.appendChild(this.renderStatusbarItem(Codicon.terminal, protocol, '传输模式')); + } + const cancel = this.createIconButton(Codicon.close, '取消运行中的传输', 'turbofile-statusbar-cancel'); + this.renderDisposables.add(cancel); + cancel.element.disabled = runningTransfers.every(task => task.cancelRequested); + cancel.element.addEventListener('click', event => { + event.stopPropagation(); + void this.cancelRunningTransfers(runningTransfers); + }); + right.appendChild(cancel.element); + } + else if (latest) { + right.appendChild(this.renderStatusbarItem(this.getTaskIcon(latest), latest.status === 'running' ? '进行中' : latest.status === 'success' ? '完成' : latest.status === 'canceled' ? '已取消' : '失败')); + } + else { + right.appendChild(this.renderStatusbarItem(Codicon.info, '任务日志')); + } + content.appendChild(left); + content.appendChild(message); + content.appendChild(right); + taskbar.appendChild(content); + return taskbar; + } + refreshTaskbar() { + if (!this.container || this.activePage !== 'transfer') { + return; + } + const current = this.container.querySelector('.turbofile-taskbar'); + if (!this.shouldRenderTaskbar()) { + current?.remove(); + return; + } + if (!current) { + const transferPage = this.container.querySelector('.turbofile-transfer-page'); + transferPage?.appendChild(this.renderTaskbar()); + return; + } + current.replaceWith(this.renderTaskbar()); + } + /** + * Keep terminal visibility independent from taskbar visibility. Running tasks may temporarily show + * the taskbar while the terminal is open; CSS reserves the taskbar height inside the terminal. + */ + isTerminalPanelVisibleForCurrentPage() { + if (this.taskbarHiddenForTerminal) { + return true; + } + const pageInstanceId = this.currentInput?.instanceId; + if (!pageInstanceId || !this.container) { + return false; + } + if (this.getBoundTerminalsForPage(pageInstanceId).length === 0) { + return false; + } + return this.layoutService.isVisible("workbench.parts.panel" /* Parts.PANEL_PART */, getWindow(this.container)); + } + shouldRenderTaskbar() { + return !this.isTerminalPanelVisibleForCurrentPage() || this.tasks.some(task => task.status === 'running'); + } + renderStatusbarItem(icon, text, title) { + const item = $('.turbofile-statusbar-item'); + if (title) { + item.title = title; + } + item.appendChild(renderIcon(icon)); + const label = $('span'); + label.textContent = text; + item.appendChild(label); + return item; + } + getLiveTransferStatusTitle(tasks) { + const items = tasks.flatMap(task => this.getLiveTransferItems(task).map(item => ({ task, item }))); + const active = items.find(({ item }) => item.status === 'running') ?? items.find(({ item }) => item.status === 'waiting') ?? items[0]; + if (!active) { + return tasks.length > 1 ? `正在传输 ${tasks.length} 个任务` : tasks[0].label; + } + return items.length > 1 ? `正在传输:${active.item.name} · ${items.length} 项` : `正在传输:${active.item.name}`; + } + getLiveTransferItems(task) { + return task.transferItems?.length ? task.transferItems : [this.createFallbackTransferItem(task)]; + } + createFallbackTransferItem(task) { + const totalBytes = task.totalBytes ?? task.bytes ?? 0; + const transferredBytes = task.transferredBytes ?? 0; + return { + name: task.label.replace(/^复制\s+/, ''), + type: 'file', + displaySize: totalBytes > 0 ? formatSize(totalBytes) : '', + rawSize: totalBytes, + status: task.status === 'running' ? 'running' : task.status, + progress: task.progress, + transferredBytes, + totalBytes, + speedBytesPerSecond: task.speedBytesPerSecond ?? 0, + remainingMs: task.remainingMs, + strategy: task.strategy, + speedSamples: task.speedSamples ?? [] + }; + } + getLiveTransferAggregate(tasks) { + let transferredBytes = 0; + let totalBytes = 0; + let speedBytesPerSecond = 0; + let startedAt; + const addStartedAt = (candidate) => { + if (candidate === undefined) { + return; + } + startedAt = startedAt === undefined ? candidate : Math.min(startedAt, candidate); + }; + for (const task of tasks) { + const taskDataStartedAt = task.transferStartedAt; + const taskVisibleStartedAt = getVisibleTransferStartedAt({ + status: task.status, + strategy: task.strategy, + startedAt: task.startedAt, + transferStartedAt: task.transferStartedAt + }); + if ((task.transferItems?.length ?? 0) > 1 && task.activeTransferItemIndex === undefined && task.totalBytes !== undefined) { + transferredBytes += task.transferredBytes ?? 0; + // task.totalBytes is already 0 while an rsync total is unknown and the real size once the + // main process resolves it, so no rsync-specific zeroing is needed here anymore. + totalBytes += task.totalBytes ?? 0; + speedBytesPerSecond += task.status === 'running' ? task.speedBytesPerSecond ?? 0 : 0; + addStartedAt(taskVisibleStartedAt); + continue; + } + const items = this.getLiveTransferItems(task); + for (const item of items) { + transferredBytes += item.transferredBytes; + totalBytes += this.getLiveTransferItemTotal(item, task); + speedBytesPerSecond += item.status === 'running' ? item.speedBytesPerSecond : 0; + addStartedAt(getVisibleTransferStartedAt({ + status: item.status, + strategy: item.strategy ?? task.strategy, + startedAt: item.startedAt ?? task.startedAt, + transferStartedAt: item.transferStartedAt ?? taskDataStartedAt + })); + } + } + const remainingBytes = Math.max(0, totalBytes - transferredBytes); + const remainingMs = totalBytes > 0 && transferredBytes < totalBytes && speedBytesPerSecond > 0 + ? (remainingBytes / speedBytesPerSecond) * 1000 + : undefined; + return { + transferredBytes, + totalBytes, + speedBytesPerSecond, + remainingMs, + startedAt + }; + } + getLiveTransferItemTotal(item, task) { + const strategy = item.strategy ?? task?.strategy; + if ((task?.status === 'running' || item.status === 'running') && this.isRsyncStrategy(strategy)) { + // Show the concurrently-computed rsync total when known, but don't fall back to the listing + // rawSize (which can disagree with rsync's own transferred count and make the bar jumpy). + return Math.max(item.totalBytes, 0); + } + return Math.max(item.totalBytes, item.rawSize, item.transferredBytes, 0); + } + isRsyncStrategy(strategy) { + return isTurboFileRsyncStrategy(strategy); + } + formatSpeed(bytesPerSecond) { + return bytesPerSecond && bytesPerSecond > 0 ? `${formatTransferRate(bytesPerSecond)}/s` : '--'; + } + formatRemaining(milliseconds) { + return milliseconds !== undefined && Number.isFinite(milliseconds) && milliseconds >= 0 ? formatDuration(milliseconds) : '--'; + } + formatElapsed(startedAt) { + if (startedAt === undefined) { + return '--'; + } + return formatDuration(Math.max(0, Date.now() - startedAt)); + } + async cancelRunningTransfers(tasks) { + for (const task of tasks) { + await this.cancelTask(task); + } + } + getRunningTaskCount() { + return this.tasks.filter(task => task.status === 'running').length; + } + updateInputRunningTaskCount() { + this.currentInput?.setRunningTaskCount(this.getRunningTaskCount()); + } + async cancelRunningTasksForClose() { + const runningTasks = this.tasks.filter(task => task.status === 'running'); + await this.cancelRunningTransfers(runningTasks); + for (const task of runningTasks) { + if (task.status === 'running') { + this.finishTask(task, 'canceled', `已取消任务:${task.label}`); + } + } + this.updateInputRunningTaskCount(); + } + renderTaskLogOverlay() { + const overlay = $('.turbofile-task-log-overlay'); + const panel = $('.turbofile-task-log-panel'); + const header = $('.turbofile-task-log-header'); + const title = $('h2'); + title.textContent = '任务日志'; + const close = this.createIconButton(Codicon.close, '关闭任务日志', 'turbofile-toolbar-button'); + this.renderDisposables.add(close); + close.element.addEventListener('click', () => { + this.isTaskLogVisible = false; + this.render(); + }); + header.appendChild(title); + header.appendChild(close.element); + const list = $('.turbofile-task-log-list'); + if (!this.tasks.length) { + const empty = $('.turbofile-task-log-empty'); + empty.textContent = '暂无任务记录'; + list.appendChild(empty); + } + else { + for (const task of this.tasks) { + list.appendChild(this.renderTaskRow(task)); + } + } + panel.appendChild(header); + panel.appendChild(list); + overlay.appendChild(panel); + overlay.addEventListener('click', event => { + if (event.target === overlay) { + this.isTaskLogVisible = false; + this.render(); + } + }); + return overlay; + } + clearFinishedTasks() { + for (let i = this.tasks.length - 1; i >= 0; i--) { + if (this.tasks[i].status !== 'running') { + this.tasks.splice(i, 1); + } + } + this.statusMessage = '已清空完成的任务。'; + this.render(); + } + renderTaskRow(task) { + const row = $('.turbofile-task-row'); + row.classList.add(`is-${task.status}`); + const name = $('.turbofile-task-row-name'); + name.appendChild(renderIcon(this.getTaskIcon(task))); + name.append(task.label); + const detail = $('.turbofile-task-row-detail'); + detail.textContent = task.detail; + const metrics = $('.turbofile-task-row-metrics'); + metrics.textContent = this.getTaskMetrics(task); + const actions = $('.turbofile-task-row-actions'); + if (task.status === 'running' && task.progressId) { + const cancel = this.createIconButton(Codicon.debugStop, '取消任务', 'turbofile-task-row-button'); + this.renderDisposables.add(cancel); + cancel.element.disabled = !!task.cancelRequested; + cancel.element.addEventListener('click', event => { + event.stopPropagation(); + void this.cancelTask(task); + }); + actions.appendChild(cancel.element); + } + else if ((task.status === 'error' || task.status === 'canceled') && task.retryTransfer) { + const retry = this.createIconButton(Codicon.redo, '重试任务', 'turbofile-task-row-button'); + this.renderDisposables.add(retry); + retry.element.addEventListener('click', event => { + event.stopPropagation(); + void this.retryTask(task); + }); + actions.appendChild(retry.element); + } + row.appendChild(name); + row.appendChild(detail); + row.appendChild(metrics); + row.appendChild(actions); + return row; + } + async cancelTask(task) { + if (task.status !== 'running' || task.cancelRequested) { + return; + } + task.cancelRequested = true; + task.detail = '正在取消任务...'; + this.statusMessage = task.detail; + this.render(); + if (!task.progressId) { + return; + } + const result = await this.sshService.cancelTransfer(task.progressId); + if (!result.ok && task.status === 'running') { + this.finishTask(task, 'error', `取消任务失败:${result.message}`, { error: result.message }); + } + } + async retryTask(task) { + const retry = task.retryTransfer; + if (!retry || (task.status !== 'error' && task.status !== 'canceled')) { + return; + } + const sourceDevice = retry.sourceDeviceId ? this.getDeviceById(retry.sourceDeviceId) : this.getPaneDevice(retry.sourceSide); + const targetDevice = this.getPaneDevice(retry.targetSide); + const nextTask = this.beginTask('transfer', `重试 ${retry.sourceItem.name}`, `${sourceDevice?.name ?? retry.sourceSide} -> ${targetDevice?.name ?? retry.targetSide}`); + nextTask.retryTransfer = retry; + const result = await this.transferItem(retry.sourceSide, retry.targetSide, retry.sourceItem, { + refreshTarget: true, + task: nextTask, + targetPath: retry.targetPath, + sourceDeviceOverride: sourceDevice + }); + if (result.ok) { + this.finishTask(nextTask, 'success', `重试完成:${retry.sourceItem.name}`, { bytes: result.bytes, strategy: result.strategy }); + return; + } + if (result.canceled) { + this.finishTask(nextTask, 'canceled', `已取消重试:${retry.sourceItem.name}`); + return; + } + this.finishTask(nextTask, 'error', `重试失败:${result.message}`, { error: result.message }); + } + updateLiveTransferTicker() { + // During rsync startup the process can be quiet while ssh connects and rsync builds its + // file list. Tick only for that startup window; once data progress arrives, progress events + // drive rendering so the status bar does not jump on two different clocks. + const needsStartupTicker = this.tasks.some(task => this.needsLiveTransferStartupTicker(task)); + if (needsStartupTicker && this.liveTransferTicker === undefined && this.container) { + this.liveTransferTicker = getWindow(this.container).setInterval(() => { + if (!this.tasks.some(task => this.needsLiveTransferStartupTicker(task))) { + this.clearLiveTransferTicker(); + return; + } + this.render(); + }, TURBOFILE_RSYNC_STARTUP_TICK_INTERVAL_MS); + } + else if (!needsStartupTicker) { + this.clearLiveTransferTicker(); + } + } + needsLiveTransferStartupTicker(task) { + if (task.kind !== 'transfer' || task.status !== 'running') { + return false; + } + if (task.transferStartedAt !== undefined) { + return false; + } + return this.getLiveTransferItems(task).some(item => item.status === 'running' + && item.transferStartedAt === undefined + && this.isRsyncStrategy(item.strategy ?? task.strategy)); + } + clearLiveTransferTicker() { + if (this.liveTransferTicker !== undefined && this.container) { + getWindow(this.container).clearInterval(this.liveTransferTicker); + } + this.liveTransferTicker = undefined; + } + beginTask(kind, label, detail, shouldRender = true) { + const task = { + id: this.taskSequence++, + kind, + label, + detail, + status: 'running', + startedAt: Date.now(), + progress: 35 + }; + this.tasks.unshift(task); + if (this.tasks.length > 16) { + this.tasks.splice(16); + } + this.statusMessage = detail; + this.updateInputRunningTaskCount(); + this.updateLiveTransferTicker(); + if (shouldRender) { + this.render(); + } + else { + this.refreshTaskbar(); + } + return task; + } + updateTask(task, detail, progress = task.progress, shouldRender = true) { + task.detail = detail; + task.progress = progress; + this.statusMessage = detail; + if (shouldRender) { + this.render(); + } + else { + this.refreshTaskbar(); + } + } + updateTransferProgress(task, event) { + const activeItem = task.activeTransferItemIndex !== undefined ? task.transferItems?.[task.activeTransferItemIndex] : undefined; + const strategy = this.getTransferStrategyFromProgress(event) ?? task.strategy; + const isRsyncProgress = strategy?.startsWith('rsync') ?? false; + const knownTotalBytes = activeItem ? activeItem.totalBytes : task.totalBytes ?? 0; + // rsync used to be forced to an unknown (0) total because it had no reliable size up front. + // The main process now computes the byte total concurrently and ships it in event.totalBytes, + // so honor it for every strategy; rsync only falls back to 0 when no total is known yet. + const totalBytes = event.totalBytes > 0 ? event.totalBytes : (isRsyncProgress ? 0 : knownTotalBytes); + const transferredBytes = Math.min(event.transferredBytes, totalBytes > 0 ? totalBytes : event.transferredBytes); + const progress = totalBytes > 0 + ? Math.max(0, Math.min(100, Math.round((transferredBytes / totalBytes) * 100))) + : event.phase === 'complete' ? 100 : Math.max(8, task.progress); + let detail = this.formatTransferProgressDetail(event, transferredBytes, totalBytes); + if (event.totalItems && event.totalItems > 1) { + const completedItems = Math.max(0, Math.min(event.completedItems ?? 0, event.totalItems)); + const currentName = event.currentName ? `:${event.currentName}` : ''; + detail = `${detail} · ${completedItems}/${event.totalItems}${currentName}`; + } + const now = Date.now(); + const speedTarget = activeItem ?? task; + const resetRsyncSpeedSample = isRsyncProgress + && transferredBytes > 0 + && task.transferStartedAt === undefined + && (speedTarget.lastSampleBytes ?? 0) <= 0; + const sampledSpeed = resetRsyncSpeedSample + ? this.resetTransferSpeedSample(speedTarget, transferredBytes, now) + : this.calculateTransferSpeed(speedTarget, transferredBytes, now); + const eventSpeed = event.speedBytesPerSecond !== undefined && event.speedBytesPerSecond >= 0 && Number.isFinite(event.speedBytesPerSecond) + ? event.speedBytesPerSecond + : undefined; + const speed = chooseDisplayTransferSpeed({ + strategy, + eventSpeedBytesPerSecond: eventSpeed, + sampledSpeedBytesPerSecond: sampledSpeed + }); + const shouldStartTransferTimingValue = shouldStartTransferTiming({ + phase: event.phase, + strategy, + transferredBytes, + speedBytesPerSecond: eventSpeed ?? speed + }); + if (shouldStartTransferTimingValue && task.transferStartedAt === undefined) { + task.transferStartedAt = now; + } + if (activeItem) { + activeItem.startedAt ??= task.startedAt; + if (shouldStartTransferTimingValue && activeItem.transferStartedAt === undefined) { + activeItem.transferStartedAt = now; + } + activeItem.status = event.phase === 'complete' ? 'success' : 'running'; + activeItem.transferredBytes = transferredBytes; + activeItem.totalBytes = totalBytes; + activeItem.progress = event.phase === 'complete' ? 100 : progress; + activeItem.speedBytesPerSecond = speed; + activeItem.remainingMs = speed > 0 && totalBytes > 0 && totalBytes > transferredBytes ? ((totalBytes - transferredBytes) / speed) * 1000 : undefined; + activeItem.strategy = strategy; + if (event.phase === 'complete') { + activeItem.endedAt = now; + } + } + const aggregate = this.getLiveTransferAggregate([task]); + task.transferredBytes = activeItem ? aggregate.transferredBytes : transferredBytes; + task.totalBytes = activeItem ? aggregate.totalBytes : totalBytes; + task.speedBytesPerSecond = activeItem ? aggregate.speedBytesPerSecond : speed; + task.remainingMs = aggregate.remainingMs; + task.strategy = strategy; + task.progress = activeItem && aggregate.totalBytes > 0 + ? Math.max(0, Math.min(100, Math.round((aggregate.transferredBytes / aggregate.totalBytes) * 100))) + : event.phase === 'complete' ? 100 : progress; + task.detail = detail; + this.statusMessage = detail; + this.updateLiveTransferTicker(); + const renderIntervalMs = strategy?.startsWith('rsync') ? RSYNC_PROGRESS_RENDER_INTERVAL_MS : TRANSFER_PROGRESS_RENDER_INTERVAL_MS; + if (event.phase === 'complete' || (totalBytes > 0 && transferredBytes >= totalBytes) || now - (task.lastProgressRenderAt ?? 0) > renderIntervalMs) { + task.lastProgressRenderAt = now; + if (this.activePage === 'transfer' && !this.isTaskLogVisible) { + this.refreshTaskbar(); + } + else { + this.render(); + } + } + } + calculateTransferSpeed(target, transferredBytes, now) { + const previousAt = target.lastSampleAt; + const previousBytes = target.lastSampleBytes; + const previousSpeed = target.speedBytesPerSecond ?? 0; + if (previousAt === undefined || previousBytes === undefined || now <= previousAt || transferredBytes < previousBytes) { + target.lastSampleAt = now; + target.lastSampleBytes = transferredBytes; + return previousSpeed; + } + const elapsed = now - previousAt; + if (elapsed < TRANSFER_SPEED_SAMPLE_MIN_INTERVAL_MS) { + return previousSpeed; + } + target.lastSampleAt = now; + target.lastSampleBytes = transferredBytes; + const rawSpeed = elapsed > 0 ? ((transferredBytes - previousBytes) / elapsed) * 1000 : 0; + this.appendTransferSpeedSample(target.speedSamples ??= [], rawSpeed); + return this.getSmoothedTransferSpeed(target.speedSamples, previousSpeed); + } + resetTransferSpeedSample(target, transferredBytes, now) { + target.lastSampleAt = now; + target.lastSampleBytes = transferredBytes; + return target.speedBytesPerSecond ?? 0; + } + appendTransferSpeedSample(samples, speed) { + if (!Number.isFinite(speed) || speed <= 0) { + return; + } + samples.push(speed); + if (samples.length > TRANSFER_SPEED_SAMPLE_MAX_COUNT) { + samples.splice(0, samples.length - TRANSFER_SPEED_SAMPLE_MAX_COUNT); + } + } + getSmoothedTransferSpeed(samples, previousSpeed) { + const validSamples = samples.filter(speed => Number.isFinite(speed) && speed > 0); + if (!validSamples.length) { + return previousSpeed; + } + const sorted = [...validSamples].sort((a, b) => a - b); + const trimmed = sorted.length >= 5 ? sorted.slice(1, -1) : sorted; + const average = trimmed.reduce((sum, speed) => sum + speed, 0) / trimmed.length; + return previousSpeed > 0 ? previousSpeed * 0.65 + average * 0.35 : average; + } + getTransferStrategyFromProgress(event) { + if (event.strategy) { + return event.strategy; + } + const detail = event.detail?.toLowerCase() ?? ''; + if (event.phase === 'rsync-direct') { + return detail.includes('parallel') ? 'rsync-direct-parallel' : 'rsync-direct'; + } + if (event.phase === 'relay-download' || event.phase === 'relay-upload') { + if (detail.includes('rsync relay')) { + return 'rsync-relay'; + } + return 'sftp-relay'; + } + if (detail.includes('rsync relay')) { + return 'rsync-relay'; + } + if (detail.includes('direct sftp')) { + return 'sftp-direct'; + } + if (detail.includes('direct scp')) { + return 'scp-direct'; + } + if (detail.includes('scp upload')) { + return 'scp-upload'; + } + if (detail.includes('scp download')) { + return 'scp-download'; + } + if (detail.includes('sftp upload')) { + return 'sftp-upload'; + } + if (detail.includes('sftp download')) { + return 'sftp-download'; + } + if (detail.includes('sftp relay')) { + return 'sftp-relay'; + } + if (detail.includes('direct rsync')) { + return 'rsync-direct'; + } + if (detail.includes('direct tar')) { + return 'tar-direct'; + } + if (detail.includes('tar relay')) { + return 'tar-relay'; + } + if (detail.includes('tar upload')) { + return 'tar-upload'; + } + if (detail.includes('tar download')) { + return 'tar-download'; + } + if (detail.includes('remote copy')) { + return 'remote-copy'; + } + if (detail.includes('local copy')) { + return 'local-copy'; + } + return undefined; + } + getTransferProtocolLabel(strategy) { + switch (strategy) { + case 'tar-direct': + return 'tar 直连'; + case 'tar-relay': + return 'tar 中转'; + case 'tar-upload': + return 'tar 上传'; + case 'tar-download': + return 'tar 下载'; + case 'rsync-direct': + case 'rsync-direct-parallel': + return 'rsync 直连'; + case 'sftp-direct': + return 'sftp 直连'; + case 'scp-direct': + return 'scp 直连'; + case 'scp-relay': + return 'scp 中转'; + case 'sftp-upload': + return 'sftp 上传'; + case 'sftp-download': + return 'sftp 下载'; + case 'scp-upload': + return 'scp 上传'; + case 'scp-download': + return 'scp 下载'; + case 'rsync-upload': + return 'rsync 上传'; + case 'rsync-download': + return 'rsync 下载'; + case 'rsync-relay': + return 'rsync 中转'; + case 'sftp-relay': + return 'sftp 中转'; + case 'remote-copy': + return '同机复制'; + case 'local-copy': + return '本地复制'; + case 'remote-move': + return '同机移动'; + case 'local-move': + return '本机移动'; + default: + return strategy; + } + } + getPlannedTransferStrategy(sourceDevice, targetDevice, sourceItem) { + if (sourceDevice.id === LOCAL_WINDOWS_DEVICE_ID && targetDevice.id === LOCAL_WINDOWS_DEVICE_ID) { + return 'local-copy'; + } + if (this.isSameDeviceForNativeOperation(sourceDevice, targetDevice)) { + return 'remote-copy'; + } + const sourceEndpoint = this.toPlanningEndpoint(sourceDevice, sourceItem.path ?? ''); + const targetEndpoint = this.toPlanningEndpoint(targetDevice, ''); + const plan = resolveTransferPlan({ + source: sourceEndpoint, + target: targetEndpoint, + sourceType: sourceItem.type, + itemCount: 1 + }); + return this.transferAttemptToPlannedStrategy(plan.attempts[0], sourceEndpoint, targetEndpoint); + } + getPlannedBatchTransferStrategy(sourceDevice, targetDevice, sourceItems) { + if (!sourceItems.length) { + return 'scp-direct'; + } + if (sourceDevice.id === LOCAL_WINDOWS_DEVICE_ID && targetDevice.id === LOCAL_WINDOWS_DEVICE_ID) { + return 'local-copy'; + } + if (this.isSameDeviceForNativeOperation(sourceDevice, targetDevice)) { + return 'remote-copy'; + } + const sourceEndpoint = this.toPlanningEndpoint(sourceDevice, sourceItems[0].path ?? ''); + const targetEndpoint = this.toPlanningEndpoint(targetDevice, ''); + const plan = resolveTransferPlan({ + source: sourceEndpoint, + target: targetEndpoint, + sourceType: sourceItems.some(item => item.directory) ? 'directory' : sourceItems[0].type, + itemCount: sourceItems.length + }); + return this.transferAttemptToPlannedStrategy(plan.attempts[0], sourceEndpoint, targetEndpoint); + } + primeTransferTaskStrategy(task, sourceDevice, targetDevice, sourceItems) { + const plannedStrategy = sourceItems.length > 1 + ? this.getPlannedBatchTransferStrategy(sourceDevice, targetDevice, sourceItems) + : this.getPlannedTransferStrategy(sourceDevice, targetDevice, sourceItems[0]); + const initialStrategy = this.shouldDeferSshToSshStrategy(sourceDevice, targetDevice) ? undefined : plannedStrategy; + task.strategy = initialStrategy; + for (const transferItem of task.transferItems ?? []) { + transferItem.strategy = initialStrategy; + } + this.updateLiveTransferTicker(); + if (this.activePage === 'transfer' && !this.isTaskLogVisible) { + this.refreshTaskbar(); + } + else { + this.render(); + } + } + toPlanningEndpoint(device, path) { + if (device.id === LOCAL_WINDOWS_DEVICE_ID) { + return { kind: 'local', path }; + } + return { + kind: 'ssh', + path, + label: device.name, + supportsRsync: device.capabilities.rsync, + supportsTar: device.capabilities.tar, + preferredProtocol: device.preferredTransferProtocol + }; + } + transferAttemptToPlannedStrategy(attempt, source, target) { + if (!attempt) { + return 'scp-direct'; + } + if (attempt.route === 'direct') { + switch (attempt.protocol) { + case 'rsync': + return 'rsync-direct'; + case 'tar': + return 'tar-direct'; + case 'sftp': + return 'sftp-direct'; + case 'scp': + return 'scp-direct'; + } + } + if (attempt.route === 'relay') { + switch (attempt.protocol) { + case 'rsync': + return 'rsync-relay'; + case 'tar': + return 'tar-relay'; + case 'sftp': + return 'sftp-relay'; + case 'scp': + return 'scp-relay'; + } + } + if (source.kind === 'local' && target.kind === 'ssh') { + return `${attempt.protocol}-upload`; + } + if (source.kind === 'ssh' && target.kind === 'local') { + return `${attempt.protocol}-download`; + } + return 'local-copy'; + } + shouldDeferSshToSshStrategy(sourceDevice, targetDevice) { + return sourceDevice.id !== LOCAL_WINDOWS_DEVICE_ID + && targetDevice.id !== LOCAL_WINDOWS_DEVICE_ID + && !this.isSameDeviceForNativeOperation(sourceDevice, targetDevice); + } + formatTransferProgressDetail(event, transferredBytes, totalBytes) { + const phase = event.phase; + const phaseLabel = phase === 'relay-download' + ? '中转下载' + : phase === 'relay-upload' + ? '中转上传' + : phase === 'rsync-direct' + ? '直连传输' + : phase === 'prepare' + ? '准备传输' + : phase === 'complete' + ? '传输完成' + : '正在传输'; + const extraParts = []; + if (event.toCheck !== undefined && event.totalChecks !== undefined) { + extraParts.push(`to-check ${event.toCheck}/${event.totalChecks}`); + } + if (event.transferIndex !== undefined) { + extraParts.push(`xfr#${event.transferIndex}`); + } + const extra = extraParts.length > 0 ? ` (${extraParts.join('; ')})` : ''; + if (totalBytes > 0) { + return `${phaseLabel}:${formatSize(transferredBytes)} / ${formatSize(totalBytes)}${extra}`; + } + if (transferredBytes > 0) { + return `${phaseLabel}:${formatSize(transferredBytes)}${extra}`; + } + return extra ? `${phaseLabel} (${extraParts.join('; ')})` : phaseLabel; + } + finishTask(task, status, detail, options = {}, shouldRender = true) { + task.status = status; + task.detail = detail; + task.endedAt = Date.now(); + if (task.kind === 'transfer') { + const timingStartedAt = task.transferStartedAt ?? task.startedAt; + const elapsedMs = Math.max(1, task.endedAt - timingStartedAt); + task.durationMs = Math.max(task.durationMs ?? 0, options.durationMs ?? 0, elapsedMs); + } + else { + task.durationMs = options.durationMs ?? task.durationMs; + } + task.progress = status === 'canceled' ? Math.max(task.progress, 0) : 100; + task.bytes = options.bytes; + task.averageBytesPerSecond = options.averageBytesPerSecond + ?? (options.bytes !== undefined && task.durationMs !== undefined && task.durationMs > 0 ? Math.round(options.bytes / (task.durationMs / 1000)) : task.averageBytesPerSecond); + if (options.bytes !== undefined) { + task.transferredBytes = options.bytes; + task.totalBytes = options.bytes; + } + task.strategy = options.strategy; + task.error = options.error; + if (task.transferItems?.length) { + for (const item of task.transferItems) { + if (status === 'success' && item.status !== 'success') { + item.status = 'success'; + item.progress = 100; + item.transferredBytes = this.getLiveTransferItemTotal(item); + item.endedAt = task.endedAt; + } + else if (status === 'canceled' && (item.status === 'waiting' || item.status === 'running')) { + item.status = 'canceled'; + item.endedAt = task.endedAt; + } + else if (status === 'error' && item.status === 'running') { + item.status = 'error'; + item.error = options.error; + item.endedAt = task.endedAt; + } + } + } + this.statusMessage = detail; + this.updateInputRunningTaskCount(); + this.updateLiveTransferTicker(); + if (shouldRender) { + this.render(); + } + else { + this.refreshTaskbar(); + } + } + getTaskStats() { + const stats = { running: 0, success: 0, error: 0, canceled: 0 }; + for (const task of this.tasks) { + stats[task.status]++; + } + return stats; + } + formatTaskLine(task) { + const status = task.status === 'running' ? '进行中' : task.status === 'success' ? '完成' : task.status === 'canceled' ? '已取消' : '失败'; + const metrics = this.getTaskMetrics(task); + return `${status}:${task.label}${metrics ? ` · ${metrics}` : ''}`; + } + getTaskMetrics(task) { + const timingStartedAt = task.transferStartedAt ?? task.startedAt; + const elapsedMs = task.durationMs ?? ((task.endedAt ?? Date.now()) - timingStartedAt); + const duration = formatDuration(elapsedMs); + const parts = [duration]; + if (task.status === 'running' && task.totalBytes !== undefined && task.transferredBytes !== undefined) { + if (task.totalBytes > 0) { + parts.push(`${formatSize(task.transferredBytes)} / ${formatSize(task.totalBytes)}`); + } + else if (task.transferredBytes > 0) { + parts.push(formatSize(task.transferredBytes)); + } + const runningThroughput = formatThroughput(task.transferredBytes, Date.now() - timingStartedAt); + if (runningThroughput) { + parts.push(runningThroughput); + } + } + if (task.bytes !== undefined) { + parts.push(formatSize(task.bytes)); + if (task.averageBytesPerSecond && task.averageBytesPerSecond > 0) { + parts.push(`${formatTransferRate(task.averageBytesPerSecond)}/s`); + } + else { + const throughput = formatThroughput(task.bytes, elapsedMs); + if (throughput) { + parts.push(throughput); + } + } + } + if (task.strategy) { + parts.push(task.strategy); + } + if (task.error) { + parts.push(task.error); + } + return parts.join(' · '); + } + getTaskIcon(task) { + if (task.status === 'running') { + return Codicon.loading; + } + if (task.status === 'error') { + return Codicon.error; + } + if (task.status === 'canceled') { + return Codicon.circleSlash; + } + return Codicon.check; + } + renderLegacyImageGridOverlay() { + const state = this.imageGridState; + const overlay = $('.turbofile-image-grid-overlay'); + overlay.addEventListener('click', event => { + if (event.target === overlay) { + this.closeImageGridPage(); + } + }); + const panel = $('.turbofile-image-grid-panel'); + if (!state) { + return overlay; + } + const selected = state.images[state.selectedIndex]; + const header = $('.turbofile-image-grid-header'); + const title = $('.turbofile-image-grid-title'); + title.appendChild(renderIcon(Codicon.fileMedia)); + title.append(`图片浏览 · ${this.getSideLabel(state.side)} · ${state.images.length} 张`); + header.appendChild(title); + const actions = $('.turbofile-image-grid-actions'); + const previous = this.createIconButton(Codicon.chevronLeft, '上一张', 'turbofile-toolbar-button'); + previous.element.addEventListener('click', () => this.moveImageGridPreview(-1)); + actions.appendChild(previous.element); + const next = this.createIconButton(Codicon.chevronRight, '下一张', 'turbofile-toolbar-button'); + next.element.addEventListener('click', () => this.moveImageGridPreview(1)); + actions.appendChild(next.element); + const open = this.createButton('打开', 'turbofile-secondary-button'); + open.element.disabled = !selected?.item.path; + open.element.addEventListener('click', () => { + if (selected?.item.path) { + this.closeImageGridPage(); + void this.previewSelected(state.side, selected.item); + } + }); + actions.appendChild(open.element); + const close = this.createIconButton(Codicon.close, '关闭', 'turbofile-toolbar-button'); + close.element.addEventListener('click', () => this.closeImageGridPage()); + actions.appendChild(close.element); + header.appendChild(actions); + panel.appendChild(header); + const preview = $('.turbofile-image-grid-preview'); + if (selected?.thumbnailContent) { + const image = document.createElement('img'); + image.className = 'turbofile-image-grid-preview-image'; + image.src = `data:${selected.thumbnailMime};base64,${selected.thumbnailContent}`; + image.alt = selected.item.name; + preview.appendChild(image); + } + else { + const placeholder = $('.turbofile-image-grid-placeholder'); + placeholder.appendChild(renderIcon(Codicon.fileMedia)); + placeholder.append(selected?.thumbnailError ?? (selected?.thumbnailLoading ? '正在加载图片...' : '准备加载图片')); + preview.appendChild(placeholder); + } + const caption = $('.turbofile-image-grid-caption'); + caption.textContent = selected ? `${state.selectedIndex + 1} / ${state.images.length} · ${selected.item.name}` : ''; + preview.appendChild(caption); + panel.appendChild(preview); + const strip = $('.turbofile-image-grid-strip'); + for (let index = 0; index < state.images.length; index++) { + const image = state.images[index]; + const card = $('button.turbofile-image-grid-card'); + card.type = 'button'; + card.title = image.item.name; + if (index === state.selectedIndex) { + card.classList.add('is-selected'); + } + card.addEventListener('pointerdown', event => this.selectImageGridItemWithPointer(index, event)); + card.addEventListener('contextmenu', event => this.selectImageGridItemWithContextMenu(index, event)); + if (image.thumbnailContent) { + const thumb = document.createElement('img'); + thumb.loading = 'lazy'; + thumb.src = `data:${image.thumbnailMime};base64,${image.thumbnailContent}`; + thumb.alt = image.item.name; + card.appendChild(thumb); + } + else { + const placeholder = $('.turbofile-image-grid-card-placeholder'); + placeholder.appendChild(renderIcon(Codicon.fileMedia)); + const name = $('span'); + name.textContent = image.thumbnailLoading ? '加载中' : image.item.name; + placeholder.appendChild(name); + card.appendChild(placeholder); + } + strip.appendChild(card); + } + panel.appendChild(strip); + overlay.appendChild(panel); + return overlay; + } + renderImageGridPage(state = this.imageGridState) { + const page = $('.turbofile-image-page'); + if (!state) { + return page; + } + page.classList.toggle('is-pane-mode', state.displayMode === 'pane'); + page.dataset.turbofileSide = state.side; + page.addEventListener('pointerdown', () => this.setActiveImageGridState(state)); + page.addEventListener('focusin', () => this.setActiveImageGridState(state)); + // The scroll body is recreated on every full render; drop the cached width so this pass + // re-measures (and so a post-attach measurement below picks up the real body width). + this.invalidateImageGridBodyWidthCache(state); + this.normalizeImageGridStateColumns(state); + const range = this.getImageGridVirtualRange(state); + const header = $('.turbofile-image-page-header'); + const headerLeft = $('.turbofile-image-page-header-left'); + const title = $('.turbofile-image-page-title'); + title.appendChild(renderIcon(Codicon.fileMedia)); + title.append('图片网格'); + headerLeft.appendChild(title); + const columns = $('.turbofile-image-page-columns'); + const label = $('span.turbofile-image-page-columns-label'); + label.textContent = '列数'; + columns.appendChild(label); + for (const option of []) { + const button = $('button.turbofile-image-page-columns-button'); + button.type = 'button'; + button.textContent = String(option); + if (state.columns === option) { + button.classList.add('is-active'); + } + button.addEventListener('click', () => { + this.setActiveImageGridState(state); + this.setImageGridColumns(state, option); + }); + columns.appendChild(button); + } + headerLeft.appendChild(columns); + header.appendChild(headerLeft); + const actions = $('.turbofile-image-page-actions'); + const deleteButton = $('button.turbofile-image-page-action.is-delete.is-danger'); + const selectedCount = this.getImageGridDeleteTargets(state).length; + deleteButton.type = 'button'; + deleteButton.title = selectedCount ? '\u5220\u9664\u9009\u4e2d\u56fe\u7247' : '\u8bf7\u5148\u9009\u62e9\u8981\u5220\u9664\u7684\u56fe\u7247'; + deleteButton.disabled = selectedCount === 0; + deleteButton.setAttribute('aria-label', '\u5220\u9664\u9009\u4e2d\u56fe\u7247'); + deleteButton.appendChild(renderIcon(Codicon.trash)); + deleteButton.addEventListener('click', () => { + this.setActiveImageGridState(state); + void this.deleteSelectedImageGridItems(state); + }); + actions.appendChild(deleteButton); + actions.appendChild(this.renderImageGridSortControl(state)); + actions.appendChild(this.renderImageGridScaleControl(state)); + const closeIcon = $('button.turbofile-image-page-action.is-close'); + closeIcon.type = 'button'; + closeIcon.title = '关闭'; + closeIcon.setAttribute('aria-label', '关闭图片网格'); + closeIcon.appendChild(renderIcon(Codicon.close)); + closeIcon.addEventListener('click', () => this.closeImageGridPage(state)); + actions.appendChild(closeIcon); + const close = this.createButton('关闭', 'turbofile-secondary-button'); + close.element.addEventListener('click', () => this.closeImageGridPage(state)); + actions.appendChild(close.element); + header.appendChild(actions); + page.appendChild(header); + const body = $('.turbofile-image-page-body'); + body.tabIndex = 0; + body.scrollTop = state.scrollTop; + body.addEventListener('pointerdown', event => { + this.setActiveImageGridState(state); + const target = event.target instanceof HTMLElement ? event.target : undefined; + if (!target?.closest('.turbofile-image-page-card, button, .turbofile-image-page-sort, .turbofile-image-page-scale')) { + body.focus({ preventScroll: true }); + } + }); + body.addEventListener('focusin', () => this.setActiveImageGridState(state)); + body.addEventListener('paste', event => { + this.setActiveImageGridState(state); + event.preventDefault(); + void this.pasteIntoPane(getTurboFileImageGridPasteTargetSide(state.side, this.internalTransferClipboard?.sourceSide)); + }); + this.registerImageGridDropTarget(body, state); + body.addEventListener('scroll', () => this.handleImageGridScroll(body, state), { passive: true }); + page.addEventListener('wheel', event => this.handleImageGridWheel(event, body, state), { passive: false, capture: true }); + const grid = $('.turbofile-image-page-grid'); + grid.setAttribute('role', 'listbox'); + grid.setAttribute('aria-multiselectable', 'true'); + grid.style.setProperty('--turbofile-image-grid-columns', String(this.getImageGridEffectiveColumns(state))); + grid.style.setProperty('--turbofile-image-grid-scale', String(state.thumbnailScale)); + this.renderImageGridSlice(grid, state, range); + body.appendChild(grid); + page.appendChild(body); + const scrollTop = state.scrollTop; + getWindow(body).requestAnimationFrame(() => { + if (!body.isConnected) { + return; + } + this.reflowAttachedImageGridBody(body, state, scrollTop); + }); + return page; + } + reflowAttachedImageGridBody(body, state, scrollTop) { + // Pane grids are measured before they are attached to the DOM during render. Re-measure once + // attached so the virtual slice uses the actual left/right panel width instead of the window width. + this.invalidateImageGridBodyWidthCache(state); + this.clearImageGridLayoutMetrics(state); + state.viewportHeight = body.clientHeight || state.viewportHeight; + state.scrollTop = Math.max(0, scrollTop); + this.normalizeImageGridStateColumns(state); + const range = this.getImageGridVirtualRange(state); + const rendered = state.renderedRange; + const columns = this.getImageGridEffectiveColumns(state); + if (!rendered || rendered.startIndex !== range.startIndex || rendered.endIndex !== range.endIndex || rendered.columns !== columns || rendered.total !== state.images.length) { + this.updateImageGridVirtualSlice(state, range); + } + if (scrollTop > 0) { + body.scrollTop = scrollTop; + } + else if (body.scrollLeft) { + body.scrollLeft = 0; + } + this.scheduleImageGridVisibleLoad(state); + } + updateImageGridAfterLocalMutation(state) { + const body = this.getImageGridBodyElement(state); + const scrollTop = body?.scrollTop ?? state.scrollTop; + state.scrollTop = Math.max(0, scrollTop); + if (body) { + state.viewportHeight = body.clientHeight || state.viewportHeight; + } + this.invalidateImageGridBodyWidthCache(state); + this.clearImageGridLayoutMetrics(state); + state.renderedRange = undefined; + this.normalizeImageGridStateColumns(state); + this.updateImageGridVirtualSlice(state, this.getImageGridVirtualRange(state)); + if (body) { + body.scrollTop = Math.min(scrollTop, body.scrollHeight); + state.scrollTop = body.scrollTop; + } + this.updateImageGridSelection(state); + this.updateImageGridSelectionStatus(state); + if (state.previewVisible) { + this.updateImagePreviewOverlay(state); + } + this.refreshTaskbar(); + } + renderImageGridScaleControl(state) { + const select = $('.turbofile-image-page-scale'); + const button = $('button.turbofile-image-page-scale-button'); + const menu = $('.turbofile-image-page-scale-menu'); + button.type = 'button'; + button.title = '图片放大倍率'; + button.setAttribute('aria-haspopup', 'listbox'); + button.setAttribute('aria-expanded', 'false'); + button.setAttribute('aria-label', '图片放大倍率'); + button.appendChild(renderIcon(Codicon.zoomIn)); + button.appendChild($('span.turbofile-image-page-scale-label', undefined, this.getImageGridScaleLabel(state.thumbnailScale))); + button.appendChild(renderIcon(Codicon.chevronDown)); + menu.setAttribute('role', 'menu'); + menu.setAttribute('aria-label', '图片放大倍率'); + for (const scale of IMAGE_GRID_THUMBNAIL_SCALE_OPTIONS) { + const isActive = state.thumbnailScale === scale; + const item = $('button.turbofile-image-page-scale-option'); + item.type = 'button'; + item.dataset.scale = String(scale); + item.setAttribute('role', 'option'); + item.setAttribute('aria-selected', isActive ? 'true' : 'false'); + if (isActive) { + item.classList.add('is-active'); + } + const check = $('span.turbofile-image-page-scale-check'); + if (isActive) { + check.appendChild(renderIcon(Codicon.check)); + } + item.appendChild(check); + item.appendChild($('span.turbofile-image-page-scale-option-label', undefined, this.getImageGridScaleLabel(scale))); + item.addEventListener('click', event => { + event.preventDefault(); + event.stopPropagation(); + select.classList.remove('is-open'); + button.setAttribute('aria-expanded', 'false'); + this.setActiveImageGridState(state); + this.setImageGridThumbnailScale(state, scale); + }); + menu.appendChild(item); + } + const closeMenu = () => { + select.classList.remove('is-open'); + button.setAttribute('aria-expanded', 'false'); + }; + const toggleMenu = (event) => { + event.preventDefault(); + event.stopPropagation(); + const nextOpen = !select.classList.contains('is-open'); + select.classList.toggle('is-open', nextOpen); + button.setAttribute('aria-expanded', String(nextOpen)); + if (nextOpen) { + const active = menu.querySelector('.turbofile-image-page-scale-option.is-active') ?? menu.querySelector('.turbofile-image-page-scale-option'); + active?.focus(); + } + }; + button.addEventListener('click', toggleMenu); + button.addEventListener('keydown', event => { + if (event.key === 'Enter' || event.key === ' ' || event.key === 'ArrowDown') { + toggleMenu(event); + } + }); + const ownerWindow = getWindow(select); + const handleWindowClick = (event) => { + if (!select.contains(event.target)) { + closeMenu(); + } + }; + const handleWindowKeydown = (event) => { + if (event.key === 'Escape' && select.classList.contains('is-open')) { + event.preventDefault(); + closeMenu(); + button.focus(); + } + }; + ownerWindow.addEventListener('click', handleWindowClick); + ownerWindow.addEventListener('keydown', handleWindowKeydown); + this.renderDisposables.add({ dispose: () => ownerWindow.removeEventListener('click', handleWindowClick) }); + this.renderDisposables.add({ dispose: () => ownerWindow.removeEventListener('keydown', handleWindowKeydown) }); + select.appendChild(button); + select.appendChild(menu); + return select; + } + renderImageGridSortControl(state) { + const select = $('.turbofile-image-page-sort'); + const button = $('button.turbofile-image-page-sort-button'); + const menu = $('.turbofile-image-page-sort-menu'); + button.type = 'button'; + button.title = '排序方式'; + button.setAttribute('aria-haspopup', 'menu'); + button.setAttribute('aria-expanded', 'false'); + button.setAttribute('aria-label', '排序方式'); + button.appendChild(renderIcon(Codicon.listOrdered)); + button.appendChild($('span.turbofile-image-page-sort-label', undefined, this.getImageGridSortLabel(state))); + button.appendChild(renderIcon(state.sortDescending ? Codicon.arrowDown : Codicon.arrowUp)); + button.appendChild(renderIcon(Codicon.chevronDown)); + menu.setAttribute('role', 'menu'); + menu.setAttribute('aria-label', '图片排序方式'); + for (const option of IMAGE_GRID_SORT_OPTIONS) { + const isActive = state.sortKey === option.key; + const item = $('button.turbofile-image-page-sort-option'); + item.type = 'button'; + item.setAttribute('role', 'menuitemradio'); + item.setAttribute('aria-checked', isActive ? 'true' : 'false'); + if (isActive) { + item.classList.add('is-active'); + } + const check = $('span.turbofile-image-page-sort-check'); + if (isActive) { + check.appendChild(renderIcon(Codicon.check)); + } + item.appendChild(check); + item.appendChild($('span.turbofile-image-page-sort-option-label', undefined, option.label)); + item.addEventListener('click', event => { + event.preventDefault(); + event.stopPropagation(); + select.classList.remove('is-open'); + button.setAttribute('aria-expanded', 'false'); + this.setActiveImageGridState(state); + this.setImageGridSortKey(state, option.key); + }); + menu.appendChild(item); + } + const divider = $('.turbofile-image-page-sort-divider'); + divider.setAttribute('role', 'separator'); + menu.appendChild(divider); + for (const option of IMAGE_GRID_SORT_DIRECTION_OPTIONS) { + const isActive = state.sortDescending === option.descending; + const item = $('button.turbofile-image-page-sort-option'); + item.type = 'button'; + item.setAttribute('role', 'menuitemradio'); + item.setAttribute('aria-checked', isActive ? 'true' : 'false'); + if (isActive) { + item.classList.add('is-active'); + } + const check = $('span.turbofile-image-page-sort-check'); + if (isActive) { + check.appendChild(renderIcon(Codicon.check)); + } + item.appendChild(check); + item.appendChild($('span.turbofile-image-page-sort-option-label', undefined, option.label)); + item.addEventListener('click', event => { + event.preventDefault(); + event.stopPropagation(); + select.classList.remove('is-open'); + button.setAttribute('aria-expanded', 'false'); + this.setActiveImageGridState(state); + this.setImageGridSortDirection(state, option.descending); + }); + menu.appendChild(item); + } + const closeMenu = () => { + select.classList.remove('is-open'); + button.setAttribute('aria-expanded', 'false'); + }; + const toggleMenu = (event) => { + event.preventDefault(); + event.stopPropagation(); + const nextOpen = !select.classList.contains('is-open'); + select.classList.toggle('is-open', nextOpen); + button.setAttribute('aria-expanded', String(nextOpen)); + if (nextOpen) { + const active = menu.querySelector('.turbofile-image-page-sort-option.is-active') ?? menu.querySelector('.turbofile-image-page-sort-option'); + active?.focus(); + } + }; + button.addEventListener('click', toggleMenu); + button.addEventListener('keydown', event => { + if (event.key === 'Enter' || event.key === ' ' || event.key === 'ArrowDown') { + toggleMenu(event); + } + }); + const ownerWindow = getWindow(select); + const handleWindowClick = (event) => { + if (!select.contains(event.target)) { + closeMenu(); + } + }; + const handleWindowKeydown = (event) => { + if (event.key === 'Escape' && select.classList.contains('is-open')) { + event.preventDefault(); + closeMenu(); + button.focus(); + } + }; + ownerWindow.addEventListener('click', handleWindowClick); + ownerWindow.addEventListener('keydown', handleWindowKeydown); + this.renderDisposables.add({ dispose: () => ownerWindow.removeEventListener('click', handleWindowClick) }); + this.renderDisposables.add({ dispose: () => ownerWindow.removeEventListener('keydown', handleWindowKeydown) }); + select.appendChild(button); + select.appendChild(menu); + return select; + } + renderImageGridSlice(grid, state, range) { + clearNode(grid); + grid.style.setProperty('--turbofile-image-grid-columns', String(this.getImageGridEffectiveColumns(state))); + grid.style.setProperty('--turbofile-image-grid-scale', String(state.thumbnailScale)); + this.releaseImageGridImagesOutsideRange(state, range); + if (range.topSpacerHeight > 0) { + const topSpacer = $('.turbofile-image-page-spacer'); + topSpacer.style.height = `${range.topSpacerHeight}px`; + grid.appendChild(topSpacer); + } + for (let index = range.startIndex; index < range.endIndex; index++) { + grid.appendChild(this.renderImageGridCard(state, state.images[index], index)); + } + if (range.bottomSpacerHeight > 0) { + const bottomSpacer = $('.turbofile-image-page-spacer'); + bottomSpacer.style.height = `${range.bottomSpacerHeight}px`; + grid.appendChild(bottomSpacer); + } + state.renderedRange = { + startIndex: range.startIndex, + endIndex: range.endIndex, + columns: this.getImageGridEffectiveColumns(state), + total: state.images.length + }; + this.scheduleImageGridVisibleLoad(state); + } + /** + * Loads thumbnails for only the page currently in view, after scrolling settles. A fast drag or a + * big jump renders many slices in quick succession; debouncing here means we load just the page the + * user lands on (the previous in-flight batch is invalidated via the load token), so the landing + * page fills in fast instead of waiting behind loads for pages that were merely scrolled past. + */ + scheduleImageGridVisibleLoad(state) { + this.clearImageGridVisibleLoadTimer(state); + const timer = setTimeout(() => { + this.imageGridVisibleLoadTimers.delete(state); + if (!this.isImageGridSurfaceActive(state)) { + return; + } + state.thumbnailLoadToken++; + const range = this.getImageGridVirtualRange(state); + const indexes = []; + for (let index = range.startIndex; index < range.endIndex; index++) { + indexes.push(index); + } + if (indexes.length) { + this.queueImageGridThumbnailLoads(state, indexes, true); + } + }, IMAGE_GRID_VISIBLE_LOAD_DEBOUNCE_MS); + this.imageGridVisibleLoadTimers.set(state, timer); + } + renderImageGridCard(state, image, index) { + const card = $('div.turbofile-image-page-card'); + const cardSize = this.getImageGridCardSize(state, image); + card.tabIndex = 0; + card.draggable = true; + card.setAttribute('role', 'option'); + card.title = image.item.name; + card.dataset.imageIndex = String(index); + const isSelected = state.selectedIndexes.has(index); + if (isSelected) { + card.classList.add('is-selected'); + } + if (state.selectedIndex === index) { + card.classList.add('is-focused'); + } + card.setAttribute('aria-selected', isSelected ? 'true' : 'false'); + const preview = $('.turbofile-image-page-card-preview'); + this.applyImageGridCardPreviewSize(preview, cardSize); + const thumbnailSrc = image.thumbnailUrl || (image.thumbnailContent ? `data:${image.thumbnailMime};base64,${image.thumbnailContent}` : ''); + if (thumbnailSrc) { + const thumb = document.createElement('img'); + thumb.loading = 'eager'; + thumb.decoding = 'async'; + this.applyImageGridCardImageSize(thumb, cardSize); + thumb.addEventListener('load', () => { + const previousArea = getImageGridPixelArea(image); + image.width = Math.max(1, thumb.naturalWidth || image.width || 1); + image.height = Math.max(1, thumb.naturalHeight || image.height || 1); + if (!this.resortImageGridAfterSizeUpdate(state, image, previousArea) && getImageGridPixelArea(image) !== previousArea) { + const currentIndex = state.images.indexOf(image); + if (currentIndex >= 0) { + this.updateImageGridCard(state, currentIndex); + } + } + }); + thumb.addEventListener('error', () => this.handleImageGridThumbnailError(state, index)); + thumb.src = thumbnailSrc; + thumb.alt = image.item.name; + preview.appendChild(thumb); + } + else { + const placeholder = $('.turbofile-image-page-card-placeholder'); + placeholder.appendChild(renderIcon(Codicon.fileMedia)); + preview.appendChild(placeholder); + } + this.appendImageGridCardChrome(preview, image, index); + card.appendChild(preview); + const name = $('.turbofile-image-page-card-name'); + name.textContent = image.item.name; + name.addEventListener('dblclick', event => event.stopPropagation()); + card.appendChild(name); + card.addEventListener('pointerdown', event => { + this.setActiveImageGridState(state); + this.selectImageGridItemWithPointer(index, event); + }); + card.addEventListener('dragstart', event => this.beginImageGridCardDrag(state, index, event)); + card.addEventListener('dragend', () => this.clearCurrentFileDragState()); + card.addEventListener('contextmenu', event => { + this.setActiveImageGridState(state); + this.selectImageGridItemWithContextMenu(index, event); + }); + card.addEventListener('dblclick', event => { + event.preventDefault(); + this.setActiveImageGridState(state); + this.openImageGridPreview(index); + }); + card.addEventListener('keydown', event => { + this.setActiveImageGridState(state); + if (event.key === 'Enter') { + event.preventDefault(); + this.openImageGridPreview(index); + } + else if (event.key === ' ' || event.key === 'Spacebar') { + event.preventDefault(); + this.selectImageGridItem(index, event); + } + }); + return card; + } + beginImageGridCardDrag(state, index, event) { + if (!event.dataTransfer || !this.isImageGridSurfaceActive(state) || index < 0 || index >= state.images.length) { + return; + } + this.setActiveImageGridState(state); + const image = state.images[index]; + if (!image?.item.path || image.item.parent) { + return; + } + if (!state.selectedIndexes.has(index)) { + this.setSingleImageGridSelection(state, index); + this.updateImageGridSelection(state); + this.updateImageGridSelectionStatus(state); + } + this.syncImageGridSelectionToPane(state); + this.setActiveFilePaneSide(state.side); + this.currentFileDragSide = state.side; + this.hasStartedNativeWindowsDrag = false; + event.dataTransfer.effectAllowed = getTurboFileRowDragEffectAllowed(); + event.dataTransfer.setData('application/x-turbofile-side', state.side); + event.dataTransfer.setData('application/x-turbofile-operation', 'copy'); + event.dataTransfer.setData('text/plain', this.getImageGridTransferItems(state).map(item => item.name).join('\n')); + this.tryStartImmediateLocalWindowsDrag(state.side, event); + } + copyImageGridSelectionToInternalClipboard(state, operation) { + if (!this.isImageGridSurfaceActive(state)) { + return; + } + this.setActiveImageGridState(state); + this.syncImageGridSelectionToPane(state); + this.copySelectedToInternalClipboard(state.side, operation, false); + } + syncImageGridSelectionToPane(state) { + const paneState = this.paneStates[state.side]; + const selectedItems = this.getImageGridTransferItems(state); + paneState.selectedPaths.clear(); + for (const item of selectedItems) { + if (item.path) { + paneState.selectedPaths.add(item.path); + } + } + const focusedPath = state.images[state.selectedIndex]?.item.path ?? selectedItems[0]?.path; + paneState.selectedPath = focusedPath; + const anchorPath = state.selectionAnchorIndex !== undefined ? state.images[state.selectionAnchorIndex]?.item.path : undefined; + paneState.selectionAnchorPath = anchorPath ?? focusedPath; + } + getImageGridTransferItems(state) { + const selected = this.getImageGridSelectedImages(state) + .map(({ image }) => image.item) + .filter(item => !!item.path && !item.parent); + if (selected.length) { + return selected; + } + const focused = state.images[state.selectedIndex]?.item; + return focused?.path && !focused.parent ? [focused] : []; + } + registerImageGridDropTarget(body, state) { + body.classList.toggle('is-external-drop-target', this.externalDropTargetSide === state.side); + body.addEventListener('dragover', event => { + const sourceSide = event.dataTransfer?.getData('application/x-turbofile-side'); + if (sourceSide && sourceSide !== state.side) { + this.consumeTurboFileDropEvent(event); + if (event.dataTransfer) { + event.dataTransfer.dropEffect = 'copy'; + } + this.setExternalDropTargetSide(undefined); + return; + } + if (this.hasExternalDropData(event)) { + this.consumeTurboFileDropEvent(event); + if (event.dataTransfer) { + event.dataTransfer.dropEffect = 'copy'; + } + this.setExternalDropTargetSide(state.side); + return; + } + this.setExternalDropTargetSide(undefined); + }); + body.addEventListener('dragleave', event => { + const relatedTarget = event.relatedTarget; + if (relatedTarget instanceof Node && body.contains(relatedTarget)) { + return; + } + this.setExternalDropTargetSide(undefined); + }); + body.addEventListener('drop', event => { + const initiatedAt = Date.now(); + this.consumeTurboFileDropEvent(event); + this.setExternalDropTargetSide(undefined); + const sourceSide = event.dataTransfer?.getData('application/x-turbofile-side'); + if (sourceSide && sourceSide !== state.side) { + this.clearCurrentFileDragState(); + this.deferAfterDrag(() => { + void this.transferSelected(sourceSide, state.side, initiatedAt); + this.clearCurrentFileDragState(); + }); + return; + } + const externalPaths = this.extractExternalDropPaths(event); + this.clearCurrentFileDragState(); + if (externalPaths.length) { + this.deferAfterDrag(() => { + void this.transferExternalPaths(state.side, externalPaths, 'copy', initiatedAt); + this.clearCurrentFileDragState(); + }); + } + }); + } + getOppositeSide(side) { + return side === 'left' ? 'right' : 'left'; + } + getImageGridCardSize(state, image) { + return getTurboFileImageGridCardSize({ width: image.width, height: image.height }, state.thumbnailScale, this.getImageGridBaseCardWidth(state), this.getImageGridBasePreviewHeight(state), IMAGE_GRID_CARD_VERTICAL_CHROME, IMAGE_GRID_CARD_INLINE_CHROME); + } + applyImageGridCardPreviewSize(preview, size) { + preview.style.width = `${Math.max(1, size.cardWidth - IMAGE_GRID_CARD_INLINE_CHROME)}px`; + preview.style.height = `${Math.max(1, size.rowHeight - IMAGE_GRID_CARD_VERTICAL_CHROME)}px`; + } + applyImageGridCardImageSize(image, size) { + // Fill the preview box (object-fit: cover handles the crop); the box itself is already sized to + // the card's aspect-clamped dimensions, so we don't pin the image to its raw pixel size — that is + // what turned extreme-aspect images into slivers. + void size; + image.style.width = '100%'; + image.style.height = '100%'; + } + appendImageGridCardChrome(preview, image, index) { + const info = $('.turbofile-image-page-card-info'); + const indexLabel = $('span.turbofile-image-page-card-index'); + indexLabel.textContent = String(index + 1); + info.appendChild(indexLabel); + const dimensions = $('span.turbofile-image-page-card-dimensions'); + const dimensionsLabel = this.getImageGridCardDimensionsLabel(image); + dimensions.textContent = dimensionsLabel; + if (!dimensionsLabel) { + dimensions.classList.add('is-empty'); + } + info.appendChild(dimensions); + preview.appendChild(info); + } + getImageGridCardDimensionsLabel(image) { + return formatTurboFileImageDimensions(image, '×'); + } + renderImagePreviewOverlay(state = this.getActiveImageGridPreviewState()) { + const overlay = $('.turbofile-image-preview-overlay'); + if (!state?.previewVisible) { + return overlay; + } + this.setActiveImageGridState(state); + overlay.tabIndex = -1; + overlay.setAttribute('role', 'dialog'); + overlay.setAttribute('aria-modal', 'true'); + overlay.setAttribute('aria-label', 'Image preview'); + overlay.addEventListener('wheel', event => this.handleImagePreviewWheel(event, state)); + overlay.addEventListener('click', event => { + if (event.target === overlay) { + this.closeImageGridPreview(state); + } + }); + overlay.addEventListener('keydown', event => { + if (event.key === 'Escape') { + event.preventDefault(); + this.closeImageGridPreview(state); + } + else if (event.key === 'ArrowLeft') { + event.preventDefault(); + this.moveImageGridPreview(-1, state); + } + else if (event.key === 'ArrowRight') { + event.preventDefault(); + this.moveImageGridPreview(1, state); + } + }); + const dismiss = $('button.turbofile-image-preview-dismiss'); + dismiss.type = 'button'; + dismiss.setAttribute('aria-label', '关闭预览'); + dismiss.addEventListener('click', () => this.closeImageGridPreview(state)); + overlay.appendChild(dismiss); + const stage = $('.turbofile-image-preview-stage'); + this.renderImagePreviewStageContent(stage, state); + overlay.appendChild(stage); + const toolbar = $('.turbofile-image-preview-toolbar'); + const pixelInspector = this.renderImagePreviewTopAction(Codicon.symbolColor, 'RGB 像素检查', () => this.toggleImagePreviewPixelInspector(state), state.pixelInspectorVisible); + pixelInspector.dataset.turbofilePreviewToggle = 'pixel-inspector'; + toolbar.appendChild(pixelInspector); + const losslessSampling = this.renderImagePreviewTopAction(Codicon.symbolRuler, '无损像素缩放', () => this.toggleImagePreviewSamplingMode(state), state.previewSamplingMode === 'lossless'); + losslessSampling.dataset.turbofilePreviewToggle = 'lossless-sampling'; + toolbar.appendChild(losslessSampling); + const histogram = this.renderImagePreviewTopAction(Codicon.graphLine, '直方图', () => this.toggleImagePreviewHistogram(state), state.histogramVisible); + histogram.dataset.turbofilePreviewToggle = 'histogram'; + toolbar.appendChild(histogram); + toolbar.appendChild(this.renderImagePreviewTopAction(Codicon.download, '下载图片', () => { void this.downloadImageGridPreview(state); })); + toolbar.appendChild(this.renderImagePreviewTopAction(Codicon.trash, '删除图片', () => { void this.deleteImageGridPreview(state); })); + toolbar.appendChild(this.renderImagePreviewTopAction(Codicon.close, '关闭', () => this.closeImageGridPreview(state))); + overlay.appendChild(toolbar); + if (state.histogramVisible) { + overlay.appendChild(this.renderImagePreviewHistogramPanel(state)); + } + overlay.appendChild(this.renderImagePreviewSideButton(state, -1)); + overlay.appendChild(this.renderImagePreviewSideButton(state, 1)); + const footer = $('.turbofile-image-preview-footer'); + footer.appendChild(this.renderImagePreviewZoomControls(state)); + footer.appendChild(this.renderImagePreviewInfoStrip(state)); + overlay.appendChild(footer); + const navigation = $('.turbofile-image-preview-navigation'); + const previous = $('button.turbofile-image-preview-nav-button'); + previous.type = 'button'; + previous.appendChild(renderIcon(Codicon.chevronLeft)); + previous.addEventListener('click', () => this.moveImageGridPreview(-1, state)); + navigation.appendChild(previous); + const next = $('button.turbofile-image-preview-nav-button'); + next.type = 'button'; + next.appendChild(renderIcon(Codicon.chevronRight)); + next.addEventListener('click', () => this.moveImageGridPreview(1, state)); + navigation.appendChild(next); + overlay.appendChild(navigation); + getWindow(overlay).requestAnimationFrame(() => { + if (overlay.isConnected) { + overlay.focus({ preventScroll: true }); + } + }); + return overlay; + } + renderImagePreviewTopAction(icon, label, handler, active = false) { + const button = $('button.turbofile-image-preview-action'); + button.type = 'button'; + button.title = label; + button.setAttribute('aria-label', label); + if (active) { + button.classList.add('is-active'); + button.setAttribute('aria-pressed', 'true'); + } + button.appendChild(renderIcon(icon)); + button.addEventListener('click', event => { + event.stopPropagation(); + handler(); + }); + return button; + } + renderImagePreviewZoomControls(state) { + const controls = $('.turbofile-image-preview-zoom-controls'); + controls.setAttribute('role', 'group'); + controls.setAttribute('aria-label', 'Zoom controls'); + const zoomOut = this.renderImagePreviewTopAction(Codicon.zoomOut, '缩小', () => this.zoomImagePreviewByOculanteStep(-IMAGE_PREVIEW_BUTTON_ZOOM_DELTA, state)); + zoomOut.classList.add('is-transient'); + zoomOut.disabled = state.previewZoom <= IMAGE_PREVIEW_ZOOM_MIN; + controls.appendChild(zoomOut); + const value = $('button.turbofile-image-preview-zoom-value'); + value.type = 'button'; + value.disabled = true; + value.textContent = this.getImagePreviewZoomLabel(state); + controls.appendChild(value); + const zoomIn = this.renderImagePreviewTopAction(Codicon.zoomIn, '放大', () => this.zoomImagePreviewByOculanteStep(IMAGE_PREVIEW_BUTTON_ZOOM_DELTA, state)); + zoomIn.classList.add('is-transient'); + zoomIn.disabled = state.previewZoom >= IMAGE_PREVIEW_ZOOM_MAX; + controls.appendChild(zoomIn); + const reset = this.renderImagePreviewTopAction(Codicon.screenNormal, '适应窗口', () => this.resetImagePreviewView(state)); + reset.classList.add('is-reset'); + reset.disabled = state.previewFitPending; + controls.appendChild(reset); + return controls; + } + renderImagePreviewInfoStrip(state) { + const info = $('.turbofile-image-preview-caption.turbofile-image-preview-info-strip'); + this.renderImagePreviewInfoStripContent(info, state); + return info; + } + renderImagePreviewHistogramPanel(state) { + const panel = $('.turbofile-image-preview-histogram'); + panel.style.left = `${state.histogramX}px`; + panel.style.top = `${state.histogramY}px`; + const header = $('.turbofile-image-preview-histogram-header'); + header.append('Histogram'); + const close = $('button.turbofile-image-preview-histogram-close'); + close.type = 'button'; + close.title = '关闭直方图'; + close.setAttribute('aria-label', '关闭直方图'); + close.appendChild(renderIcon(Codicon.close)); + close.addEventListener('click', event => { + event.stopPropagation(); + this.toggleImagePreviewHistogram(state); + }); + header.appendChild(close); + this.attachImagePreviewHistogramDrag(panel, state); + panel.appendChild(header); + const canvas = document.createElement('canvas'); + canvas.className = 'turbofile-image-preview-histogram-canvas'; + canvas.width = 260; + canvas.height = 130; + panel.appendChild(canvas); + const selected = state.images[state.selectedIndex]; + const src = this.getImagePreviewSource(selected); + const analysis = selected && src ? this.prepareImagePreviewAnalysis(selected, src) : undefined; + this.drawImagePreviewHistogram(canvas, analysis); + return panel; + } + renderImagePreviewInfoStripContent(info, state) { + clearNode(info); + info.setAttribute('aria-label', this.getImagePreviewCaption(state)); + const image = state.images[state.selectedIndex]; + if (!image) { + return; + } + const index = $('span.turbofile-image-preview-info-index'); + index.textContent = `${state.selectedIndex + 1} / ${state.images.length}`; + info.appendChild(index); + const name = $('span.turbofile-image-preview-info-name'); + name.textContent = image.item.name; + info.appendChild(name); + const dimensions = $('span.turbofile-image-preview-info-dimensions'); + dimensions.textContent = formatTurboFileImageDimensions(image) || (image.previewLoading ? 'Loading original' : 'Original image'); + info.appendChild(dimensions); + } + renderImagePreviewSideButton(state, delta) { + const button = $('button.turbofile-image-preview-side'); + button.type = 'button'; + button.classList.add(delta < 0 ? 'is-left' : 'is-right'); + const targetIndex = state.selectedIndex + delta; + const image = state.images[targetIndex]; + button.disabled = !image; + if (image) { + const src = this.getImagePreviewThumbnailSource(image); + if (src) { + const thumb = document.createElement('img'); + thumb.src = src; + thumb.alt = image.item.name; + button.appendChild(thumb); + } + } + const icon = $('span.turbofile-image-preview-side-icon'); + icon.appendChild(renderIcon(delta < 0 ? Codicon.chevronLeft : Codicon.chevronRight)); + button.appendChild(icon); + button.addEventListener('click', () => this.moveImageGridPreview(delta, state)); + return button; + } + renderImagePreviewStageContent(stage, state) { + clearNode(stage); + const selected = state.images[state.selectedIndex]; + const previewSrc = this.getImagePreviewSource(selected); + if (selected && previewSrc && this.isImagePreviewDecoded(selected, previewSrc)) { + const figure = $('.turbofile-image-preview-figure'); + figure.setAttribute('role', 'img'); + figure.setAttribute('aria-label', selected.item.name); + if (state.pixelInspectorVisible) { + figure.classList.add('is-pixel-inspector'); + } + this.appendImagePreviewCanvas(figure, selected, previewSrc, state); + figure.addEventListener('pointerdown', event => this.handleImagePreviewPointerDown(event, state)); + figure.addEventListener('contextmenu', event => this.showPreviewImageContextMenu(event)); + if (state.pixelInspectorVisible) { + this.attachImagePreviewPixelInspector(stage, figure, selected, previewSrc); + } + stage.appendChild(figure); + this.renderImagePreviewCanvas(state); + return; + } + // While the full-resolution original is still loading/decoding, show the grid thumbnail (already + // in memory) immediately so the preview feels instant — the sharp original swaps in on a later + // re-render once decoded. This mirrors how the web gallery shows a thumbnail then sharpens. + const interimSrc = selected && !selected.previewError ? this.getImagePreviewThumbnailSource(selected) : ''; + if (selected && interimSrc) { + const figure = $('.turbofile-image-preview-figure.is-preview-interim'); + figure.setAttribute('role', 'img'); + figure.setAttribute('aria-label', selected.item.name); + const interim = document.createElement('img'); + interim.className = 'turbofile-image-preview-interim'; + interim.src = interimSrc; + interim.alt = selected.item.name; + interim.draggable = false; + figure.appendChild(interim); + stage.appendChild(figure); + return; + } + const placeholder = $('.turbofile-image-preview-placeholder'); + placeholder.appendChild(renderIcon(Codicon.fileMedia)); + placeholder.append(selected?.previewError ?? (selected?.previewLoading || previewSrc ? 'Loading image...' : 'Preparing image...')); + stage.appendChild(placeholder); + } + getImagePreviewSource(image) { + return this.getImagePreviewFullSource(image); + } + getImagePreviewFullSource(image) { + return image?.previewUrl + || (image?.previewContent ? `data:${image.previewMime};base64,${image.previewContent}` : ''); + } + getImagePreviewThumbnailSource(image) { + return image?.thumbnailUrl + || (image?.thumbnailContent ? `data:${image.thumbnailMime};base64,${image.thumbnailContent}` : '') + || this.getImagePreviewFullSource(image); + } + getImagePreviewKey(image, src) { + return `${image.item.path ?? image.item.name}|${src}`; + } + isImagePreviewDecoded(image, src) { + return !!image.previewImage && image.previewImageKey === this.getImagePreviewKey(image, src); + } + appendImagePreviewCanvas(figure, image, src, state) { + const ownerDocument = figure.ownerDocument; + const canvas = ownerDocument.createElement('canvas'); + canvas.className = 'turbofile-image-preview-canvas'; + canvas.setAttribute('aria-label', image.item.name); + figure.appendChild(canvas); + this.prepareImagePreviewRender(image, src, canvas, state); + } + prepareImagePreviewRender(image, src, canvas, state) { + const context = canvas.getContext('2d'); + if (!context) { + return undefined; + } + const key = this.getImagePreviewKey(image, src); + const current = this.imagePreviewRender; + if (current?.key === key) { + current.canvas = canvas; + current.context = context; + this.observeImagePreviewCanvas(canvas, state); + this.queueImagePreviewCanvasRender(state); + return current; + } + const decodedImage = image.previewImageKey === key ? image.previewImage : undefined; + state.previewFitPending = true; + const render = { + key, + canvas, + context, + image: decodedImage, + width: Math.max(1, decodedImage?.naturalWidth || image.width || 1), + height: Math.max(1, decodedImage?.naturalHeight || image.height || 1), + ready: !!decodedImage, + requestSeq: ++this.imagePreviewRenderSeq + }; + this.imagePreviewRender = render; + this.observeImagePreviewCanvas(canvas, state); + this.queueImagePreviewCanvasRender(state); + if (decodedImage) { + return render; + } + const loader = new Image(); + loader.decoding = 'async'; + loader.onload = () => { + if (this.imagePreviewRender !== render || render.requestSeq !== this.imagePreviewRenderSeq) { + return; + } + render.image = loader; + render.width = Math.max(1, loader.naturalWidth || image.width || 1); + render.height = Math.max(1, loader.naturalHeight || image.height || 1); + render.ready = true; + image.previewImage = loader; + image.previewImageKey = key; + image.previewDecodeFailures = 0; + const previousArea = getImageGridPixelArea(image); + image.width = render.width; + image.height = render.height; + this.resortImageGridAfterSizeUpdate(state, image, previousArea); + this.renderImagePreviewCanvas(state); + }; + loader.onerror = () => { + if (this.imagePreviewRender === render) { + render.ready = false; + this.renderImagePreviewCanvas(state); + } + }; + loader.src = src; + return render; + } + async ensureImagePreviewElementLoaded(state, index) { + const image = state.images[index]; + const src = this.getImagePreviewSource(image); + if (!image || !src) { + return false; + } + const key = this.getImagePreviewKey(image, src); + if (this.isImagePreviewDecoded(image, src)) { + return true; + } + if (!image.previewImageLoading || image.previewImageLoadingKey !== key) { + const loader = new Image(); + loader.decoding = 'async'; + image.previewImageLoadingKey = key; + image.previewImageLoading = this.loadImagePreviewElement(loader, src); + } + const loading = image.previewImageLoading; + if (!loading) { + return false; + } + try { + const loader = await loading; + if (!this.isImageGridSurfaceActive(state) || image.previewImageLoadingKey !== key || !this.shouldKeepImageGridPreview(state, index)) { + return false; + } + image.previewImage = loader; + image.previewImageKey = key; + image.previewDecodeFailures = 0; + const previousArea = getImageGridPixelArea(image); + image.width = Math.max(1, loader.naturalWidth || image.width || 1); + image.height = Math.max(1, loader.naturalHeight || image.height || 1); + this.resortImageGridAfterSizeUpdate(state, image, previousArea); + return true; + } + catch (error) { + if (this.isImageGridSurfaceActive(state) && this.shouldKeepImageGridPreview(state, index)) { + if (!this.retryImageGridPreviewAfterDecodeFailure(state, index, image)) { + image.previewError = error instanceof Error ? error.message : String(error); + } + } + return false; + } + finally { + this.clearImagePreviewElementLoadingState(image, key); + } + } + retryImageGridPreviewAfterDecodeFailure(state, index, image) { + const hadPreviewSource = !!(image.previewUrl || image.previewContent); + if (!hadPreviewSource) { + return false; + } + image.previewDecodeFailures = (image.previewDecodeFailures ?? 0) + 1; + if (image.previewDecodeFailures > IMAGE_GRID_DECODE_RETRY_LIMIT) { + return false; + } + this.releaseImageBlobUrl(image.previewUrl); + image.previewUrl = undefined; + image.previewContent = ''; + image.previewError = undefined; + image.previewLoading = false; + image.previewImage = undefined; + image.previewImageKey = undefined; + image.previewImageLoading = undefined; + image.previewImageLoadingKey = undefined; + state.loadedPreviewIndexes.delete(index); + this.updateImagePreviewOverlay(state); + setTimeout(() => { + if (this.isImageGridSurfaceActive(state) && this.shouldKeepImageGridPreview(state, index)) { + void this.ensureImageGridPreviewLoaded(state, index, this.imageGridPreviewRequestSeq); + } + }, IMAGE_GRID_TRANSIENT_RETRY_DELAY_MS); + return true; + } + loadImagePreviewElement(loader, src) { + return this.withImagePreviewElementLoadTimeout(loader, src); + } + withImagePreviewElementLoadTimeout(loader, src) { + return withTurboFileTimeout(new Promise((resolve, reject) => { + let retries = 0; + loader.onload = async () => { + try { + await loader.decode?.(); + } + catch { + // Some formats resolve onload but reject decode; the loaded element can still be drawn. + } + resolve(loader); + }; + loader.onerror = () => { + if (retries < 1) { + retries++; + setTimeout(() => { + loader.src = src; + }, 180); + return; + } + reject(new Error('Failed to load image')); + }; + loader.src = src; + }), IMAGE_PREVIEW_ELEMENT_LOAD_TIMEOUT_MS, 'Image preview load timed out').catch(error => { + loader.onload = null; + loader.onerror = null; + loader.src = ''; + throw error; + }); + } + clearImagePreviewElementLoadingState(image, key) { + if (image.previewImageLoadingKey !== key) { + return; + } + image.previewImageLoading = undefined; + image.previewImageLoadingKey = undefined; + } + observeImagePreviewCanvas(canvas, state = this.getActiveImageGridPreviewState() ?? this.imageGridState) { + this.imagePreviewResizeObserver?.disconnect(); + if (typeof ResizeObserver === 'undefined') { + return; + } + this.imagePreviewResizeObserver = new ResizeObserver(() => this.renderImagePreviewCanvas(state)); + this.imagePreviewResizeObserver.observe(canvas); + } + queueImagePreviewCanvasRender(state = this.getActiveImageGridPreviewState() ?? this.imageGridState) { + const canvas = this.imagePreviewRender?.canvas; + const ownerWindow = canvas ? getWindow(canvas) : getWindow(this.container ?? document.body); + ownerWindow.requestAnimationFrame(() => this.renderImagePreviewCanvas(state)); + } + renderImagePreviewCanvas(state = this.getActiveImageGridPreviewState()) { + const render = this.imagePreviewRender; + if (!state?.previewVisible || !render) { + return; + } + const rect = render.canvas.getBoundingClientRect(); + const cssWidth = Math.max(1, rect.width); + const cssHeight = Math.max(1, rect.height); + const ownerWindow = getWindow(render.canvas); + const dpr = Math.max(1, ownerWindow.devicePixelRatio || 1); + const backingWidth = Math.max(1, Math.round(cssWidth * dpr)); + const backingHeight = Math.max(1, Math.round(cssHeight * dpr)); + if (render.canvas.width !== backingWidth || render.canvas.height !== backingHeight) { + render.canvas.width = backingWidth; + render.canvas.height = backingHeight; + } + render.context.setTransform(dpr, 0, 0, dpr, 0, 0); + render.context.clearRect(0, 0, cssWidth, cssHeight); + if (!render.ready || !render.image) { + this.updateImagePreviewZoomValue(state); + return; + } + if (state.previewFitPending) { + this.resetImagePreviewViewToFit(state, render, cssWidth, cssHeight); + } + this.limitImagePreviewOffset(state, render, cssWidth, cssHeight); + const shouldSmooth = state.previewSamplingMode === 'smooth'; + render.context.imageSmoothingEnabled = shouldSmooth; + render.context.imageSmoothingQuality = 'low'; + const draw = this.getImagePreviewDrawRects(state, render, cssWidth, cssHeight); + if (draw) { + render.context.drawImage(render.image, draw.sourceX, draw.sourceY, draw.sourceWidth, draw.sourceHeight, draw.destX, draw.destY, draw.destWidth, draw.destHeight); + } + this.updateImagePreviewZoomValue(state); + } + getImagePreviewDrawRects(state, render, viewportWidth, viewportHeight) { + const scale = Math.max(IMAGE_PREVIEW_ZOOM_MIN, state.previewZoom); + const scaledWidth = render.width * scale; + const scaledHeight = render.height * scale; + const imageLeft = state.previewPanX; + const imageTop = state.previewPanY; + const imageRight = imageLeft + scaledWidth; + const imageBottom = imageTop + scaledHeight; + const destX = Math.max(0, imageLeft); + const destY = Math.max(0, imageTop); + const destRight = Math.min(viewportWidth, imageRight); + const destBottom = Math.min(viewportHeight, imageBottom); + const destWidth = destRight - destX; + const destHeight = destBottom - destY; + if (destWidth <= 0 || destHeight <= 0) { + return undefined; + } + return { + sourceX: (destX - imageLeft) / scale, + sourceY: (destY - imageTop) / scale, + sourceWidth: destWidth / scale, + sourceHeight: destHeight / scale, + destX, + destY, + destWidth, + destHeight + }; + } + resetImagePreviewViewToFit(state, render, viewportWidth, viewportHeight) { + const fitScale = Math.min(viewportWidth / render.width, viewportHeight / render.height); + const scale = Math.min(Math.max(IMAGE_PREVIEW_ZOOM_MIN, fitScale), 1); + state.previewZoom = scale; + state.previewPanX = (viewportWidth - render.width * scale) / 2; + state.previewPanY = (viewportHeight - render.height * scale) / 2; + state.previewFitPending = false; + } + limitImagePreviewOffset(state, render, viewportWidth, viewportHeight) { + const rect = render.canvas.getBoundingClientRect(); + const width = Math.max(1, viewportWidth ?? rect.width); + const height = Math.max(1, viewportHeight ?? rect.height); + const scaledWidth = render.width * state.previewZoom; + const scaledHeight = render.height * state.previewZoom; + state.previewPanX = scaledWidth <= width + ? (width - scaledWidth) / 2 + : Math.min(0, Math.max(width - scaledWidth, state.previewPanX)); + state.previewPanY = scaledHeight <= height + ? (height - scaledHeight) / 2 + : Math.min(0, Math.max(height - scaledHeight, state.previewPanY)); + } + handleImagePreviewWheel(event, state = this.getActiveImageGridPreviewState()) { + event.preventDefault(); + event.stopPropagation(); + const render = this.imagePreviewRender; + if (!state?.previewVisible || !render?.ready) { + return; + } + this.setActiveImageGridState(state); + const wheelDelta = Math.max(-IMAGE_PREVIEW_WHEEL_DELTA_LIMIT, Math.min(IMAGE_PREVIEW_WHEEL_DELTA_LIMIT, -event.deltaY / IMAGE_PREVIEW_WHEEL_DIVISOR)); + const scaleDelta = wheelDelta * state.previewZoom * IMAGE_PREVIEW_ZOOM_RATIO; + this.setImageGridPreviewZoom(state.previewZoom + scaleDelta, undefined, state); + } + handleImagePreviewPointerDown(event, state = this.getActiveImageGridPreviewState()) { + if (event.button !== 0) { + return; + } + const render = this.imagePreviewRender; + if (!state?.previewVisible || !render?.ready) { + return; + } + this.setActiveImageGridState(state); + event.preventDefault(); + event.stopPropagation(); + const figure = event.currentTarget; + figure.setPointerCapture(event.pointerId); + const startX = event.clientX; + const startY = event.clientY; + const initialPanX = state.previewPanX; + const initialPanY = state.previewPanY; + const move = (moveEvent) => { + state.previewPanX = initialPanX + moveEvent.clientX - startX; + state.previewPanY = initialPanY + moveEvent.clientY - startY; + state.previewFitPending = false; + this.limitImagePreviewOffset(state, render); + this.renderImagePreviewCanvas(state); + }; + const end = (endEvent) => { + figure.removeEventListener('pointermove', move); + figure.removeEventListener('pointerup', end); + figure.removeEventListener('pointercancel', end); + if (figure.hasPointerCapture(endEvent.pointerId)) { + figure.releasePointerCapture(endEvent.pointerId); + } + }; + figure.addEventListener('pointermove', move); + figure.addEventListener('pointerup', end); + figure.addEventListener('pointercancel', end); + } + attachImagePreviewPixelInspector(stage, figure, image, src) { + if (figure.dataset.turbofilePixelInspectorAttached === 'true') { + return; + } + const tooltip = $('.turbofile-image-preview-pixel-tooltip'); + tooltip.textContent = 'Loading RGB...'; + stage.appendChild(tooltip); + figure.dataset.turbofilePixelInspectorAttached = 'true'; + this.prepareImagePreviewAnalysis(image, src); + figure.addEventListener('mousemove', event => { + if (figure.dataset.turbofilePixelInspectorAttached !== 'true') { + tooltip.classList.remove('is-visible'); + return; + } + this.updateImagePreviewPixelTooltip(event, stage, tooltip, image, src); + }); + figure.addEventListener('mouseleave', () => { + tooltip.classList.remove('is-visible'); + }); + } + updateImagePreviewPixelTooltip(event, stage, tooltip, image, src) { + const figure = event.currentTarget; + const state = this.getActiveImageGridPreviewState(); + if (!state?.pixelInspectorVisible || figure?.dataset.turbofilePixelInspectorAttached !== 'true' || !figure.classList.contains('is-pixel-inspector')) { + tooltip.classList.remove('is-visible'); + return; + } + const render = this.imagePreviewRender; + const analysis = this.prepareImagePreviewAnalysis(image, src); + if (!render || !analysis) { + return; + } + const canvasRect = render.canvas.getBoundingClientRect(); + const stageRect = stage.getBoundingClientRect(); + const draw = this.getImagePreviewDrawRects(state, render, canvasRect.width, canvasRect.height); + if (!draw) { + tooltip.classList.remove('is-visible'); + return; + } + const imageX = draw.sourceX + (event.clientX - canvasRect.left - draw.destX) * (draw.sourceWidth / draw.destWidth); + const imageY = draw.sourceY + (event.clientY - canvasRect.top - draw.destY) * (draw.sourceHeight / draw.destHeight); + const x = Math.max(0, Math.min(Math.max(0, analysis.width - 1), Math.floor(imageX))); + const y = Math.max(0, Math.min(Math.max(0, analysis.height - 1), Math.floor(imageY))); + tooltip.style.left = `${event.clientX - stageRect.left + 12}px`; + tooltip.style.top = `${event.clientY - stageRect.top + 12}px`; + tooltip.classList.add('is-visible'); + if (!analysis.ready) { + tooltip.textContent = `X: ${x}, Y: ${y}\nLoading RGB...`; + return; + } + const pixel = analysis.context.getImageData(x, y, 1, 1).data; + const r = pixel[0]; + const g = pixel[1]; + const b = pixel[2]; + tooltip.textContent = `X: ${x}, Y: ${y}\nRGB (${r}, ${g}, ${b})\nHSV (${this.toHsvColor(r, g, b)})`; + } + toHsvColor(r, g, b) { + const red = r / 255; + const green = g / 255; + const blue = b / 255; + const max = Math.max(red, green, blue); + const min = Math.min(red, green, blue); + const delta = max - min; + let hue = 0; + if (delta !== 0) { + if (max === red) { + hue = ((green - blue) / delta) % 6; + } + else if (max === green) { + hue = (blue - red) / delta + 2; + } + else { + hue = (red - green) / delta + 4; + } + hue *= 60; + if (hue < 0) { + hue += 360; + } + } + const saturation = max === 0 ? 0 : (delta / max) * 100; + const value = max * 100; + return `${Math.round(hue)}°, ${Math.round(saturation)}%, ${Math.round(value)}%`; + } + prepareImagePreviewAnalysis(image, src) { + const ownerDocument = this.container?.ownerDocument ?? document; + const key = this.getImagePreviewKey(image, src); + if (this.imagePreviewAnalysis?.key === key) { + return this.imagePreviewAnalysis; + } + const canvas = ownerDocument.createElement('canvas'); + const context = canvas.getContext('2d', { willReadFrequently: true }); + if (!context) { + return undefined; + } + const analysis = { + key, + canvas, + context, + width: Math.max(1, image.width ?? 1), + height: Math.max(1, image.height ?? 1), + ready: false + }; + this.imagePreviewAnalysis = analysis; + const loader = new Image(); + loader.decoding = 'async'; + loader.onload = () => { + if (this.imagePreviewAnalysis !== analysis) { + return; + } + analysis.width = Math.max(1, loader.naturalWidth || image.width || 1); + analysis.height = Math.max(1, loader.naturalHeight || image.height || 1); + canvas.width = analysis.width; + canvas.height = analysis.height; + try { + context.clearRect(0, 0, analysis.width, analysis.height); + context.drawImage(loader, 0, 0, analysis.width, analysis.height); + analysis.ready = true; + } + catch { + analysis.ready = false; + } + this.updateImagePreviewHistogramCanvas(); + }; + loader.src = src; + return analysis; + } + drawImagePreviewHistogram(canvas, analysis) { + const context = canvas.getContext('2d'); + if (!context) { + return; + } + const width = canvas.width; + const height = canvas.height; + context.clearRect(0, 0, width, height); + context.fillStyle = '#808080'; + context.fillRect(0, 0, width, height); + context.strokeStyle = 'rgba(255, 255, 255, 0.08)'; + for (let x = 0; x <= 5; x++) { + const lineX = Math.round((x / 5) * width) + 0.5; + context.beginPath(); + context.moveTo(lineX, 0); + context.lineTo(lineX, height); + context.stroke(); + } + if (!analysis?.ready) { + context.fillStyle = '#ffffff'; + context.font = '12px Archivo, Segoe UI, sans-serif'; + context.fillText('Loading histogram...', 12, 24); + return; + } + const data = analysis.context.getImageData(0, 0, analysis.width, analysis.height).data; + const red = new Uint32Array(256); + const green = new Uint32Array(256); + const blue = new Uint32Array(256); + for (let offset = 0; offset < data.length; offset += 4) { + red[data[offset]]++; + green[data[offset + 1]]++; + blue[data[offset + 2]]++; + } + const max = Math.max(1, ...Array.from(red), ...Array.from(green), ...Array.from(blue)); + this.drawImagePreviewHistogramChannel(context, red, max, width, height, 'rgba(255, 0, 32, 0.86)'); + this.drawImagePreviewHistogramChannel(context, green, max, width, height, 'rgba(0, 235, 74, 0.76)'); + this.drawImagePreviewHistogramChannel(context, blue, max, width, height, 'rgba(0, 46, 255, 0.82)'); + } + drawImagePreviewHistogramChannel(context, bins, max, width, height, color) { + context.beginPath(); + context.moveTo(0, height); + for (let index = 0; index < bins.length; index++) { + const x = (index / (bins.length - 1)) * width; + const y = height - (bins[index] / max) * (height - 6); + context.lineTo(x, y); + } + context.lineTo(width, height); + context.closePath(); + context.fillStyle = color; + context.fill(); + } + updateImagePreviewHistogramCanvas(state = this.getActiveImageGridPreviewState()) { + const canvas = this.container?.querySelector('.turbofile-image-preview-histogram-canvas'); + if (canvas) { + const selected = state?.images[state.selectedIndex]; + const src = this.getImagePreviewSource(selected); + const analysis = selected && src ? this.prepareImagePreviewAnalysis(selected, src) : undefined; + this.drawImagePreviewHistogram(canvas, analysis); + } + } + attachImagePreviewHistogramDrag(panel, state) { + panel.addEventListener('pointerdown', event => { + if (event.target.closest('button')) { + return; + } + event.preventDefault(); + const ownerWindow = getWindow(panel); + const startX = event.clientX; + const startY = event.clientY; + const initialX = state.histogramX; + const initialY = state.histogramY; + panel.setPointerCapture(event.pointerId); + const move = (moveEvent) => { + state.histogramX = Math.max(8, initialX + moveEvent.clientX - startX); + state.histogramY = Math.max(8, initialY + moveEvent.clientY - startY); + panel.style.left = `${state.histogramX}px`; + panel.style.top = `${state.histogramY}px`; + }; + const up = (upEvent) => { + ownerWindow.removeEventListener('pointermove', move); + ownerWindow.removeEventListener('pointerup', up); + ownerWindow.removeEventListener('pointercancel', up); + if (panel.hasPointerCapture(upEvent.pointerId)) { + panel.releasePointerCapture(upEvent.pointerId); + } + }; + ownerWindow.addEventListener('pointermove', move); + ownerWindow.addEventListener('pointerup', up); + ownerWindow.addEventListener('pointercancel', up); + }); + } + updateImagePreviewOverlay(state = this.getActiveImageGridPreviewState()) { + if (!state?.previewVisible || !this.container) { + return; + } + this.setActiveImageGridState(state); + const stage = this.container.querySelector('.turbofile-image-preview-stage'); + if (stage) { + this.renderImagePreviewStageContent(stage, state); + } + const caption = this.container.querySelector('.turbofile-image-preview-caption'); + if (caption) { + this.renderImagePreviewInfoStripContent(caption, state); + } + const zoomValue = this.container.querySelector('.turbofile-image-preview-zoom-value'); + if (zoomValue) { + zoomValue.textContent = this.getImagePreviewZoomLabel(state); + } + if (state.histogramVisible) { + this.updateImagePreviewHistogramCanvas(state); + } + this.updateImagePreviewSideButtons(state); + } + updateImagePreviewSideButtons(state) { + const overlay = this.container?.querySelector('.turbofile-image-preview-overlay'); + if (!overlay || this.getActiveImageGridPreviewState() !== state || !state.previewVisible) { + return; + } + for (const button of Array.from(overlay.querySelectorAll('.turbofile-image-preview-side'))) { + button.remove(); + } + overlay.appendChild(this.renderImagePreviewSideButton(state, -1)); + overlay.appendChild(this.renderImagePreviewSideButton(state, 1)); + } + updateImagePreviewToggleButton(toggle, active) { + const button = this.container?.querySelector(`.turbofile-image-preview-action[data-turbofile-preview-toggle="${toggle}"]`); + if (!button) { + return; + } + button.classList.toggle('is-active', active); + if (active) { + button.setAttribute('aria-pressed', 'true'); + } + else { + button.removeAttribute('aria-pressed'); + } + } + updateImagePreviewPixelInspectorInPlace(state) { + const stage = this.container?.querySelector('.turbofile-image-preview-stage'); + const figure = stage?.querySelector('.turbofile-image-preview-figure'); + if (!stage || !figure) { + return; + } + figure.classList.toggle('is-pixel-inspector', state.pixelInspectorVisible); + const tooltip = stage.querySelector('.turbofile-image-preview-pixel-tooltip'); + if (!state.pixelInspectorVisible) { + delete figure.dataset.turbofilePixelInspectorAttached; + tooltip?.remove(); + return; + } + const selected = state.images[state.selectedIndex]; + const previewSrc = this.getImagePreviewSource(selected); + if (selected && previewSrc) { + this.attachImagePreviewPixelInspector(stage, figure, selected, previewSrc); + } + } + updateImagePreviewHistogramInPlace(state) { + const overlay = this.container?.querySelector('.turbofile-image-preview-overlay'); + const panel = overlay?.querySelector('.turbofile-image-preview-histogram'); + if (!overlay) { + return; + } + if (!state.histogramVisible) { + panel?.remove(); + return; + } + if (panel) { + this.updateImagePreviewHistogramCanvas(state); + return; + } + overlay.appendChild(this.renderImagePreviewHistogramPanel(state)); + } + renderSyncPreviewOverlay() { + const state = this.syncPreviewState; + const overlay = $('.turbofile-sync-overlay'); + const panel = $('.turbofile-sync-panel'); + if (!state) { + return overlay; + } + const totalPlan = state.plan.map((_, index) => this.getSyncPreviewStep(state, index)); + const selectedPlan = this.getSelectedSyncPlan(state); + const totalStats = this.getSyncPlanStats(totalPlan); + const selectedStats = this.getSyncPlanStats(selectedPlan); + const header = $('.turbofile-sync-header'); + const title = $('.turbofile-sync-title'); + title.appendChild(renderIcon(Codicon.sync)); + title.append('同步预览'); + header.appendChild(title); + const headerActions = $('.turbofile-sync-header-actions'); + const close = this.createIconButton(Codicon.close, '取消同步', 'turbofile-toolbar-button'); + close.element.addEventListener('click', () => this.resolveSyncPreview(undefined)); + headerActions.appendChild(close.element); + header.appendChild(headerActions); + panel.appendChild(header); + const summary = $('.turbofile-sync-summary'); + const mode = state.plan.some(step => step.recursive) ? '选中目录递归差异同步' : '当前列表轻量同步'; + summary.appendChild(this.renderSyncSummaryMetric('模式', mode)); + summary.appendChild(this.renderSyncSummaryMetric('已选择', `${selectedPlan.length} / ${state.plan.length} 项`)); + summary.appendChild(this.renderSyncSummaryMetric('大小', formatSize(selectedStats.bytes))); + summary.appendChild(this.renderSyncSummaryMetric('新增', `${selectedStats.missing} / ${totalStats.missing}`)); + summary.appendChild(this.renderSyncSummaryMetric('较新', `${selectedStats.newer} / ${totalStats.newer}`)); + summary.appendChild(this.renderSyncSummaryMetric('大小不同', `${selectedStats.size} / ${totalStats.size}`)); + summary.appendChild(this.renderSyncSummaryMetric('方向', `${selectedStats.leftToRight} 左->右 / ${selectedStats.rightToLeft} 右->左`)); + panel.appendChild(summary); + const toolbar = $('.turbofile-sync-toolbar'); + const hint = $('.turbofile-sync-hint'); + hint.textContent = '冲突项可逐条切换方向,也可以用规则按钮批量决定方向。'; + toolbar.appendChild(hint); + const directionActions = $('.turbofile-sync-direction-actions'); + const hasReversibleItems = state.plan.some(step => !!step.reverse); + const autoDirection = this.createButton('自动方向', 'turbofile-secondary-button'); + autoDirection.element.disabled = !hasReversibleItems || state.reversedIndexes.size === 0; + autoDirection.element.addEventListener('click', () => this.setSyncConflictDirection(state, 'auto')); + directionActions.appendChild(autoDirection.element); + const leftToRight = this.createButton('冲突左->右', 'turbofile-secondary-button'); + leftToRight.element.disabled = !hasReversibleItems; + leftToRight.element.addEventListener('click', () => this.setSyncConflictDirection(state, 'left-to-right')); + directionActions.appendChild(leftToRight.element); + const rightToLeft = this.createButton('冲突右->左', 'turbofile-secondary-button'); + rightToLeft.element.disabled = !hasReversibleItems; + rightToLeft.element.addEventListener('click', () => this.setSyncConflictDirection(state, 'right-to-left')); + directionActions.appendChild(rightToLeft.element); + toolbar.appendChild(directionActions); + const selectAll = this.createButton('全选', 'turbofile-secondary-button'); + selectAll.element.addEventListener('click', () => { + state.selectedIndexes.clear(); + for (let index = 0; index < state.plan.length; index++) { + state.selectedIndexes.add(index); + } + this.render(); + }); + toolbar.appendChild(selectAll.element); + const clearAll = this.createButton('清空', 'turbofile-secondary-button'); + clearAll.element.addEventListener('click', () => { + state.selectedIndexes.clear(); + this.render(); + }); + toolbar.appendChild(clearAll.element); + const start = this.createButton(`开始同步 ${selectedPlan.length} 项`, 'turbofile-primary-button'); + start.element.disabled = selectedPlan.length === 0; + start.element.addEventListener('click', () => this.resolveSyncPreview(selectedPlan)); + toolbar.appendChild(start.element); + panel.appendChild(toolbar); + const list = $('.turbofile-sync-list'); + for (const [index] of state.plan.entries()) { + list.appendChild(this.renderSyncPreviewRow(state, this.getSyncPreviewStep(state, index), index)); + } + panel.appendChild(list); + overlay.appendChild(panel); + return overlay; + } + renderSyncSummaryMetric(label, value) { + const metric = $('.turbofile-sync-summary-metric'); + const labelElement = $('span'); + labelElement.textContent = label; + const valueElement = $('strong'); + valueElement.textContent = value; + metric.appendChild(labelElement); + metric.appendChild(valueElement); + return metric; + } + renderSyncPreviewRow(state, step, index) { + const originalStep = state.plan[index]; + const row = $('.turbofile-sync-row'); + const checked = state.selectedIndexes.has(index); + if (!checked) { + row.classList.add('is-skipped'); + } + if (state.reversedIndexes.has(index)) { + row.classList.add('is-reversed'); + } + const setChecked = (value) => { + if (value) { + state.selectedIndexes.add(index); + } + else { + state.selectedIndexes.delete(index); + } + this.render(); + }; + row.addEventListener('click', event => { + const target = event.target; + if (target instanceof HTMLInputElement || (target instanceof HTMLElement && target.closest('button'))) { + return; + } + setChecked(!state.selectedIndexes.has(index)); + }); + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.checked = checked; + checkbox.className = 'turbofile-sync-checkbox'; + checkbox.addEventListener('change', event => { + event.stopPropagation(); + setChecked(checkbox.checked); + }); + row.appendChild(checkbox); + const direction = document.createElement('button'); + direction.type = 'button'; + direction.className = 'turbofile-sync-direction'; + direction.disabled = !originalStep.reverse; + direction.title = originalStep.reverse ? '切换同步方向' : '缺失项无法切换方向'; + if (state.reversedIndexes.has(index)) { + direction.classList.add('is-reversed'); + } + if (originalStep.reverse) { + direction.appendChild(renderIcon(Codicon.arrowSwap)); + } + direction.append(this.getSyncDirectionLabel(step)); + direction.addEventListener('click', event => { + event.preventDefault(); + event.stopPropagation(); + if (!originalStep.reverse) { + return; + } + if (state.reversedIndexes.has(index)) { + state.reversedIndexes.delete(index); + } + else { + state.reversedIndexes.add(index); + } + this.render(); + }); + row.appendChild(direction); + const reason = $('.turbofile-sync-reason'); + reason.textContent = this.getSyncReasonLabel(step.reason); + reason.classList.add(`is-${step.reason}`); + row.appendChild(reason); + const path = $('.turbofile-sync-path'); + path.title = step.relativePath; + path.textContent = step.relativePath; + row.appendChild(path); + const meta = $('.turbofile-sync-meta'); + const size = step.item.directory ? '目录' : formatSize(step.item.rawSize); + meta.textContent = `${size} · ${step.item.modified || '未知时间'}`; + row.appendChild(meta); + return row; + } + getSelectedSyncPlan(state) { + const selectedPlan = []; + for (const [index] of state.plan.entries()) { + if (state.selectedIndexes.has(index)) { + selectedPlan.push(this.getSyncPreviewStep(state, index)); + } + } + return selectedPlan; + } + getSyncPreviewStep(state, index) { + const step = state.plan[index]; + if (state.reversedIndexes.has(index) && step.reverse) { + return { + ...step, + sourceSide: step.reverse.sourceSide, + targetSide: step.reverse.targetSide, + item: step.reverse.item, + targetPath: step.reverse.targetPath + }; + } + return step; + } + setSyncConflictDirection(state, direction) { + if (direction === 'auto') { + state.reversedIndexes.clear(); + this.render(); + return; + } + for (const [index, step] of state.plan.entries()) { + if (!step.reverse) { + continue; + } + if (this.matchesSyncDirection(step, direction)) { + state.reversedIndexes.delete(index); + } + else if (this.matchesSyncDirection(step.reverse, direction)) { + state.reversedIndexes.add(index); + } + } + this.render(); + } + matchesSyncDirection(step, direction) { + return direction === 'left-to-right' + ? step.sourceSide === 'left' && step.targetSide === 'right' + : step.sourceSide === 'right' && step.targetSide === 'left'; + } + getSyncPlanStats(plan) { + let missing = 0; + let newer = 0; + let size = 0; + let bytes = 0; + let leftToRight = 0; + let rightToLeft = 0; + for (const step of plan) { + if (step.reason === 'missing') { + missing++; + } + else if (step.reason === 'newer') { + newer++; + } + else { + size++; + } + bytes += step.item.directory ? 0 : step.item.rawSize; + if (step.sourceSide === 'left' && step.targetSide === 'right') { + leftToRight++; + } + else if (step.sourceSide === 'right' && step.targetSide === 'left') { + rightToLeft++; + } + } + return { missing, newer, size, bytes, leftToRight, rightToLeft }; + } + resolveSyncPreview(plan) { + const state = this.syncPreviewState; + this.syncPreviewState = undefined; + state?.resolve(plan); + this.render(); + } + togglePaneMaximized(side) { + this.maximizedPane = this.maximizedPane === side ? undefined : side; + this.fileListRenderedRange.left = undefined; + this.fileListRenderedRange.right = undefined; + this.render(); + } + appendPaneSortButton(parent, side, iconColor) { + const state = this.paneStates[side]; + const button = document.createElement('button'); + button.className = 'turbofile-toolbar-button turbofile-sort-button'; + button.title = `排序:${this.getPaneSortLabel(state.sortKey)},${this.getPaneSortDirectionLabel(state.sortDirection)}`; + button.appendChild(renderTurboFileSortIcon(iconColor)); + button.addEventListener('click', event => this.showPaneSortMenu(side, event)); + parent.appendChild(button); + } + appendToolbarButton(parent, icon, title, handler, turboIcon, iconColor) { + const button = document.createElement('button'); + button.className = 'turbofile-toolbar-button'; + button.title = title; + button.appendChild(turboIcon ? renderTurboFileIcon(turboIcon, iconColor) : renderIcon(icon)); + if (handler) { + button.addEventListener('click', handler); + } + parent.appendChild(button); + } + createButton(label, className) { + const element = document.createElement('button'); + element.className = className; + element.textContent = label; + return { + element, + dispose: () => element.remove() + }; + } + createIconButton(icon, title, className) { + const element = document.createElement('button'); + element.className = className; + element.title = title; + element.appendChild(renderIcon(icon)); + return { + element, + dispose: () => element.remove() + }; + } + renderDialogOverlay() { + const state = this.dialogState; + const overlay = $('.turbofile-dialog-overlay'); + if (!state) { + return overlay; + } + const panel = $('.turbofile-dialog-panel'); + if (state.danger) { + panel.classList.add('is-danger'); + } + const header = $('.turbofile-dialog-header'); + const icon = $('.turbofile-dialog-icon'); + icon.appendChild(renderIcon(state.danger ? Codicon.trash : state.kind === 'prompt' ? Codicon.edit : Codicon.info)); + header.appendChild(icon); + const copy = $('.turbofile-dialog-copy'); + const title = $('h3'); + title.textContent = state.title; + const message = $('p'); + message.textContent = state.message; + copy.appendChild(title); + copy.appendChild(message); + if (state.detail) { + const detail = $('div.turbofile-dialog-detail'); + detail.textContent = state.detail; + copy.appendChild(detail); + } + header.appendChild(copy); + panel.appendChild(header); + let input; + if (state.kind === 'prompt') { + input = $('input.turbofile-dialog-input'); + input.value = state.defaultValue ?? ''; + input.addEventListener('keydown', event => { + if (event.key === 'Enter') { + event.preventDefault(); + this.resolveDialog(input?.value); + } + else if (event.key === 'Escape') { + event.preventDefault(); + this.resolveDialog(undefined); + } + }); + panel.appendChild(input); + getWindow(input).requestAnimationFrame(() => input?.focus()); + } + const actions = $('.turbofile-dialog-actions'); + if (state.cancelLabel) { + const cancel = $('button.turbofile-dialog-button.turbofile-dialog-button-secondary'); + cancel.type = 'button'; + cancel.textContent = state.cancelLabel; + cancel.addEventListener('click', () => this.resolveDialog(undefined)); + actions.appendChild(cancel); + } + const confirm = $('button.turbofile-dialog-button.turbofile-dialog-button-primary'); + confirm.type = 'button'; + confirm.textContent = state.confirmLabel; + confirm.classList.toggle('is-danger', !!state.danger); + confirm.addEventListener('click', () => this.resolveDialog(state.kind === 'prompt' ? input?.value : true)); + actions.appendChild(confirm); + panel.appendChild(actions); + overlay.appendChild(panel); + overlay.addEventListener('click', event => { + if (event.target === overlay) { + this.resolveDialog(undefined); + } + }); + return overlay; + } + showDialogConfirm(title, message, detail, danger = false) { + return new Promise(resolve => { + this.dialogState = { + kind: 'confirm', + title, + message, + detail, + danger, + confirmLabel: danger ? '删除' : '确定', + cancelLabel: '取消', + resolve: value => resolve(value === true) + }; + this.render(); + }); + } + showDialogPrompt(title, message, defaultValue = '') { + return new Promise(resolve => { + this.dialogState = { + kind: 'prompt', + title, + message, + defaultValue, + confirmLabel: '确定', + cancelLabel: '取消', + resolve: value => resolve(typeof value === 'string' ? value : undefined) + }; + this.render(); + }); + } + showDialogInfo(title, message, detail) { + this.dialogState = { + kind: 'info', + title, + message, + detail, + confirmLabel: '知道了', + resolve: () => undefined + }; + this.render(); + } + resolveDialog(value) { + const state = this.dialogState; + if (!state) { + return; + } + this.dialogState = undefined; + state.resolve(value); + this.render(); + } + showFileContextMenu(side, item, event) { + event.preventDefault(); + event.stopPropagation(); + this.setActiveFilePaneSide(side); + const anchor = new StandardMouseEvent(getWindow(event), event); + if (item.path) { + const state = this.paneStates[side]; + if (item.parent || !state.selectedPaths.has(item.path)) { + this.selectItem(side, item, undefined, false); + this.statusMessage = item.parent ? '已选择上级目录入口。' : `已选择 ${item.name}。可右键新建、重命名、复制路径、计算大小、解压、压缩或删除。`; + this.render(); + } + } + this.contextMenuService.showContextMenu({ + getAnchor: () => anchor, + getActions: () => this.getFileContextActions(side, item), + getActionsContext: () => item, + skipTelemetry: true + }); + } + showPaneContextMenu(side, event) { + event.preventDefault(); + this.setActiveFilePaneSide(side); + const anchor = new StandardMouseEvent(getWindow(event), event); + this.contextMenuService.showContextMenu({ + getAnchor: () => anchor, + getActions: () => this.getPaneContextActions(side), + skipTelemetry: true + }); + } + showPaneSortMenu(side, event) { + event.preventDefault(); + event.stopPropagation(); + this.setActiveFilePaneSide(side); + const anchor = new StandardMouseEvent(getWindow(event), event); + this.contextMenuService.showContextMenu({ + getAnchor: () => anchor, + getActions: () => this.getPaneSortActions(side), + getActionViewItem: (action, options) => action.id.startsWith(`turbofile.sort.${side}.`) + ? new ActionViewItem(null, action, { ...options, icon: true, label: true }) + : undefined, + skipTelemetry: true + }); + } + showPreviewImageContextMenu(event) { + event.preventDefault(); + event.stopPropagation(); + const anchor = new StandardMouseEvent(getWindow(event), event); + this.contextMenuService.showContextMenu({ + getAnchor: () => anchor, + getActions: () => [ + this.menuAction('turbofile.copyPreviewImage', '复制', Codicon.copy, () => { void this.copyPreviewImageToClipboard(); }, this.canCopyPreviewImage()) + ], + skipTelemetry: true + }); + } + getFileContextActions(side, item) { + const canOperate = !!item.path && !item.parent; + const canExtract = canOperate && !item.directory && isArchiveFile(item.name); + if (item.parent) { + return this.getPaneContextActions(side); + } + return [ + this.menuAction('turbofile.newFolder', '新建文件夹', Codicon.newFolder, () => this.createFolder(side)), + this.menuAction('turbofile.newFile', '新建文件', Codicon.newFile, () => this.createFile(side)), + this.menuAction('turbofile.rename', '重命名', Codicon.edit, () => this.renameSelected(side), canOperate), + this.menuAction('turbofile.copyPath', '复制路径', Codicon.copy, () => this.copySelectedPath(side), canOperate), + this.menuAction('turbofile.calculateSize', '计算大小', Codicon.symbolNumeric, () => this.calculateSelectedSize(side), canOperate), + this.menuAction('turbofile.extract', '解压', Codicon.unarchive, () => this.extractSelected(side), canExtract), + this.menuAction('turbofile.compress', '压缩', Codicon.archive, () => this.compressSelected(side), canOperate), + this.menuAction('turbofile.delete', '删除', Codicon.trash, () => this.deleteSelected(side), canOperate) + ]; + } + getPaneContextActions(side) { + return [ + this.menuAction('turbofile.newFolder', '新建文件夹', Codicon.newFolder, () => this.createFolder(side)), + this.menuAction('turbofile.newFile', '新建文件', Codicon.newFile, () => this.createFile(side)) + ]; + } + getPaneSortActions(side) { + const state = this.paneStates[side]; + return [ + ...PANE_SORT_OPTIONS.map(option => this.menuAction(`turbofile.sort.${side}.key.${option.key}`, option.label, 'turbofile-sort-action', () => this.setPaneSortKey(side, option.key), true, state.sortKey === option.key)), + new Separator(), + ...PANE_SORT_DIRECTION_OPTIONS.map(option => this.menuAction(`turbofile.sort.${side}.direction.${option.direction}`, option.label, 'turbofile-sort-action', () => this.setPaneSortDirection(side, option.direction), true, state.sortDirection === option.direction)) + ]; + } + setPaneSortKey(side, key) { + const state = this.paneStates[side]; + if (state.sortKey === key) { + return; + } + state.sortKey = key; + this.applyPaneSortChange(side); + } + applyPaneSortChange(side) { + const state = this.paneStates[side]; + // Switching to a size/modified sort on a names-only listing needs the full attributes to + // sort stably: reload the directory so it comes back already sorted, instead of re-sorting blank + // rows and then jumping as enrichVisibleRows() fills them in. + if (this.paneSortNeedsAttributes(side) && state.items.some(item => item.attrsPending)) { + void this.refreshPane(side, { silent: true }); + return; + } + this.resortPaneItems(side); + } + setPaneSortDirection(side, direction) { + const state = this.paneStates[side]; + if (state.sortDirection === direction) { + return; + } + state.sortDirection = direction; + this.applyPaneSortChange(side); + } + menuAction(id, label, icon, run, enabled = true, checked) { + return toAction({ + id, + label, + enabled, + checked, + class: typeof icon === 'string' ? icon : ThemeIcon.asClassName(icon), + run + }); + } + getSideLabel(side) { + return side === 'left' ? '左侧' : '右侧'; + } + async testCurrentSshConnection() { + const target = this.getPaneDevice('right')?.id !== LOCAL_WINDOWS_DEVICE_ID ? this.getPaneDevice('right') : this.devices[0]; + if (!target || target.id === LOCAL_WINDOWS_DEVICE_ID || !this.deviceStore) { + this.statusMessage = '请先添加或切换到一个 SSH 设备。'; + this.render(); + return; + } + const task = this.beginTask('ssh-test', `测试 SSH ${target.name}`, `${target.host}:${target.port || 22}`); + try { + const result = await this.sshService.testConnection(await this.getSshConnectionOptions(target)); + if (result.ok) { + this.finishTask(task, 'success', `SSH 连接成功:${target.name}`); + } + else { + this.finishTask(task, 'error', `SSH 连接失败:${result.message}`, { error: result.message }); + } + } + catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.finishTask(task, 'error', `SSH 连接失败:${message}`, { error: message }); + } + } + async refreshPane(side, options = {}) { + const device = this.getPaneDevice(side); + const state = this.paneStates[side]; + if (!device || !this.deviceStore) { + return; + } + const targetPath = options.path ?? state.path; + const requestId = ++this.paneRequestSeq[side]; + const pathChanged = targetPath !== state.path; + if (pathChanged) { + this.resetPaneScroll(side); + } + state.path = targetPath; + state.loading = true; + state.error = undefined; + if (pathChanged) { + state.items = []; + this.clearPaneSelection(side); + } + if (!options.silent) { + this.render(); + } + try { + const listing = await this.loadPaneDirectory(device, targetPath, !!options.force, 'foreground', this.paneSortNeedsAttributes(side)); + if (this.paneRequestSeq[side] !== requestId || this.paneStates[side].path !== targetPath) { + return; + } + state.path = listing.path; + state.items = this.sortPaneItems(side, listing.items); + state.error = undefined; + this.setDeviceRuntimeStatus(device, 'online'); + this.rememberPaneDevicePath(side, device, listing.path); + this.prefetchNeighborDirectories(device, listing.path, listing.items); + this.clearPaneSelection(side); + this.statusMessage = `${device.name} 已加载 ${state.items.filter(item => !item.parent).length} 项。`; + this.rememberPaneDeviceSnapshot(side); + } + catch (error) { + if (this.paneRequestSeq[side] === requestId) { + if (await this.recoverMissingPanePath(side, device, targetPath, error, options)) { + return; + } + const message = error instanceof Error ? error.message : String(error); + state.error = message; + this.setDeviceRuntimeStatus(device, 'offline'); + this.statusMessage = `${device.name} 目录刷新失败:${message}`; + } + } + finally { + if (this.paneRequestSeq[side] === requestId) { + state.loading = false; + this.render(); + } + } + } + async loadPaneDirectory(device, path, force, purpose = 'foreground', needsAttributes = false) { + const cacheKey = this.getPaneDirectoryCacheKey(device, path); + const now = Date.now(); + if (!force) { + const cached = this.paneDirectoryCache.get(cacheKey); + // A names-only cached listing cannot satisfy a size/modified sort; fall through to a full load. + if (cached && cached.expiresAt > now && (!needsAttributes || this.cachedListingHasAttributes(cached))) { + return cached; + } + // An in-flight names-only load likewise cannot be reused when attributes are required. + if (!needsAttributes) { + const inflight = this.paneDirectoryInflight.getReusable(cacheKey, purpose); + if (inflight) { + return inflight; + } + } + } + else { + this.paneDirectoryCache.delete(cacheKey); + this.paneDirectoryInflight.delete(cacheKey); + } + let promise; + promise = (async () => { + const isRemote = device.id !== LOCAL_WINDOWS_DEVICE_ID; + // Remote directories load names + type only by default (one `find` round trip, NO per-file + // stat), so even a 30k-file directory lists near-instantly and its SSH channel is released in + // milliseconds. Holding channels open for a full stat of every entry (as an always-full load + // did) starves the connection's MaxSessions pool when combined with prefetch + image reads, + // which is what left directories intermittently stuck on a blocking loading row. Size/modified are + // filled in lazily for the visible rows by enrichVisibleRows(); only a size/modified + // sort — which genuinely needs those attributes to order correctly — loads the full listing. + const listing = isRemote + ? (needsAttributes + ? await this.sshService.listRemoteDirectory(await this.getSshConnectionOptions(device), path, force) + : await this.sshService.listRemoteDirectoryNames(await this.getSshConnectionOptions(device), path, force)) + : (needsAttributes + ? await this.sshService.listLocalDirectory(path, force) + : await this.sshService.listLocalDirectoryNames(path, force)); + this.setDeviceRuntimeStatus(device, 'online'); + const items = this.toFileItems(device, listing.path, listing.entries); + if (!needsAttributes) { + for (const item of items) { + // A names-only listing carries no modified time (sentinel 0); blank those rows and + // flag them for lazy enrichment. Rows that already have a real time (e.g. served from + // a cached full listing) are left intact so their size/date stay visible immediately. + if (!item.parent && item.rawModified === 0) { + item.attrsPending = true; + item.size = ''; + item.modified = ''; + } + } + } + const entry = { + expiresAt: Date.now() + PANE_DIRECTORY_CACHE_TTL_MS, + path: listing.path, + items + }; + if (this.paneDirectoryInflight.isCurrent(cacheKey, promise)) { + this.paneDirectoryCache.set(cacheKey, entry); + this.trimPaneDirectoryCache(); + } + return entry; + })(); + this.paneDirectoryInflight.set(cacheKey, purpose, promise); + return await promise; + } + async recoverMissingPanePath(side, device, missingPath, error, options) { + if (!this.isMissingPanePathError(error)) { + return false; + } + const fallbackPaths = this.getMissingPanePathFallbacks(device, missingPath); + this.forgetMissingPanePath(device, missingPath); + if (!fallbackPaths.length) { + return false; + } + const state = this.paneStates[side]; + state.error = undefined; + state.items = []; + this.clearPaneSelection(side); + this.statusMessage = `${device.name} 鍘熺洰褰曞凡涓嶅瓨鍦紝宸茶嚜鍔ㄥ洖閫€鍒?${fallbackPaths[0]}`; + await this.refreshPane(side, { ...options, path: fallbackPaths[0], force: true }); + return true; + } + async prefetchDefaultDeviceDirectories() { + const devices = this.getAllDevices(); + for (const device of devices) { + await this.prefetchPaneDeviceDefaultDirectory('left', device); + } + } + async prefetchPaneDeviceDefaultDirectory(side, device) { + const path = this.getRememberedPaneDevicePath(side, device) ?? this.getRememberedPaneDevicePath(side === 'left' ? 'right' : 'left', device) ?? this.getDefaultPaneDevicePath(device); + if (!path || this.getCachedPaneDirectory(device, path)) { + return; + } + const cacheKey = this.getPaneDirectoryCacheKey(device, path); + if (this.paneDirectoryInflight.has(cacheKey)) { + return; + } + try { + await this.loadPaneDirectory(device, path, false, 'prefetch'); + } + catch { + // Background warmup must never slow down the visible UI. + } + } + getCachedPaneDirectory(device, path) { + const cacheKey = this.getPaneDirectoryCacheKey(device, path); + const cached = this.paneDirectoryCache.get(cacheKey); + if (!cached) { + return undefined; + } + if (cached.expiresAt <= Date.now()) { + this.paneDirectoryCache.delete(cacheKey); + return undefined; + } + return cached; + } + applyPaneDirectoryEntry(side, device, listing) { + const state = this.paneStates[side]; + state.path = listing.path; + state.items = this.sortPaneItems(side, listing.items); + state.error = undefined; + state.loading = false; + this.setDeviceRuntimeStatus(device, 'online'); + this.clearPaneSelection(side); + this.rememberPaneDevicePath(side, device, listing.path); + this.rememberPaneDeviceSnapshot(side); + this.prefetchNeighborDirectories(device, listing.path, listing.items); + this.statusMessage = `${device.name} 已加载 ${state.items.filter(item => !item.parent).length} 项。`; + } + prefetchPaneDirectory(side, path, intent = 'idle') { + if (!path) { + return; + } + const device = this.getPaneDevice(side); + if (!device) { + return; + } + if (intent === 'commit') { + // The user is clicking this directory: start loading it right away, bypassing the serial + // prefetch queue, so the data is in flight before the double-click fires. + this.prefetchPaneDirectoryNow(device, path); + return; + } + this.enqueuePaneDirectoryPrefetch(device, path, intent === 'hover'); + } + prefetchPaneDirectoryNow(device, path) { + if (this.getCachedPaneDirectory(device, path)) { + return; + } + const cacheKey = this.getPaneDirectoryCacheKey(device, path); + if (this.paneDirectoryInflight.has(cacheKey)) { + return; + } + void this.loadPaneDirectory(device, path, false, 'prefetch').catch(() => undefined); + } + prefetchNeighborDirectories(device, currentPath, items) { + const candidates = [ + getPaneParentPath(currentPath, this.isWindowsDevice(device)), + ...items.filter(item => item.directory && !item.parent && item.path).slice(0, PANE_DIRECTORY_PREFETCH_LIMIT).map(item => item.path) + ].filter((path) => !!path); + for (const path of candidates) { + this.enqueuePaneDirectoryPrefetch(device, path); + } + } + enqueuePaneDirectoryPrefetch(device, path, prioritize = false) { + if (this.getCachedPaneDirectory(device, path)) { + return; + } + const cacheKey = this.getPaneDirectoryCacheKey(device, path); + if (this.paneDirectoryInflight.has(cacheKey)) { + return; + } + this.paneDirectoryPrefetchQueue.enqueue(cacheKey, async () => { + if (this.getCachedPaneDirectory(device, path) || this.paneDirectoryInflight.has(cacheKey)) { + return; + } + await this.loadPaneDirectory(device, path, false, 'prefetch').catch(() => undefined); + }); + if (prioritize) { + // The mouse is hovering this directory: move it ahead of queued neighbour prefetches so + // it is the next one loaded. + this.paneDirectoryPrefetchQueue.prioritize(cacheKey); + } + } + getPaneDirectoryCacheKey(device, path) { + return `${device.id}|${device.host}|${device.port || 0}|${device.username}|${device.authType ?? ''}|${path}`; + } + invalidatePaneDirectoryCaches(device, paths) { + const keys = new Set(paths.map(path => this.getPaneDirectoryCacheKey(device, path))); + for (const key of keys) { + this.paneDirectoryCache.delete(key); + this.paneDirectoryInflight.delete(key); + } + } + getVisiblePaneSidesForDeviceDirectories(device, paths) { + const windowsPath = this.isWindowsDevice(device); + return ['left', 'right'].filter(side => { + const paneDevice = this.getPaneDevice(side); + if (!paneDevice || !this.isSameDeviceForNativeOperation(device, paneDevice)) { + return false; + } + return paths.some(path => this.pathsEqualForDevice(this.paneStates[side].path, path, windowsPath)); + }); + } + isMissingPanePathError(error) { + const message = error instanceof Error ? error.message : String(error); + return /no such file|no such file or directory|file not found|path not found|enoent/i.test(message); + } + getMissingPanePathFallbacks(device, missingPath) { + const windowsPath = this.isWindowsDevice(device); + const fallbackPaths = []; + const addFallback = (path) => { + if (!path) { + return; + } + const exists = windowsPath + ? fallbackPaths.some(candidate => isSameWindowsPath(candidate, path)) + : fallbackPaths.includes(path); + if (!exists) { + fallbackPaths.push(path); + } + }; + let currentPath = missingPath; + for (let depth = 0; depth < 16; depth++) { + const parentPath = getPaneParentPath(currentPath, windowsPath); + if (!parentPath) { + break; + } + addFallback(parentPath); + currentPath = parentPath; + } + addFallback(this.getDefaultPaneDevicePath(device)); + if (!windowsPath) { + addFallback('/'); + } + return fallbackPaths; + } + forgetMissingPanePath(device, missingPath) { + for (const side of ['left', 'right']) { + const rememberedPath = this.getRememberedPaneDevicePath(side, device); + if (this.isPanePathMatch(device, rememberedPath, missingPath)) { + this.storageService.remove(this.getPaneDevicePathStorageKey(side, device), 0 /* StorageScope.PROFILE */); + } + const snapshotKey = this.getPaneDeviceMemoryKey(side, device); + const snapshot = this.paneDeviceSnapshots.get(snapshotKey); + if (snapshot && this.isPanePathMatch(device, snapshot.path, missingPath)) { + this.paneDeviceSnapshots.delete(snapshotKey); + } + } + const cacheKey = this.getPaneDirectoryCacheKey(device, missingPath); + this.paneDirectoryCache.delete(cacheKey); + this.paneDirectoryInflight.delete(cacheKey); + } + isPanePathMatch(device, left, right) { + if (!left || !right) { + return false; + } + return this.isWindowsDevice(device) ? isSameWindowsPath(left, right) : left === right; + } + clearPaneDirectoryCachesForDevice(deviceId) { + for (const key of Array.from(this.paneDirectoryCache.keys())) { + if (key.startsWith(`${deviceId}|`)) { + this.paneDirectoryCache.delete(key); + } + } + for (const key of Array.from(this.paneDirectoryInflight.keys())) { + if (key.startsWith(`${deviceId}|`)) { + this.paneDirectoryInflight.delete(key); + } + } + for (const key of Array.from(this.paneDeviceSnapshots.keys())) { + if (key.endsWith(`|${deviceId}`)) { + this.paneDeviceSnapshots.delete(key); + } + } + } + trimPaneDirectoryCache() { + while (this.paneDirectoryCache.size > PANE_DIRECTORY_CACHE_MAX_ENTRIES) { + const firstKey = this.paneDirectoryCache.keys().next().value; + if (!firstKey) { + return; + } + this.paneDirectoryCache.delete(firstKey); + } + } + selectItem(side, item, event, shouldRender = true) { + if (!item.path) { + return; + } + const state = this.paneStates[side]; + const isToggle = !!event && (event.ctrlKey || event.metaKey); + const isRange = !!event?.shiftKey && !!state.selectionAnchorPath && !item.parent; + if (isRange) { + const anchorIndex = state.items.findIndex(candidate => candidate.path === state.selectionAnchorPath); + const itemIndex = state.items.findIndex(candidate => candidate.path === item.path); + if (anchorIndex >= 0 && itemIndex >= 0) { + state.selectedPaths.clear(); + const start = Math.min(anchorIndex, itemIndex); + const end = Math.max(anchorIndex, itemIndex); + for (let index = start; index <= end; index++) { + const candidate = state.items[index]; + if (candidate.path && !candidate.parent) { + state.selectedPaths.add(candidate.path); + } + } + state.selectedPath = item.path; + } + else { + this.setSingleSelection(side, item); + } + } + else if (isToggle && !item.parent) { + if (state.selectedPaths.has(item.path)) { + state.selectedPaths.delete(item.path); + if (state.selectedPath === item.path) { + state.selectedPath = this.getLastSelectedPath(side); + } + } + else { + state.selectedPaths.add(item.path); + state.selectedPath = item.path; + } + state.selectionAnchorPath = item.path; + if (!state.selectedPaths.size) { + state.selectedPath = undefined; + } + } + else { + this.setSingleSelection(side, item); + } + const selectedItems = this.getSelectedTransferItems(side); + if (selectedItems.length > 1) { + this.statusMessage = `${this.getSideLabel(side)}已选择 ${selectedItems.length} 项。可批量复制、剪切或删除。`; + } + else if (!item.parent) { + this.statusMessage = `${this.getSideLabel(side)}已选择:${item.name}`; + } + if (shouldRender) { + this.render(); + } + } + selectAllPaneItems(side) { + this.setActiveFilePaneSide(side); + const state = this.paneStates[side]; + const selectablePaths = state.items + .filter(item => !!item.path && !item.parent) + .map(item => item.path); + state.selectedPaths.clear(); + for (const path of selectablePaths) { + state.selectedPaths.add(path); + } + const focusedPath = state.selectedPath && state.selectedPaths.has(state.selectedPath) + ? state.selectedPath + : selectablePaths[0]; + state.selectedPath = focusedPath; + state.selectionAnchorPath = focusedPath; + this.statusMessage = selectablePaths.length + ? `${this.getSideLabel(side)}已全选 ${selectablePaths.length} 项。` + : `${this.getSideLabel(side)}当前目录暂无可选项目。`; + this.render(); + } + isEditableKeyboardTarget(target) { + const element = target instanceof HTMLElement ? target : undefined; + if (!element) { + return false; + } + return element.isContentEditable || !!element.closest('input, textarea, select, [contenteditable="true"]'); + } + updateRenderedSelection(side) { + const pane = this.container?.querySelector(`.turbofile-pane[data-turbofile-side="${side}"]`); + if (!pane) { + return; + } + const state = this.paneStates[side]; + for (const row of pane.querySelectorAll('.turbofile-file-row')) { + const path = row.dataset.turbofilePath; + row.classList.toggle('is-selected', !!path && state.selectedPaths.has(path)); + } + const count = pane.querySelector('.turbofile-browser-item-count'); + if (count) { + this.updatePaneItemCountElement(side, count); + } + this.updatePaneSelectionBadge(side, pane); + const meta = this.container?.querySelector('.turbofile-task-meta'); + if (meta && !this.tasks[0]) { + meta.textContent = this.statusMessage; + } + this.refreshTaskbar(); + } + updatePaneSelectionBadge(side, pane) { + const pathbar = pane.querySelector('.turbofile-pathbar'); + if (!pathbar) { + return; + } + const selectedCount = this.getSelectedTransferItems(side).length; + let selectionCount = pathbar.querySelector('.turbofile-selection-count'); + if (selectedCount <= 1) { + selectionCount?.remove(); + return; + } + if (!selectionCount) { + selectionCount = $('.turbofile-selection-count'); + pathbar.appendChild(selectionCount); + } + selectionCount.textContent = `已选 ${selectedCount} 项`; + } + updatePaneItemCountElement(side, element) { + const selected = this.getPaneSelectedItemCount(side); + const total = this.getPaneItemCount(side); + element.textContent = `${selected} / ${total}`; + element.title = `已选择 ${selected} / 共 ${total} 项`; + } + getPaneSelectedItemCount(side) { + return Math.min(this.paneStates[side].selectedPaths.size, this.getPaneItemCount(side)); + } + getPaneItemCount(side) { + const items = this.paneStates[side].items; + return Math.max(0, items.length - (items[0]?.parent ? 1 : 0)); + } + async openItem(side, item) { + if (!item.path) { + return; + } + if (item.directory || item.parent) { + await this.navigatePaneDirectory(side, item.path); + return; + } + await this.previewSelected(side, item); + } + createTransferTaskItem(item) { + const knownSize = item.directory ? 0 : Math.max(0, item.rawSize); + const displaySize = item.directory && knownSize === 0 ? '' : item.size; + return { + name: item.name, + type: item.type, + displaySize, + rawSize: knownSize, + status: 'waiting', + progress: 0, + transferredBytes: 0, + totalBytes: knownSize, + speedBytesPerSecond: 0, + speedSamples: [] + }; + } + async ensureTransferItemSize(device, sourceItem, transferItem, task, options = {}) { + if (!sourceItem.path || sourceItem.parent) { + return; + } + if (!sourceItem.directory) { + const size = Math.max(0, sourceItem.rawSize); + transferItem.rawSize = size; + transferItem.totalBytes = Math.max(transferItem.totalBytes, size); + transferItem.displaySize = formatSize(size); + return; + } + if (transferItem.rawSize > 0) { + transferItem.displaySize = formatSize(transferItem.rawSize); + transferItem.totalBytes = Math.max(transferItem.totalBytes, transferItem.rawSize); + return; + } + if (options.skipDirectorySize) { + transferItem.displaySize = ''; + transferItem.totalBytes = Math.max(transferItem.totalBytes, 0); + return; + } + this.updateTask(task, `正在计算目录大小:${sourceItem.name}`, Math.max(10, task.progress)); + const result = await this.sshService.calculatePathSize(await this.toTransferEndpoint(device, sourceItem.path)); + if (!result.ok) { + transferItem.displaySize = ''; + return; + } + const size = Math.max(0, result.size); + transferItem.rawSize = size; + transferItem.totalBytes = Math.max(transferItem.totalBytes, size); + transferItem.displaySize = size > 0 ? formatSize(size) : ''; + } + setExternalDropTargetSide(side) { + if (this.externalDropTargetSide === side) { + return; + } + this.externalDropTargetSide = side; + for (const candidate of ['left', 'right']) { + this.getPaneFileListElement(candidate)?.classList.toggle('is-external-drop-target', side === candidate); + this.getPaneImageGridBodyElement(candidate)?.classList.toggle('is-external-drop-target', side === candidate); + } + } + getPaneFileListElement(side) { + return this.container?.querySelector(`.turbofile-pane[data-turbofile-side="${side}"] .turbofile-file-list`) ?? undefined; + } + getPaneImageGridBodyElement(side) { + return this.container?.querySelector(`.turbofile-pane[data-turbofile-side="${side}"] .turbofile-image-page-body`) ?? undefined; + } + getImageGridRootElement(state) { + if (!this.container) { + return undefined; + } + if (state.displayMode === 'pane') { + return this.container.querySelector(`.turbofile-pane[data-turbofile-side="${state.side}"] .turbofile-image-page`) ?? undefined; + } + return this.container.querySelector('.turbofile-editor-content > .turbofile-image-page') ?? this.container.querySelector('.turbofile-image-page:not(.is-pane-mode)') ?? undefined; + } + getImageGridBodyElement(state) { + return this.getImageGridRootElement(state)?.querySelector('.turbofile-image-page-body') ?? undefined; + } + hasExternalDropData(event) { + const dataTransfer = event.dataTransfer; + if (!dataTransfer) { + return false; + } + // During `dragover` the browser withholds getData() and an empty files list for security, so the + // only reliable signals that an OS file/folder is being dragged are the type list and the item + // kinds. Check both (case-insensitively) plus the on-drop signals, so an external drag is + // detected the whole time and never falls through to the workbench's open-file behaviour. + const types = Array.from(dataTransfer.types || []); + if (types.some(type => type === DataTransfers.FILES || type.toLowerCase() === 'files')) { + return true; + } + if (dataTransfer.items && Array.from(dataTransfer.items).some(item => item.kind === 'file')) { + return true; + } + if (dataTransfer.files && dataTransfer.files.length > 0) { + return true; + } + return !!dataTransfer.getData(CodeDataTransfers.FILES) || !!dataTransfer.getData(DataTransfers.RESOURCES); + } + extractExternalDropPaths(event) { + const dataTransfer = event.dataTransfer; + if (!dataTransfer) { + return []; + } + const paths = []; + const seen = new Set(); + const acceptPath = (candidate) => { + if (!candidate || !isSupportedWindowsDropPath(candidate)) { + return; + } + const key = getWindowsPathKey(candidate); + if (seen.has(key)) { + return; + } + seen.add(key); + paths.push(candidate); + }; + const rawCodeFiles = dataTransfer.getData(CodeDataTransfers.FILES); + if (rawCodeFiles) { + try { + const codeFiles = JSON.parse(rawCodeFiles); + for (const codeFile of codeFiles) { + acceptPath(codeFile); + } + } + catch { + // Ignore malformed drop data and continue with the next source. + } + } + const rawResources = dataTransfer.getData(DataTransfers.RESOURCES); + if (rawResources) { + try { + const resources = JSON.parse(rawResources); + for (const resource of resources) { + const uri = URI.parse(resource); + if (uri.scheme === 'file') { + acceptPath(uri.fsPath); + } + } + } + catch { + // Ignore malformed resource payloads and continue with native files. + } + } + for (const file of Array.from(dataTransfer.files)) { + acceptPath(getPathForFile(file)); + } + return paths; + } + async transferExternalPaths(targetSide, sourcePaths, operation = 'copy', initiatedAt) { + this.taskbarHiddenForTerminal = false; + const targetDevice = this.getPaneDevice(targetSide); + if (!targetDevice) { + this.statusMessage = '当前目标面板未选择设备,无法接收本地 Windows 文件。'; + this.render(); + return false; + } + const normalizedSourcePaths = await this.sshService.normalizeLocalWindowsPaths(sourcePaths); + const sourceItems = (await Promise.all(normalizedSourcePaths.map(path => this.resolveExternalTransferItem(path)))).filter((item) => !!item); + if (!sourceItems.length) { + this.statusMessage = '未识别到可传输的 Windows 文件或文件夹。'; + this.render(); + return false; + } + if (sourceItems.length > 1) { + const transferred = await this.transferItemsBatch(targetSide, targetSide, this.localWindowsDevice, targetDevice, sourceItems, operation, initiatedAt); + if (operation === 'move') { + await this.refreshLocalPanesForWindowsPaths(sourceItems.map(item => item.path).filter(Boolean)); + } + return transferred; + } + const batch = sourceItems.length > 1; + const actionLabel = operation === 'move' ? '剪切粘贴' : '本地复制'; + const label = batch ? `${actionLabel} ${sourceItems.length} 项` : `${actionLabel} ${sourceItems[0].name}`; + const task = this.beginTask('transfer', label, `Windows -> ${targetDevice.name}`); + task.progress = 0; + task.cancelRequested = false; + task.transferItems = sourceItems.map(item => this.createTransferTaskItem(item)); + task.speedSamples = []; + this.primeTransferTaskStrategy(task, this.localWindowsDevice, targetDevice, sourceItems); + if (!batch) { + task.retryTransfer = { + sourceSide: targetSide, + targetSide, + sourceItem: { ...sourceItems[0] }, + targetPath: joinPanePath(this.paneStates[targetSide].path, sourceItems[0].name, this.isWindowsDevice(targetDevice)), + sourceDeviceId: LOCAL_WINDOWS_DEVICE_ID + }; + } + if (operation === 'move' && targetDevice.id === LOCAL_WINDOWS_DEVICE_ID) { + return this.moveExternalWindowsItemsNatively(targetSide, task, sourceItems); + } + let completed = 0; + let failed = 0; + let totalBytes = 0; + const strategies = new Set(); + const failures = []; + for (const [index, sourceItem] of sourceItems.entries()) { + if (task.cancelRequested) { + for (const pending of task.transferItems.slice(index)) { + pending.status = 'canceled'; + pending.endedAt = Date.now(); + } + this.finishTask(task, 'canceled', batch ? `已取消外部拖入传输:${sourceItem.name}` : `已取消传输:${sourceItem.name}`, { bytes: totalBytes }); + return false; + } + const progress = Math.min(88, 12 + Math.round((index / sourceItems.length) * 70)); + const transferItem = task.transferItems[index]; + task.activeTransferItemIndex = index; + transferItem.status = 'running'; + transferItem.startedAt = Date.now(); + transferItem.progress = 0; + transferItem.error = undefined; + transferItem.speedBytesPerSecond = 0; + transferItem.lastSampleAt = undefined; + transferItem.lastSampleBytes = undefined; + transferItem.speedSamples = []; + await this.ensureTransferItemSize(this.localWindowsDevice, sourceItem, transferItem, task, { + skipDirectorySize: !!sourceItem.directory + }); + this.updateTask(task, batch ? `正在传输 ${index + 1}/${sourceItems.length}:${sourceItem.name}` : `正在传输:${sourceItem.name}`, progress); + const result = await this.transferItem(targetSide, targetSide, sourceItem, { + refreshTarget: false, + task, + sourceDeviceOverride: this.localWindowsDevice, + targetDeviceOverride: targetDevice, + initiatedAt + }); + if (result.ok) { + completed++; + totalBytes += result.bytes; + transferItem.status = 'success'; + transferItem.progress = 100; + transferItem.transferredBytes = result.bytes; + transferItem.totalBytes = Math.max(result.bytes, transferItem.totalBytes); + transferItem.strategy = result.strategy ?? transferItem.strategy; + transferItem.endedAt = Date.now(); + if (result.strategy) { + strategies.add(result.strategy); + } + } + else if (result.canceled) { + transferItem.status = 'canceled'; + transferItem.endedAt = Date.now(); + for (const pending of task.transferItems.slice(index + 1)) { + pending.status = 'canceled'; + pending.endedAt = Date.now(); + } + this.finishTask(task, 'canceled', batch ? `已取消外部拖入传输:${sourceItem.name}` : `已取消传输:${sourceItem.name}`, { bytes: totalBytes }); + return false; + } + else { + failed++; + transferItem.status = 'error'; + transferItem.error = result.message; + transferItem.endedAt = Date.now(); + failures.push(`${sourceItem.name}: ${result.message}`); + } + } + this.updateTask(task, `正在刷新目标目录:${targetDevice.name}`, 92); + await this.refreshPane(targetSide, { force: true }); + const strategy = [...strategies].join(', ') || undefined; + if (failed > 0) { + this.finishTask(task, 'error', batch ? `本地 Windows 传输完成 ${completed} 项,失败 ${failed} 项。` : `传输失败:${failures[0]}`, { bytes: totalBytes, strategy, error: failures[0] }); + return false; + } + if (operation === 'move') { + const cleanupFailures = await this.deleteExternalSourceItems(task, sourceItems); + await this.refreshLocalPanesForWindowsPaths(sourceItems.map(item => item.path).filter(Boolean)); + if (cleanupFailures.length) { + this.finishTask(task, 'error', `目标已复制,但删除本地来源失败 ${cleanupFailures.length} 项。`, { bytes: totalBytes, strategy, error: cleanupFailures[0] }); + return false; + } + } + const successMessage = batch + ? `${operation === 'move' ? '本地剪切传输' : '本地复制传输'}完成:${completed} 项,${formatSize(totalBytes)}。` + : `${operation === 'move' ? '剪切传输' : '复制传输'}完成:${sourceItems[0].name},策略 ${strategy ?? 'unknown'},${formatSize(totalBytes)}。`; + this.finishTask(task, 'success', successMessage, { bytes: totalBytes, strategy }); + return true; + } + async pasteWindowsClipboardFiles(targetSide) { + const clipboardFiles = await this.sshService.readWindowsClipboardFiles(); + if (!clipboardFiles.ok || !clipboardFiles.paths.length) { + this.statusMessage = '剪贴板里没有可粘贴的本地 Windows 文件或文件夹。'; + this.render(); + return; + } + const pasteKey = this.getWindowsClipboardPasteKey(targetSide, clipboardFiles.paths, clipboardFiles.operation); + if (this.windowsClipboardPasteInFlight.has(pasteKey)) { + return; + } + this.windowsClipboardPasteInFlight.add(pasteKey); + try { + await this.transferExternalPaths(targetSide, clipboardFiles.paths, clipboardFiles.operation); + } + finally { + this.windowsClipboardPasteInFlight.delete(pasteKey); + } + } + getWindowsClipboardPasteKey(targetSide, paths, operation) { + const targetPath = this.paneStates[targetSide].path.toLowerCase(); + const sourcePaths = paths + .map(path => path.replace(/\//g, '\\').replace(/\\+$/, '').toLowerCase()) + .sort() + .join('\n'); + return `${targetSide}:${targetPath}:${operation}:${sourcePaths}`; + } + copySelectedToInternalClipboard(sourceSide, operation, shouldRender = true) { + const sourceDevice = this.getPaneDevice(sourceSide); + const items = this.getSelectedTransferItems(sourceSide); + if (!sourceDevice || !items.length) { + this.statusMessage = operation === 'move' ? '请先选择要剪切的文件或目录。' : '请先选择要复制的文件或目录。'; + if (shouldRender) { + this.render(); + } + else { + this.refreshTaskbar(); + } + return; + } + this.internalTransferClipboard = { + operation, + sourceSide, + sourceDevice: { ...sourceDevice }, + sourcePath: this.paneStates[sourceSide].path, + items: items.map(item => ({ ...item })), + createdAt: Date.now() + }; + const action = operation === 'move' ? '剪切' : '复制'; + this.statusMessage = `已${action} ${items.length} 项,可在目标文件浏览区按 Ctrl+V 粘贴。`; + if (shouldRender) { + this.render(); + } + else { + this.refreshTaskbar(); + } + } + async pasteIntoPane(targetSide) { + if (this.internalTransferClipboard?.items.length) { + await this.pasteInternalTransferClipboard(targetSide); + return; + } + await this.pasteWindowsClipboardFiles(targetSide); + } + async pasteInternalTransferClipboard(targetSide) { + const clipboard = this.internalTransferClipboard; + const targetDevice = this.getPaneDevice(targetSide); + if (!clipboard?.items.length || !targetDevice) { + this.statusMessage = '当前没有可粘贴的内部文件,或目标面板未选择设备。'; + this.render(); + return false; + } + const sourceDevice = clipboard.sourceDevice; + const sourceItems = clipboard.items.map(item => ({ ...item })); + if (sourceItems.length > 1) { + const transferred = await this.transferItemsBatch(clipboard.sourceSide, targetSide, sourceDevice, targetDevice, sourceItems, clipboard.operation); + if (transferred && clipboard.operation === 'move') { + this.internalTransferClipboard = undefined; + } + return transferred; + } + const batch = sourceItems.length > 1; + const actionLabel = clipboard.operation === 'move' ? '剪切粘贴' : '复制粘贴'; + const task = this.beginTask('transfer', batch ? `${actionLabel} ${sourceItems.length} 项` : `${actionLabel} ${sourceItems[0].name}`, `${sourceDevice.name} -> ${targetDevice.name}`); + task.progress = 0; + task.cancelRequested = false; + task.transferItems = sourceItems.map(item => this.createTransferTaskItem(item)); + task.speedSamples = []; + if (clipboard.operation === 'move' && this.isSameDeviceForNativeOperation(sourceDevice, targetDevice)) { + const moved = await this.moveInternalItemsNatively(task, sourceDevice, clipboard.sourceSide, targetSide, sourceItems); + if (moved) { + this.internalTransferClipboard = undefined; + } + return moved; + } + let completed = 0; + let failed = 0; + let totalBytes = 0; + const strategies = new Set(); + const failures = []; + for (const [index, sourceItem] of sourceItems.entries()) { + if (task.cancelRequested) { + for (const pending of task.transferItems.slice(index)) { + pending.status = 'canceled'; + pending.endedAt = Date.now(); + } + this.finishTask(task, 'canceled', batch ? `已取消内部粘贴:${sourceItem.name}` : `已取消粘贴:${sourceItem.name}`, { bytes: totalBytes }); + return false; + } + const transferItem = task.transferItems[index]; + task.activeTransferItemIndex = index; + transferItem.status = 'running'; + transferItem.startedAt = Date.now(); + transferItem.progress = 0; + transferItem.error = undefined; + transferItem.speedBytesPerSecond = 0; + transferItem.lastSampleAt = undefined; + transferItem.lastSampleBytes = undefined; + transferItem.speedSamples = []; + const progress = Math.min(88, 12 + Math.round((index / sourceItems.length) * 70)); + await this.ensureTransferItemSize(sourceDevice, sourceItem, transferItem, task, { + skipDirectorySize: !!sourceItem.directory + }); + const targetPath = joinPanePath(this.paneStates[targetSide].path, sourceItem.name, this.isWindowsDevice(targetDevice)); + if (this.isSameTransferTarget(sourceDevice, sourceItem.path, targetDevice, targetPath)) { + failed++; + transferItem.status = 'error'; + transferItem.error = '来源和目标路径相同,已跳过。'; + transferItem.endedAt = Date.now(); + failures.push(`${sourceItem.name}: 来源和目标路径相同`); + continue; + } + this.updateTask(task, batch ? `正在粘贴 ${index + 1}/${sourceItems.length}:${sourceItem.name}` : `正在粘贴:${sourceItem.name}`, progress); + const result = await this.transferItem(clipboard.sourceSide, targetSide, sourceItem, { + refreshTarget: false, + task, + sourceDeviceOverride: sourceDevice, + targetDeviceOverride: targetDevice + }); + if (result.ok) { + completed++; + totalBytes += result.bytes; + transferItem.status = 'success'; + transferItem.progress = 100; + transferItem.transferredBytes = result.bytes; + transferItem.totalBytes = Math.max(result.bytes, transferItem.totalBytes); + transferItem.strategy = result.strategy ?? transferItem.strategy; + transferItem.endedAt = Date.now(); + if (result.strategy) { + strategies.add(result.strategy); + } + } + else if (result.canceled) { + transferItem.status = 'canceled'; + transferItem.endedAt = Date.now(); + for (const pending of task.transferItems.slice(index + 1)) { + pending.status = 'canceled'; + pending.endedAt = Date.now(); + } + this.finishTask(task, 'canceled', batch ? `已取消内部粘贴:${sourceItem.name}` : `已取消粘贴:${sourceItem.name}`, { bytes: totalBytes }); + return false; + } + else { + failed++; + transferItem.status = 'error'; + transferItem.error = result.message; + transferItem.endedAt = Date.now(); + failures.push(`${sourceItem.name}: ${result.message}`); + } + } + this.updateTask(task, `正在刷新目录:${sourceDevice.name} / ${targetDevice.name}`, 92); + await Promise.all([ + this.refreshPane(clipboard.sourceSide, { force: true }), + this.refreshPane(targetSide, { force: true }) + ]); + const strategy = [...strategies].join(', ') || undefined; + if (failed > 0) { + this.finishTask(task, 'error', batch ? `内部粘贴完成 ${completed} 项,失败 ${failed} 项。` : `内部粘贴失败:${failures[0]}`, { bytes: totalBytes, strategy, error: failures[0] }); + return false; + } + if (clipboard.operation === 'move') { + const cleanupFailures = await this.deleteInternalClipboardSources(task, sourceDevice, clipboard.sourceSide, sourceItems); + if (cleanupFailures.length) { + this.finishTask(task, 'error', `目标已复制,但删除来源失败 ${cleanupFailures.length} 项。`, { bytes: totalBytes, strategy, error: cleanupFailures[0] }); + return false; + } + this.internalTransferClipboard = undefined; + } + const successMessage = batch + ? `${clipboard.operation === 'move' ? '剪切粘贴' : '复制粘贴'}完成:${completed} 项,${formatSize(totalBytes)}。` + : `${clipboard.operation === 'move' ? '剪切粘贴' : '复制粘贴'}完成:${sourceItems[0].name},策略 ${strategy ?? 'unknown'},${formatSize(totalBytes)}。`; + this.finishTask(task, 'success', successMessage, { bytes: totalBytes, strategy }); + return true; + } + isSameTransferTarget(sourceDevice, sourcePath, targetDevice, targetPath) { + if (!sourcePath || !this.isSameDeviceForNativeOperation(sourceDevice, targetDevice)) { + return false; + } + if (this.isWindowsDevice(sourceDevice)) { + return isSameWindowsPath(sourcePath, targetPath); + } + return trimTrailingSlash(sourcePath) === trimTrailingSlash(targetPath); + } + isSameDeviceForNativeOperation(sourceDevice, targetDevice) { + if (sourceDevice.id === targetDevice.id) { + return true; + } + if (sourceDevice.id === LOCAL_WINDOWS_DEVICE_ID || targetDevice.id === LOCAL_WINDOWS_DEVICE_ID) { + return false; + } + return sourceDevice.host === targetDevice.host + && (sourceDevice.port || 22) === (targetDevice.port || 22) + && sourceDevice.username === targetDevice.username; + } + pathsEqualForDevice(left, right, windowsPath) { + return windowsPath ? isSameWindowsPath(left, right) : trimTrailingSlash(left) === trimTrailingSlash(right); + } + /** + * True when `candidate` is `ancestor` itself or nested below it. Used to forbid moving a folder + * into itself or one of its own descendants. + */ + isPathInsideOrEqual(candidate, ancestor, windowsPath) { + if (windowsPath) { + const c = candidate.replace(/\//g, '\\').replace(/\\+$/, '').toLowerCase(); + const a = ancestor.replace(/\//g, '\\').replace(/\\+$/, '').toLowerCase(); + return c === a || c.startsWith(`${a}\\`); + } + const c = trimTrailingSlash(candidate); + const a = trimTrailingSlash(ancestor); + return c === a || c.startsWith(`${a}/`); + } + /** + * The subset of the current selection on `side` that can actually be moved into `targetDir`: + * skips items already living in that directory (no-op) and folders that would be moved into + * themselves or a descendant. + */ + getDirectoryMoveTargets(side, targetDir) { + const device = this.getPaneDevice(side); + if (!device || !targetDir) { + return []; + } + const windowsPath = this.isWindowsDevice(device); + return this.getSelectedTransferItems(side).filter(item => { + if (!item.path || item.parent) { + return false; + } + const itemParent = getPaneParentPath(item.path, windowsPath); + if (itemParent && this.pathsEqualForDevice(itemParent, targetDir, windowsPath)) { + return false; + } + if (item.directory && this.isPathInsideOrEqual(targetDir, item.path, windowsPath)) { + return false; + } + return true; + }); + } + canMoveSelectionIntoDirectory(side, targetItem, targetDir) { + if (!targetItem.directory && !targetItem.parent) { + return false; + } + return this.getDirectoryMoveTargets(side, targetDir).length > 0; + } + /** + * Moves the current same-pane selection into `targetDir` using a native same-device rename, the + * same primitive used by cut/paste. Powers dragging rows onto a folder or the ".." row. + */ + async moveSelectedItemsIntoDirectory(side, targetDir, targetLabel) { + const device = this.getPaneDevice(side); + if (!device) { + return; + } + const sourceItems = this.getDirectoryMoveTargets(side, targetDir); + if (!sourceItems.length) { + return; + } + const refreshPaths = getTurboFileDirectoryMoveRefreshPaths(this.paneStates[side].path, targetDir); + const windowsPath = this.isWindowsDevice(device); + const batch = sourceItems.length > 1; + const strategy = windowsPath ? 'local-move' : 'remote-move'; + const task = this.beginTask('transfer', batch ? `移动 ${sourceItems.length} 项到 ${targetLabel}` : `移动 ${sourceItems[0].name} 到 ${targetLabel}`, device.name); + task.progress = 0; + task.cancelRequested = false; + task.transferItems = sourceItems.map(item => this.createTransferTaskItem(item)); + task.speedSamples = []; + this.invalidatePaneDirectoryCaches(device, refreshPaths); + this.removePaneItems(side, sourceItems); + const failures = []; + let completed = 0; + let totalBytes = 0; + for (const [index, transferItem] of task.transferItems.entries()) { + task.activeTransferItemIndex = index; + transferItem.status = 'running'; + transferItem.startedAt = Date.now(); + transferItem.progress = 0; + transferItem.strategy = strategy; + } + this.updateTask(task, batch ? `正在移动 ${sourceItems.length} 项到 ${targetLabel}` : `正在移动:${sourceItems[0].name}`, 45); + // A same-device move is a metadata-only rename: hand the whole selection to one batched + // movePathsIntoDirectory (POSIX remote: a single `xargs -0 mv` round trip) instead of issuing one + // renamePath round trip per item, which made dragging many files feel like a slow per-file copy. + const endpoints = await Promise.all(sourceItems.map(item => this.toTransferEndpoint(device, item.path))); + const result = await this.sshService.movePathsIntoDirectory(endpoints, targetDir); + for (const [index, sourceItem] of sourceItems.entries()) { + const transferItem = task.transferItems[index]; + const itemBytes = Math.max(sourceItem.rawSize, transferItem.totalBytes, 0); + if (result.ok) { + completed++; + totalBytes += itemBytes; + transferItem.status = 'success'; + transferItem.progress = 100; + transferItem.transferredBytes = itemBytes; + transferItem.totalBytes = Math.max(itemBytes, transferItem.totalBytes); + transferItem.endedAt = Date.now(); + } + else { + transferItem.status = 'error'; + transferItem.error = result.message; + transferItem.endedAt = Date.now(); + failures.push(`${sourceItem.name}: ${result.message}`); + } + } + this.invalidatePaneDirectoryCaches(device, refreshPaths); + this.updateTask(task, `正在刷新目录:${device.name}`, 92); + this.invalidatePaneDirectoryCaches(device, refreshPaths); + const visibleRefreshSides = this.getVisiblePaneSidesForDeviceDirectories(device, refreshPaths); + await Promise.all([ + ...visibleRefreshSides.map(side => this.refreshPane(side, { force: true })), + this.loadPaneDirectory(device, targetDir, true, 'prefetch').catch(() => undefined) + ]); + if (failures.length) { + this.finishTask(task, 'error', batch ? `移动完成 ${completed} 项,失败 ${failures.length} 项。` : `移动失败:${failures[0]}`, { bytes: totalBytes, strategy, error: failures[0] }); + return; + } + this.finishTask(task, 'success', batch ? `移动完成:${completed} 项 → ${targetLabel}` : `移动完成:${sourceItems[0].name} → ${targetLabel}`, { bytes: totalBytes, strategy }); + } + async moveInternalItemsNatively(task, device, sourceSide, targetSide, sourceItems) { + const batch = sourceItems.length > 1; + const failures = []; + const strategy = this.isWindowsDevice(device) ? 'local-move' : 'remote-move'; + let completed = 0; + let totalBytes = 0; + this.removePaneItems(sourceSide, sourceItems.filter(item => !!item.path)); + for (const [index, sourceItem] of sourceItems.entries()) { + const transferItem = task.transferItems?.[index]; + task.activeTransferItemIndex = index; + if (transferItem) { + transferItem.status = 'running'; + transferItem.startedAt = Date.now(); + transferItem.progress = 0; + transferItem.error = undefined; + transferItem.speedBytesPerSecond = 0; + transferItem.lastSampleAt = undefined; + transferItem.lastSampleBytes = undefined; + transferItem.speedSamples = []; + transferItem.strategy = strategy; + } + if (!sourceItem.path) { + failures.push(`${sourceItem.name}: path missing`); + continue; + } + const itemBytes = Math.max(sourceItem.rawSize, transferItem?.totalBytes ?? 0, 0); + const targetPath = joinPanePath(this.paneStates[targetSide].path, sourceItem.name, this.isWindowsDevice(device)); + if (this.isSameTransferTarget(device, sourceItem.path, device, targetPath)) { + if (transferItem) { + transferItem.status = 'error'; + transferItem.error = '源路径和目标路径相同,已跳过。'; + transferItem.endedAt = Date.now(); + } + failures.push(`${sourceItem.name}: 源路径和目标路径相同`); + continue; + } + this.updateTask(task, batch ? `正在移动 ${index + 1}/${sourceItems.length}:${sourceItem.name}` : `正在移动:${sourceItem.name}`, Math.min(88, 12 + Math.round((index / sourceItems.length) * 70))); + const result = await this.sshService.renamePath(await this.toTransferEndpoint(device, sourceItem.path), targetPath); + if (result.ok) { + completed++; + totalBytes += itemBytes; + if (transferItem) { + transferItem.status = 'success'; + transferItem.progress = 100; + transferItem.transferredBytes = itemBytes; + transferItem.totalBytes = Math.max(itemBytes, transferItem.totalBytes); + transferItem.strategy = strategy; + transferItem.endedAt = Date.now(); + } + } + else { + if (transferItem) { + transferItem.status = 'error'; + transferItem.error = result.message; + transferItem.endedAt = Date.now(); + } + failures.push(`${sourceItem.name}: ${result.message}`); + } + } + this.updateTask(task, `正在刷新目录:${device.name}`, 92); + await Promise.all([...new Set([sourceSide, targetSide])].map(side => this.refreshPane(side, { force: true }))); + if (failures.length) { + this.finishTask(task, 'error', batch ? `同设备移动完成 ${completed} 项,失败 ${failures.length} 项。` : `同设备移动失败:${failures[0]}`, { bytes: totalBytes, strategy, error: failures[0] }); + return false; + } + this.finishTask(task, 'success', batch ? `同设备移动完成:${completed} 项。` : `同设备移动完成:${sourceItems[0].name}`, { bytes: totalBytes, strategy }); + return true; + } + async moveExternalWindowsItemsNatively(targetSide, task, sourceItems) { + const batch = sourceItems.length > 1; + const failures = []; + const strategy = 'local-move'; + let completed = 0; + let totalBytes = 0; + for (const [index, sourceItem] of sourceItems.entries()) { + const transferItem = task.transferItems?.[index]; + task.activeTransferItemIndex = index; + if (transferItem) { + transferItem.status = 'running'; + transferItem.startedAt = Date.now(); + transferItem.progress = 0; + transferItem.error = undefined; + transferItem.speedBytesPerSecond = 0; + transferItem.lastSampleAt = undefined; + transferItem.lastSampleBytes = undefined; + transferItem.speedSamples = []; + transferItem.strategy = strategy; + } + if (!sourceItem.path) { + failures.push(`${sourceItem.name}: path missing`); + continue; + } + const itemBytes = Math.max(sourceItem.rawSize, transferItem?.totalBytes ?? 0, 0); + const targetPath = joinPanePath(this.paneStates[targetSide].path, sourceItem.name, true); + if (this.isSameTransferTarget(this.localWindowsDevice, sourceItem.path, this.localWindowsDevice, targetPath)) { + if (transferItem) { + transferItem.status = 'error'; + transferItem.error = '源路径和目标路径相同,已跳过。'; + transferItem.endedAt = Date.now(); + } + failures.push(`${sourceItem.name}: 源路径和目标路径相同`); + continue; + } + this.updateTask(task, batch ? `正在本机移动 ${index + 1}/${sourceItems.length}:${sourceItem.name}` : `正在本机移动:${sourceItem.name}`, Math.min(88, 12 + Math.round((index / sourceItems.length) * 70))); + const result = await this.sshService.renamePath(await this.toTransferEndpoint(this.localWindowsDevice, sourceItem.path), targetPath); + if (result.ok) { + completed++; + totalBytes += itemBytes; + if (transferItem) { + transferItem.status = 'success'; + transferItem.progress = 100; + transferItem.transferredBytes = itemBytes; + transferItem.totalBytes = Math.max(itemBytes, transferItem.totalBytes); + transferItem.strategy = strategy; + transferItem.endedAt = Date.now(); + } + } + else { + if (transferItem) { + transferItem.status = 'error'; + transferItem.error = result.message; + transferItem.endedAt = Date.now(); + } + failures.push(`${sourceItem.name}: ${result.message}`); + } + } + this.updateTask(task, '正在刷新本机目录', 92); + await Promise.all([ + this.refreshPane(targetSide, { force: true }), + this.refreshLocalPanesForWindowsPaths(sourceItems.map(item => item.path).filter(Boolean)) + ]); + if (failures.length) { + this.finishTask(task, 'error', batch ? `本机移动完成 ${completed} 项,失败 ${failures.length} 项。` : `本机移动失败:${failures[0]}`, { bytes: totalBytes, strategy, error: failures[0] }); + return false; + } + this.finishTask(task, 'success', batch ? `本机移动完成:${completed} 项。` : `本机移动完成:${sourceItems[0].name}`, { bytes: totalBytes, strategy }); + return true; + } + async deleteInternalClipboardSources(task, sourceDevice, sourceSide, sourceItems) { + const failures = []; + const batch = sourceItems.length > 1; + this.removePaneItems(sourceSide, sourceItems.filter(item => !!item.path)); + for (const [index, item] of sourceItems.entries()) { + if (!item.path) { + continue; + } + this.updateTask(task, batch ? `正在删除来源 ${index + 1}/${sourceItems.length}:${item.name}` : `正在删除来源:${item.name}`, Math.min(98, 92 + Math.round(((index + 1) / sourceItems.length) * 6))); + const result = await this.sshService.deletePath(await this.toTransferEndpoint(sourceDevice, item.path), true); + if (!result.ok) { + failures.push(`${item.name}: ${result.message}`); + } + } + await this.refreshPane(sourceSide, { force: true }); + return failures; + } + async deleteExternalSourceItems(task, sourceItems) { + const failures = []; + for (const [index, item] of sourceItems.entries()) { + if (!item.path) { + continue; + } + this.updateTask(task, `正在删除本地来源 ${index + 1}/${sourceItems.length}:${item.name}`, Math.min(98, 92 + Math.round(((index + 1) / sourceItems.length) * 6))); + const result = await this.sshService.deletePath(await this.toTransferEndpoint(this.localWindowsDevice, item.path), true); + if (!result.ok) { + failures.push(`${item.name}: ${result.message}`); + } + } + return failures; + } + async refreshLocalPanesForWindowsPaths(paths) { + const parentPaths = paths + .map(path => getPaneParentPath(path, true)) + .filter((path) => !!path); + if (!parentPaths.length) { + return; + } + const refreshes = []; + for (const side of ['left', 'right']) { + const device = this.getPaneDevice(side); + if (device?.id !== LOCAL_WINDOWS_DEVICE_ID) { + continue; + } + if (parentPaths.some(parentPath => isSameWindowsPath(parentPath, this.paneStates[side].path))) { + refreshes.push(this.refreshPane(side, { force: true })); + } + } + await Promise.all(refreshes); + } + async resolveExternalTransferItem(path) { + const name = getPathBasename(path); + if (!name) { + return undefined; + } + const parentPath = getPaneParentPath(path, true); + if (parentPath) { + try { + const listing = await this.sshService.listLocalDirectory(parentPath); + const entry = listing.entries.find(candidate => isSameWindowsPath(candidate.path, path)); + if (entry) { + return this.toFileItem(entry); + } + } + catch { + // Fall back to a best-effort item when the parent listing is unavailable. + } + } + return { + name, + path, + size: '', + rawSize: 0, + modified: '', + rawModified: 0, + type: getWindowsDriveRoot(path) ? 'directory' : 'unknown', + directory: !!getWindowsDriveRoot(path) + }; + } + async transferItemsToWindowsDirectory(sourceSide, sourceDevice, sourceItems, targetDirectory, initiatedAt) { + this.taskbarHiddenForTerminal = false; + const items = sourceItems.filter((item) => !!item.path && !item.parent); + if (!items.length) { + this.statusMessage = '没有可拖到 Windows 的文件。'; + this.render(); + return false; + } + const targetDevice = this.localWindowsDevice; + if (items.length === 1) { + const sourceItem = items[0]; + const task = this.beginTask('transfer', `拖出 ${sourceItem.name}`, `${sourceDevice.name} -> Windows`); + task.progress = 0; + task.cancelRequested = false; + task.transferItems = [this.createTransferTaskItem(sourceItem)]; + task.speedSamples = []; + this.primeTransferTaskStrategy(task, sourceDevice, targetDevice, items); + task.activeTransferItemIndex = 0; + const transferItem = task.transferItems[0]; + transferItem.status = 'running'; + transferItem.startedAt = Date.now(); + await this.ensureTransferItemSize(sourceDevice, sourceItem, transferItem, task, { + skipDirectorySize: !!sourceItem.directory + }); + const result = await this.transferItem(sourceSide, sourceSide, sourceItem, { + refreshTarget: false, + task, + targetPath: joinPanePath(targetDirectory, sourceItem.name, true), + targetDeviceOverride: targetDevice, + initiatedAt + }); + if (result.ok) { + transferItem.status = 'success'; + transferItem.progress = 100; + transferItem.transferredBytes = result.bytes; + transferItem.totalBytes = Math.max(result.bytes, transferItem.totalBytes); + transferItem.strategy = result.strategy ?? transferItem.strategy; + transferItem.endedAt = Date.now(); + this.finishTask(task, 'success', `拖出完成:${sourceItem.name},${formatSize(result.bytes)}。`, { bytes: result.bytes, strategy: result.strategy, durationMs: result.durationMs, averageBytesPerSecond: result.averageBytesPerSecond }); + return true; + } + transferItem.status = result.canceled ? 'canceled' : 'error'; + transferItem.error = result.canceled ? undefined : result.message; + transferItem.endedAt = Date.now(); + this.finishTask(task, result.canceled ? 'canceled' : 'error', result.canceled ? `已取消拖出:${sourceItem.name}` : `拖出失败:${result.message}`, { bytes: result.bytes, strategy: result.strategy, error: result.canceled ? undefined : result.message, durationMs: result.durationMs, averageBytesPerSecond: result.averageBytesPerSecond }); + return false; + } + const task = this.beginTask('transfer', `拖出 ${items.length} 项`, `${sourceDevice.name} -> Windows`); + task.progress = 0; + task.cancelRequested = false; + task.transferItems = items.map(item => this.createTransferTaskItem(item)); + task.speedSamples = []; + this.primeTransferTaskStrategy(task, sourceDevice, targetDevice, items); + const progressId = `turbofile-windows-drag-${task.id}-${Date.now()}`; + task.progressId = progressId; + for (const transferItem of task.transferItems) { + transferItem.progressId = progressId; + transferItem.status = 'running'; + transferItem.startedAt = Date.now(); + } + const progressDisposable = this.sshService.onDynamicTransferProgress(progressId)(event => this.updateTransferProgress(task, event)); + try { + const sourceEndpoint = await this.toTransferEndpoint(sourceDevice, items[0].path); + const targetDirectoryEndpoint = { + kind: 'local', + path: targetDirectory, + label: 'Windows' + }; + const expectedBytes = items.reduce((sum, item) => sum + (item.directory ? 0 : Math.max(0, item.rawSize)), 0); + const plannedStrategy = this.getPlannedBatchTransferStrategy(sourceDevice, targetDevice, items); + task.strategy = plannedStrategy; + for (const transferItem of task.transferItems) { + transferItem.strategy = plannedStrategy; + } + this.updateTask(task, `正在拖出到 Windows 0/${items.length}`, 8); + const result = await this.sshService.transferBatch({ + source: sourceEndpoint, + targetDirectory: targetDirectoryEndpoint, + items: items.map(item => ({ + sourcePath: item.path, + targetName: item.name, + displayName: item.name, + sourceType: item.type, + expectedBytes: item.directory ? 0 : Math.max(0, item.rawSize) + })), + operation: 'copy', + progressId, + expectedBytes, + strategyHint: 'auto', + clientStartedAt: initiatedAt ?? task.startedAt + }); + const failedPaths = new Set(result.failures.map(failure => failure.path)); + for (const [index, item] of items.entries()) { + const transferItem = task.transferItems[index]; + if (!transferItem) { + continue; + } + transferItem.strategy = result.strategy ?? transferItem.strategy; + transferItem.endedAt = Date.now(); + if (result.canceled) { + transferItem.status = 'canceled'; + } + else if (failedPaths.has(item.path)) { + transferItem.status = 'error'; + transferItem.error = result.failures.find(failure => failure.path === item.path)?.message; + } + else { + transferItem.status = 'success'; + transferItem.progress = 100; + transferItem.transferredBytes = Math.max(transferItem.transferredBytes, transferItem.totalBytes, item.rawSize, 0); + } + } + if (result.canceled) { + this.finishTask(task, 'canceled', `已取消拖出到 Windows:${items.length} 项`, { bytes: result.bytes, strategy: result.strategy, durationMs: result.durationMs, averageBytesPerSecond: result.averageBytesPerSecond }); + return false; + } + if (!result.ok) { + this.finishTask(task, 'error', `拖出到 Windows 完成 ${result.completed} 项,失败 ${result.failed} 项。`, { bytes: result.bytes, strategy: result.strategy, error: result.failures[0]?.message, durationMs: result.durationMs, averageBytesPerSecond: result.averageBytesPerSecond }); + return false; + } + this.finishTask(task, 'success', `拖出到 Windows 完成:${result.completed} 项,${formatSize(result.bytes)}。`, { bytes: result.bytes, strategy: result.strategy, durationMs: result.durationMs, averageBytesPerSecond: result.averageBytesPerSecond }); + return true; + } + catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.finishTask(task, 'error', `拖出到 Windows 失败:${message}`, { error: message }); + return false; + } + finally { + progressDisposable.dispose(); + } + } + async transferSelected(sourceSide, targetSide, initiatedAt) { + this.taskbarHiddenForTerminal = false; + const sourceDevice = this.getPaneDevice(sourceSide); + const targetDevice = this.getPaneDevice(targetSide); + const sourceItems = this.getSelectedTransferItems(sourceSide); + if (!sourceDevice || !targetDevice || !sourceItems.length) { + this.statusMessage = '请先在来源面板选择一个或多个文件/目录。'; + this.render(); + return false; + } + if (sourceItems.length > 1) { + return this.transferItemsBatch(sourceSide, targetSide, sourceDevice, targetDevice, sourceItems, 'copy', initiatedAt); + } + const batch = sourceItems.length > 1; + const label = batch ? `复制 ${sourceItems.length} 项` : `复制 ${sourceItems[0].name}`; + const task = this.beginTask('transfer', label, `${sourceDevice.name} -> ${targetDevice.name}`); + task.progress = 0; + task.cancelRequested = false; + task.transferItems = sourceItems.map(item => this.createTransferTaskItem(item)); + task.speedSamples = []; + this.primeTransferTaskStrategy(task, sourceDevice, targetDevice, sourceItems); + if (!batch) { + task.retryTransfer = { sourceSide, targetSide, sourceItem: { ...sourceItems[0] } }; + } + let completed = 0; + let failed = 0; + let totalBytes = 0; + const strategies = new Set(); + const failures = []; + for (const [index, sourceItem] of sourceItems.entries()) { + if (task.cancelRequested) { + for (const pending of task.transferItems.slice(index)) { + pending.status = 'canceled'; + pending.endedAt = Date.now(); + } + this.finishTask(task, 'canceled', batch ? `已取消批量传输:${sourceItem.name}` : `已取消传输:${sourceItem.name}`, { bytes: totalBytes }); + return false; + } + const progress = Math.min(88, 12 + Math.round((index / sourceItems.length) * 70)); + const transferItem = task.transferItems[index]; + task.activeTransferItemIndex = index; + transferItem.status = 'running'; + transferItem.startedAt = Date.now(); + transferItem.progress = 0; + transferItem.error = undefined; + transferItem.speedBytesPerSecond = 0; + transferItem.lastSampleAt = undefined; + transferItem.lastSampleBytes = undefined; + transferItem.speedSamples = []; + await this.ensureTransferItemSize(sourceDevice, sourceItem, transferItem, task, { + skipDirectorySize: !!sourceItem.directory + }); + this.updateTask(task, batch ? `正在传输 ${index + 1}/${sourceItems.length}:${sourceItem.name}` : `正在传输:${sourceItem.name}`, progress); + const result = await this.transferItem(sourceSide, targetSide, sourceItem, { refreshTarget: false, task, initiatedAt }); + if (result.ok) { + completed++; + totalBytes += result.bytes; + transferItem.status = 'success'; + transferItem.progress = 100; + transferItem.transferredBytes = result.bytes; + transferItem.totalBytes = Math.max(result.bytes, transferItem.totalBytes); + transferItem.strategy = result.strategy ?? transferItem.strategy; + transferItem.endedAt = Date.now(); + if (result.strategy) { + strategies.add(result.strategy); + } + } + else if (result.canceled) { + transferItem.status = 'canceled'; + transferItem.endedAt = Date.now(); + for (const pending of task.transferItems.slice(index + 1)) { + pending.status = 'canceled'; + pending.endedAt = Date.now(); + } + this.finishTask(task, 'canceled', batch ? `已取消批量传输:${sourceItem.name}` : `已取消传输:${sourceItem.name}`, { bytes: totalBytes }); + return false; + } + else { + failed++; + transferItem.status = 'error'; + transferItem.error = result.message; + transferItem.endedAt = Date.now(); + failures.push(`${sourceItem.name}: ${result.message}`); + } + } + this.updateTask(task, `正在刷新目录:${sourceDevice.name} / ${targetDevice.name}`, 92); + await this.refreshPanesAfterTransfer(sourceSide, targetSide, 'copy', sourceDevice, targetDevice); + const strategy = [...strategies].join(', ') || undefined; + if (failed > 0) { + this.finishTask(task, 'error', batch ? `批量传输完成 ${completed} 项,失败 ${failed} 项。` : `传输失败:${failures[0]}`, { bytes: totalBytes, strategy, error: failures[0] }); + return false; + } + const successMessage = batch + ? `批量传输完成:${completed} 项,${formatSize(totalBytes)}。` + : `传输完成:${sourceItems[0].name},策略 ${strategy ?? 'unknown'},${formatSize(totalBytes)}。`; + this.finishTask(task, 'success', successMessage, { bytes: totalBytes, strategy }); + return true; + } + async transferItem(sourceSide, targetSide, sourceItem, options) { + const targetState = this.paneStates[targetSide]; + const sourceDevice = options.sourceDeviceOverride ?? this.getPaneDevice(sourceSide); + const targetDevice = options.targetDeviceOverride ?? this.getPaneDevice(targetSide); + if (!sourceDevice || !targetDevice || !sourceItem.path || sourceItem.parent) { + return { ok: false, message: '来源或目标无效。', bytes: 0 }; + } + const targetPath = options.targetPath ?? joinPanePath(targetState.path, sourceItem.name, this.isWindowsDevice(targetDevice)); + const progressId = options.task ? `turbofile-transfer-${options.task.id}-${Date.now()}` : undefined; + if (options.task) { + options.task.progressId = progressId; + const activeItem = options.task.activeTransferItemIndex !== undefined ? options.task.transferItems?.[options.task.activeTransferItemIndex] : undefined; + if (activeItem) { + activeItem.progressId = progressId; + } + } + const progressDisposable = progressId && options.task + ? this.sshService.onDynamicTransferProgress(progressId)(event => this.updateTransferProgress(options.task, event)) + : undefined; + try { + const sourceEndpoint = await this.toTransferEndpoint(sourceDevice, sourceItem.path); + const targetEndpoint = await this.toTransferEndpoint(targetDevice, targetPath); + const plannedStrategy = this.getPlannedTransferStrategy(sourceDevice, targetDevice, sourceItem); + const activeItem = options.task?.activeTransferItemIndex !== undefined ? options.task.transferItems?.[options.task.activeTransferItemIndex] : undefined; + const expectedBytes = sourceItem.directory + ? Math.max(activeItem?.totalBytes ?? 0, activeItem?.rawSize ?? 0, 0) + : Math.max(activeItem?.totalBytes ?? 0, activeItem?.rawSize ?? 0, sourceItem.rawSize, 0); + const initialStrategy = this.shouldDeferSshToSshStrategy(sourceDevice, targetDevice) ? undefined : plannedStrategy; + if (activeItem) { + activeItem.strategy = initialStrategy; + } + if (options.task) { + options.task.strategy = initialStrategy; + } + if (options.task) { + this.updateTask(options.task, `正在传输:${sourceItem.name} -> ${targetDevice.name}`, Math.max(35, options.task.progress)); + } + const result = await this.sshService.transferFile({ + source: sourceEndpoint, + target: targetEndpoint, + sourceType: sourceItem.type, + overwrite: true, + progressId, + expectedBytes, + clientStartedAt: options.initiatedAt ?? options.task?.startedAt + }); + if (result.ok) { + if (options.task && result.durationMs !== undefined) { + options.task.durationMs = (options.task.durationMs ?? 0) + result.durationMs; + options.task.averageBytesPerSecond = undefined; + } + if (options.refreshTarget) { + if (options.task) { + this.updateTask(options.task, `正在刷新目标目录:${targetDevice.name}`, 90); + } + await this.refreshPane(targetSide, { force: true }); + } + return { ok: true, message: result.message, bytes: result.bytes, strategy: result.strategy, durationMs: result.durationMs, averageBytesPerSecond: result.averageBytesPerSecond }; + } + return { ok: false, message: result.message, bytes: result.bytes, strategy: result.strategy, durationMs: result.durationMs, averageBytesPerSecond: result.averageBytesPerSecond, canceled: result.canceled }; + } + catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { ok: false, message, bytes: 0 }; + } + finally { + progressDisposable?.dispose(); + } + } + async refreshPanesAfterTransfer(sourceSide, targetSide, operation, sourceDevice, targetDevice) { + const plan = createTurboFilePostTransferRefreshPlan(operation, sourceSide, targetSide, this.isSameDeviceForNativeOperation(sourceDevice, targetDevice)); + if (plan.sequential) { + for (const side of plan.sides) { + await this.refreshPane(side, { force: true }); + } + return; + } + await Promise.all(plan.sides.map(side => this.refreshPane(side, { force: true }))); + } + async transferItemsBatch(sourceSide, targetSide, sourceDevice, targetDevice, sourceItems, operation, initiatedAt) { + const items = sourceItems.filter(item => !!item.path && !item.parent); + if (!items.length) { + return false; + } + const actionLabel = operation === 'move' ? '剪切传输' : '批量传输'; + const task = this.beginTask('transfer', `${actionLabel} ${items.length} 项`, `${sourceDevice.name} -> ${targetDevice.name}`); + task.progress = 0; + task.cancelRequested = false; + task.transferItems = items.map(item => this.createTransferTaskItem(item)); + task.speedSamples = []; + this.primeTransferTaskStrategy(task, sourceDevice, targetDevice, items); + const progressId = `turbofile-batch-${task.id}-${Date.now()}`; + task.progressId = progressId; + for (const transferItem of task.transferItems) { + transferItem.progressId = progressId; + transferItem.status = 'running'; + transferItem.startedAt = Date.now(); + } + const progressDisposable = this.sshService.onDynamicTransferProgress(progressId)(event => this.updateTransferProgress(task, event)); + // Folders report 0 bytes in the listing, so a multi-folder batch total would otherwise be far + // too low. Fill in each folder's real size in the background so the displayed total becomes + // accurate — this runs alongside the transfer and never delays its start or slows it down. + this.fillBatchDirectorySizesInBackground(task, sourceDevice, items); + try { + const sourceEndpoint = await this.toTransferEndpoint(sourceDevice, items[0].path); + const targetDirectoryEndpoint = await this.toTransferEndpoint(targetDevice, this.paneStates[targetSide].path); + const expectedBytes = items.reduce((sum, item) => sum + (item.directory ? 0 : Math.max(0, item.rawSize)), 0); + const plannedStrategy = this.getPlannedBatchTransferStrategy(sourceDevice, targetDevice, items); + const initialStrategy = this.shouldDeferSshToSshStrategy(sourceDevice, targetDevice) ? undefined : plannedStrategy; + task.strategy = initialStrategy; + for (const transferItem of task.transferItems) { + transferItem.strategy = initialStrategy; + } + this.updateTask(task, `正在批量传输 0/${items.length}`, 8); + const result = await this.sshService.transferBatch({ + source: sourceEndpoint, + targetDirectory: targetDirectoryEndpoint, + items: items.map(item => ({ + sourcePath: item.path, + targetName: item.name, + displayName: item.name, + sourceType: item.type, + expectedBytes: item.directory ? 0 : Math.max(0, item.rawSize) + })), + operation, + progressId, + expectedBytes, + strategyHint: 'auto', + clientStartedAt: initiatedAt ?? task.startedAt + }); + const failedPaths = new Set(result.failures.map(failure => failure.path)); + for (const [index, item] of items.entries()) { + const transferItem = task.transferItems[index]; + if (!transferItem) { + continue; + } + transferItem.strategy = result.strategy ?? transferItem.strategy; + transferItem.endedAt = Date.now(); + if (result.canceled) { + transferItem.status = 'canceled'; + } + else if (failedPaths.has(item.path)) { + transferItem.status = 'error'; + transferItem.error = result.failures.find(failure => failure.path === item.path)?.message; + } + else { + transferItem.status = 'success'; + transferItem.progress = 100; + transferItem.transferredBytes = Math.max(transferItem.transferredBytes, transferItem.totalBytes, item.rawSize, 0); + } + } + this.updateTask(task, `正在刷新目录:${sourceDevice.name} / ${targetDevice.name}`, 92); + await this.refreshPanesAfterTransfer(sourceSide, targetSide, operation, sourceDevice, targetDevice); + if (result.canceled) { + this.finishTask(task, 'canceled', `已取消批量传输:${items.length} 项`, { bytes: result.bytes, strategy: result.strategy, durationMs: result.durationMs, averageBytesPerSecond: result.averageBytesPerSecond }); + return false; + } + if (!result.ok) { + this.finishTask(task, 'error', `批量传输完成 ${result.completed} 项,失败 ${result.failed} 项。`, { bytes: result.bytes, strategy: result.strategy, error: result.failures[0]?.message, durationMs: result.durationMs, averageBytesPerSecond: result.averageBytesPerSecond }); + return false; + } + this.finishTask(task, 'success', `${operation === 'move' ? '剪切传输' : '批量传输'}完成:${result.completed} 项,${formatSize(result.bytes)}。`, { bytes: result.bytes, strategy: result.strategy, durationMs: result.durationMs, averageBytesPerSecond: result.averageBytesPerSecond }); + return true; + } + catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.finishTask(task, 'error', `批量传输失败:${message}`, { error: message }); + return false; + } + finally { + progressDisposable.dispose(); + } + } + /** + * Compute the real byte size of every folder in a batch in the background and feed it into the + * matching transfer item's total, so the aggregate "total size" shown for a multi-folder transfer + * is accurate. Directories are sized one at a time (a single `du` each) to keep the connection's + * channel pressure low, and the whole thing runs concurrently with the transfer — it never blocks + * the transfer start nor competes for its bandwidth. + */ + fillBatchDirectorySizesInBackground(task, sourceDevice, items) { + const directoryEntries = items + .map((item, index) => ({ item, index })) + .filter(entry => entry.item.directory && !!entry.item.path); + if (!directoryEntries.length) { + return; + } + void (async () => { + for (const { item, index } of directoryEntries) { + if (task.status !== 'running') { + return; + } + try { + const endpoint = await this.toTransferEndpoint(sourceDevice, item.path); + const result = await this.sshService.calculatePathSize(endpoint); + const transferItem = task.transferItems?.[index]; + if (result.ok && result.size > 0 && transferItem && transferItem.status !== 'success') { + transferItem.totalBytes = Math.max(transferItem.totalBytes, result.size); + if ((this.activePage === 'transfer' || this.isTaskLogVisible) && this.isEditorVisible) { + this.render(); + } + } + } + catch { + // Best-effort: keep the approximate total if a folder's size cannot be computed. + } + } + })(); + } + /** + * Inserts a just-created item into the pane immediately instead of re-listing the whole directory. + * A forced re-list after every "new folder/file" was the slow part — on a large or high-latency + * remote directory it could take many seconds or even time out. The server already confirmed the + * create, so showing the row optimistically is both instant and accurate. + */ + insertOptimisticPaneItem(side, item) { + const state = this.paneStates[side]; + if (item.path && state.items.some(existing => existing.path === item.path)) { + return; + } + state.items = this.sortPaneItems(side, [...state.items, item]); + const device = this.getPaneDevice(side); + if (device) { + const cacheKey = this.getPaneDirectoryCacheKey(device, state.path); + const cached = this.paneDirectoryCache.get(cacheKey); + if (cached) { + this.paneDirectoryCache.set(cacheKey, { ...cached, items: state.items }); + } + } + this.fileListRenderedRange[side] = undefined; + this.render(); + } + async createFolder(side) { + const state = this.paneStates[side]; + const device = this.getPaneDevice(side); + if (!device) { + return; + } + const name = await this.showDialogPrompt('新建文件夹', '请输入新文件夹名称'); + if (!name?.trim()) { + return; + } + const folderName = name.trim(); + const targetPath = joinPanePath(state.path, folderName, this.isWindowsDevice(device)); + const task = this.beginTask('mkdir', `新建文件夹 ${folderName}`, device.name); + try { + const result = await this.sshService.createDirectory(await this.toTransferEndpoint(device, targetPath)); + if (result.ok) { + this.insertOptimisticPaneItem(side, { + name: folderName, + path: targetPath, + size: '', + rawSize: 0, + modified: formatDate(Date.now()), + rawModified: Date.now(), + type: 'directory', + directory: true + }); + this.finishTask(task, 'success', `已创建文件夹:${folderName}`); + } + else { + this.finishTask(task, 'error', `新建文件夹失败:${result.message}`, { error: result.message }); + await this.refreshPane(side, { force: true }); + } + } + catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.finishTask(task, 'error', `新建文件夹失败:${message}`, { error: message }); + } + } + async createFile(side) { + const state = this.paneStates[side]; + const device = this.getPaneDevice(side); + if (!device) { + return; + } + const name = await this.showDialogPrompt('新建文件', '请输入新文件名称'); + if (!name?.trim()) { + return; + } + const fileName = name.trim(); + const targetPath = joinPanePath(state.path, fileName, this.isWindowsDevice(device)); + const task = this.beginTask('create-file', `新建文件 ${fileName}`, device.name); + try { + const result = await this.sshService.writeFile(await this.toTransferEndpoint(device, targetPath), '', 'utf8'); + if (result.ok) { + this.insertOptimisticPaneItem(side, { + name: fileName, + path: targetPath, + size: formatSize(0), + rawSize: 0, + modified: formatDate(Date.now()), + rawModified: Date.now(), + type: 'file' + }); + this.finishTask(task, 'success', `已创建文件:${fileName}`); + } + else { + this.finishTask(task, 'error', `新建文件失败:${result.message}`, { error: result.message }); + await this.refreshPane(side, { force: true }); + } + } + catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.finishTask(task, 'error', `新建文件失败:${message}`, { error: message }); + await this.refreshPane(side, { force: true }); + } + } + async deleteSelected(side) { + const items = this.getSelectedTransferItems(side); + const device = this.getPaneDevice(side); + if (!items.length || !device) { + this.statusMessage = '请先选择要删除的文件或目录。'; + this.render(); + return; + } + const batch = items.length > 1; + if (!await this.showDialogConfirm('删除确认', batch ? `确定删除选中的 ${items.length} 项吗?` : `确定删除 ${items[0].name} 吗?`, undefined, true)) { + return; + } + this.removePaneItems(side, items); + const task = this.beginTask('delete', batch ? `删除 ${items.length} 项` : `删除 ${items[0].name}`, device.name); + try { + this.updateTask(task, batch ? `正在删除 ${items.length} 项…` : `正在删除:${items[0].name}`, 40); + // Delete the whole selection in one batch round trip instead of one request per item, so + // removing a large multi-selection is near-instant rather than N sequential SSH calls. + const endpoints = await Promise.all(items.filter(item => !!item.path).map(item => this.toTransferEndpoint(device, item.path))); + const result = await this.sshService.deletePaths(endpoints, true); + this.updateTask(task, `正在刷新目录:${device.name}`, 90); + await this.refreshPane(side, { force: true }); + if (!result.ok) { + this.finishTask(task, 'error', `删除失败:${result.message}`, { error: result.message }); + return; + } + this.finishTask(task, 'success', batch ? `已删除:${items.length} 项` : `已删除:${items[0].name}`); + } + catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.finishTask(task, 'error', `删除失败:${message}`, { error: message }); + await this.refreshPane(side, { force: true }); + } + } + removePaneItems(side, items) { + if (this.removePaneItemsFromState(side, items)) { + this.render(); + } + } + removePaneItemsFromState(side, items) { + const paths = new Set(items.map(item => item.path).filter((path) => !!path)); + if (!paths.size) { + return false; + } + const state = this.paneStates[side]; + state.items = state.items.filter(item => !item.path || !paths.has(item.path)); + this.clearPaneSelection(side); + this.fileListRenderedRange[side] = undefined; + const device = this.getPaneDevice(side); + if (device) { + this.paneDirectoryCache.delete(this.getPaneDirectoryCacheKey(device, state.path)); + } + return true; + } + applyOptimisticRename(side, item, targetName, targetPath) { + if (!item.path) { + return undefined; + } + const state = this.paneStates[side]; + const index = state.items.findIndex(candidate => candidate.path === item.path); + if (index < 0) { + return undefined; + } + const snapshot = { + panePath: state.path, + index, + originalItem: state.items[index], + renamedPath: targetPath, + selectedPath: state.selectedPath, + selectedPaths: Array.from(state.selectedPaths), + selectionAnchorPath: state.selectionAnchorPath + }; + const now = Date.now(); + const renamedItem = { + ...state.items[index], + name: targetName, + path: targetPath, + modified: formatDate(now), + rawModified: now + }; + state.items = [...state.items]; + state.items[index] = renamedItem; + if (state.selectedPath === item.path) { + state.selectedPath = targetPath; + } + if (state.selectionAnchorPath === item.path) { + state.selectionAnchorPath = targetPath; + } + if (state.selectedPaths.delete(item.path)) { + state.selectedPaths.add(targetPath); + } + state.items = this.sortPaneItems(side, state.items); + this.fileListRenderedRange[side] = undefined; + const device = this.getPaneDevice(side); + if (device) { + this.paneDirectoryCache.delete(this.getPaneDirectoryCacheKey(device, state.path)); + } + this.statusMessage = `已重命名:${item.name} -> ${targetName}`; + this.rememberPaneDeviceSnapshot(side); + this.render(); + return snapshot; + } + revertOptimisticRename(side, snapshot) { + const state = this.paneStates[side]; + if (state.path !== snapshot.panePath) { + return; + } + const renamedIndex = state.items.findIndex(item => item.path === snapshot.renamedPath); + state.items = [...state.items]; + if (renamedIndex >= 0) { + state.items[renamedIndex] = snapshot.originalItem; + } + else { + state.items.splice(Math.min(snapshot.index, state.items.length), 0, snapshot.originalItem); + } + state.items = this.sortPaneItems(side, state.items); + state.selectedPath = snapshot.selectedPath; + state.selectedPaths = new Set(snapshot.selectedPaths); + state.selectionAnchorPath = snapshot.selectionAnchorPath; + this.fileListRenderedRange[side] = undefined; + this.rememberPaneDeviceSnapshot(side); + this.render(); + } + async renameSelected(side) { + const item = this.getSelectedItem(side); + const device = this.getPaneDevice(side); + if (!item?.path || !device || item.parent) { + this.statusMessage = '请先选择要重命名的文件或目录。'; + this.render(); + return; + } + this.startInlineRename(side, item); + } + startInlineRename(side, item) { + if (!item.path || item.parent) { + return; + } + this.inlineRename = { + side, + path: item.path, + originalName: item.name, + directory: !!item.directory + }; + this.ensureItemInSelection(side, item); + this.statusMessage = `正在重命名:${item.name}`; + this.render(); + } + isInlineRenameTarget(side, item) { + return !!item.path && this.inlineRename?.side === side && this.inlineRename.path === item.path; + } + validateRenameName(side, item, value) { + const device = this.getPaneDevice(side); + if (!value.trim()) { + return '名称不能为空。'; + } + if (value === '.' || value === '..') { + return '不能使用 . 或 .. 作为名称。'; + } + if (value.includes('/') || value.includes('\\')) { + return '名称不能包含路径分隔符。'; + } + if (device && this.isWindowsDevice(device) && /[<>:"|?*]/.test(value)) { + return 'Windows 名称不能包含 < > : " | ? *。'; + } + const caseInsensitive = !device || this.isWindowsDevice(device); + const nameKey = caseInsensitive ? value.toLocaleLowerCase() : value; + const duplicate = this.paneStates[side].items.some(candidate => { + if (!candidate.path || candidate.parent || candidate.path === item.path) { + return false; + } + const candidateKey = caseInsensitive ? candidate.name.toLocaleLowerCase() : candidate.name; + return candidateKey === nameKey; + }); + if (duplicate) { + return '已存在同名文件或目录。'; + } + return undefined; + } + async performRename(side, item, nextName) { + const device = this.getPaneDevice(side); + if (!item.path || !device || item.parent || !nextName.trim() || nextName === item.name) { + return; + } + const targetName = nextName; + const targetPath = joinPanePath(this.paneStates[side].path, targetName, this.isWindowsDevice(device)); + const optimisticRename = this.applyOptimisticRename(side, item, targetName, targetPath); + const task = this.beginTask('rename', `重命名 ${item.name}`, `${item.name} -> ${targetName}`); + try { + const result = await this.sshService.renamePath(await this.toTransferEndpoint(device, item.path), targetPath); + if (result.ok) { + this.updateTask(task, `正在刷新目录:${device.name}`, 90); + this.finishTask(task, 'success', `已重命名:${item.name} -> ${targetName}`); + } + else { + if (optimisticRename) { + this.revertOptimisticRename(side, optimisticRename); + } + this.finishTask(task, 'error', `重命名失败:${result.message}`, { error: result.message }); + } + } + catch (error) { + if (optimisticRename) { + this.revertOptimisticRename(side, optimisticRename); + } + const message = error instanceof Error ? error.message : String(error); + this.finishTask(task, 'error', `重命名失败:${message}`, { error: message }); + } + } + async previewSelected(side, explicitItem) { + const item = explicitItem ?? this.getSelectedItem(side); + const device = this.getPaneDevice(side); + if (!item?.path || !device || item.parent || item.directory) { + this.statusMessage = '请先选择一个文件进行预览。'; + this.render(); + return; + } + if (!isImageFile(item.name)) { + void this.openNativeDocument(side, item, device); + return; + } + this.openImageGrid(side, item.path, true, 'file-list'); + } + openImageGrid(side, selectedPath, previewVisible = false, previewOrigin = 'grid', displayMode = 'page') { + const state = this.paneStates[side]; + this.clearImageGridThumbnailQueue(); + this.clearImageGridPreviewPrefetchTimer(); + if (displayMode === 'pane') { + this.releaseImageGridState(this.paneImageGridStates[side]); + } + else if (this.imageGridState?.displayMode === 'page') { + this.releaseImageGridState(this.imageGridState); + } + const images = state.items + .filter(item => !item.parent && !item.directory && !!item.path && isImageFile(item.name)) + .map(item => { + return { + item, + thumbnailContent: '', + thumbnailMime: 'image/jpeg', + thumbnailLoading: false, + previewContent: '', + previewMime: getMimeType(item.name), + previewLoading: false, + }; + }); + if (!images.length) { + this.statusMessage = '当前目录没有可展开浏览的图片。'; + this.render(); + return; + } + sortImageGridImages(images, this.imageGridSortKey, this.imageGridSortDescending); + const selectedPanePaths = new Set(state.selectedPaths); + const selectedIndexCandidate = selectedPath + ? images.findIndex(image => image.item.path === selectedPath) + : images.findIndex(image => !!image.item.path && selectedPanePaths.has(image.item.path)); + const selectedIndex = Math.max(0, selectedIndexCandidate); + const selectedIndexes = new Set(); + for (const [index, image] of images.entries()) { + if (image.item.path && (selectedPanePaths.has(image.item.path) || image.item.path === selectedPath)) { + selectedIndexes.add(index); + } + } + if (!selectedIndexes.size) { + selectedIndexes.add(selectedIndex); + } + this.imagePreviewAnalysis = undefined; + this.clearImagePreviewRender(); + const nextState = { + side, + path: state.path, + displayMode, + images, + selectedIndex, + selectedIndexes, + selectionAnchorIndex: selectedIndex, + columns: IMAGE_GRID_COLUMN_DEFAULT, + thumbnailScale: 1, + sortKey: this.imageGridSortKey, + sortDescending: this.imageGridSortDescending, + previewVisible, + previewOrigin, + previewZoom: 1, + previewPanX: 0, + previewPanY: 0, + previewFitPending: true, + previewSamplingMode: this.defaultImagePreviewSamplingMode, + pixelInspectorVisible: false, + histogramVisible: false, + histogramX: 104, + histogramY: 72, + fileListScrollTop: state.scrollTop, + scrollTop: 0, + viewportHeight: this.getImageGridDefaultViewportHeight(), + thumbnailLoadToken: 0, + loadedThumbnailIndexes: new Set(), + loadedPreviewIndexes: new Set(), + scrollRaf: 0 + }; + this.imageGridState = nextState; + if (displayMode === 'pane') { + this.paneImageGridStates[side] = nextState; + } + this.imageGridCtrlWheelDeltaRemainder = 0; + if (displayMode === 'page') { + this.activePage = 'images'; + this.updateTransferTabsVisibility(); + this.notifyActivePageChanged(); + } + this.statusMessage = `已展开 ${this.getSideLabel(side)} 图片:${images.length} 张。`; + this.render(); + if (previewVisible) { + this.imageGridPreviewRequestSeq++; + void this.ensureImageGridPreviewLoaded(nextState, selectedIndex, this.imageGridPreviewRequestSeq); + this.scheduleImageGridPreviewNeighborsLoaded(nextState, selectedIndex); + } + } + closeImageGridPage(state = this.imageGridState) { + this.imageGridCtrlWheelDeltaRemainder = 0; + this.disposeImageGridObserver(); + this.clearImageGridScrollTimer(); + this.clearImageGridScrollRaf(); + this.clearImageGridThumbnailQueue(); + this.clearImageGridPreviewPrefetchTimer(); + this.releaseImageGridState(state); + this.imagePreviewAnalysis = undefined; + this.clearImagePreviewRender(); + if (state) { + this.restoreFileListScrollAfterImageGrid(state.side, state.fileListScrollTop); + } + if (!state || state.displayMode === 'page') { + this.activePage = 'transfer'; + this.updateTransferTabsVisibility(); + this.notifyActivePageChanged(); + } + this.imageGridState = state ? (this.paneImageGridStates[state.side] ?? this.paneImageGridStates[this.getOppositeSide(state.side)]) : undefined; + this.render(); + } + restoreFileListScrollAfterImageGrid(side, scrollTop) { + const state = this.paneStates[side]; + state.scrollTop = Math.max(0, scrollTop); + this.fileListRenderedRange[side] = undefined; + this.clearFileListScrollRaf(side); + } + selectImageGridItem(index, event) { + const state = this.imageGridState; + if (!state || index < 0 || index >= state.images.length) { + return; + } + const isToggle = !!event && (event.ctrlKey || event.metaKey); + const anchorIndex = state.selectionAnchorIndex; + const isRange = !!event?.shiftKey && anchorIndex !== undefined; + if (isRange) { + this.setImageGridRangeSelection(state, anchorIndex, index); + } + else if (isToggle) { + if (state.selectedIndexes.has(index)) { + state.selectedIndexes.delete(index); + } + else { + state.selectedIndexes.add(index); + } + state.selectedIndex = index; + state.selectionAnchorIndex = index; + } + else { + this.setSingleImageGridSelection(state, index); + } + this.updateImageGridSelection(state); + this.updateImageGridSelectionStatus(state); + this.scheduleImageGridThumbnailsLoaded(this.getImageGridLoadIndexes(index, state.images.length), state); + } + selectImageGridItemWithPointer(index, event) { + if (event.button !== 0) { + return; + } + const target = event.currentTarget; + if (target instanceof HTMLElement) { + target.focus({ preventScroll: true }); + } + this.selectImageGridItem(index, event); + } + selectImageGridItemWithContextMenu(index, event) { + event.preventDefault(); + const state = this.imageGridState; + if (!state || index < 0 || index >= state.images.length) { + return; + } + if (!event.ctrlKey && !event.metaKey && !event.shiftKey && state.selectedIndexes.has(index)) { + state.selectedIndex = index; + state.selectionAnchorIndex = index; + this.updateImageGridSelection(state); + this.updateImageGridSelectionStatus(state); + return; + } + this.selectImageGridItem(index, event); + } + setSingleImageGridSelection(state, index) { + state.selectedIndexes.clear(); + state.selectedIndexes.add(index); + state.selectedIndex = index; + state.selectionAnchorIndex = index; + } + setImageGridRangeSelection(state, anchorIndex, index) { + state.selectedIndexes.clear(); + const start = Math.max(0, Math.min(anchorIndex, index)); + const end = Math.min(state.images.length - 1, Math.max(anchorIndex, index)); + for (let candidate = start; candidate <= end; candidate++) { + state.selectedIndexes.add(candidate); + } + state.selectedIndex = index; + } + selectAllImageGridItems(state = this.imageGridState) { + if (!state || state.previewVisible) { + return; + } + state.selectedIndexes.clear(); + for (let index = 0; index < state.images.length; index++) { + state.selectedIndexes.add(index); + } + if (state.images.length) { + state.selectedIndex = Math.max(0, Math.min(state.selectedIndex, state.images.length - 1)); + state.selectionAnchorIndex = state.selectedIndex; + } + else { + state.selectedIndex = 0; + state.selectionAnchorIndex = undefined; + } + this.updateImageGridSelection(state); + this.updateImageGridSelectionStatus(state); + } + moveImageGridPreview(delta, state = this.getActiveImageGridPreviewState()) { + if (!state?.images.length) { + return; + } + this.setActiveImageGridState(state); + const baseIndex = state.previewPendingIndex ?? state.selectedIndex; + const nextIndex = (baseIndex + delta + state.images.length) % state.images.length; + if (nextIndex === state.selectedIndex) { + this.imageGridPreviewRequestSeq++; + state.previewPendingIndex = undefined; + state.previewPendingRequestSeq = undefined; + this.scheduleImageGridPreviewNeighborsLoaded(state, state.selectedIndex); + return; + } + this.imageGridPreviewRequestSeq++; + const requestSeq = this.imageGridPreviewRequestSeq; + state.previewPendingIndex = nextIndex; + state.previewPendingRequestSeq = requestSeq; + void this.ensureImageGridPreviewLoaded(state, nextIndex, requestSeq); + this.scheduleImageGridPreviewNeighborsLoaded(state, nextIndex); + this.scheduleImageGridThumbnailsLoaded(this.getImageGridLoadIndexes(nextIndex, state.images.length), state); + this.commitImageGridPreviewIndexIfReady(state, nextIndex, requestSeq); + } + commitImageGridPreviewIndexIfReady(state, index, requestSeq) { + if (this.getActiveImageGridPreviewState() !== state || !state.previewVisible || state.previewPendingIndex !== index || state.previewPendingRequestSeq !== requestSeq) { + return false; + } + const image = state.images[index]; + const src = this.getImagePreviewSource(image); + if (!image || (src ? !this.isImagePreviewDecoded(image, src) : !image.previewError)) { + return false; + } + this.setSingleImageGridSelection(state, index); + state.previewZoom = 1; + state.previewPanX = 0; + state.previewPanY = 0; + state.previewFitPending = true; + state.previewPendingIndex = undefined; + state.previewPendingRequestSeq = undefined; + this.imagePreviewAnalysis = undefined; + this.clearImagePreviewRender(); + this.releaseImageGridImagesOutsideRange(state, this.getImageGridVirtualRange(state)); + this.updateImagePreviewOverlay(state); + return true; + } + openImageGridPreview(index) { + const state = this.imageGridState; + if (!state || index < 0 || index >= state.images.length) { + return; + } + if (!state.selectedIndexes.has(index)) { + this.setSingleImageGridSelection(state, index); + } + else { + state.selectedIndex = index; + state.selectionAnchorIndex = index; + } + state.selectedIndex = index; + state.previewOrigin = 'grid'; + state.previewVisible = true; + state.previewZoom = 1; + state.previewPanX = 0; + state.previewPanY = 0; + state.previewFitPending = true; + state.previewSamplingMode = this.defaultImagePreviewSamplingMode; + state.previewPendingIndex = undefined; + state.previewPendingRequestSeq = undefined; + this.imagePreviewAnalysis = undefined; + this.clearImagePreviewRender(); + this.imageGridPreviewRequestSeq++; + this.releaseImageGridImagesOutsideRange(state, this.getImageGridVirtualRange(state)); + this.render(); + void this.ensureImageGridPreviewLoaded(state, index, this.imageGridPreviewRequestSeq); + this.scheduleImageGridPreviewNeighborsLoaded(state, index); + this.scheduleImageGridThumbnailsLoaded(this.getImageGridLoadIndexes(index, state.images.length), state); + } + closeImageGridPreview(state = this.getActiveImageGridPreviewState()) { + if (!state) { + return; + } + this.setActiveImageGridState(state); + if (state.previewOrigin === 'file-list') { + this.closeImageGridPage(); + return; + } + this.imageGridPreviewRequestSeq++; + this.clearImageGridPreviewPrefetchTimer(); + state.previewVisible = false; + state.previewZoom = 1; + state.previewPanX = 0; + state.previewPanY = 0; + state.previewFitPending = true; + state.previewSamplingMode = this.defaultImagePreviewSamplingMode; + state.previewPendingIndex = undefined; + state.previewPendingRequestSeq = undefined; + this.imagePreviewAnalysis = undefined; + this.clearImagePreviewRender(); + this.releaseImageGridImagesOutsideRange(state, this.getImageGridVirtualRange(state)); + this.render(); + } + toggleImagePreviewPixelInspector(state = this.getActiveImageGridPreviewState()) { + if (!state?.previewVisible) { + return; + } + this.setActiveImageGridState(state); + state.pixelInspectorVisible = !state.pixelInspectorVisible; + this.updateImagePreviewToggleButton('pixel-inspector', state.pixelInspectorVisible); + this.updateImagePreviewPixelInspectorInPlace(state); + } + toggleImagePreviewHistogram(state = this.getActiveImageGridPreviewState()) { + if (!state?.previewVisible) { + return; + } + this.setActiveImageGridState(state); + state.histogramVisible = !state.histogramVisible; + this.updateImagePreviewToggleButton('histogram', state.histogramVisible); + this.updateImagePreviewHistogramInPlace(state); + } + toggleImagePreviewSamplingMode(state = this.getActiveImageGridPreviewState()) { + if (!state?.previewVisible) { + return; + } + this.setActiveImageGridState(state); + state.previewSamplingMode = state.previewSamplingMode === 'lossless' ? 'smooth' : 'lossless'; + this.updateImagePreviewToggleButton('lossless-sampling', state.previewSamplingMode === 'lossless'); + this.renderImagePreviewCanvas(state); + } + zoomImagePreviewByOculanteStep(step, state = this.getActiveImageGridPreviewState()) { + const render = this.imagePreviewRender; + if (!state?.previewVisible || !render?.ready) { + return; + } + this.setActiveImageGridState(state); + const rect = render.canvas.getBoundingClientRect(); + const anchor = { + x: rect.width / 2, + y: rect.height / 2 + }; + this.setImageGridPreviewZoom(state.previewZoom + step * state.previewZoom * IMAGE_PREVIEW_ZOOM_RATIO, anchor, state); + } + setImageGridPreviewZoom(zoom, anchor, state = this.getActiveImageGridPreviewState()) { + const render = this.imagePreviewRender; + if (!state?.previewVisible || !render?.ready) { + return; + } + this.setActiveImageGridState(state); + const oldScale = Math.max(IMAGE_PREVIEW_ZOOM_MIN, state.previewZoom); + const newScale = Math.min(IMAGE_PREVIEW_ZOOM_MAX, Math.max(IMAGE_PREVIEW_ZOOM_MIN, zoom)); + if (newScale === oldScale) { + return; + } + const rect = render.canvas.getBoundingClientRect(); + const zoomAnchor = anchor ?? { x: rect.width / 2, y: rect.height / 2 }; + const delta = newScale - oldScale; + state.previewPanX -= ((zoomAnchor.x - state.previewPanX) * delta) / oldScale; + state.previewPanY -= ((zoomAnchor.y - state.previewPanY) * delta) / oldScale; + state.previewZoom = newScale; + state.previewFitPending = false; + this.limitImagePreviewOffset(state, render); + this.renderImagePreviewCanvas(state); + } + resetImagePreviewView(state = this.getActiveImageGridPreviewState()) { + if (!state?.previewVisible) { + return; + } + this.setActiveImageGridState(state); + state.previewFitPending = true; + this.renderImagePreviewCanvas(state); + } + updateImagePreviewZoomValue(state) { + const controls = this.container?.querySelector('.turbofile-image-preview-zoom-controls'); + const zoomValue = controls?.querySelector('.turbofile-image-preview-zoom-value'); + if (zoomValue) { + zoomValue.textContent = this.getImagePreviewZoomLabel(state); + } + const transientButtons = controls?.querySelectorAll('.turbofile-image-preview-action.is-transient'); + if (transientButtons?.[0]) { + transientButtons[0].disabled = state.previewZoom <= IMAGE_PREVIEW_ZOOM_MIN; + } + if (transientButtons?.[1]) { + transientButtons[1].disabled = state.previewZoom >= IMAGE_PREVIEW_ZOOM_MAX; + } + const resetButton = controls?.querySelector('.turbofile-image-preview-action.is-reset'); + if (resetButton) { + resetButton.disabled = state.previewFitPending; + } + } + getImagePreviewZoomLabel(state) { + return `${Math.round(state.previewZoom * 100)}%`; + } + clearImagePreviewRender() { + this.imagePreviewResizeObserver?.disconnect(); + this.imagePreviewResizeObserver = undefined; + this.imagePreviewRender = undefined; + this.imagePreviewRenderSeq++; + } + async downloadImageGridPreview(state = this.getActiveImageGridPreviewState()) { + const image = state?.images[state.selectedIndex]; + const device = state ? this.getPaneDevice(state.side) : undefined; + if (!state || !image?.item.path || !device) { + return; + } + this.setActiveImageGridState(state); + try { + // Always save the true original bytes — the on-screen preview may be a display-resolution + // downscale for very large images, but a download must produce the real file. + const result = await this.sshService.readImage(await this.toTransferEndpoint(device, image.item.path), { + encoding: 'base64', + forceOriginal: true, + maxBytes: 256 * 1024 * 1024, + sourceSize: image.item.rawSize, + sourceModified: image.item.rawModified + }); + if (!result.ok || !result.content) { + throw new Error(result.message || '无法读取原始图片。'); + } + const src = `data:${result.mime || getMimeType(image.item.name)};base64,${result.content}`; + const ownerDocument = this.container?.ownerDocument ?? document; + const anchor = ownerDocument.createElement('a'); + anchor.href = src; + anchor.download = image.item.name; + anchor.rel = 'noopener'; + anchor.style.display = 'none'; + ownerDocument.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + } + catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.statusMessage = `下载图片失败:${message}`; + this.render(); + } + } + canCopyPreviewImage() { + const gridState = this.getActiveImageGridPreviewState(); + if (gridState?.previewVisible) { + const image = gridState.images[gridState.selectedIndex]; + return !!image && !!this.getImagePreviewSource(image); + } + return false; + } + async copyPreviewImageToClipboard() { + try { + const gridState = this.getActiveImageGridPreviewState(); + if (gridState?.previewVisible) { + this.setActiveImageGridState(gridState); + const image = gridState.images[gridState.selectedIndex]; + const device = this.getPaneDevice(gridState.side); + if (!image?.item.path || !device) { + return; + } + const result = await this.sshService.readImage(await this.toTransferEndpoint(device, image.item.path), { + encoding: 'base64', + forceOriginal: true, + maxBytes: 128 * 1024 * 1024, + sourceSize: image.item.rawSize, + sourceModified: image.item.rawModified + }); + if (!result.ok || !result.content) { + throw new Error(result.message || '无法读取预览图片。'); + } + await this.writePreviewImageContentToClipboard(result.content, result.mime, image.item.name); + return; + } + } + catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.statusMessage = `复制图片失败:${message}`; + this.showDialogInfo('复制图片失败', message); + } + } + async writePreviewImageContentToClipboard(content, mime, name) { + const result = await this.sshService.writeClipboardImage(content, mime, 'base64'); + if (!result.ok) { + throw new Error(result.message || '复制图片失败。'); + } + this.statusMessage = `已复制图片:${name}`; + } + async deleteSelectedImageGridItems(state = this.imageGridState) { + const device = state ? this.getPaneDevice(state.side) : undefined; + if (!state || !device) { + return; + } + const targets = this.getImageGridDeleteTargets(state).filter(({ image }) => !!image.item.path); + if (!targets.length) { + this.statusMessage = '\u8bf7\u5148\u9009\u62e9\u8981\u5220\u9664\u7684\u56fe\u7247\u3002'; + this.updateImageGridSelection(state); + return; + } + const batch = targets.length > 1; + const confirmMessage = batch ? `\u786e\u5b9a\u5220\u9664\u9009\u4e2d\u7684 ${targets.length} \u5f20\u56fe\u7247\u5417\uff1f` : `\u5220\u9664 ${targets[0].image.item.name}\uff1f`; + if (!await this.showDialogConfirm('\u5220\u9664\u56fe\u7247', confirmMessage, undefined, true)) { + return; + } + const firstDeletedIndex = targets[0].index; + const keepPreviewVisible = state.previewVisible; + const scrollTop = this.getImageGridBodyElement(state)?.scrollTop ?? state.scrollTop; + const items = targets.map(({ image }) => image.item); + for (const { index, image } of [...targets].sort((a, b) => b.index - a.index)) { + this.releaseImageGridImage(image); + state.images.splice(index, 1); + } + state.selectedIndexes.clear(); + if (state.images.length) { + state.selectedIndex = Math.max(0, Math.min(firstDeletedIndex, state.images.length - 1)); + state.selectedIndexes.add(state.selectedIndex); + state.selectionAnchorIndex = state.selectedIndex; + } + else { + state.selectedIndex = 0; + state.selectionAnchorIndex = undefined; + } + state.previewZoom = 1; + state.previewPanX = 0; + state.previewPanY = 0; + state.previewFitPending = true; + state.previewSamplingMode = this.defaultImagePreviewSamplingMode; + state.previewVisible = keepPreviewVisible && state.images.length > 0; + state.scrollTop = scrollTop; + this.imageGridPreviewRequestSeq++; + state.renderedRange = undefined; + this.clearImageGridThumbnailQueue(); + this.clearImageGridPreviewPrefetchTimer(); + this.clearImageGridLoadedImageIndexes(state); + this.imagePreviewAnalysis = undefined; + this.clearImagePreviewRender(); + this.removePaneItemsFromState(state.side, items); + this.updateImageGridAfterLocalMutation(state); + const task = this.beginTask('delete', batch ? `\u5220\u9664 ${targets.length} \u5f20\u56fe\u7247` : `\u5220\u9664 ${items[0].name}`, device.name, false); + if (state.previewVisible) { + void this.ensureImageGridPreviewLoaded(state, state.selectedIndex, this.imageGridPreviewRequestSeq); + this.scheduleImageGridPreviewNeighborsLoaded(state, state.selectedIndex); + this.scheduleImageGridThumbnailsLoaded(this.getImageGridLoadIndexes(state.selectedIndex, state.images.length), state); + } + try { + this.updateTask(task, batch ? `正在删除 ${items.length} 张图片…` : `正在删除:${items[0].name}`, 40, false); + // One batch round trip for the whole selection instead of one request per image. + const endpoints = await Promise.all(items.filter(item => !!item.path).map(item => this.toTransferEndpoint(device, item.path))); + const result = await this.sshService.deletePaths(endpoints, true); + if (!result.ok) { + this.finishTask(task, 'error', `删除失败:${result.message}`, { error: result.message }, false); + await this.refreshPane(state.side, { force: true }); + return; + } + this.finishTask(task, 'success', batch ? `已删除:${items.length} 张图片` : `已删除:${items[0].name}`, {}, false); + } + catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.finishTask(task, 'error', `\u5220\u9664\u5931\u8d25\uff1a${message}`, { error: message }, false); + await this.refreshPane(state.side, { force: true }); + } + } + async deleteImageGridPreview(state = this.getActiveImageGridPreviewState()) { + const imageGridState = state; + if (imageGridState) { + await this.deleteSelectedImageGridItems(imageGridState); + return; + } + state = this.imageGridState; + const image = state?.images[state.selectedIndex]; + const device = state ? this.getPaneDevice(state.side) : undefined; + if (!state || !image?.item.path || !device) { + return; + } + if (!await this.showDialogConfirm('删除图片', `删除 ${image.item.name}?`, undefined, true)) { + return; + } + const deleteIndex = state.selectedIndex; + this.releaseImageGridImage(image); + state.images.splice(deleteIndex, 1); + this.clearImageGridLayoutMetrics(state); + state.selectedIndex = Math.max(0, Math.min(deleteIndex, state.images.length - 1)); + state.previewZoom = 1; + state.previewPanX = 0; + state.previewPanY = 0; + state.previewFitPending = true; + state.previewSamplingMode = this.defaultImagePreviewSamplingMode; + state.previewVisible = state.images.length > 0; + this.imageGridPreviewRequestSeq++; + state.renderedRange = undefined; + this.clearImageGridThumbnailQueue(); + this.clearImageGridPreviewPrefetchTimer(); + this.clearImageGridLoadedImageIndexes(state); + this.imagePreviewAnalysis = undefined; + this.clearImagePreviewRender(); + this.removePaneItemsFromState(state.side, [image.item]); + const task = this.beginTask('delete', `Delete ${image.item.name}`, device.name); + this.render(); + if (state.previewVisible) { + void this.ensureImageGridPreviewLoaded(state, state.selectedIndex, this.imageGridPreviewRequestSeq); + this.scheduleImageGridPreviewNeighborsLoaded(state, state.selectedIndex); + this.scheduleImageGridThumbnailsLoaded(this.getImageGridLoadIndexes(state.selectedIndex, state.images.length), state); + } + try { + const result = await this.sshService.deletePath(await this.toTransferEndpoint(device, image.item.path), true); + if (result.ok) { + this.finishTask(task, 'success', `Deleted: ${image.item.name}`); + } + else { + this.finishTask(task, 'error', `Delete failed: ${result.message}`, { error: result.message }); + } + } + catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.finishTask(task, 'error', `Delete failed: ${message}`, { error: message }); + } + finally { + await this.refreshPane(state.side, { force: true }); + } + } + setImageGridColumns(state, columns) { + if (!state) { + return; + } + const nextColumns = this.normalizeImageGridColumns(columns, state); + if (state.columns === nextColumns) { + return; + } + state.columns = nextColumns; + state.renderedRange = undefined; + this.clearImageGridThumbnailQueue(); + this.render(); + } + setImageGridThumbnailScale(state, scale) { + if (!state) { + return; + } + const nextScale = this.normalizeImageGridThumbnailScale(scale); + if (state.thumbnailScale === nextScale) { + return; + } + state.thumbnailScale = nextScale; + this.clearImageGridLayoutMetrics(state); + state.renderedRange = undefined; + this.clearImageGridThumbnailQueue(); + this.clearImageGridLoadedImageIndexes(state); + for (const image of state.images) { + this.releaseImageBlobUrl(image.thumbnailUrl); + image.thumbnailUrl = undefined; + image.thumbnailContent = ''; + image.thumbnailLoading = false; + image.thumbnailError = undefined; + } + this.render(); + } + normalizeImageGridThumbnailScale(scale) { + const rounded = Math.round(scale); + return IMAGE_GRID_THUMBNAIL_SCALE_OPTIONS.includes(rounded) ? rounded : 1; + } + getImageGridScaleLabel(scale) { + const normalized = this.normalizeImageGridThumbnailScale(scale); + return normalized === 1 ? '1x' : `${normalized}x`; + } + normalizeImageGridStateColumns(state) { + const nextColumns = this.normalizeImageGridColumns(state.columns, state); + if (state.columns !== nextColumns) { + state.columns = nextColumns; + state.renderedRange = undefined; + } + } + setImageGridSortKey(state, key) { + if (!state) { + return; + } + const nextDescending = state.sortKey === key ? state.sortDescending : true; + this.applyImageGridSort(state, key, nextDescending); + } + setImageGridSortDirection(state, descending) { + if (!state) { + return; + } + this.applyImageGridSort(state, state.sortKey, descending); + } + applyImageGridSort(state, key, descending) { + state.sortKey = key; + state.sortDescending = descending; + this.imageGridSortKey = key; + this.imageGridSortDescending = descending; + this.resortImageGridState(state); + this.clearImageGridThumbnailQueue(); + this.clearImageGridLoadedImageIndexes(state); + this.render(); + } + resortImageGridState(state) { + const selectedPath = state.images[state.selectedIndex]?.item.path; + const selectedPaths = new Set(this.getImageGridSelectedImages(state) + .map(({ image }) => image.item.path) + .filter((path) => !!path)); + const anchorPath = state.selectionAnchorIndex !== undefined ? state.images[state.selectionAnchorIndex]?.item.path : undefined; + sortImageGridImages(state.images, state.sortKey, state.sortDescending); + state.selectedIndexes.clear(); + for (const [index, image] of state.images.entries()) { + if (image.item.path && selectedPaths.has(image.item.path)) { + state.selectedIndexes.add(index); + } + } + if (selectedPath) { + const nextIndex = state.images.findIndex(image => image.item.path === selectedPath); + if (nextIndex >= 0) { + state.selectedIndex = nextIndex; + } + } + if (!state.selectedIndexes.size && state.images[state.selectedIndex]) { + state.selectedIndexes.add(state.selectedIndex); + } + const nextAnchorIndex = anchorPath ? state.images.findIndex(image => image.item.path === anchorPath) : -1; + state.selectionAnchorIndex = nextAnchorIndex >= 0 ? nextAnchorIndex : state.selectedIndex; + state.renderedRange = undefined; + } + resortImageGridAfterSizeUpdate(state, image, previousArea) { + if (state.sortKey !== 'size' || getImageGridPixelArea(image) === previousArea) { + return false; + } + this.resortImageGridState(state); + this.clearImageGridThumbnailQueue(); + this.clearImageGridLoadedImageIndexes(state); + this.render(); + return true; + } + getImageGridSortLabel(state) { + return IMAGE_GRID_SORT_OPTIONS.find(option => option.key === state.sortKey)?.label ?? '名称'; + } + getImagePreviewCaption(state) { + const image = state.images[state.selectedIndex]; + if (!image) { + return ''; + } + const dimensions = formatTurboFileImageDimensions(image, 'x'); + const sizeText = dimensions ? `原图 ${dimensions}` : image.previewLoading ? '正在加载原图' : '原图'; + return `${image.item.name} (${state.selectedIndex + 1}/${state.images.length}) | ${sizeText}`; + } + getImageGridLoadIndexes(selectedIndex, total) { + const indexes = new Set(); + for (let offset = -IMAGE_GRID_NEIGHBOR_LOAD_COUNT; offset <= IMAGE_GRID_NEIGHBOR_LOAD_COUNT; offset++) { + const index = selectedIndex + offset; + if (index >= 0 && index < total) { + indexes.add(index); + } + } + return [...indexes]; + } + scheduleImageGridThumbnailsLoaded(indexes, state = this.imageGridState) { + if (!state || !indexes.length || !this.container) { + return; + } + const ownerWindow = getWindow(this.container); + ownerWindow.requestAnimationFrame(() => { + ownerWindow.setTimeout(() => { + if (this.isImageGridSurfaceActive(state)) { + this.queueImageGridThumbnailLoads(state, indexes, true); + } + }, 0); + }); + } + scheduleImageGridPreviewNeighborsLoaded(state, index) { + this.clearImageGridPreviewPrefetchTimer(); + if (!this.container || !state.images.length) { + return; + } + this.imageGridPreviewPrefetchTimer = setTimeout(() => { + this.imageGridPreviewPrefetchTimer = undefined; + if (!this.isImageGridSurfaceActive(state)) { + return; + } + for (let offset = 1; offset <= IMAGE_GRID_PREVIEW_NEIGHBOR_COUNT; offset++) { + const previous = (index - offset + state.images.length) % state.images.length; + const next = (index + offset) % state.images.length; + void this.ensureImageGridPreviewLoaded(state, previous, undefined, true); + if (next !== previous) { + void this.ensureImageGridPreviewLoaded(state, next, undefined, true); + } + } + }, IMAGE_GRID_PREVIEW_PREFETCH_DELAY_MS); + } + queueImageGridThumbnailLoads(state, indexes, priority) { + if (!this.isImageGridSurfaceActive(state)) { + return; + } + // SSH grids load the whole visible batch in a single round trip (raw bytes, server does no image + // processing), so the entire page appears together instead of trickling in one card at a time. + const device = this.getPaneDevice(state.side); + if (device && this.isRemoteSshImageDevice(device)) { + void this.loadImageGridThumbnailsBatch(state, device, indexes); + return; + } + const requests = []; + for (const index of [...new Set(indexes)]) { + const image = state.images[index]; + if (!image || image.thumbnailUrl || image.thumbnailContent || image.thumbnailLoading || image.thumbnailError) { + continue; + } + const key = this.getImageGridQueueKey(state, index); + if (this.imageGridThumbnailQueued.has(key)) { + continue; + } + this.imageGridThumbnailQueued.add(key); + requests.push({ state, index, key }); + } + if (!requests.length) { + return; + } + if (priority) { + this.imageGridThumbnailQueue.unshift(...requests); + } + else { + this.imageGridThumbnailQueue.push(...requests); + } + this.trimImageGridThumbnailQueue(state); + this.processImageGridThumbnailQueue(); + } + /** + * Loads all not-yet-loaded visible SSH thumbnails for the given indexes in one batched round trip + * (see ITurboFileSshService.readImageBatch). Decoding happens on the client; oversized files fall + * back to the per-image path. Nothing is cached — bytes become a blob URL and are released as soon + * as the card scrolls away. + */ + async loadImageGridThumbnailsBatch(state, device, indexes) { + const pending = []; + for (const index of [...new Set(indexes)]) { + const image = state.images[index]; + if (!image?.item.path) { + continue; + } + if (image.thumbnailUrl || image.thumbnailContent || image.thumbnailLoading || image.thumbnailError) { + continue; + } + if (!this.isImageGridIndexNearViewport(state, index)) { + continue; + } + image.thumbnailLoading = true; + pending.push({ index, image, path: image.item.path, rawSize: Math.max(0, image.item.rawSize) }); + } + if (!pending.length) { + return; + } + const token = state.thumbnailLoadToken; + const targetWidth = this.getImageGridThumbnailRequestWidth(state); + const chunks = []; + for (let offset = 0; offset < pending.length; offset += IMAGE_GRID_BATCH_CHUNK_COUNT) { + chunks.push(pending.slice(offset, offset + IMAGE_GRID_BATCH_CHUNK_COUNT)); + } + // Process chunks in parallel (bounded) so the remote device thumbnails several streams at once. + // A stale token (user scrolled to a different page) stops the loop so the new page gets priority. + let cursor = 0; + const worker = async () => { + while (cursor < chunks.length && this.isImageGridSurfaceActive(state) && state.thumbnailLoadToken === token) { + const chunk = chunks[cursor++]; + await this.loadImageGridThumbnailChunk(state, device, chunk, targetWidth, token); + } + }; + const workers = Array.from({ length: Math.min(IMAGE_GRID_BATCH_CONCURRENCY, chunks.length) }, () => worker()); + await Promise.all(workers); + // Clear the loading flag on anything left unprocessed (grid changed, or this load was superseded). + for (const chunk of chunks) { + for (const target of chunk) { + if (target.image.thumbnailLoading && !target.image.thumbnailUrl) { + target.image.thumbnailLoading = false; + } + } + } + // If a newer load superseded this one, some now-visible images we had marked as loading were + // skipped by that newer load (it saw thumbnailLoading=true) and have just been reset here. Run + // one more pass for the current page so those never get orphaned as blank placeholders. + if (this.isImageGridSurfaceActive(state) && state.thumbnailLoadToken !== token) { + this.scheduleImageGridVisibleLoad(state); + } + } + async loadImageGridThumbnailChunk(state, device, chunk, targetWidth, token) { + if (!this.isImageGridSurfaceActive(state) || state.thumbnailLoadToken !== token) { + for (const target of chunk) { + target.image.thumbnailLoading = false; + } + return; + } + let entries = []; + try { + const endpoint = await this.toTransferEndpoint(device, state.path ?? '/'); + entries = await this.sshService.readImageBatch(endpoint, chunk.map(target => target.path), targetWidth); + } + catch { + this.retryImageGridThumbnailChunkAfterTransientFailure(state, chunk, token); + return; + } + if (!this.isImageGridSurfaceActive(state)) { + for (const target of chunk) { + target.image.thumbnailLoading = false; + } + return; + } + if (entries.some(entry => entry.transient)) { + this.retryImageGridThumbnailChunkAfterTransientFailure(state, chunk, token); + return; + } + const byPath = new Map(entries.map(entry => [entry.path, entry])); + for (const target of chunk) { + this.applyBatchedImageGridThumbnail(state, device, target, byPath.get(target.path)); + } + } + retryImageGridThumbnailChunkAfterTransientFailure(state, chunk, token) { + if (!this.isImageGridSurfaceActive(state) || state.thumbnailLoadToken !== token) { + for (const target of chunk) { + target.image.thumbnailLoading = false; + } + return; + } + for (const target of chunk) { + target.image.thumbnailLoading = false; + target.image.thumbnailError = undefined; + this.updateImageGridCard(state, target.index); + } + this.scheduleImageGridTransientRetry(state); + } + scheduleImageGridTransientRetry(state) { + setTimeout(() => { + if (this.isImageGridSurfaceActive(state)) { + this.scheduleImageGridVisibleLoad(state); + } + }, IMAGE_GRID_TRANSIENT_RETRY_DELAY_MS); + } + applyBatchedImageGridThumbnail(state, device, target, entry) { + target.image.thumbnailLoading = false; + if (entry?.oversized) { + // Only happens when the remote has no image tool and the original is huge; load it on its own. + void this.applySshThumbnailViaProtocol(state, device, target.index); + return; + } + if (!entry?.ok || !entry.content) { + target.image.thumbnailError = entry?.message ?? '加载失败'; + this.updateImageGridCard(state, target.index); + return; + } + if (!this.isImageGridSurfaceActive(state) || !this.isImageGridIndexNearViewport(state, target.index)) { + return; + } + const mime = entry.mime ?? getMimeType(target.image.item.name); + const bytes = decodeBase64(entry.content).buffer; + // If the remote already downscaled (or the original was small) the bytes are tiny — show them via + // a native directly. If they came back large (a device with no image tool streamed the raw + // original, e.g. an uncompressed .bmp), downscale on the client so the card holds only a small + // thumbnail. This keeps both fast paths and full compatibility with tool-less boards. + void this.assignBatchedThumbnail(state, target, bytes, mime); + } + async assignBatchedThumbnail(state, target, bytes, mime) { + const url = bytes.byteLength > IMAGE_GRID_CLIENT_DOWNSCALE_BYTES + ? (await this.createDownscaledThumbnailUrl(bytes, mime, this.getImageGridThumbnailRequestWidth(state))) ?? this.createBlobUrlFromBytes(bytes, mime) + : this.createBlobUrlFromBytes(bytes, mime); + if (!this.isImageGridSurfaceActive(state) || !this.isImageGridIndexNearViewport(state, target.index)) { + this.releaseImageBlobUrl(url); + return; + } + this.releaseImageBlobUrl(target.image.thumbnailUrl); + target.image.thumbnailContent = ''; + target.image.thumbnailUrl = url; + target.image.thumbnailMime = mime; + target.image.thumbnailError = url ? undefined : '加载失败'; + if (url) { + target.image.thumbnailDecodeFailures = 0; + state.loadedThumbnailIndexes.add(target.index); + } + this.updateImageGridCard(state, target.index); + } + async createDownscaledThumbnailUrl(bytes, mime, targetWidth) { + const ownerWindow = this.container ? getWindow(this.container) : undefined; + if (!ownerWindow || typeof ownerWindow.createImageBitmap !== 'function') { + return undefined; + } + const blobBytes = new Uint8Array(bytes.byteLength); + blobBytes.set(bytes); + const sourceBlob = new Blob([blobBytes.buffer], { type: mime || 'application/octet-stream' }); + let bitmap; + try { + // resizeWidth keeps aspect ratio; the decode + resize run off the main thread. + bitmap = await ownerWindow.createImageBitmap(sourceBlob, { resizeWidth: Math.max(64, Math.round(targetWidth)), resizeQuality: 'medium' }); + const canvas = ownerWindow.document.createElement('canvas'); + canvas.width = bitmap.width; + canvas.height = bitmap.height; + const context = canvas.getContext('2d'); + if (!context) { + return undefined; + } + context.drawImage(bitmap, 0, 0); + return await new Promise(resolve => { + canvas.toBlob(blob => resolve(blob ? URL.createObjectURL(blob) : undefined), 'image/jpeg', 0.82); + }); + } + catch { + return undefined; + } + finally { + bitmap?.close(); + } + } + createBlobUrlFromBytes(bytes, mime) { + try { + const blobBytes = new Uint8Array(bytes.byteLength); + blobBytes.set(bytes); + const blob = new Blob([blobBytes.buffer], { type: mime || 'application/octet-stream' }); + return URL.createObjectURL(blob); + } + catch { + return undefined; + } + } + async applySshThumbnailViaProtocol(state, device, index) { + const image = state.images[index]; + if (!image?.item.path || !this.isImageGridSurfaceActive(state)) { + return; + } + const requestWidth = this.getImageGridThumbnailRequestWidth(state); + const result = await this.createImageGridResourceUrl(device, image.item.path, createTurboFileImageGridResourceOptions(requestWidth, image.item.rawSize, image.item.rawModified)); + if (!this.isImageGridSurfaceActive(state) || !this.isImageGridIndexNearViewport(state, index)) { + if (result.url) { + this.releaseImageBlobUrl(result.url); + } + return; + } + this.releaseImageBlobUrl(image.thumbnailUrl); + image.thumbnailContent = ''; + image.thumbnailUrl = result.ok ? result.url : undefined; + image.thumbnailMime = result.mime || getMimeType(image.item.name); + image.thumbnailError = result.ok ? undefined : result.message; + if (result.ok) { + image.thumbnailDecodeFailures = 0; + state.loadedThumbnailIndexes.add(index); + } + this.updateImageGridCard(state, index); + } + processImageGridThumbnailQueue() { + if (!this.getVisibleImageGridStates().length) { + return; + } + const activeState = this.imageGridState && this.isImageGridSurfaceActive(this.imageGridState) + ? this.imageGridState + : this.getVisibleImageGridStates()[0]; + const limit = activeState ? this.getImageGridThumbnailParallelLimit(activeState) : IMAGE_GRID_REMOTE_PARALLEL_LIMIT; + const available = Math.max(0, limit - this.imageGridThumbnailLoadingCount); + for (let started = 0; started < available && this.imageGridThumbnailQueue.length;) { + const request = this.imageGridThumbnailQueue.shift(); + if (!request) { + return; + } + this.imageGridThumbnailQueued.delete(request.key); + if (!this.shouldLoadImageGridThumbnail(request.state, request.index)) { + continue; + } + const image = request.state.images[request.index]; + if (!image) { + continue; + } + image.thumbnailLoading = true; + this.imageGridThumbnailLoadingCount++; + started++; + void this.runImageGridThumbnailRequest(request); + } + } + async runImageGridThumbnailRequest(request) { + try { + await this.loadImageGridThumbnail(request.state, request.index); + } + finally { + this.imageGridThumbnailLoadingCount = Math.max(0, this.imageGridThumbnailLoadingCount - 1); + this.processImageGridThumbnailQueue(); + } + } + shouldLoadImageGridThumbnail(state, index) { + if (!this.isImageGridSurfaceActive(state) || !this.isImageGridIndexNearViewport(state, index)) { + return false; + } + const image = state.images[index]; + return !!image && !image.thumbnailUrl && !image.thumbnailContent && !image.thumbnailLoading && !image.thumbnailError; + } + async readImageGridImage(device, path, options) { + const cacheKey = this.getImageReadCacheKey(device, path, options); + const cached = this.imageReadCache.get(cacheKey); + if (cached) { + this.imageReadCache.delete(cacheKey); + this.imageReadCache.set(cacheKey, cached); + return { + ok: true, + message: 'Image loaded from cache.', + content: cached.content, + mime: cached.mime, + size: cached.size, + width: cached.width, + height: cached.height, + originalWidth: cached.originalWidth, + originalHeight: cached.originalHeight + }; + } + const result = await this.sshService.readImage(await this.toTransferEndpoint(device, path), options); + if (result.ok && result.content && result.size <= IMAGE_READ_CACHE_MAX_BYTES) { + this.imageReadCache.set(cacheKey, { + content: result.content, + mime: result.mime, + size: result.size, + width: result.width, + height: result.height, + originalWidth: result.originalWidth, + originalHeight: result.originalHeight + }); + this.trimImageReadCache(); + } + return result; + } + isRemoteSshImageDevice(device) { + return device.id !== LOCAL_WINDOWS_DEVICE_ID; + } + async createImageGridResourceUrl(device, path, options) { + return this.sshService.createImageResource(await this.toTransferEndpoint(device, path), options); + } + getImageReadCacheKey(device, path, options) { + return JSON.stringify({ + device: device.id, + host: device.host, + port: device.port || 0, + path, + width: options.width || 0, + height: options.height || 0, + quality: options.quality || 0, + format: options.format || '', + forceOriginal: !!options.forceOriginal, + sourceSize: options.sourceSize || 0, + sourceModified: options.sourceModified || 0 + }); + } + trimImageReadCache() { + while (this.imageReadCache.size > IMAGE_READ_CACHE_MAX_ENTRIES) { + const firstKey = this.imageReadCache.keys().next().value; + if (!firstKey) { + return; + } + this.imageReadCache.delete(firstKey); + } + } + createImageObjectUrl(result) { + if (!result.ok) { + return undefined; + } + try { + let bytes; + if (result.buffer) { + bytes = result.buffer.buffer; + } + else if (result.content) { + bytes = decodeBase64(result.content).buffer; + } + else { + return undefined; + } + const blobBytes = new Uint8Array(bytes.byteLength); + blobBytes.set(bytes); + const blob = new Blob([blobBytes.buffer], { type: result.mime || 'application/octet-stream' }); + return URL.createObjectURL(blob); + } + catch { + return undefined; + } + } + async loadImageGridThumbnail(state, index) { + const image = state.images[index]; + const device = this.getPaneDevice(state.side); + if (!image?.item.path || !device || !this.isImageGridSurfaceActive(state) || !this.isImageGridIndexNearViewport(state, index)) { + return; + } + try { + const requestWidth = this.getImageGridThumbnailRequestWidth(state); + if (this.isRemoteSshImageDevice(device)) { + const result = await this.createImageGridResourceUrl(device, image.item.path, createTurboFileImageGridResourceOptions(requestWidth, image.item.rawSize, image.item.rawModified)); + if (!this.isImageGridSurfaceActive(state) || !this.isImageGridIndexNearViewport(state, index)) { + if (result.url) { + this.releaseImageBlobUrl(result.url); + } + return; + } + this.releaseImageBlobUrl(image.thumbnailUrl); + image.thumbnailContent = ''; + image.thumbnailUrl = result.ok ? result.url : undefined; + image.thumbnailMime = result.mime || getMimeType(image.item.name); + image.thumbnailError = result.ok ? undefined : result.message; + if (result.ok) { + image.thumbnailDecodeFailures = 0; + state.loadedThumbnailIndexes.add(index); + } + this.updateImageGridCard(state, index); + return; + } + const shouldReadOriginalFirst = this.shouldUseOriginalImageGridCardSource(image.width, image.height); + let result = shouldReadOriginalFirst + ? await this.readImageGridImage(device, image.item.path, { + encoding: 'base64', + forceOriginal: true, + sourceSize: image.item.rawSize, + sourceModified: image.item.rawModified + }) + : await this.readImageGridImage(device, image.item.path, { + encoding: 'base64', + width: requestWidth, + quality: IMAGE_GRID_THUMB_QUALITY, + format: 'webp', + interpolation: 'lanczos', + sourceSize: image.item.rawSize, + sourceModified: image.item.rawModified + }); + if (!this.isImageGridSurfaceActive(state) || !this.isImageGridIndexNearViewport(state, index)) { + return; + } + const resolvedWidth = result.width ?? image.width; + const resolvedHeight = result.height ?? image.height; + if (!shouldReadOriginalFirst && result.ok && this.shouldUseOriginalImageGridCardSource(resolvedWidth, resolvedHeight)) { + const originalResult = await this.readImageGridImage(device, image.item.path, { + encoding: 'base64', + forceOriginal: true, + sourceSize: image.item.rawSize, + sourceModified: image.item.rawModified + }); + if (!this.isImageGridSurfaceActive(state) || !this.isImageGridIndexNearViewport(state, index)) { + return; + } + if (originalResult.ok) { + result = originalResult; + } + } + this.releaseImageBlobUrl(image.thumbnailUrl); + const thumbnailUrl = result.ok ? this.createImageObjectUrl(result) : undefined; + image.thumbnailContent = thumbnailUrl ? '' : result.ok ? result.content : ''; + image.thumbnailUrl = thumbnailUrl; + image.thumbnailMime = result.mime || getMimeType(image.item.name); + image.thumbnailError = result.ok ? undefined : result.message; + const previousArea = getImageGridPixelArea(image); + image.width = result.width ?? resolvedWidth; + image.height = result.height ?? resolvedHeight; + image.originalWidth = result.originalWidth ?? image.originalWidth; + image.originalHeight = result.originalHeight ?? image.originalHeight; + if (result.ok) { + image.thumbnailDecodeFailures = 0; + state.loadedThumbnailIndexes.add(index); + } + if (this.resortImageGridAfterSizeUpdate(state, image, previousArea)) { + return; + } + this.updateImageGridCard(state, index); + } + catch (error) { + if (this.isImageGridSurfaceActive(state)) { + image.thumbnailError = error instanceof Error ? error.message : String(error); + this.updateImageGridCard(state, index); + } + } + finally { + if (this.isImageGridSurfaceActive(state)) { + image.thumbnailLoading = false; + } + } + } + async ensureImageGridPreviewLoaded(state, index, requestSeq, silent = false) { + const image = state.images[index]; + const device = this.getPaneDevice(state.side); + if (!image?.item.path || !device || !this.isImageGridSurfaceActive(state)) { + return; + } + if (this.getImagePreviewSource(image)) { + await this.ensureImagePreviewElementLoaded(state, index); + this.finishImageGridPreviewLoad(state, index, requestSeq, silent); + return; + } + if (image.previewLoading) { + return; + } + if (!this.shouldKeepImageGridPreview(state, index)) { + return; + } + const request = this.getImagePreviewRequestSize(); + image.previewLoading = true; + if (!silent && state.selectedIndex === index && (!requestSeq || requestSeq === this.imageGridPreviewRequestSeq)) { + this.updateImagePreviewOverlay(state); + } + try { + // Read the original via the same cat-based batch path the grid uses: it works where SFTP is + // flaky/restricted (some NAS) and needs no remote image tool, which is exactly where the old + // resource/protocol preview path was failing. Skip this fast path for very large originals — + // reading + decoding a huge file raw would spike memory; those use the resource path, which can + // downscale on the remote. + const previewRawSize = Math.max(0, image.item.rawSize); + const canReadOriginalRaw = previewRawSize === 0 || previewRawSize <= IMAGE_PREVIEW_RAW_MAX_BYTES; + if (this.isRemoteSshImageDevice(device) && canReadOriginalRaw) { + const endpoint = await this.toTransferEndpoint(device, image.item.path); + let entries = []; + try { + entries = await this.sshService.readImageBatch(endpoint, [image.item.path], 0); + } + catch { + entries = []; + } + if (!this.isImageGridSurfaceActive(state) || !this.shouldKeepImageGridPreview(state, index)) { + return; + } + const entry = entries.find(candidate => candidate.path === image.item.path) ?? entries[0]; + if (entry?.ok && entry.content) { + const mime = entry.mime ?? getMimeType(image.item.name); + this.releaseImageBlobUrl(image.previewUrl); + image.previewContent = ''; + image.previewUrl = this.createBlobUrlFromBytes(decodeBase64(entry.content).buffer, mime); + image.previewMime = mime; + image.previewError = image.previewUrl ? undefined : '加载失败'; + if (image.previewUrl) { + state.loadedPreviewIndexes.add(index); + await this.ensureImagePreviewElementLoaded(state, index); + } + return; + } + // Oversized/unavailable (or a Windows remote): fall back to the resource path, which can + // downscale a very large original remotely. + const result = await this.createImageGridResourceUrl(device, image.item.path, createTurboFileImagePreviewResourceOptions(this.isWindowsDevice(device), request, image.item.rawSize, image.item.rawModified)); + if (!this.isImageGridSurfaceActive(state) || !this.shouldKeepImageGridPreview(state, index)) { + if (result.url) { + this.releaseImageBlobUrl(result.url); + } + return; + } + this.releaseImageBlobUrl(image.previewUrl); + image.previewContent = ''; + image.previewUrl = result.ok ? result.url : undefined; + image.previewMime = result.mime || getMimeType(image.item.name); + image.previewError = result.ok ? undefined : result.message; + if (result.ok) { + state.loadedPreviewIndexes.add(index); + await this.ensureImagePreviewElementLoaded(state, index); + } + return; + } + const result = await this.readImageGridImage(device, image.item.path, createTurboFileImagePreviewReadOptions(image.item.rawSize, image.item.rawModified)); + if (!this.isImageGridSurfaceActive(state) || !this.shouldKeepImageGridPreview(state, index)) { + return; + } + this.releaseImageBlobUrl(image.previewUrl); + const previewUrl = result.ok ? this.createImageObjectUrl(result) : undefined; + image.previewContent = previewUrl ? '' : result.ok ? result.content : ''; + image.previewUrl = previewUrl; + image.previewMime = result.mime || getMimeType(image.item.name); + image.previewError = result.ok ? undefined : result.message; + const previousArea = getImageGridPixelArea(image); + image.width = result.width ?? image.width; + image.height = result.height ?? image.height; + image.originalWidth = result.originalWidth ?? image.originalWidth; + image.originalHeight = result.originalHeight ?? image.originalHeight; + this.resortImageGridAfterSizeUpdate(state, image, previousArea); + if (result.ok) { + state.loadedPreviewIndexes.add(index); + await this.ensureImagePreviewElementLoaded(state, index); + } + } + catch (error) { + if (this.isImageGridSurfaceActive(state)) { + image.previewError = error instanceof Error ? error.message : String(error); + } + } + finally { + if (this.isImageGridSurfaceActive(state)) { + image.previewLoading = false; + this.finishImageGridPreviewLoad(state, index, requestSeq, silent); + } + } + } + finishImageGridPreviewLoad(state, index, requestSeq, silent = false) { + if (!this.isImageGridSurfaceActive(state)) { + return; + } + if (state.previewPendingIndex === index && state.previewPendingRequestSeq !== undefined && this.commitImageGridPreviewIndexIfReady(state, index, state.previewPendingRequestSeq)) { + this.scheduleImageGridPreviewNeighborsLoaded(state, index); + return; + } + if (!silent && state.selectedIndex === index && (!requestSeq || requestSeq === this.imageGridPreviewRequestSeq)) { + this.updateImagePreviewOverlay(state); + this.scheduleImageGridPreviewNeighborsLoaded(state, index); + } + } + bindImageGridObserver() { + const state = this.imageGridState; + if (!state || !this.isImageGridSurfaceActive(state) || !this.container) { + this.disposeImageGridObserver(); + return; + } + if (state.displayMode === 'pane') { + this.disposeImageGridObserver(); + for (const paneState of this.getVisibleImageGridStates()) { + this.scheduleImageGridVisibleLoad(paneState); + } + return; + } + const root = this.getImageGridRootElement(state); + const body = root?.querySelector('.turbofile-image-page-body'); + if (!body || !('IntersectionObserver' in getWindow(this.container))) { + return; + } + this.disposeImageGridObserver(); + this.imageGridObserver = new IntersectionObserver(entries => { + if (!this.isImageGridSurfaceActive(state)) { + return; + } + let sawVisible = false; + for (const entry of entries) { + if (!entry.isIntersecting) { + continue; + } + const value = Number(entry.target.dataset.imageIndex); + if (Number.isFinite(value)) { + sawVisible = true; + this.imageGridObserver?.unobserve(entry.target); + } + } + if (sawVisible) { + // Load only the settled visible page, not every card that flickers past during a fast scroll. + this.scheduleImageGridVisibleLoad(state); + } + }, { + root: body, + rootMargin: '320px', + threshold: 0.01 + }); + let hasCards = false; + for (const card of Array.from(root?.querySelectorAll('.turbofile-image-page-card[data-image-index]') ?? [])) { + this.imageGridObserver.observe(card); + hasCards = true; + } + if (hasCards) { + this.scheduleImageGridVisibleLoad(state); + } + } + disposeImageGridObserver() { + if (!this.imageGridObserver) { + return; + } + this.imageGridObserver.disconnect(); + this.imageGridObserver = undefined; + } + updateImageGridCard(state, index) { + if (!this.container || !this.isImageGridSurfaceActive(state)) { + return; + } + const root = this.getImageGridRootElement(state); + const card = root?.querySelector(`.turbofile-image-page-card[data-image-index="${index}"]`); + const image = state?.images[index]; + if (!card || !image) { + return; + } + const preview = card.querySelector('.turbofile-image-page-card-preview'); + if (!preview) { + return; + } + clearNode(preview); + const cardSize = this.getImageGridCardSize(state, image); + this.applyImageGridCardPreviewSize(preview, cardSize); + const thumbnailSrc = image.thumbnailUrl || (image.thumbnailContent ? `data:${image.thumbnailMime};base64,${image.thumbnailContent}` : ''); + if (thumbnailSrc) { + const thumb = document.createElement('img'); + thumb.loading = 'eager'; + thumb.decoding = 'async'; + this.applyImageGridCardImageSize(thumb, cardSize); + thumb.addEventListener('error', () => this.handleImageGridThumbnailError(state, index)); + thumb.src = thumbnailSrc; + thumb.alt = image.item.name; + preview.appendChild(thumb); + } + else { + const placeholder = $('.turbofile-image-page-card-placeholder'); + placeholder.appendChild(renderIcon(Codicon.fileMedia)); + preview.appendChild(placeholder); + } + this.appendImageGridCardChrome(preview, image, index); + } + getImageGridDefaultViewportHeight() { + const height = this.container?.clientHeight ?? 0; + return Math.max(480, height - 44); + } + getImageGridEffectiveColumns(state) { + const base = Math.max(1, state.columns); + return getTurboFileImageGridEffectiveColumns(base, this.getImageGridBodyWidth(state), this.getImageGridMaxCardWidth(state), IMAGE_GRID_CARD_GAP); + } + getImageGridVirtualRange(state) { + const total = state.images.length; + if (!total) { + return { startIndex: 0, endIndex: 0, topSpacerHeight: 0, bottomSpacerHeight: 0 }; + } + const columns = this.getImageGridEffectiveColumns(state); + const rowHeight = this.getImageGridRowHeight(state); + const rowStep = rowHeight + IMAGE_GRID_CARD_GAP; + const totalRows = Math.ceil(total / columns); + const viewportHeight = Math.max(1, state.viewportHeight || this.getImageGridDefaultViewportHeight()); + const startRow = Math.max(0, Math.floor(state.scrollTop / rowStep) - IMAGE_GRID_OVERSCAN_ROWS); + const endRow = Math.min(totalRows, Math.ceil((state.scrollTop + viewportHeight) / rowStep) + IMAGE_GRID_OVERSCAN_ROWS); + const startIndex = Math.min(total, startRow * columns); + const endIndex = Math.min(total, Math.max(startIndex, endRow * columns)); + const topSpacerHeight = startRow * rowStep; + const bottomSpacerHeight = Math.max(0, (totalRows - endRow) * rowStep); + return { startIndex, endIndex, topSpacerHeight, bottomSpacerHeight }; + } + getImageGridRowHeight(state) { + return this.getImageGridLayoutMetrics(state).rowHeight; + } + getImageGridMaxCardWidth(state) { + return this.getImageGridLayoutMetrics(state).maxCardWidth; + } + getImageGridBaseCardWidth(state) { + const columns = Math.max(1, state.columns); + const width = this.getImageGridBodyWidth(state); + const available = width - (IMAGE_GRID_CARD_GAP * Math.max(0, columns - 1)); + return Math.max(IMAGE_GRID_THUMB_MIN, available / columns); + } + getImageGridBasePreviewHeight(state) { + return this.getImageGridBaseCardWidth(state) * 150 / 220; + } + getImageGridLayoutMetrics(state) { + const baseCardWidth = this.getImageGridBaseCardWidth(state); + if (state.layoutMetrics?.state === state && state.layoutMetrics.scale === state.thumbnailScale && state.layoutMetrics.baseCardWidth === baseCardWidth) { + return state.layoutMetrics; + } + let cardWidth = baseCardWidth; + let imageWidth = IMAGE_GRID_THUMB_MIN; + let rowHeight = this.getImageGridBasePreviewHeight(state) + IMAGE_GRID_CARD_VERTICAL_CHROME; + for (const image of state.images) { + const size = this.getImageGridCardSize(state, image); + cardWidth = Math.max(cardWidth, size.cardWidth); + imageWidth = Math.max(imageWidth, size.imageWidth); + rowHeight = Math.max(rowHeight, size.rowHeight); + } + state.layoutMetrics = { + state, + scale: state.thumbnailScale, + baseCardWidth, + maxCardWidth: Math.max(1, Math.round(cardWidth)), + maxImageWidth: Math.max(1, Math.round(imageWidth)), + rowHeight: Math.max(150, Math.round(rowHeight)) + }; + return state.layoutMetrics; + } + clearImageGridLayoutMetrics(state) { + if (state) { + state.layoutMetrics = undefined; + } + } + getImageGridBodyWidth(state) { + if (state.bodyWidthCache !== undefined) { + return state.bodyWidthCache; + } + const body = this.getImageGridBodyElement(state); + const measured = body?.clientWidth ?? 0; + const width = measured > 0 + ? Math.max(320, measured - 28) + : Math.max(320, (this.container?.clientWidth ?? 1440) - 28); + state.bodyWidthCache = width; + return width; + } + invalidateImageGridBodyWidthCache(state) { + if (state) { + state.bodyWidthCache = undefined; + } + } + invalidateAllImageGridLayoutCaches() { + for (const state of this.getVisibleImageGridStates()) { + this.invalidateImageGridBodyWidthCache(state); + this.clearImageGridLayoutMetrics(state); + state.renderedRange = undefined; + } + } + getImageGridThumbnailRequestWidth(state) { + const ownerWindow = this.container?.ownerDocument.defaultView; + const dpr = Math.min(IMAGE_DPR_CAP, ownerWindow?.devicePixelRatio || 1); + const previewBoxWidth = Math.max(1, this.getImageGridMaxCardWidth(state) - IMAGE_GRID_CARD_INLINE_CHROME); + const scaled = Math.round(previewBoxWidth * dpr); + return clampNumber(quantizeNumber(scaled, IMAGE_GRID_THUMB_STEP), IMAGE_GRID_THUMB_MIN, IMAGE_GRID_THUMB_MAX); + } + shouldUseOriginalImageGridCardSource(width, height) { + return !!width && !!height && width <= IMAGE_GRID_ORIGINAL_CARD_DIMENSION_THRESHOLD && height <= IMAGE_GRID_ORIGINAL_CARD_DIMENSION_THRESHOLD; + } + getImagePreviewRequestSize() { + const ownerWindow = this.container?.ownerDocument.defaultView; + const dpr = Math.min(IMAGE_DPR_CAP, ownerWindow?.devicePixelRatio || 1); + const viewportWidth = ownerWindow?.innerWidth || this.container?.clientWidth || 1440; + const viewportHeight = ownerWindow?.innerHeight || this.container?.clientHeight || 900; + const widthRaw = clampNumber(Math.round(viewportWidth * 0.98 * dpr), IMAGE_PREVIEW_MIN_DIM, IMAGE_PREVIEW_MAX_DIM); + const heightRaw = clampNumber(Math.round(viewportHeight * 0.95 * dpr), IMAGE_PREVIEW_MIN_DIM, IMAGE_PREVIEW_MAX_DIM); + return { + width: clampNumber(quantizeNumber(widthRaw, IMAGE_PREVIEW_DIM_STEP), IMAGE_PREVIEW_MIN_DIM, IMAGE_PREVIEW_MAX_DIM), + height: clampNumber(quantizeNumber(heightRaw, IMAGE_PREVIEW_DIM_STEP), IMAGE_PREVIEW_MIN_DIM, IMAGE_PREVIEW_MAX_DIM), + quality: IMAGE_PREVIEW_QUALITY + }; + } + getImageGridThumbnailParallelLimit(state) { + const device = this.getPaneDevice(state.side); + if (!device || device.id === LOCAL_WINDOWS_DEVICE_ID) { + return IMAGE_GRID_LOCAL_PARALLEL_LIMIT; + } + return device.platform === 'windows' ? IMAGE_GRID_REMOTE_WINDOWS_PARALLEL_LIMIT : IMAGE_GRID_REMOTE_PARALLEL_LIMIT; + } + handleImageGridScroll(body, state) { + this.setActiveImageGridState(state); + state.scrollTop = body.scrollTop; + state.viewportHeight = body.clientHeight || state.viewportHeight; + this.trimImageGridThumbnailQueue(state); + if (state.scrollRaf || !this.container) { + return; + } + state.scrollRaf = getWindow(this.container).requestAnimationFrame(() => { + state.scrollRaf = 0; + if (!this.isImageGridSurfaceActive(state)) { + return; + } + const range = this.getImageGridVirtualRange(state); + const rendered = state.renderedRange; + if (rendered && rendered.startIndex === range.startIndex && rendered.endIndex === range.endIndex && rendered.columns === this.getImageGridEffectiveColumns(state) && rendered.total === state.images.length) { + return; + } + this.clearImageGridScrollTimer(); + this.updateImageGridVirtualSlice(state, range); + }); + } + handleImageGridWheel(event, body, state) { + this.setActiveImageGridState(state); + if (!event.ctrlKey || event.altKey || event.metaKey) { + return; + } + event.preventDefault(); + const deltaModeScale = event.deltaMode === WheelEvent.DOM_DELTA_LINE + ? 16 + : event.deltaMode === WheelEvent.DOM_DELTA_PAGE + ? Math.max(body.clientHeight, IMAGE_GRID_CTRL_WHEEL_STEP_THRESHOLD) + : 1; + this.imageGridCtrlWheelDeltaRemainder += event.deltaY * deltaModeScale; + while (this.imageGridCtrlWheelDeltaRemainder <= -IMAGE_GRID_CTRL_WHEEL_STEP_THRESHOLD) { + this.imageGridCtrlWheelDeltaRemainder += IMAGE_GRID_CTRL_WHEEL_STEP_THRESHOLD; + this.setImageGridColumns(state, state.columns - 1); + } + while (this.imageGridCtrlWheelDeltaRemainder >= IMAGE_GRID_CTRL_WHEEL_STEP_THRESHOLD) { + this.imageGridCtrlWheelDeltaRemainder -= IMAGE_GRID_CTRL_WHEEL_STEP_THRESHOLD; + this.setImageGridColumns(state, state.columns + 1); + } + } + normalizeImageGridColumns(columns, state = this.imageGridState) { + const rounded = Math.round(columns); + return clampNumber(rounded, IMAGE_GRID_COLUMN_MIN, this.getImageGridMaxColumns(state)); + } + getImageGridMaxColumns(state) { + const width = state ? this.getImageGridBodyWidth(state) : Math.max(320, (this.container?.clientWidth ?? 1440) - 28); + const fitByWidth = Math.floor((width + IMAGE_GRID_CARD_GAP) / (IMAGE_GRID_THUMB_MIN + IMAGE_GRID_CARD_GAP)); + return Math.max(IMAGE_GRID_COLUMN_MIN, Math.min(IMAGE_GRID_COLUMN_MAX, fitByWidth)); + } + updateImageGridVirtualSlice(state, range) { + if (!this.container || !this.isImageGridSurfaceActive(state)) { + return; + } + const root = this.getImageGridRootElement(state); + const grid = root?.querySelector('.turbofile-image-page-grid'); + if (!grid) { + return; + } + // The body-width cache means renderImageGridSlice no longer forces a layout mid-refill, so the + // browser should not collapse scrollHeight and clamp scrollTop. As a safety net, capture the + // live scroll position before the rebuild and only restore it if the rebuild actually drove + // scrollTop backwards (a collapse). Using the live pre-render value — not the stale state — + // ensures we never yank against ongoing inertial scrolling, which is what caused the jitter. + const body = root?.querySelector('.turbofile-image-page-body'); + const scrollTopBeforeRender = body ? body.scrollTop : state.scrollTop; + this.renderImageGridSlice(grid, state, range); + if (body && body.scrollTop < scrollTopBeforeRender) { + body.scrollTop = scrollTopBeforeRender; + state.scrollTop = scrollTopBeforeRender; + } + } + clearImageGridScrollTimer() { + for (const timer of this.imageGridVisibleLoadTimers.values()) { + clearTimeout(timer); + } + this.imageGridVisibleLoadTimers.clear(); + if (!this.imageGridScrollRenderTimer) { + return; + } + clearTimeout(this.imageGridScrollRenderTimer); + this.imageGridScrollRenderTimer = undefined; + } + clearImageGridVisibleLoadTimer(state) { + const timer = this.imageGridVisibleLoadTimers.get(state); + if (!timer) { + return; + } + clearTimeout(timer); + this.imageGridVisibleLoadTimers.delete(state); + } + clearImageGridScrollRaf() { + const seen = new Set(); + for (const state of [this.imageGridState, ...Object.values(this.paneImageGridStates)]) { + if (state && !seen.has(state)) { + seen.add(state); + this.clearImageGridStateScrollRaf(state); + } + } + } + clearImageGridStateScrollRaf(state) { + if (!state.scrollRaf || !this.container) { + state.scrollRaf = 0; + return; + } + getWindow(this.container).cancelAnimationFrame(state.scrollRaf); + state.scrollRaf = 0; + } + clearImageGridPreviewPrefetchTimer() { + if (!this.imageGridPreviewPrefetchTimer) { + return; + } + clearTimeout(this.imageGridPreviewPrefetchTimer); + this.imageGridPreviewPrefetchTimer = undefined; + } + getImageGridVisibleIndexes(state) { + const rendered = state.renderedRange; + const range = rendered && rendered.columns === this.getImageGridEffectiveColumns(state) && rendered.total === state.images.length + ? { startIndex: rendered.startIndex, endIndex: rendered.endIndex } + : this.getImageGridVirtualRange(state); + const indexes = []; + for (let index = range.startIndex; index < range.endIndex; index++) { + indexes.push(index); + } + return indexes; + } + trimImageGridThumbnailQueue(state) { + if (!this.imageGridThumbnailQueue.length) { + return; + } + const visible = new Set(this.getImageGridVisibleIndexes(state)); + for (let index = this.imageGridThumbnailQueue.length - 1; index >= 0; index--) { + const request = this.imageGridThumbnailQueue[index]; + if (request.state !== state || !visible.has(request.index)) { + this.imageGridThumbnailQueued.delete(request.key); + this.imageGridThumbnailQueue.splice(index, 1); + } + } + } + clearImageGridThumbnailQueue() { + this.imageGridThumbnailQueue.length = 0; + this.imageGridThumbnailQueued.clear(); + } + clearImageGridLoadedImageIndexes(state) { + state.loadedThumbnailIndexes.clear(); + state.loadedPreviewIndexes.clear(); + } + isImageGridIndexNearViewport(state, index) { + const rendered = state.renderedRange; + if (rendered && rendered.columns === this.getImageGridEffectiveColumns(state) && rendered.total === state.images.length) { + return index >= rendered.startIndex && index < rendered.endIndex; + } + const range = this.getImageGridVirtualRange(state); + return index >= range.startIndex && index < range.endIndex; + } + releaseImageGridImagesOutsideRange(state, range) { + const keepRowWidth = this.getImageGridEffectiveColumns(state); + const keepThumbnailStart = Math.max(0, range.startIndex - keepRowWidth); + const keepThumbnailEnd = Math.min(state.images.length, range.endIndex + keepRowWidth); + for (const index of Array.from(state.loadedThumbnailIndexes)) { + const image = state.images[index]; + if (!image) { + state.loadedThumbnailIndexes.delete(index); + continue; + } + const keepThumbnail = index >= keepThumbnailStart && index < keepThumbnailEnd; + if (!keepThumbnail && (image.thumbnailUrl || image.thumbnailContent || image.thumbnailError)) { + this.releaseImageBlobUrl(image.thumbnailUrl); + image.thumbnailUrl = undefined; + image.thumbnailContent = ''; + image.thumbnailError = undefined; + image.thumbnailDecodeFailures = undefined; + state.loadedThumbnailIndexes.delete(index); + } + } + for (const index of Array.from(state.loadedPreviewIndexes)) { + const image = state.images[index]; + if (!image) { + state.loadedPreviewIndexes.delete(index); + continue; + } + const keepPreview = this.shouldKeepImageGridPreview(state, index); + if (!keepPreview && (image.previewUrl || image.previewContent || image.previewError)) { + this.releaseImageBlobUrl(image.previewUrl); + image.previewUrl = undefined; + image.previewContent = ''; + image.previewError = undefined; + image.previewDecodeFailures = undefined; + image.previewImage = undefined; + image.previewImageKey = undefined; + image.previewImageLoading = undefined; + image.previewImageLoadingKey = undefined; + state.loadedPreviewIndexes.delete(index); + } + } + } + releaseImageGridImage(image) { + this.releaseImageBlobUrl(image.thumbnailUrl); + this.releaseImageBlobUrl(image.previewUrl); + image.thumbnailUrl = undefined; + image.thumbnailContent = ''; + image.thumbnailLoading = false; + image.thumbnailError = undefined; + image.thumbnailDecodeFailures = undefined; + image.previewUrl = undefined; + image.previewContent = ''; + image.previewLoading = false; + image.previewError = undefined; + image.previewDecodeFailures = undefined; + image.previewImage = undefined; + image.previewImageKey = undefined; + image.previewImageLoading = undefined; + image.previewImageLoadingKey = undefined; + } + releaseImageGridStateImages(state) { + if (!state) { + return; + } + for (const image of state.images) { + this.releaseImageGridImage(image); + } + } + getImageGridCircularDistance(state, index, selectedIndex) { + if (!state.images.length) { + return Number.MAX_SAFE_INTEGER; + } + const distance = Math.abs(index - selectedIndex); + return Math.min(distance, state.images.length - distance); + } + shouldKeepImageGridPreview(state, index) { + if (!this.isImageGridSurfaceActive(state) || !state.previewVisible) { + return false; + } + if (this.getImageGridCircularDistance(state, index, state.selectedIndex) <= IMAGE_GRID_PREVIEW_NEIGHBOR_COUNT) { + return true; + } + return state.previewPendingIndex !== undefined && this.getImageGridCircularDistance(state, index, state.previewPendingIndex) <= IMAGE_GRID_PREVIEW_NEIGHBOR_COUNT; + } + getImageGridQueueKey(state, index) { + return `${state.side}|${state.path}|${index}`; + } + handleImageGridThumbnailError(state, index) { + if (!this.isImageGridSurfaceActive(state)) { + return; + } + const image = state.images[index]; + if (!image) { + return; + } + image.thumbnailDecodeFailures = (image.thumbnailDecodeFailures ?? 0) + 1; + this.releaseImageBlobUrl(image.thumbnailUrl); + image.thumbnailUrl = undefined; + image.thumbnailContent = ''; + image.thumbnailLoading = false; + state.loadedThumbnailIndexes.delete(index); + if (image.thumbnailDecodeFailures <= IMAGE_GRID_DECODE_RETRY_LIMIT && this.isImageGridIndexNearViewport(state, index)) { + image.thumbnailError = undefined; + this.updateImageGridCard(state, index); + this.queueImageGridThumbnailLoads(state, [index], true); + return; + } + image.thumbnailError = '缩略图解码失败'; + this.updateImageGridCard(state, index); + } + getImageGridSelectedIndexes(state) { + for (const index of Array.from(state.selectedIndexes)) { + if (index < 0 || index >= state.images.length) { + state.selectedIndexes.delete(index); + } + } + return Array.from(state.selectedIndexes).sort((a, b) => a - b); + } + getImageGridSelectedImages(state) { + return this.getImageGridSelectedIndexes(state) + .map(index => ({ index, image: state.images[index] })) + .filter((entry) => !!entry.image); + } + getImageGridDeleteTargets(state) { + const selected = this.getImageGridSelectedImages(state); + if (selected.length) { + return selected; + } + const image = state.images[state.selectedIndex]; + return image ? [{ index: state.selectedIndex, image }] : []; + } + updateImageGridSelection(state = this.imageGridState) { + if (!this.container || !state) { + return; + } + const root = this.getImageGridRootElement(state); + if (!root) { + return; + } + const selectedIndexes = new Set(this.getImageGridSelectedIndexes(state)); + for (const card of Array.from(root.querySelectorAll('.turbofile-image-page-card[data-image-index]'))) { + const index = Number(card.dataset.imageIndex); + const selected = Number.isFinite(index) && selectedIndexes.has(index); + card.classList.toggle('is-selected', selected); + card.classList.toggle('is-focused', Number.isFinite(index) && state.selectedIndex === index); + card.setAttribute('aria-selected', selected ? 'true' : 'false'); + } + const deleteButton = root.querySelector('.turbofile-image-page-action.is-delete'); + if (deleteButton) { + const deleteTargetCount = this.getImageGridDeleteTargets(state).length; + deleteButton.disabled = deleteTargetCount === 0; + deleteButton.title = deleteTargetCount ? '\u5220\u9664\u9009\u4e2d\u56fe\u7247' : '\u8bf7\u5148\u9009\u62e9\u8981\u5220\u9664\u7684\u56fe\u7247'; + } + const meta = this.container.querySelector('.turbofile-task-meta'); + if (meta && !this.tasks[0]) { + meta.textContent = this.statusMessage; + } + this.refreshTaskbar(); + } + updateImageGridSelectionStatus(state) { + const selectedCount = this.getImageGridSelectedIndexes(state).length; + if (selectedCount > 1) { + this.statusMessage = `\u5df2\u9009\u62e9 ${selectedCount} \u5f20\u56fe\u7247\uff0c\u53ef\u6309 Delete \u5220\u9664\u3002`; + return; + } + if (selectedCount === 0) { + this.statusMessage = '\u672a\u9009\u62e9\u56fe\u7247\u3002'; + return; + } + const image = state.images[state.selectedIndex]; + this.statusMessage = image ? `\u5df2\u9009\u62e9\u56fe\u7247\uff1a${image.item.name}` : '\u8bf7\u5148\u9009\u62e9\u8981\u5220\u9664\u7684\u56fe\u7247\u3002'; + } + releaseImageBlobUrl(url) { + if (!url) { + return; + } + if (url.startsWith('blob:')) { + URL.revokeObjectURL(url); + return; + } + if (url.startsWith('turbofile-image:')) { + void this.sshService.releaseImageResource(url); + } + } + ensureRemoteFileSystemProvider() { + if (!this.remoteFileSystemProvider) { + const existingProvider = this.fileService.getProvider(TURBOFILE_REMOTE_DOCUMENT_SCHEME); + if (existingProvider instanceof TurboFileRemoteFileSystemProvider) { + this.remoteFileSystemProvider = existingProvider; + } + else { + this.remoteFileSystemProvider = this._register(new TurboFileRemoteFileSystemProvider(this.sshService)); + this._register(this.fileService.registerProvider(TURBOFILE_REMOTE_DOCUMENT_SCHEME, this.remoteFileSystemProvider)); + } + } + this.ensureRemoteDocumentLabelFormatter(); + return this.remoteFileSystemProvider; + } + /** + * Without a label formatter, VS Code derives the editor/tab label for our custom remote scheme + * from the full URI (authority included), so a document opened from an SSH device shows up as + * ": name" instead of just the file name. Registering a formatter that uses only the path + * makes the tab show exactly the file name, e.g. "readme.md". + */ + ensureRemoteDocumentLabelFormatter() { + if (this.remoteDocumentLabelFormatterRegistered) { + return; + } + this.remoteDocumentLabelFormatterRegistered = true; + this._register(this.labelService.registerFormatter({ + scheme: TURBOFILE_REMOTE_DOCUMENT_SCHEME, + formatting: { + label: '${path}', + separator: '/', + tildify: false, + normalizeDriveLetter: false + } + })); + } + /** + * Keeps transfer pages on the left and opened documents on the right: returns the tab index + * immediately after the last TurboFile transfer page in the current group, so a document never + * gets inserted between two transfer pages. Returns undefined to fall back to the default slot. + */ + getDocumentEditorTargetIndex() { + const editors = this.group?.editors; + if (!editors || editors.length === 0) { + return undefined; + } + let index = 0; + for (let i = 0; i < editors.length; i++) { + if (editors[i] instanceof TurboFileEditorInput) { + index = i + 1; + } + } + return index; + } + async openNativeDocument(side, item, device) { + if (!item.path) { + return; + } + if (this.imageGridState) { + this.closeImageGridPage(); + } + const reportOpenError = (error) => { + const message = error instanceof Error ? error.message : String(error); + this.statusMessage = `打开失败:${message}`; + this.render(); + }; + try { + const resource = device.id === LOCAL_WINDOWS_DEVICE_ID + ? URI.file(item.path) + : this.ensureRemoteFileSystemProvider().registerFile(device, this.getSshConnectionOptions(device), item); + const editorInput = { + resource, + forceFile: true + }; + const fileEditor = await this.textEditorService.resolveTextEditor(editorInput); + const pane = await this.editorService.openEditor(fileEditor, { + pinned: true, + revealIfOpened: true, + override: DEFAULT_EDITOR_ASSOCIATION.id, + index: this.getDocumentEditorTargetIndex() + }); + // The `index` above is only a hint and is unreliable when the document opens into a group + // other than the transfer page's (e.g. focus was in an already-open document). Enforce the + // invariant afterwards: a document tab must never sit to the left of a transfer page. + this.ensureDocumentTabRightOfTransfer(pane?.group, pane?.input); + } + catch (error) { + reportOpenError(error); + } + } + /** + * Guarantees the "transfer pages on the left, documents on the right" tab order: if the opened + * document ended up to the left of any TurboFile transfer page in its group, move it to the end. + */ + ensureDocumentTabRightOfTransfer(group, input) { + if (!group || !input) { + return; + } + const editors = group.editors; + const currentIndex = editors.indexOf(input); + if (currentIndex < 0) { + return; + } + const hasTransferToRight = editors.slice(currentIndex + 1).some(editor => editor instanceof TurboFileEditorInput); + if (hasTransferToRight) { + group.moveEditor(input, group, { index: editors.length - 1 }); + } + } + async compressSelected(side) { + const item = this.getSelectedItem(side); + const device = this.getPaneDevice(side); + if (!item?.path || !device || item.parent) { + this.statusMessage = '请先选择要压缩的文件或目录。'; + this.render(); + return; + } + if (!await this.showDialogConfirm('压缩确认', `确定压缩 ${item.name} 吗?`, '压缩进度会显示在底部任务区域。')) { + return; + } + const task = this.beginTask('compress', `压缩 ${item.name}`, device.name); + try { + const result = await this.sshService.compressPath(await this.toTransferEndpoint(device, item.path)); + this.updateTask(task, `正在刷新目录:${device.name}`, 90); + if (result.ok) { + this.finishTask(task, 'success', `压缩完成:${item.name}`); + } + else { + this.finishTask(task, 'error', `压缩失败:${result.message}`, { error: result.message }); + } + } + catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.finishTask(task, 'error', `压缩失败:${message}`, { error: message }); + } + finally { + await this.refreshPane(side, { force: true }); + } + } + async extractSelected(side) { + const item = this.getSelectedItem(side); + const device = this.getPaneDevice(side); + if (!item?.path || !device || item.parent || item.directory) { + this.statusMessage = '请先选择要解压的归档文件。'; + this.render(); + return; + } + if (!isArchiveFile(item.name)) { + this.statusMessage = '当前文件不是支持的归档格式。'; + this.render(); + return; + } + if (!await this.showDialogConfirm('解压确认', `确定解压 ${item.name} 吗?`, '解压进度会显示在底部任务区域。')) { + return; + } + const task = this.beginTask('extract', `解压 ${item.name}`, device.name); + try { + const result = await this.sshService.extractPath(await this.toTransferEndpoint(device, item.path)); + this.updateTask(task, `正在刷新目录:${device.name}`, 90); + if (result.ok) { + this.finishTask(task, 'success', `解压完成:${item.name}`); + } + else { + this.finishTask(task, 'error', `解压失败:${result.message}`, { error: result.message }); + } + } + catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.finishTask(task, 'error', `解压失败:${message}`, { error: message }); + } + finally { + await this.refreshPane(side, { force: true }); + } + } + async copyPanePath(side) { + const device = this.getPaneDevice(side); + const windowsPath = !!device && this.isWindowsDevice(device); + const path = this.formatPanePathForDisplay(this.paneStates[side].path, windowsPath); + await this.clipboardService.writeText(path); + this.statusMessage = `已复制当前路径:${path}`; + this.render(); + } + async copySelectedPath(side) { + const item = this.getSelectedItem(side); + const device = this.getPaneDevice(side); + if (!item?.path || !device || item.parent) { + this.statusMessage = '请先选择要复制路径的文件或目录。'; + this.render(); + return; + } + const path = this.formatPanePathForDisplay(item.path, this.isWindowsDevice(device)); + await this.clipboardService.writeText(path); + this.statusMessage = `已复制路径:${path}`; + await this.refreshPane(side, { force: true }); + this.render(); + } + async calculateSelectedSize(side) { + const item = this.getSelectedItem(side); + const device = this.getPaneDevice(side); + if (!item?.path || !device || item.parent) { + this.statusMessage = '请先选择要计算大小的文件或目录。'; + this.render(); + return; + } + const displayPath = this.formatPanePathForDisplay(item.path, this.isWindowsDevice(device)); + const task = this.beginTask('size', `计算大小 ${item.name}`, device.name); + try { + const result = await this.sshService.calculatePathSize(await this.toTransferEndpoint(device, item.path)); + this.updateTask(task, `正在刷新目录:${device.name}`, 90); + if (result.ok) { + const sizeText = result.humanSize ?? formatSize(result.size); + this.finishTask(task, 'success', `${item.name} 大小:${sizeText}`); + this.showDialogInfo('计算大小完成', `${item.name} 大小:${sizeText}`, `设备:${device.name}\n路径:${displayPath}`); + } + else { + this.finishTask(task, 'error', `计算大小失败:${result.message}`, { error: result.message }); + this.showDialogInfo('计算大小失败', result.message, `设备:${device.name}\n路径:${displayPath}`); + } + } + catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.finishTask(task, 'error', `计算大小失败:${message}`, { error: message }); + this.showDialogInfo('计算大小失败', message, `设备:${device.name}\n路径:${displayPath}`); + } + finally { + await this.refreshPane(side, { force: true }); + } + } + async searchInPane(side) { + const keyword = (await this.quickInputService.input({ + title: '递归查找', + placeHolder: '输入文件名、目录名或关键字', + prompt: `搜索${this.getSideLabel(side)}当前已加载目录` + }))?.trim(); + if (!keyword) { + return; + } + const normalizedKeyword = keyword.toLowerCase(); + const state = this.paneStates[side]; + const matches = state.items.filter(item => !item.parent && item.path && item.name.toLowerCase().includes(normalizedKeyword)); + if (!matches.length) { + this.statusMessage = `当前目录未找到:${keyword}`; + this.render(); + return; + } + let target = matches[0]; + if (matches.length > 1) { + const device = this.getPaneDevice(side); + const windowsPath = !!device && this.isWindowsDevice(device); + const picks = matches.slice(0, 80).map(item => ({ + label: item.name, + description: item.directory ? '文件夹' : item.size, + detail: this.formatPanePathForDisplay(item.path ?? '', windowsPath), + item + })); + const picked = await this.quickInputService.pick(picks, { + title: '搜索结果', + placeHolder: `找到 ${matches.length} 项,选择要定位的文件或文件夹` + }); + if (!picked) { + return; + } + target = picked.item; + } + this.revealPaneItem(side, target, `已定位:${target.name}`); + } + revealPaneItem(side, item, message) { + if (!item.path) { + return; + } + const state = this.paneStates[side]; + const index = this.getVisibleFileItems(state).findIndex(candidate => candidate.path === item.path); + if (index >= 0) { + state.scrollTop = Math.max(0, (index - 4) * FILE_LIST_ROW_HEIGHT); + this.fileListRenderedRange[side] = undefined; + } + this.setSingleSelection(side, item); + this.statusMessage = message; + this.render(); + } + async openTerminal(side) { + const device = this.getPaneDevice(side); + const state = this.paneStates[side]; + const pageInstanceId = this.currentInput?.instanceId; + if (!device) { + return; + } + if (!pageInstanceId) { + return; + } + this.taskbarHiddenForTerminal = true; + this.terminalRevealInProgress = true; + this.render(); + const title = this.getTerminalTitle(side, device); + try { + turboFileTerminalPanelHiddenByPageId.delete(pageInstanceId); + if (device.id === LOCAL_WINDOWS_DEVICE_ID) { + const defaultProfile = this.terminalProfileService.getDefaultProfile(); + const instance = await this.createAndRevealTerminal({ + cwd: state.path, + location: TerminalLocation.Panel, + config: defaultProfile ? { + ...defaultProfile, + profileName: title, + overrideName: true, + icon: defaultProfile.icon ?? Codicon.terminal + } : undefined + }); + this.bindTerminalToPage(pageInstanceId, instance); + await this.revealBoundTerminalsForPage(pageInstanceId, instance, true); + this.statusMessage = `已打开本地终端:${title}`; + this.render(); + this.refocusTerminalAfterRender(instance); + return; + } + const connection = this.getSshConnectionOptions(device); + const instance = await this.createAndRevealTerminal({ + location: TerminalLocation.Panel, + config: { + name: title, + icon: Codicon.terminal, + isFeatureTerminal: false, + customPtyImplementation: (id, cols, rows) => new TurboFileSshTerminalProcess(id, cols, rows, this.sshService, connection, state.path) + } + }); + this.bindTerminalToPage(pageInstanceId, instance); + await this.revealBoundTerminalsForPage(pageInstanceId, instance, true); + this.statusMessage = `已打开内置 SSH 终端:${title}`; + this.render(); + this.refocusTerminalAfterRender(instance); + } + catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.restoreTaskbarAfterTerminal(); + this.statusMessage = device.id === LOCAL_WINDOWS_DEVICE_ID + ? `打开本地终端失败:${message}` + : `打开 SSH 终端失败:${message}`; + this.render(); + } + finally { + this.terminalRevealInProgress = false; + } + } + restoreTaskbarAfterTerminal() { + if (!this.taskbarHiddenForTerminal) { + return; + } + this.taskbarHiddenForTerminal = false; + if (this.activePage === 'transfer') { + this.render(); + } + } + isCurrentTransferEditorActive() { + return this.isEditorVisible + && this.activePage === 'transfer' + && !!this.currentInput + && this.editorService.activeEditor === this.currentInput; + } + rememberTerminalPanelHiddenForCurrentPage() { + if (!this.isCurrentTransferEditorActive()) { + return; + } + // Only treat a panel hide as "the user hid this page's terminal" while the transfer page is + // actually showing. A hide triggered while on another in-app page (images, themes, tasks, …) + // must not mark the terminal hidden, otherwise it would appear to vanish when returning to the + // transfer page even though it is still open and bound. + const pageInstanceId = this.currentInput?.instanceId; + if (!pageInstanceId || this.getBoundTerminalsForPage(pageInstanceId).length === 0) { + return; + } + turboFileTerminalPanelHiddenByPageId.add(pageInstanceId); + } + beginTerminalPanelVisibilityChange() { + if (turboFileTerminalPanelVisibilityResetHandle !== undefined) { + getWindow(this.container).clearTimeout(turboFileTerminalPanelVisibilityResetHandle); + turboFileTerminalPanelVisibilityResetHandle = undefined; + } + turboFileTerminalPanelVisibilityChangeDepth++; + turboFileTerminalPanelVisibilityInProgress = true; + } + endTerminalPanelVisibilityChange() { + turboFileTerminalPanelVisibilityChangeDepth = Math.max(0, turboFileTerminalPanelVisibilityChangeDepth - 1); + if (turboFileTerminalPanelVisibilityChangeDepth > 0) { + return; + } + turboFileTerminalPanelVisibilityResetHandle = getWindow(this.container).setTimeout(() => { + turboFileTerminalPanelVisibilityResetHandle = undefined; + if (turboFileTerminalPanelVisibilityChangeDepth === 0) { + turboFileTerminalPanelVisibilityInProgress = false; + } + }, 100); + } + bindTerminalToPage(pageInstanceId, instance) { + this.clearTerminalBindingForInstance(instance.instanceId); + turboFileTerminalPageByTerminalId.set(instance.instanceId, pageInstanceId); + turboFileLastActiveTerminalByPageId.set(pageInstanceId, instance.instanceId); + } + getBoundTerminalsForPage(pageInstanceId) { + const instances = []; + for (const [terminalInstanceId, boundPageInstanceId] of [...turboFileTerminalPageByTerminalId]) { + if (boundPageInstanceId !== pageInstanceId) { + continue; + } + const instance = this.terminalService.getInstanceFromId(terminalInstanceId); + if (!instance || instance.isDisposed) { + this.clearTerminalBindingForInstance(terminalInstanceId); + continue; + } + instances.push(instance); + } + return instances; + } + getBoundTerminalsForOtherPages(pageInstanceId) { + const instances = []; + for (const [terminalInstanceId, boundPageInstanceId] of [...turboFileTerminalPageByTerminalId]) { + if (boundPageInstanceId === pageInstanceId) { + continue; + } + const instance = this.terminalService.getInstanceFromId(terminalInstanceId); + if (!instance || instance.isDisposed) { + this.clearTerminalBindingForInstance(terminalInstanceId); + continue; + } + instances.push(instance); + } + return instances; + } + async revealBoundTerminalsForPage(pageInstanceId, activeInstance, focus) { + // Guard the ENTIRE reveal, including the background/foreground swaps below. Moving another + // page's terminals to the background can momentarily hide the panel; that must never be + // mistaken for the user manually hiding it, otherwise this page would be remembered as + // "panel hidden" and its terminals would appear to vanish on the next page switch. + this.beginTerminalPanelVisibilityChange(); + try { + for (const candidate of this.getBoundTerminalsForOtherPages(pageInstanceId)) { + this.terminalService.moveToBackground(candidate); + } + const pageTerminals = this.getBoundTerminalsForPage(pageInstanceId); + if (pageTerminals.length === 0) { + this.restoreTaskbarAfterTerminal(); + this.terminalGroupService.hidePanel(); + return; + } + for (const instance of pageTerminals) { + await this.terminalService.showBackgroundTerminal(instance, true); + } + const lastActiveTerminalId = turboFileLastActiveTerminalByPageId.get(pageInstanceId); + const lastActiveInstance = lastActiveTerminalId !== undefined + ? pageTerminals.find(instance => instance.instanceId === lastActiveTerminalId) + : undefined; + activeInstance = activeInstance ?? lastActiveInstance ?? pageTerminals[0]; + turboFileLastActiveTerminalByPageId.set(pageInstanceId, activeInstance.instanceId); + if (!focus && turboFileTerminalPanelHiddenByPageId.has(pageInstanceId)) { + this.restoreTaskbarAfterTerminal(); + this.terminalGroupService.hidePanel(); + return; + } + this.taskbarHiddenForTerminal = true; + this.terminalService.setActiveInstance(activeInstance); + if (focus) { + await this.terminalService.focusInstance(activeInstance); + return; + } + await this.terminalGroupService.showPanel(false); + } + finally { + this.endTerminalPanelVisibilityChange(); + } + } + async syncActiveTerminalForCurrentPage() { + if (!this.isEditorVisible) { + return; + } + const pageInstanceId = this.currentInput?.instanceId; + if (!pageInstanceId) { + return; + } + await this.revealBoundTerminalsForPage(pageInstanceId, undefined, false); + } + clearTerminalBindingForInstance(terminalInstanceId) { + const pageInstanceId = turboFileTerminalPageByTerminalId.get(terminalInstanceId); + if (!pageInstanceId) { + return; + } + turboFileTerminalPageByTerminalId.delete(terminalInstanceId); + if (turboFileLastActiveTerminalByPageId.get(pageInstanceId) === terminalInstanceId) { + const nextTerminal = this.getBoundTerminalsForPage(pageInstanceId)[0]; + if (nextTerminal) { + turboFileLastActiveTerminalByPageId.set(pageInstanceId, nextTerminal.instanceId); + } + else { + turboFileLastActiveTerminalByPageId.delete(pageInstanceId); + turboFileTerminalPanelHiddenByPageId.delete(pageInstanceId); + } + } + } + disposeBoundTerminalsForPage(pageInstanceId) { + const pageTerminals = this.getBoundTerminalsForPage(pageInstanceId); + turboFileLastActiveTerminalByPageId.delete(pageInstanceId); + turboFileTerminalPanelHiddenByPageId.delete(pageInstanceId); + for (const instance of pageTerminals) { + this.clearTerminalBindingForInstance(instance.instanceId); + void this.terminalService.safeDisposeTerminal(instance); + } + if (this.currentInput?.instanceId === pageInstanceId) { + this.restoreTaskbarAfterTerminal(); + this.terminalGroupService.hidePanel(); + } + } + /** + * Re-focus the terminal after the editor re-render that follows opening/revealing it. xterm only + * accepts keyboard input once its textarea is focused while attached and laid out; focusing before + * the render commits (or having the render move focus) is the usual cause of "the terminal opened + * but I can't type until I click it". A post-render animation frame guarantees focus lands last. + */ + refocusTerminalAfterRender(instance) { + const targetWindow = getWindow(this.container); + targetWindow.requestAnimationFrame(() => { + if (instance.isDisposed || !this.isEditorVisible || this.activePage !== 'transfer') { + return; + } + this.terminalService.setActiveInstance(instance); + void this.terminalService.focusInstance(instance); + }); + } + async createAndRevealTerminal(options) { + const instance = await this.terminalService.createTerminal(options); + this.terminalService.setActiveInstance(instance); + await this.terminalService.focusInstance(instance); + return instance; + } + getTerminalTitle(side, device) { + return `极速传 ${this.getSideLabel(side)} ${this.getDeviceDisplayLabel(device)}`; + } + async syncSelectedDirectories() { + const leftDevice = this.getPaneDevice('left'); + const rightDevice = this.getPaneDevice('right'); + if (!leftDevice || !rightDevice) { + this.statusMessage = '请先在左右两侧选择可用设备。'; + this.render(); + return; + } + const task = this.beginTask('sync', '同步当前目录', `${leftDevice.name} <-> ${rightDevice.name}`); + try { + this.updateTask(task, '正在扫描同步差异...', 8); + const plan = await this.buildSyncPlan(task); + if (!plan.length) { + this.finishTask(task, 'success', '两侧同步范围已经一致。'); + return; + } + this.updateTask(task, '等待确认同步范围...', 12); + const confirmedPlan = await this.showSyncPlanPreview(plan); + if (!confirmedPlan) { + this.finishTask(task, 'success', '已取消同步。'); + return; + } + if (!confirmedPlan.length) { + this.finishTask(task, 'success', '未选择同步项目。'); + return; + } + let completed = 0; + let failed = 0; + let totalBytes = 0; + const strategies = new Set(); + const failures = []; + for (const [index, step] of confirmedPlan.entries()) { + const sourceDevice = this.getPaneDevice(step.sourceSide); + const targetDevice = this.getPaneDevice(step.targetSide); + const progress = Math.min(88, 10 + Math.round((index / confirmedPlan.length) * 72)); + const displayPath = step.relativePath || step.item.name; + this.updateTask(task, `同步 ${displayPath}:${sourceDevice?.name ?? step.sourceSide} -> ${targetDevice?.name ?? step.targetSide}`, progress); + const result = await this.transferItem(step.sourceSide, step.targetSide, step.item, { refreshTarget: false, task, targetPath: step.targetPath }); + if (result.ok) { + completed++; + totalBytes += result.bytes; + if (result.strategy) { + strategies.add(result.strategy); + } + } + else if (result.canceled) { + this.finishTask(task, 'canceled', `已取消同步:${displayPath}`, { bytes: totalBytes }); + return; + } + else { + failed++; + failures.push(`${displayPath}: ${result.message}`); + } + } + this.updateTask(task, '正在刷新两侧目录', 92); + await Promise.all([this.refreshPane('left'), this.refreshPane('right')]); + const strategy = [...strategies].join(', ') || undefined; + if (failed > 0) { + this.finishTask(task, 'error', `同步完成 ${completed} 项,失败 ${failed} 项。`, { bytes: totalBytes, strategy, error: failures[0] }); + return; + } + this.finishTask(task, 'success', `同步完成:${completed} 项。`, { bytes: totalBytes, strategy }); + } + catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.finishTask(task, 'error', `同步失败:${message}`, { error: message }); + } + } + showSyncPlanPreview(plan) { + return new Promise(resolve => { + this.syncPreviewState = { + plan, + selectedIndexes: new Set(plan.map((_, index) => index)), + reversedIndexes: new Set(), + resolve + }; + this.render(); + }); + } + async buildSyncPlan(task) { + const recursivePlan = await this.tryBuildRecursiveSyncPlan(task); + return recursivePlan ?? this.buildFlatSyncPlan(); + } + async tryBuildRecursiveSyncPlan(task) { + const roots = this.getRecursiveSyncRoots(); + const leftDevice = this.getPaneDevice('left'); + const rightDevice = this.getPaneDevice('right'); + if (!roots || !leftDevice || !rightDevice || !roots.left.path || !roots.right.path) { + return undefined; + } + if (task) { + this.updateTask(task, `正在递归扫描:${roots.left.name} / ${roots.right.name}`, 8); + } + const [leftEntries, rightEntries] = await Promise.all([ + this.collectSyncTree('left', roots.left.path, '', { count: 0 }, task), + this.collectSyncTree('right', roots.right.path, '', { count: 0 }, task) + ]); + const caseInsensitive = this.isWindowsDevice(leftDevice) || this.isWindowsDevice(rightDevice); + return this.buildSyncPlanFromEntries(leftEntries, rightEntries, roots.left.path, roots.right.path, caseInsensitive, true); + } + getRecursiveSyncRoots() { + const leftSelected = this.getSelectedDirectory('left'); + const rightSelected = this.getSelectedDirectory('right'); + if (leftSelected && rightSelected) { + return { left: leftSelected, right: rightSelected }; + } + if (leftSelected) { + const rightMatch = this.findPaneDirectoryByName('right', leftSelected.name); + return rightMatch ? { left: leftSelected, right: rightMatch } : undefined; + } + if (rightSelected) { + const leftMatch = this.findPaneDirectoryByName('left', rightSelected.name); + return leftMatch ? { left: leftMatch, right: rightSelected } : undefined; + } + return undefined; + } + getSelectedDirectory(side) { + const selected = this.getSelectedItem(side); + return selected?.path && selected.directory && !selected.parent && selected.type !== 'unknown' ? selected : undefined; + } + findPaneDirectoryByName(side, name) { + const device = this.getPaneDevice(side); + const caseInsensitive = !!device && this.isWindowsDevice(device); + const key = caseInsensitive ? name.toLocaleLowerCase() : name; + return this.paneStates[side].items.find(item => { + if (!item.path || !item.directory || item.parent || item.type === 'unknown') { + return false; + } + return (caseInsensitive ? item.name.toLocaleLowerCase() : item.name) === key; + }); + } + async collectSyncTree(side, rootPath, relativeRoot, counter, task) { + counter.count++; + if (counter.count > SYNC_RECURSIVE_SCAN_LIMIT) { + throw new Error(`递归同步扫描超过 ${SYNC_RECURSIVE_SCAN_LIMIT} 项,请进入更小的目录后再同步。`); + } + if (task && counter.count % 25 === 0) { + this.updateTask(task, `正在扫描${this.getSideLabel(side)}:${relativeRoot || '.'}`, 8); + } + const items = await this.listPaneDirectory(side, rootPath); + const entries = []; + for (const item of items) { + if (!item.path || item.parent || item.type === 'unknown') { + continue; + } + const relativePath = relativeRoot ? `${relativeRoot}/${item.name}` : item.name; + counter.count++; + if (counter.count > SYNC_RECURSIVE_SCAN_LIMIT) { + throw new Error(`递归同步扫描超过 ${SYNC_RECURSIVE_SCAN_LIMIT} 项,请进入更小的目录后再同步。`); + } + if (item.directory) { + const childEntries = await this.collectSyncTree(side, item.path, relativePath, counter, task); + if (childEntries.length) { + entries.push(...childEntries); + } + else { + entries.push({ relativePath, item }); + } + continue; + } + entries.push({ relativePath, item }); + } + return entries; + } + async listPaneDirectory(side, path) { + const device = this.getPaneDevice(side); + if (!device) { + return []; + } + const listing = await this.loadPaneDirectory(device, path, false); + return listing.items.filter(item => !!item.path && !item.parent && item.type !== 'unknown'); + } + buildFlatSyncPlan() { + const leftDevice = this.getPaneDevice('left'); + const rightDevice = this.getPaneDevice('right'); + const caseInsensitive = !!leftDevice && !!rightDevice && (this.isWindowsDevice(leftDevice) || this.isWindowsDevice(rightDevice)); + const leftEntries = this.getSyncItems('left').map(item => ({ relativePath: item.name, item })); + const rightEntries = this.getSyncItems('right').map(item => ({ relativePath: item.name, item })); + return this.buildSyncPlanFromEntries(leftEntries, rightEntries, this.paneStates.left.path, this.paneStates.right.path, caseInsensitive, false); + } + buildSyncPlanFromEntries(leftEntries, rightEntries, leftRootPath, rightRootPath, caseInsensitive, recursive) { + const leftByPath = new Map(leftEntries.map(entry => [this.getSyncPathKey(entry.relativePath, caseInsensitive), entry])); + const rightByPath = new Map(rightEntries.map(entry => [this.getSyncPathKey(entry.relativePath, caseInsensitive), entry])); + const plan = []; + for (const leftEntry of leftEntries) { + const rightEntry = rightByPath.get(this.getSyncPathKey(leftEntry.relativePath, caseInsensitive)); + if (!rightEntry) { + plan.push({ + sourceSide: 'left', + targetSide: 'right', + item: leftEntry.item, + reason: 'missing', + relativePath: leftEntry.relativePath, + targetPath: recursive ? this.joinRelativePanePath('right', rightRootPath, leftEntry.relativePath) : undefined, + recursive + }); + continue; + } + const decision = this.getSyncDecision(leftEntry.item, rightEntry.item); + if (decision === 'left') { + const relativePath = leftEntry.relativePath; + plan.push({ + sourceSide: 'left', + targetSide: 'right', + item: leftEntry.item, + reason: leftEntry.item.rawSize !== rightEntry.item.rawSize ? 'size' : 'newer', + relativePath, + targetPath: recursive ? this.joinRelativePanePath('right', rightRootPath, relativePath) : undefined, + recursive, + reverse: { + sourceSide: 'right', + targetSide: 'left', + item: rightEntry.item, + targetPath: recursive ? this.joinRelativePanePath('left', leftRootPath, relativePath) : undefined + } + }); + } + } + for (const rightEntry of rightEntries) { + const leftEntry = leftByPath.get(this.getSyncPathKey(rightEntry.relativePath, caseInsensitive)); + if (!leftEntry) { + plan.push({ + sourceSide: 'right', + targetSide: 'left', + item: rightEntry.item, + reason: 'missing', + relativePath: rightEntry.relativePath, + targetPath: recursive ? this.joinRelativePanePath('left', leftRootPath, rightEntry.relativePath) : undefined, + recursive + }); + continue; + } + const decision = this.getSyncDecision(leftEntry.item, rightEntry.item); + if (decision === 'right') { + const relativePath = rightEntry.relativePath; + plan.push({ + sourceSide: 'right', + targetSide: 'left', + item: rightEntry.item, + reason: rightEntry.item.rawSize !== leftEntry.item.rawSize ? 'size' : 'newer', + relativePath, + targetPath: recursive ? this.joinRelativePanePath('left', leftRootPath, relativePath) : undefined, + recursive, + reverse: { + sourceSide: 'left', + targetSide: 'right', + item: leftEntry.item, + targetPath: recursive ? this.joinRelativePanePath('right', rightRootPath, relativePath) : undefined + } + }); + } + } + return plan; + } + getSyncItems(side) { + const selected = this.getSelectedItem(side); + if (selected?.path && !selected.parent && selected.type !== 'unknown') { + return [selected]; + } + return this.paneStates[side].items.filter(item => !!item.path && !item.parent && !item.directory && item.type !== 'unknown'); + } + getSyncPathKey(relativePath, caseInsensitive) { + const normalized = relativePath.replace(/\\/g, '/'); + return caseInsensitive ? normalized.toLocaleLowerCase() : normalized; + } + joinRelativePanePath(side, rootPath, relativePath) { + const device = this.getPaneDevice(side); + const windowsPath = !!device && this.isWindowsDevice(device); + return relativePath + .split('/') + .filter(segment => !!segment) + .reduce((currentPath, segment) => joinPanePath(currentPath, segment, windowsPath), rootPath); + } + getSyncDirectionLabel(step) { + return step.sourceSide === 'left' ? '左 -> 右' : '右 -> 左'; + } + getSyncReasonLabel(reason) { + if (reason === 'missing') { + return '新增'; + } + if (reason === 'newer') { + return '较新'; + } + return '大小不同'; + } + getSyncDecision(leftItem, rightItem) { + if (leftItem.directory || rightItem.directory) { + return; + } + const skewMs = 1000; + if (leftItem.rawModified > rightItem.rawModified + skewMs) { + return 'left'; + } + if (rightItem.rawModified > leftItem.rawModified + skewMs) { + return 'right'; + } + if (leftItem.rawSize !== rightItem.rawSize) { + return leftItem.rawModified >= rightItem.rawModified ? 'left' : 'right'; + } + return undefined; + } + async toTransferEndpoint(device, path) { + if (device.id === LOCAL_WINDOWS_DEVICE_ID) { + return { + kind: 'local', + path, + label: device.name + }; + } + return { + kind: 'ssh', + path, + connection: await this.getSshConnectionOptions(device), + label: device.name, + supportsRsync: device.capabilities.rsync, + supportsTar: device.capabilities.tar ?? device.capabilities.archive, + preferredProtocol: device.preferredTransferProtocol + }; + } + async getSshConnectionOptions(device) { + return { + host: device.host, + port: device.port, + username: device.username, + password: await this.deviceStore?.getPassword(device.id), + privateKey: await this.deviceStore?.getPrivateKey(device.id), + privateKeyPath: device.privateKeyPath, + passphrase: await this.deviceStore?.getPassphrase(device.id) + }; + } + async getSshConnectionOptionsFromDraft(draft, editingDevice) { + return { + host: draft.host, + port: draft.port || 22, + username: draft.username, + password: draft.password ?? (editingDevice ? await this.deviceStore?.getPassword(editingDevice.id) : undefined), + privateKey: draft.privateKey ?? (editingDevice ? await this.deviceStore?.getPrivateKey(editingDevice.id) : undefined), + privateKeyPath: draft.privateKeyPath, + passphrase: draft.passphrase ?? (editingDevice ? await this.deviceStore?.getPassphrase(editingDevice.id) : undefined) + }; + } + clearPaneSelection(side) { + const state = this.paneStates[side]; + state.selectedPath = undefined; + state.selectedPaths.clear(); + state.selectionAnchorPath = undefined; + } + setSingleSelection(side, item) { + const state = this.paneStates[side]; + state.selectedPath = item.path; + state.selectedPaths.clear(); + if (item.path && !item.parent) { + state.selectedPaths.add(item.path); + } + state.selectionAnchorPath = item.path; + } + isItemSelected(side, item) { + if (!item.path) { + return false; + } + const state = this.paneStates[side]; + return state.selectedPaths.has(item.path) || state.selectedPath === item.path; + } + ensureItemInSelection(side, item) { + if (!item.path || item.parent) { + return; + } + if (!this.paneStates[side].selectedPaths.has(item.path)) { + this.selectItem(side, item, undefined, false); + this.render(); + } + } + getSelectedTransferItems(side) { + const state = this.paneStates[side]; + const selectedItems = state.items.filter(item => !!item.path && !item.parent && state.selectedPaths.has(item.path)); + if (selectedItems.length) { + return selectedItems; + } + const selected = this.getSelectedItem(side); + return selected?.path && !selected.parent ? [selected] : []; + } + getLastSelectedPath(side) { + const state = this.paneStates[side]; + for (let index = state.items.length - 1; index >= 0; index--) { + const item = state.items[index]; + if (item.path && state.selectedPaths.has(item.path)) { + return item.path; + } + } + return undefined; + } + getSelectedItem(side) { + const state = this.paneStates[side]; + if (state.selectedPath) { + const focused = state.items.find(item => item.path === state.selectedPath); + if (focused) { + return focused; + } + } + return state.items.find(item => !!item.path && state.selectedPaths.has(item.path)); + } + toFileItems(device, currentPath, entries) { + const parentPath = getPaneParentPath(currentPath, this.isWindowsDevice(device)); + const parent = parentPath ? [{ + name: '..', + path: parentPath, + size: '', + rawSize: 0, + modified: '双击返回上级', + rawModified: 0, + type: 'directory', + directory: true, + parent: true + }] : []; + const showDotNames = this.isWindowsDevice(device); + return [...parent, ...entries.filter(entry => showDotNames || !isHiddenFileName(entry.name)).map(entry => this.toFileItem(entry))]; + } + toFileItem(entry) { + return { + name: entry.name, + path: entry.path, + size: entry.type === 'directory' ? '' : formatSize(entry.size), + rawSize: entry.size, + modified: formatDate(entry.modified), + rawModified: entry.modified, + type: entry.type, + directory: entry.type === 'directory' + }; + } + getAllDevices() { + return [this.localWindowsDevice, ...this.devices]; + } + createLocalWindowsDevice() { + const defaultPath = this.getConfiguredLocalWindowsDefaultPath(); + return { + id: LOCAL_WINDOWS_DEVICE_ID, + name: 'Windows', + host: 'localhost', + port: 0, + username: 'local', + authType: 'password', + defaultPath, + platform: 'windows', + capabilities: { + ssh: false, + sftp: false, + scp: true, + rsync: true, + tar: true, + shell: true, + archive: true + }, + preferredTransferProtocol: this.getConfiguredLocalWindowsTransferProtocol() + }; + } + getConfiguredLocalWindowsDefaultPath() { + const savedPath = this.storageService.get(TURBOFILE_LOCAL_WINDOWS_DEFAULT_PATH_STORAGE_KEY, 0 /* StorageScope.PROFILE */); + return savedPath || this.getLocalWindowsDefaultPath(); + } + getLocalWindowsDeviceDefaultPath() { + return this.localWindowsDevice.defaultPath || this.getConfiguredLocalWindowsDefaultPath(); + } + getConfiguredLocalWindowsTransferProtocol() { + const protocol = this.storageService.get(TURBOFILE_LOCAL_WINDOWS_TRANSFER_PROTOCOL_STORAGE_KEY, 0 /* StorageScope.PROFILE */); + return protocol === 'auto-fast' || protocol === 'rsync' || protocol === 'sftp' || protocol === 'scp' ? protocol : defaultTurboFileTransferProtocol; + } + saveLocalWindowsDevice(draft) { + const defaultPath = draft.defaultPath?.trim() || this.getLocalWindowsDefaultPath(); + const preferredTransferProtocol = this.normalizeTransferProtocol(draft.preferredTransferProtocol ?? defaultTurboFileTransferProtocol); + this.storageService.store(TURBOFILE_LOCAL_WINDOWS_DEFAULT_PATH_STORAGE_KEY, defaultPath, 0 /* StorageScope.PROFILE */, 1 /* StorageTarget.MACHINE */); + this.storageService.store(TURBOFILE_LOCAL_WINDOWS_TRANSFER_PROTOCOL_STORAGE_KEY, preferredTransferProtocol, 0 /* StorageScope.PROFILE */, 1 /* StorageTarget.MACHINE */); + this.localWindowsDevice = { + ...this.localWindowsDevice, + name: draft.name.trim() || 'Windows', + defaultPath, + preferredTransferProtocol + }; + return this.localWindowsDevice; + } + syncLocalWindowsDefaultPath(previousDefaultPath, nextDefaultPath) { + if (!nextDefaultPath) { + return false; + } + let refreshed = false; + for (const key of Array.from(this.paneDirectoryCache.keys())) { + if (key.startsWith(`${LOCAL_WINDOWS_DEVICE_ID}|`)) { + this.paneDirectoryCache.delete(key); + } + } + const systemUserPath = this.getLocalWindowsDefaultPath(); + for (const side of ['left', 'right']) { + const state = this.paneStates[side]; + if (state.deviceId !== LOCAL_WINDOWS_DEVICE_ID) { + continue; + } + const shouldUseNewDefault = !state.path + || isSameWindowsPath(state.path, previousDefaultPath) + || isSameWindowsPath(state.path, systemUserPath); + if (!shouldUseNewDefault) { + continue; + } + state.path = nextDefaultPath; + state.items = []; + state.error = undefined; + this.resetPaneScroll(side); + this.clearPaneSelection(side); + this.rememberPaneDevicePath(side, this.localWindowsDevice, nextDefaultPath); + void this.refreshPane(side, { path: nextDefaultPath, force: true, silent: true }); + refreshed = true; + } + return refreshed; + } + getLocalWindowsDefaultPath() { + return this.environmentService.userHome.fsPath || LOCAL_WINDOWS_FALLBACK_PATH; + } + /** + * The real Desktop directory used by the "桌面" shortcut. Defaults to the profile Desktop and is + * upgraded to the OS known-folder location once {@link refreshWindowsDesktopPath} resolves it, so + * a Desktop relocated to another drive (e.g. D:\Desktop) is navigated to — and dropped into — + * correctly. + */ + getWindowsDesktopPath() { + return this.resolvedWindowsDesktopPath || this.getWindowsDesktopFallbackPath(); + } + getWindowsDesktopFallbackPath() { + return joinPanePath(this.getLocalWindowsDefaultPath(), 'Desktop', true); + } + async refreshWindowsDesktopPath() { + try { + const previousDesktopPath = this.getWindowsDesktopPath(); + const profileDesktopPath = this.getWindowsDesktopFallbackPath(); + const desktop = (await this.sshService.getLocalDesktopPath())?.trim(); + if (desktop && !isSameWindowsPath(desktop, this.resolvedWindowsDesktopPath ?? '')) { + this.resolvedWindowsDesktopPath = desktop; + this.retargetWindowsDesktopPanes(previousDesktopPath, profileDesktopPath, desktop); + if (this.activePage === 'transfer') { + this.render(); + } + } + } + catch { + // Keep the profile-based Desktop fallback if the real path cannot be resolved. + } + } + retargetWindowsDesktopPanes(previousDesktopPath, profileDesktopPath, nextDesktopPath) { + for (const side of ['left', 'right']) { + const state = this.paneStates[side]; + if (state.deviceId !== LOCAL_WINDOWS_DEVICE_ID || !shouldRetargetWindowsDesktopPane(state.path, previousDesktopPath, profileDesktopPath)) { + continue; + } + state.path = nextDesktopPath; + state.items = []; + state.error = undefined; + this.resetPaneScroll(side); + this.clearPaneSelection(side); + this.rememberPaneDevicePath(side, this.localWindowsDevice, nextDesktopPath); + void this.refreshPane(side, { path: nextDesktopPath, force: true, silent: true }); + } + } + async refreshWindowsDriveRoots() { + const letters = 'CDEFGHIJKLMNOPQRSTUVWXYZ'.split(''); + const probes = await Promise.all(letters.map(async (letter) => { + const root = `${letter}:\\`; + try { + await this.sshService.listLocalDirectory(root); + return root; + } + catch { + return undefined; + } + })); + this.availableWindowsDriveRoots = probes.filter((root) => !!root); + if (!this.availableWindowsDriveRoots.length) { + this.availableWindowsDriveRoots = [LOCAL_WINDOWS_FALLBACK_PATH]; + } + if (this.activePage === 'transfer') { + this.render(); + } + } + getWindowsLocationLabel(path) { + const desktopPath = this.getWindowsDesktopPath(); + if (isSameWindowsPath(path, desktopPath)) { + return '桌面'; + } + const driveRoot = getWindowsDriveRoot(path); + if (driveRoot) { + return this.getWindowsDriveLabel(driveRoot); + } + return 'Windows'; + } + getWindowsDriveLabel(driveRoot) { + const drive = driveRoot.substring(0, 1).toUpperCase(); + return `${drive}盘`; + } + getWindowsLocationIconClass(path) { + const desktopPath = this.getWindowsDesktopPath(); + if (isSameWindowsPath(path, desktopPath)) { + return 'turbofile-location-icon-windows'; + } + const driveRoot = getWindowsDriveRoot(path); + if (driveRoot) { + return 'turbofile-location-icon-windows'; + } + return 'turbofile-location-icon-drive'; + } + getWindowsLocationMenuIconClass(path) { + return `turbofile-windows-location-action turbofile-location-menu-icon ${this.getWindowsLocationIconClass(path)}`; + } + showWindowsLocationMenu(side, event) { + event.preventDefault(); + event.stopPropagation(); + this.setActiveFilePaneSide(side); + const anchor = event instanceof MouseEvent ? new StandardMouseEvent(getWindow(event), event) : event.currentTarget; + this.contextMenuService.showContextMenu({ + getAnchor: () => anchor, + getActions: () => this.getWindowsLocationActions(side), + getActionViewItem: (action, options) => action.id.startsWith(`turbofile.windowsLocation.${side}.`) + ? new ActionViewItem(null, action, { ...options, icon: true, label: true }) + : undefined, + skipTelemetry: true + }); + } + getWindowsLocationActions(side) { + const state = this.paneStates[side]; + const desktopPath = this.getWindowsDesktopPath(); + const actions = [ + this.menuAction(`turbofile.windowsLocation.${side}.desktop`, '桌面', this.getWindowsLocationMenuIconClass(desktopPath), () => void this.openWindowsDesktopPath(side), true, isSameWindowsPath(state.path, desktopPath)), + new Separator() + ]; + for (const root of this.getWindowsDriveRoots()) { + const drive = root.substring(0, 1).toUpperCase(); + actions.push(this.menuAction(`turbofile.windowsLocation.${side}.drive.${drive}`, this.getWindowsDriveLabel(root), this.getWindowsLocationMenuIconClass(root), () => this.openWindowsLocalPath(side, root, this.getWindowsDriveLabel(root)), true, isPathOnWindowsDrive(state.path, root))); + } + return actions; + } + getWindowsDriveRoots() { + const roots = new Set(); + for (const root of this.availableWindowsDriveRoots) { + roots.add(root); + } + for (const path of [this.getLocalWindowsDefaultPath(), LOCAL_WINDOWS_FALLBACK_PATH, this.paneStates.left.path, this.paneStates.right.path]) { + const root = getWindowsDriveRoot(path); + if (root) { + roots.add(root); + } + } + return Array.from(roots).sort((left, right) => left.localeCompare(right)); + } + async openWindowsDesktopPath(side) { + if (!this.resolvedWindowsDesktopPath) { + await this.refreshWindowsDesktopPath(); + } + this.openWindowsLocalPath(side, this.getWindowsDesktopPath(), '桌面'); + } + openWindowsLocalPath(side, path, label) { + const device = this.getPaneDevice(side); + if (device?.id !== LOCAL_WINDOWS_DEVICE_ID) { + return; + } + const state = this.paneStates[side]; + state.path = path; + state.items = []; + state.error = undefined; + this.resetPaneScroll(side); + this.clearPaneSelection(side); + this.statusMessage = `已在${this.getSideLabel(side)}打开 Windows 位置:${label}`; + this.render(); + void this.refreshPane(side); + } + getPaneDevice(side) { + const state = this.paneStates[side]; + return this.getAllDevices().find(device => device.id === state.deviceId); + } + getDeviceById(deviceId) { + return this.getAllDevices().find(device => device.id === deviceId); + } + renderFileBrowserIcon(item) { + if (item.parent) { + const icon = $('span.turbofile-file-icon-slot.turbofile-file-parent-icon'); + icon.setAttribute('aria-hidden', 'true'); + icon.appendChild(renderTurboFileIcon('arrow-up-circle', 'primary')); + return icon; + } + if (item.directory) { + const icon = $('span.turbofile-file-icon-slot.turbofile-file-folder-icon'); + icon.setAttribute('aria-hidden', 'true'); + icon.appendChild(renderTurboFileIcon('folder-fill', 'warning')); + return icon; + } + const slot = $('span.turbofile-file-icon-slot.turbofile-file-theme-icon-slot'); + slot.setAttribute('aria-hidden', 'true'); + const icon = $('span.turbofile-file-theme-icon.monaco-icon-label'); + const resource = item.path ? URI.file(item.path) : undefined; + icon.classList.add(...getIconClasses(this.modelService, this.languageService, resource, FileKind.FILE)); + slot.appendChild(icon); + return slot; + } + getDeviceDisplayLabel(device) { + if (device.id === LOCAL_WINDOWS_DEVICE_ID) { + return 'Windows'; + } + const host = device.host; + return `${device.name} (${host})`; + } + renderDeviceSelectIcon(_device) { + const icon = $('span.turbofile-device-select-icon'); + icon.setAttribute('aria-hidden', 'true'); + icon.appendChild(renderTurboFileDeviceMonitorIcon()); + return icon; + } + renderDevicePageIcon(device) { + if (!this.isWindowsDevice(device)) { + return renderIcon(Codicon.server); + } + const icon = $('span.turbofile-device-page-icon'); + icon.setAttribute('aria-hidden', 'true'); + icon.appendChild(renderTurboFileDeviceMonitorIcon()); + return icon; + } + formatPanePathForDisplay(path, windowsPath) { + if (!windowsPath) { + return this.normalizePanePath(path || '/', false); + } + return this.normalizePanePath(path || LOCAL_WINDOWS_FALLBACK_PATH, true); + } + normalizePaneInputPath(path, windowsPath) { + const trimmed = path.trim(); + if (!trimmed) { + return ''; + } + return this.normalizePanePath(trimmed, windowsPath); + } + normalizePanePath(path, windowsPath) { + if (windowsPath) { + return path.replace(/\//g, '\\'); + } + let normalized = path.replace(/\\/g, '/').trim(); + if (!normalized || normalized === '.' || normalized === './' || normalized === '/.' || normalized === '/./') { + return '/'; + } + if (normalized.startsWith('./')) { + normalized = normalized.substring(1); + } + if (!normalized.startsWith('/')) { + normalized = `/${normalized}`; + } + normalized = normalized.replace(/\/+/g, '/').replace(/\/\.(?=\/|$)/g, ''); + if (normalized.length > 1) { + normalized = normalized.replace(/\/+$/, ''); + } + return normalized || '/'; + } + getDefaultPaneDevicePath(device) { + return device.id === LOCAL_WINDOWS_DEVICE_ID + ? this.getLocalWindowsDeviceDefaultPath() + : this.normalizePanePath(device.defaultPath || '/', false); + } + getPaneDeviceMemoryKey(side, device) { + return `${side}|${device.id}`; + } + getPaneDevicePathStorageKey(side, device) { + return `turbofile.panePath.${side}.${encodeURIComponent(device.id)}`; + } + rememberPaneDevicePath(side, device, path) { + if (!path) { + return; + } + const normalizedPath = this.normalizePanePath(path, this.isWindowsDevice(device)); + this.storageService.store(this.getPaneDevicePathStorageKey(side, device), normalizedPath, 0 /* StorageScope.PROFILE */, 0 /* StorageTarget.USER */); + } + getRememberedPaneDevicePath(side, device) { + const path = this.storageService.get(this.getPaneDevicePathStorageKey(side, device), 0 /* StorageScope.PROFILE */); + return path ? this.normalizePanePath(path, this.isWindowsDevice(device)) : undefined; + } + getInitialPaneDevicePath(side, device) { + const rememberedPath = this.getRememberedPaneDevicePath(side, device); + if (device.id !== LOCAL_WINDOWS_DEVICE_ID) { + return rememberedPath ?? this.getDefaultPaneDevicePath(device); + } + const defaultPath = this.getLocalWindowsDeviceDefaultPath(); + const systemUserPath = this.getLocalWindowsDefaultPath(); + if (rememberedPath && !isSameWindowsPath(rememberedPath, systemUserPath)) { + return rememberedPath; + } + return defaultPath; + } + rememberPaneDeviceSnapshot(side) { + // Keep this page's own device/path/sort selection fresh so a later group merge or tab switch + // restores it instead of inheriting the receiving pane's previous page. + this.capturePaneSelectionForCurrentPage(); + const device = this.getPaneDevice(side); + const state = this.paneStates[side]; + if (!device || !state.path) { + return; + } + this.rememberPaneDevicePath(side, device, state.path); + if (!state.items.length && state.error) { + return; + } + this.paneDeviceSnapshots.set(this.getPaneDeviceMemoryKey(side, device), { + path: state.path, + items: state.items, + scrollTop: state.scrollTop, + expiresAt: Date.now() + PANE_DEVICE_SNAPSHOT_TTL_MS + }); + } + getPaneDeviceSnapshot(side, device) { + const key = this.getPaneDeviceMemoryKey(side, device); + const snapshot = this.paneDeviceSnapshots.get(key); + if (!snapshot) { + return undefined; + } + if (snapshot.expiresAt <= Date.now()) { + this.paneDeviceSnapshots.delete(key); + return undefined; + } + return snapshot; + } + setPaneDevice(side, device) { + const state = this.paneStates[side]; + if (side === 'right') { + this.hasAppliedInitialRightPaneDefault = true; + } + if (state.deviceId === device.id) { + state.error = undefined; + this.clearPaneSelection(side); + this.render(); + void this.refreshPane(side, { path: state.path || this.getDefaultPaneDevicePath(device), force: true, silent: true }); + return; + } + this.rememberPaneDeviceSnapshot(side); + ++this.paneRequestSeq[side]; + state.deviceId = device.id; + const targetPath = this.getInitialPaneDevicePath(side, device); + const snapshot = this.getPaneDeviceSnapshot(side, device); + const cached = this.getCachedPaneDirectory(device, targetPath); + state.path = targetPath; + state.items = this.sortPaneItems(side, snapshot?.path === targetPath ? snapshot.items : cached?.items ?? []); + state.error = undefined; + this.resetPaneScroll(side); + if (snapshot?.path === targetPath) { + state.scrollTop = snapshot.scrollTop; + } + this.clearPaneSelection(side); + this.statusMessage = `已在${this.getSideLabel(side)}打开设备:${device.name}`; + this.render(); + if (cached) { + this.applyPaneDirectoryEntry(side, device, cached); + this.render(); + return; + } + void this.refreshPane(side, { path: targetPath, silent: true }); + } + /** + * Mirror the current page's left/right device + path + sort selection into the module-level map so + * it can be restored if this page is later shown by a different editor pane instance (group merge, + * tab switch). Cheap and idempotent; called from the same spots that snapshot pane contents. + */ + capturePaneSelectionForCurrentPage() { + const pageInstanceId = this.currentInput?.instanceId; + if (!pageInstanceId) { + return; + } + const capture = (side) => { + const state = this.paneStates[side]; + return { deviceId: state.deviceId, path: state.path, sortKey: state.sortKey, sortDirection: state.sortDirection }; + }; + turboFilePaneSelectionByPageId.set(pageInstanceId, { left: capture('left'), right: capture('right') }); + } + /** + * Restore a page's own pane selection when this editor pane starts showing it. Returns true when a + * saved selection was applied; false when the page has none yet (a brand-new page). + */ + restorePaneSelectionForPage(pageInstanceId) { + const snapshot = turboFilePaneSelectionByPageId.get(pageInstanceId); + if (!snapshot) { + return false; + } + const devices = this.getAllDevices(); + for (const side of ['left', 'right']) { + const saved = snapshot[side]; + const state = this.paneStates[side]; + state.sortKey = saved.sortKey; + state.sortDirection = saved.sortDirection; + if (devices.some(device => device.id === saved.deviceId)) { + state.deviceId = saved.deviceId; + state.path = saved.path; + } + } + this.hasAppliedInitialRightPaneDefault = true; + return true; + } + /** + * Give a page that has no saved selection a fresh default (local on the left, first SSH device on + * the right) rather than letting it inherit the selection of whatever page this reused pane showed + * before. + */ + resetPaneSelectionToDefault() { + for (const side of ['left', 'right']) { + this.paneStates[side].deviceId = LOCAL_WINDOWS_DEVICE_ID; + this.paneStates[side].path = ''; + } + this.hasAppliedInitialRightPaneDefault = false; + this.ensurePaneDeviceSelection({ preferFirstSshOnRight: true }); + for (const side of ['left', 'right']) { + const device = this.getPaneDevice(side); + if (device && !this.paneStates[side].path) { + this.paneStates[side].path = this.getDefaultPaneDevicePath(device); + } + } + } + ensurePaneDeviceSelection(options = {}) { + const devices = this.getAllDevices(); + for (const side of ['left', 'right']) { + const state = this.paneStates[side]; + if (!devices.some(device => device.id === state.deviceId)) { + state.deviceId = devices[0].id; + state.path = this.getDefaultPaneDevicePath(devices[0]); + } + } + if (options.preferFirstSshOnRight && !this.hasAppliedInitialRightPaneDefault && this.devices[0] && this.paneStates.right.deviceId === LOCAL_WINDOWS_DEVICE_ID) { + this.paneStates.right.deviceId = this.devices[0].id; + this.paneStates.right.path = this.devices[0].defaultPath || '/'; + this.hasAppliedInitialRightPaneDefault = true; + } + } + isWindowsDevice(device) { + return device.id === LOCAL_WINDOWS_DEVICE_ID || device.platform === 'windows'; + } + normalizeTransferProtocol(value) { + return value === 'auto-fast' || value === 'rsync' || value === 'scp' || value === 'sftp' ? value : defaultTurboFileTransferProtocol; + } + renderDeviceForm() { + const overlay = $('.turbofile-device-overlay'); + const form = document.createElement('form'); + form.className = 'turbofile-device-form'; + this.bindDeviceFormEventIsolation(form); + const editingDevice = this.editingDeviceId ? this.getAllDevices().find(device => device.id === this.editingDeviceId) : undefined; + const isEditing = !!editingDevice; + const isEditingLocalWindows = editingDevice?.id === LOCAL_WINDOWS_DEVICE_ID; + const title = $('.turbofile-device-form-title'); + title.textContent = isEditingLocalWindows ? '编辑本机 Windows' : isEditing ? '编辑 SSH 设备' : '添加 SSH 设备'; + form.appendChild(title); + if (isEditingLocalWindows) { + this.appendInputField(form, 'name', '设备名称', 'Windows', 'text', false, editingDevice.name); + this.appendHiddenInput(form, 'host', editingDevice.host); + this.appendHiddenInput(form, 'port', String(editingDevice.port || 0)); + this.appendHiddenInput(form, 'username', editingDevice.username || 'local'); + this.appendHiddenInput(form, 'platform', 'windows'); + this.appendHiddenInput(form, 'authType', 'password'); + this.appendProtocolField(form, editingDevice); + this.appendInputField(form, 'defaultPath', '默认目录', this.getLocalWindowsDefaultPath(), 'text', true, editingDevice.defaultPath || this.getLocalWindowsDefaultPath()); + } + else { + this.appendInputField(form, 'name', '设备名称', 'Linux Server', 'text', false, editingDevice?.name); + this.appendInputField(form, 'host', 'IP / 主机名', '192.168.10.15', 'text', false, editingDevice?.host); + this.appendInputField(form, 'port', '端口', '22', 'number', false, String(editingDevice?.port || 22)); + this.appendInputField(form, 'username', '用户名', 'root', 'text', false, editingDevice?.username); + this.appendSelectField(form, 'platform', '系统类型', [ + ['linux', 'Linux'], + ['wsl', 'WSL'], + ['unknown', 'Unknown'] + ], editingDevice?.platform ?? 'linux'); + this.appendSelectField(form, 'authType', '认证方式', [ + ['password', '密码'], + ['privateKey', '私钥'] + ], editingDevice?.authType ?? 'password'); + this.appendProtocolField(form, editingDevice); + this.appendInputField(form, 'password', '密码', isEditing ? '留空保持原密码' : '', 'password', true); + this.appendInputField(form, 'privateKeyPath', '私钥路径', 'C:\\Users\\User\\.ssh\\id_rsa', 'text', true, editingDevice?.privateKeyPath); + this.appendInputField(form, 'passphrase', '私钥口令', isEditing ? '留空保持原私钥口令' : '', 'password', true); + this.appendInputField(form, 'defaultPath', '默认目录', '/home', 'text', true, editingDevice?.defaultPath); + } + const message = $('.turbofile-device-form-message'); + message.textContent = this.deviceFormMessage; + form.appendChild(message); + const actions = $('.turbofile-device-form-actions'); + const cancel = this.createButton('取消', 'turbofile-secondary-button'); + const save = this.createButton('保存设备', 'turbofile-primary-button'); + cancel.element.type = 'button'; + save.element.type = 'submit'; + actions.appendChild(cancel.element); + actions.appendChild(save.element); + form.appendChild(actions); + cancel.element.addEventListener('click', () => { + this.isDeviceFormVisible = false; + this.editingDeviceId = undefined; + this.render(); + }); + form.addEventListener('submit', async (event) => { + event.preventDefault(); + const draft = this.readDeviceDraft(form, message); + if (!draft || !this.deviceStore) { + return; + } + message.textContent = draft.preferredTransferProtocol === 'sftp' ? '正在保存设备...' : '正在保存并检测设备能力...'; + try { + const previousLocalWindowsDefaultPath = isEditingLocalWindows ? this.getLocalWindowsDeviceDefaultPath() : undefined; + let savedDevice = isEditingLocalWindows ? this.saveLocalWindowsDevice(draft) : editingDevice ? await this.deviceStore.updateDevice(editingDevice.id, draft) : await this.deviceStore.saveDevice(draft); + const capabilityResult = isEditingLocalWindows ? undefined : await this.detectAndStoreDeviceCapabilities(savedDevice); + if (!isEditingLocalWindows) { + savedDevice = this.deviceStore.loadDevices().find(device => device.id === savedDevice.id) ?? savedDevice; + this.devices = this.deviceStore.loadDevices(); + } + this.clearPaneDirectoryCachesForDevice(savedDevice.id); + this.ensurePaneDeviceSelection(); + const refreshedLocalWindowsPanes = isEditingLocalWindows + ? this.syncLocalWindowsDefaultPath(previousLocalWindowsDefaultPath ?? this.getLocalWindowsDefaultPath(), savedDevice.defaultPath || this.getLocalWindowsDeviceDefaultPath()) + : false; + if (capabilityResult) { + this.deviceConnectionMessages.set(savedDevice.id, capabilityResult); + this.setDeviceRuntimeStatus(savedDevice, capabilityResult.status === 'error' ? 'offline' : 'online'); + } + else if (isEditingLocalWindows) { + this.deviceConnectionMessages.set(savedDevice.id, { status: 'success', message: '本机配置已保存,Windows 与服务器互传会按所选协议执行。' }); + this.setDeviceRuntimeStatus(savedDevice, 'online'); + } + else if (savedDevice.preferredTransferProtocol === 'sftp') { + this.deviceConnectionMessages.set(savedDevice.id, { status: 'success', message: 'SFTP 稳定模式,无需额外检测。' }); + } + this.deviceFormMessage = `${editingDevice ? '已更新' : '已保存'}设备:${savedDevice.name}${capabilityResult ? `,${capabilityResult.message}` : ''}`; + this.statusMessage = `${this.deviceFormMessage}。可在设备下拉菜单中选择。`; + this.isDeviceFormVisible = false; + this.editingDeviceId = undefined; + this.render(); + if (!isEditingLocalWindows || !refreshedLocalWindowsPanes) { + void this.refreshPane('right'); + } + } + catch (error) { + const reason = error instanceof Error ? error.message : String(error); + this.deviceFormMessage = `保存失败:${reason}`; + message.textContent = this.deviceFormMessage; + this.statusMessage = this.deviceFormMessage; + } + }); + overlay.appendChild(form); + window.setTimeout(() => { + const firstInput = form.querySelector('input[name="name"]'); + firstInput?.focus({ preventScroll: true }); + firstInput?.select(); + }, 0); + return overlay; + } + bindDeviceFormEventIsolation(form) { + const stopPropagation = (event) => event.stopPropagation(); + for (const eventName of ['pointerdown', 'mousedown', 'mouseup', 'click', 'dblclick', 'keydown', 'keyup', 'keypress']) { + form.addEventListener(eventName, stopPropagation); + } + } + appendInputField(parent, name, label, placeholder, type = 'text', wide = false, value) { + const field = $('.turbofile-device-field'); + if (wide) { + field.classList.add('is-wide'); + } + const labelElement = document.createElement('label'); + labelElement.textContent = label; + labelElement.htmlFor = `turbofile-device-${name}`; + const input = document.createElement('input'); + input.id = `turbofile-device-${name}`; + input.name = name; + input.type = type; + input.placeholder = placeholder; + input.value = value ?? (name === 'port' ? '22' : ''); + this.bindDeviceFormControl(input); + field.appendChild(labelElement); + field.appendChild(input); + parent.appendChild(field); + } + appendHiddenInput(parent, name, value) { + const input = document.createElement('input'); + input.type = 'hidden'; + input.name = name; + input.value = value; + parent.appendChild(input); + } + appendProtocolField(parent, editingDevice) { + const isLocalWindowsDevice = editingDevice?.id === LOCAL_WINDOWS_DEVICE_ID || editingDevice?.platform === 'windows'; + const field = $('.turbofile-device-field.is-wide'); + const labelElement = document.createElement('label'); + labelElement.textContent = '传输模式'; + labelElement.htmlFor = 'turbofile-device-preferredTransferProtocol'; + const row = $('.turbofile-device-protocol-row'); + row.style.display = 'grid'; + row.style.gridTemplateColumns = 'minmax(180px, 1fr) auto auto'; + row.style.gap = '8px'; + row.style.alignItems = 'center'; + const select = document.createElement('select'); + select.id = 'turbofile-device-preferredTransferProtocol'; + select.name = 'preferredTransferProtocol'; + const protocolOptions = [ + ['auto-fast', '智能极速(推荐)'], + ['scp', 'SCP 文件'], + ['sftp', 'SFTP 稳定模式'], + ['rsync', 'rsync 增量'] + ]; + for (const [value, text] of protocolOptions) { + const option = document.createElement('option'); + option.value = value; + option.textContent = text; + option.selected = (editingDevice?.preferredTransferProtocol ?? defaultTurboFileTransferProtocol) === value; + select.appendChild(option); + } + this.bindDeviceFormControl(select); + const detect = this.createButton('检测', 'turbofile-secondary-button').element; + detect.type = 'button'; + const install = this.createButton('安装 rsync/sshpass', 'turbofile-secondary-button').element; + install.type = 'button'; + install.style.display = 'none'; + const getLocalWindowsProtocolStatus = () => { + const protocol = this.normalizeTransferProtocol(select.value); + return protocol === 'rsync' + ? 'Windows 本机已内置 rsync,可直接用于本机与 SSH 设备增量传输,无需单独安装 Cygwin/MSYS2。' + : '本机模式用于 Windows 与服务器互传,无需 SSH 检测。'; + }; + const status = $('.turbofile-device-protocol-status'); + status.style.gridColumn = '1 / -1'; + status.style.minHeight = '18px'; + status.style.color = 'var(--vscode-descriptionForeground)'; + status.textContent = isLocalWindowsDevice ? getLocalWindowsProtocolStatus() : select.value === 'sftp' ? 'SFTP 稳定模式,无需额外检测。' : '选择后将自动检测设备能力。'; + let lastMissingDependencies = []; + let detectionRun = 0; + const setStatus = (kind, message) => { + status.textContent = message; + status.style.color = kind === 'success' + ? 'var(--vscode-testing-iconPassed)' + : kind === 'error' + ? 'var(--vscode-testing-iconFailed)' + : 'var(--vscode-descriptionForeground)'; + }; + const setInstallVisible = (visible) => { + install.style.display = visible ? '' : 'none'; + }; + const runDetection = async () => { + const protocol = this.normalizeTransferProtocol(select.value); + lastMissingDependencies = []; + setInstallVisible(false); + if (isLocalWindowsDevice) { + setStatus('idle', getLocalWindowsProtocolStatus()); + return; + } + if (protocol === 'sftp') { + setStatus('idle', 'SFTP 稳定模式,无需额外检测。'); + return; + } + const draft = this.readDeviceDraft(parent, status); + if (!draft) { + setInstallVisible(false); + return; + } + const runId = ++detectionRun; + detect.disabled = true; + install.disabled = true; + setStatus('testing', '正在检测设备传输能力...'); + try { + const result = await this.detectDraftProtocolCapabilities(draft, editingDevice); + if (runId !== detectionRun) { + return; + } + if (!result) { + setStatus('idle', 'SFTP 稳定模式,无需额外检测。'); + setInstallVisible(false); + return; + } + lastMissingDependencies = result.missingDependencies ?? []; + setStatus(result.status, result.message); + setInstallVisible(protocol === 'rsync' && lastMissingDependencies.length > 0 && !!result.capabilities); + } + catch (error) { + if (runId !== detectionRun) { + return; + } + const reason = error instanceof Error ? error.message : String(error); + setStatus('error', `能力检测失败:${reason}`); + setInstallVisible(false); + } + finally { + if (runId === detectionRun) { + detect.disabled = false; + install.disabled = false; + } + } + }; + detect.addEventListener('click', () => { void runDetection(); }); + select.addEventListener('change', () => { void runDetection(); }); + install.addEventListener('click', async () => { + const dependencies = lastMissingDependencies.filter((dependency) => dependency === 'rsync' || dependency === 'sshpass'); + if (!dependencies.length) { + return; + } + const confirmed = getWindow(this.container).confirm(`将在远端服务器安装:${dependencies.join('、')}\n\n该操作会修改远端系统包,请确认你信任这台服务器并拥有安装权限。`); + if (!confirmed) { + return; + } + const draft = this.readDeviceDraft(parent, status); + if (!draft) { + return; + } + detect.disabled = true; + install.disabled = true; + setStatus('testing', '正在远端安装 rsync/sshpass...'); + const result = await this.sshService.installTransferDependencies(await this.getSshConnectionOptionsFromDraft(draft, editingDevice), dependencies); + if (result.capabilities && editingDevice && this.deviceStore) { + this.deviceStore.updateDeviceCapabilities(editingDevice.id, this.toDeviceCapabilities(result.capabilities)); + this.devices = this.deviceStore.loadDevices(); + } + if (!result.ok) { + lastMissingDependencies = result.missingPackages; + setStatus('error', `安装后仍不可用:${result.message}`); + setInstallVisible(lastMissingDependencies.length > 0); + } + else { + setStatus('success', 'rsync/sshpass 安装成功,正在重新检测...'); + await runDetection(); + } + detect.disabled = false; + install.disabled = false; + }); + row.appendChild(select); + if (!isLocalWindowsDevice) { + row.appendChild(detect); + row.appendChild(install); + } + row.appendChild(status); + field.appendChild(labelElement); + field.appendChild(row); + parent.appendChild(field); + if (select.value === 'rsync') { + window.setTimeout(() => { void runDetection(); }, 0); + } + } + appendSelectField(parent, name, label, options, selectedValue) { + const field = $('.turbofile-device-field'); + const labelElement = document.createElement('label'); + labelElement.textContent = label; + labelElement.htmlFor = `turbofile-device-${name}`; + const select = document.createElement('select'); + select.id = `turbofile-device-${name}`; + select.name = name; + for (const [value, text] of options) { + const option = document.createElement('option'); + option.value = value; + option.textContent = text; + option.selected = selectedValue === value; + select.appendChild(option); + } + this.bindDeviceFormControl(select); + field.appendChild(labelElement); + field.appendChild(select); + parent.appendChild(field); + } + bindDeviceFormControl(control) { + control.addEventListener('pointerdown', event => { + event.stopPropagation(); + control.focus({ preventScroll: true }); + }); + for (const eventName of ['mousedown', 'mouseup', 'click', 'dblclick', 'keydown', 'keyup', 'keypress', 'input', 'change']) { + control.addEventListener(eventName, event => event.stopPropagation()); + } + } + readDeviceDraft(form, message) { + const formData = new FormData(form); + const host = String(formData.get('host') || '').trim(); + const username = String(formData.get('username') || '').trim(); + if (!host || !username) { + message.textContent = '请填写 IP / 主机名和用户名。'; + return undefined; + } + return { + name: String(formData.get('name') || host).trim(), + host, + port: Number(formData.get('port')) || 22, + username, + authType: String(formData.get('authType') || 'password'), + password: String(formData.get('password') || '') || undefined, + privateKeyPath: String(formData.get('privateKeyPath') || '') || undefined, + passphrase: String(formData.get('passphrase') || '') || undefined, + defaultPath: String(formData.get('defaultPath') || '') || undefined, + platform: String(formData.get('platform') || 'unknown'), + preferredTransferProtocol: this.normalizeTransferProtocol(String(formData.get('preferredTransferProtocol') || defaultTurboFileTransferProtocol)) + }; + } +}; +TurboFileEditor = TurboFileEditor_1 = __decorate([ + __param(1, ITelemetryService), + __param(2, IWorkbenchThemeService), + __param(3, IStorageService), + __param(4, INativeEnvironmentService), + __param(5, ITurboFileSshService), + __param(6, IContextMenuService), + __param(7, IFileService), + __param(8, IModelService), + __param(9, ILanguageService), + __param(10, IInstantiationService), + __param(11, IEditorService), + __param(12, ITextEditorService), + __param(13, ITerminalService), + __param(14, ITerminalGroupService), + __param(15, ITerminalProfileService), + __param(16, IQuickInputService), + __param(17, IClipboardService), + __param(18, IConfigurationService), + __param(19, IWorkbenchLayoutService), + __param(20, IUpdateService), + __param(21, IProductService), + __param(22, ILabelService) +], TurboFileEditor); +export { TurboFileEditor }; +class TurboFileRemoteFileSystemProvider extends Disposable { + constructor(sshService) { + super(); + this.sshService = sshService; + this.capabilities = 2 /* FileSystemProviderCapabilities.FileReadWrite */ | 1024 /* FileSystemProviderCapabilities.PathCaseSensitive */; + this.onDidChangeCapabilities = Event.None; + this._onDidChangeFile = this._register(new Emitter()); + this.onDidChangeFile = this._onDidChangeFile.event; + this.devices = new Map(); + this.stats = new Map(); + this.remotePaths = new Map(); + } + registerFile(device, connection, item) { + const resource = URI.from({ + scheme: TURBOFILE_REMOTE_DOCUMENT_SCHEME, + authority: device.id, + path: toRemoteDocumentUriPath(item.path ?? '/') + }); + this.devices.set(device.id, { connection, label: device.name }); + this.remotePaths.set(resource.toString(), item.path ?? resource.path); + this.stats.set(resource.toString(), this.toStat(item)); + return resource; + } + watch(_resource, _opts) { + return Disposable.None; + } + async stat(resource) { + const cached = this.stats.get(resource.toString()); + if (cached) { + return cached; + } + const path = this.toRemotePath(resource); + if (path === '/') { + return { type: FileType.Directory, ctime: 0, mtime: 0, size: 0 }; + } + const entry = await this.lookupEntry(resource); + const stat = this.toEntryStat(entry); + this.stats.set(resource.toString(), stat); + return stat; + } + async mkdir(resource) { + const result = await this.sshService.createDirectory(await this.toEndpoint(resource)); + if (!result.ok) { + throw createTurboFileProviderError(result.message, FileSystemProviderErrorCode.Unknown); + } + this.stats.set(resource.toString(), { type: FileType.Directory, ctime: Date.now(), mtime: Date.now(), size: 0 }); + this._onDidChangeFile.fire([{ resource, type: 1 /* FileChangeType.ADDED */ }]); + } + async readdir(resource) { + const registeredDevice = this.getRegisteredDevice(resource); + const listing = await this.sshService.listRemoteDirectory(await registeredDevice.connection, this.toRemotePath(resource)); + return listing.entries.map(entry => { + const childResource = URI.from({ + scheme: TURBOFILE_REMOTE_DOCUMENT_SCHEME, + authority: resource.authority, + path: toRemoteDocumentUriPath(entry.path) + }); + this.remotePaths.set(childResource.toString(), entry.path); + this.stats.set(childResource.toString(), this.toEntryStat(entry)); + return [entry.name, toProviderFileType(entry.type)]; + }); + } + async delete(resource, opts) { + const result = await this.sshService.deletePath(await this.toEndpoint(resource), opts.recursive); + if (!result.ok) { + throw createTurboFileProviderError(result.message, FileSystemProviderErrorCode.Unknown); + } + this.stats.delete(resource.toString()); + this.remotePaths.delete(resource.toString()); + this._onDidChangeFile.fire([{ resource, type: 2 /* FileChangeType.DELETED */ }]); + } + async rename(from, to, _opts) { + const result = await this.sshService.renamePath(await this.toEndpoint(from), this.toRemotePath(to)); + if (!result.ok) { + throw createTurboFileProviderError(result.message, FileSystemProviderErrorCode.Unknown); + } + const fromKey = from.toString(); + const toKey = to.toString(); + const stat = this.stats.get(fromKey); + if (stat) { + this.stats.set(toKey, { ...stat, mtime: Date.now() }); + this.stats.delete(fromKey); + } + this.remotePaths.set(toKey, this.toRemotePath(to)); + this.remotePaths.delete(fromKey); + this._onDidChangeFile.fire([ + { resource: from, type: 2 /* FileChangeType.DELETED */ }, + { resource: to, type: 1 /* FileChangeType.ADDED */ } + ]); + } + async readFile(resource) { + const result = await this.sshService.readFile(await this.toEndpoint(resource), { encoding: 'base64', maxBytes: 256 * 1024 * 1024 }); + if (!result.ok) { + throw createTurboFileProviderError(result.message, FileSystemProviderErrorCode.Unknown); + } + const now = Date.now(); + const previous = this.stats.get(resource.toString()); + this.stats.set(resource.toString(), { + type: FileType.File, + ctime: previous?.ctime ?? now, + mtime: previous?.mtime ?? now, + size: result.size + }); + return decodeBase64(result.content).buffer; + } + async writeFile(resource, content, _opts) { + const result = await this.sshService.writeFile(await this.toEndpoint(resource), encodeBase64(VSBuffer.wrap(content)), 'base64'); + if (!result.ok) { + throw createTurboFileProviderError(result.message, FileSystemProviderErrorCode.Unknown); + } + const now = Date.now(); + const previous = this.stats.get(resource.toString()); + this.stats.set(resource.toString(), { + type: FileType.File, + ctime: previous?.ctime ?? now, + mtime: now, + size: content.byteLength + }); + this._onDidChangeFile.fire([{ resource, type: 0 /* FileChangeType.UPDATED */ }]); + } + async lookupEntry(resource) { + const registeredDevice = this.getRegisteredDevice(resource); + const path = this.toRemotePath(resource); + const parentPath = getPaneParentPath(path, false) ?? '/'; + const listing = await this.sshService.listRemoteDirectory(await registeredDevice.connection, parentPath); + const name = getPathBasename(path); + const entry = listing.entries.find(entry => entry.path === path || entry.name === name); + if (!entry) { + throw createTurboFileProviderError(`File not found: ${path}`, FileSystemProviderErrorCode.FileNotFound); + } + return entry; + } + async toEndpoint(resource) { + const registeredDevice = this.getRegisteredDevice(resource); + return { + kind: 'ssh', + path: this.toRemotePath(resource), + connection: await registeredDevice.connection, + label: registeredDevice.label + }; + } + getRegisteredDevice(resource) { + const registeredDevice = this.devices.get(resource.authority); + if (!registeredDevice) { + throw createTurboFileProviderError(`Remote device is not registered: ${resource.authority}`, FileSystemProviderErrorCode.Unavailable); + } + return registeredDevice; + } + toRemotePath(resource) { + return this.remotePaths.get(resource.toString()) ?? resource.path; + } + toStat(item) { + const mtime = item.rawModified || Date.now(); + return { + type: toProviderFileType(item.type), + ctime: mtime, + mtime, + size: item.rawSize + }; + } + toEntryStat(entry) { + return { + type: toProviderFileType(entry.type), + ctime: entry.modified, + mtime: entry.modified, + size: entry.size + }; + } +} +class TurboFileSshTerminalProcess extends BasePty { + constructor(id, cols, rows, sshService, connection, cwd) { + super(id, false); + this.sshService = sshService; + this.connection = connection; + this.cwd = cwd; + this.terminalDisposables = this._register(new DisposableStore()); + this.pendingInput = []; + this.isShutdown = false; + this._lastDimensions.cols = cols; + this._lastDimensions.rows = rows; + this._properties.cwd = cwd; + this._properties.initialCwd = cwd; + void Promise.resolve(connection).then(resolvedConnection => { + this._properties.title = `极速传 ${resolvedConnection.username}@${resolvedConnection.host}`; + this.handleDidChangeProperty({ type: "title" /* ProcessPropertyType.Title */, value: this._properties.title }); + }, () => undefined); + } + async start() { + try { + const connection = await this.connection; + const session = await this.sshService.createSshTerminal(connection, this.cwd, this._lastDimensions.cols, this._lastDimensions.rows); + this.sessionId = session.sessionId; + this._properties.cwd = session.cwd; + this._properties.initialCwd = session.cwd; + this.terminalDisposables.add(this.sshService.onDynamicSshTerminalData(session.sessionId)(data => this.handleData(data))); + this.terminalDisposables.add(this.sshService.onDynamicSshTerminalExit(session.sessionId)(code => { + this.sessionId = undefined; + this.terminalDisposables.clear(); + this.handleExit(code); + })); + this.handleReady({ pid: session.pid, cwd: session.cwd, windowsPty: undefined }); + for (const data of this.pendingInput.splice(0)) { + void this.sshService.writeSshTerminal(session.sessionId, data); + } + return undefined; + } + catch (error) { + return { message: error instanceof Error ? error.message : String(error) }; + } + } + shutdown(_immediate) { + if (this.isShutdown) { + return; + } + this.isShutdown = true; + const sessionId = this.sessionId; + this.sessionId = undefined; + this.terminalDisposables.clear(); + if (sessionId) { + void this.sshService.shutdownSshTerminal(sessionId); + } + this.handleExit(undefined); + } + input(data) { + if (this.isShutdown) { + return; + } + if (!this.sessionId) { + this.pendingInput.push(data); + return; + } + void this.sshService.writeSshTerminal(this.sessionId, data); + } + sendSignal(signal) { + if (signal === 'SIGINT') { + this.input('\x03'); + return; + } + if (signal === 'SIGTERM' || signal === 'SIGKILL') { + this.shutdown(true); + } + } + processBinary(data) { + this.input(data); + return Promise.resolve(); + } + resize(cols, rows) { + if (this.isShutdown || (this._lastDimensions.cols === cols && this._lastDimensions.rows === rows)) { + return; + } + this._lastDimensions.cols = cols; + this._lastDimensions.rows = rows; + if (this.sessionId) { + void this.sshService.resizeSshTerminal(this.sessionId, cols, rows); + } + } + clearBuffer() { + // The SSH PTY stream is live-only; VS Code owns the visible scrollback buffer. + } + acknowledgeDataEvent(_charCount) { + // ssh2 channels provide their own backpressure, so no extra browser ack is needed. + } + setUnicodeVersion(_version) { + return Promise.resolve(); + } + getInitialCwd() { + return Promise.resolve(this._properties.initialCwd); + } + getCwd() { + return Promise.resolve(this._properties.cwd || this._properties.initialCwd); + } + refreshProperty(type) { + return Promise.resolve(this._properties[type]); + } + updateProperty(type, value) { + this._properties[type] = value; + this.handleDidChangeProperty({ type, value }); + return Promise.resolve(); + } +} +function getPaneParentPath(path, windowsPath) { + if (!path) { + return undefined; + } + if (!windowsPath) { + const normalized = path.length > 1 ? path.replace(/\/+$/, '') : path; + if (normalized === '/') { + return undefined; + } + const index = normalized.lastIndexOf('/'); + return index <= 0 ? '/' : normalized.substring(0, index); + } + const normalized = path.replace(/[\\/]+$/, ''); + if (/^[A-Za-z]:$/.test(normalized)) { + return undefined; + } + const slash = Math.max(normalized.lastIndexOf('\\'), normalized.lastIndexOf('/')); + if (slash <= 2 && /^[A-Za-z]:/.test(normalized)) { + return `${normalized.substring(0, 2)}\\`; + } + if (slash <= 0) { + return undefined; + } + return normalized.substring(0, slash); +} +function getWindowsDriveRoot(path) { + const match = /^([A-Za-z]):/.exec(path.replace(/\//g, '\\').trim()); + return match ? `${match[1].toUpperCase()}:\\` : undefined; +} +function isSameWindowsPath(left, right) { + return getWindowsPathKey(left) === getWindowsPathKey(right); +} +function isPathOnWindowsDrive(path, driveRoot) { + return getWindowsDriveRoot(path) === getWindowsDriveRoot(driveRoot); +} +function getWindowsPathKey(path) { + return path.replace(/\//g, '\\').replace(/\\+$/, '').toLowerCase(); +} +function isSupportedWindowsDropPath(path) { + return /^[A-Za-z]:[\\/]/.test(path) || /^\\\\[^\\]+\\[^\\]+/.test(path); +} +function joinPanePath(parentPath, name, windowsPath) { + if (!windowsPath) { + if (!parentPath || parentPath === '/') { + return `/${name}`; + } + return parentPath.endsWith('/') ? `${parentPath}${name}` : `${parentPath}/${name}`; + } + if (!parentPath) { + return name; + } + const separator = parentPath.includes('/') && !parentPath.includes('\\') ? '/' : '\\'; + return parentPath.endsWith('\\') || parentPath.endsWith('/') ? `${parentPath}${name}` : `${parentPath}${separator}${name}`; +} +function toRemoteDocumentUriPath(path) { + const normalized = path.replace(/\\/g, '/'); + return normalized.startsWith('/') ? normalized : `/${normalized}`; +} +function getPathBasename(path) { + const normalized = path.replace(/[\\/]+$/, ''); + const slash = Math.max(normalized.lastIndexOf('/'), normalized.lastIndexOf('\\')); + return slash >= 0 ? normalized.substring(slash + 1) : normalized; +} +function toProviderFileType(type) { + if (type === 'directory') { + return FileType.Directory; + } + if (type === 'file') { + return FileType.File; + } + if (type === 'symlink') { + return FileType.File | FileType.SymbolicLink; + } + return FileType.Unknown; +} +function createTurboFileProviderError(message, code) { + return createFileSystemProviderError(message, code); +} +function renderTurboFileSortIcon(color) { + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.setAttribute('viewBox', '0 0 16 16'); + svg.setAttribute('aria-hidden', 'true'); + svg.classList.add('turbofile-bi-icon'); + if (color) { + svg.classList.add(`turbofile-text-${color}`); + } + const createPath = (d) => { + const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + path.setAttribute('d', d); + path.setAttribute('fill', 'none'); + path.setAttribute('stroke', 'currentColor'); + path.setAttribute('stroke-linecap', 'round'); + path.setAttribute('stroke-linejoin', 'round'); + path.setAttribute('stroke-width', '1.6'); + return path; + }; + svg.appendChild(createPath('M4 13V3')); + svg.appendChild(createPath('M1.8 5.2 4 3l2.2 2.2')); + svg.appendChild(createPath('M12 3v10')); + svg.appendChild(createPath('M9.8 10.8 12 13l2.2-2.2')); + return svg; +} +function renderTurboFileIcon(icon, color) { + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.setAttribute('viewBox', '0 0 16 16'); + svg.setAttribute('aria-hidden', 'true'); + svg.classList.add('turbofile-bi-icon'); + if (color) { + svg.classList.add(`turbofile-text-${color}`); + } + for (const path of getTurboFileIconPaths(icon)) { + const pathElement = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + pathElement.setAttribute('d', path.d); + pathElement.setAttribute('fill', 'currentColor'); + if (path.fillRule) { + pathElement.setAttribute('fill-rule', path.fillRule); + } + svg.appendChild(pathElement); + } + return svg; +} +let turboFileDeviceMonitorIconId = 0; +function renderTurboFileDeviceMonitorIcon() { + const screenId = `turbofileDeviceScreen${++turboFileDeviceMonitorIconId}`; + const shineId = `turbofileDeviceShine${turboFileDeviceMonitorIconId}`; + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.setAttribute('viewBox', '0 0 24 24'); + svg.setAttribute('aria-hidden', 'true'); + svg.classList.add('turbofile-device-monitor-svg'); + const create = (tag, attrs) => { + const element = document.createElementNS('http://www.w3.org/2000/svg', tag); + for (const [name, value] of Object.entries(attrs)) { + element.setAttribute(name, value); + } + return element; + }; + const defs = create('defs', {}); + const screen = create('linearGradient', { id: screenId, x1: '4', y1: '4', x2: '20', y2: '16', gradientUnits: 'userSpaceOnUse' }); + screen.appendChild(create('stop', { 'stop-color': '#5de7ef' })); + screen.appendChild(create('stop', { offset: '1', 'stop-color': '#2e8cff' })); + const shine = create('linearGradient', { id: shineId, x1: '6', y1: '5', x2: '18', y2: '13', gradientUnits: 'userSpaceOnUse' }); + shine.appendChild(create('stop', { 'stop-color': '#ffffff', 'stop-opacity': '.42' })); + shine.appendChild(create('stop', { offset: '1', 'stop-color': '#ffffff', 'stop-opacity': '0' })); + defs.appendChild(screen); + defs.appendChild(shine); + svg.appendChild(defs); + svg.appendChild(create('rect', { x: '3.4', y: '4.2', width: '17.2', height: '12', rx: '1.4', fill: '#1f2933' })); + svg.appendChild(create('rect', { x: '4.6', y: '5.3', width: '14.8', height: '9.7', rx: '.7', fill: `url(#${screenId})` })); + svg.appendChild(create('path', { fill: `url(#${shineId})`, d: 'M5.6 6.1h12.8v3.2c-2.6-.8-5.2-.9-7.8-.3-1.8.4-3.4.3-5-.1V6.1z' })); + svg.appendChild(create('path', { fill: '#343a40', d: 'M10.6 16.2h2.8v2.1h3.7v1.5H6.9v-1.5h3.7v-2.1z' })); + svg.appendChild(create('path', { fill: '#8a98a8', d: 'M7.8 20h8.4c.4 0 .7.3.7.7v.2H7.1v-.2c0-.4.3-.7.7-.7z' })); + svg.appendChild(create('path', { fill: '#ffffff', 'fill-opacity': '.28', d: 'M5.4 5.9h13.2v.8H5.4z' })); + return svg; +} +function renderTurboFileAppearanceIcon(icon) { + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.setAttribute('viewBox', '0 0 24 24'); + svg.setAttribute('aria-hidden', 'true'); + svg.classList.add('turbofile-appearance-icon'); + if (icon === 'sun') { + for (const d of ['M12 2.75V5.1', 'M12 18.9v2.35', 'M4.64 4.64l1.66 1.66', 'M17.7 17.7l1.66 1.66', 'M2.75 12H5.1', 'M18.9 12h2.35', 'M4.64 19.36l1.66-1.66', 'M17.7 6.3l1.66-1.66']) { + const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + path.setAttribute('d', d); + svg.appendChild(path); + } + const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); + circle.setAttribute('cx', '12'); + circle.setAttribute('cy', '12'); + circle.setAttribute('r', '4'); + svg.appendChild(circle); + return svg; + } + const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + if (icon === 'moon') { + path.setAttribute('d', 'M20.2 14.4A7.7 7.7 0 0 1 9.6 3.8 8.2 8.2 0 1 0 20.2 14.4Z'); + } + else { + path.setAttribute('d', 'M4 5.5h16v10.5H4zM9 20h6M12 16v4'); + } + svg.appendChild(path); + return svg; +} +function getTurboFileIconPaths(icon) { + switch (icon) { + case 'archive': + return [ + { d: 'M0 2a1 1 0 0 1 1-1h14a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1v7.5a2.5 2.5 0 0 1-2.5 2.5h-9A2.5 2.5 0 0 1 1 12.5V5a1 1 0 0 1-1-1V2zm2 3v7.5A1.5 1.5 0 0 0 3.5 14h9a1.5 1.5 0 0 0 1.5-1.5V5H2zm13-3H1v2h14V2zM5 7.5a.5.5 0 0 1 .5-.5h5a.5.5 0 0 1 0 1h-5a.5.5 0 0 1-.5-.5z' } + ]; + case 'arrow-clockwise': + return [ + { d: 'M8 3a5 5 0 1 0 4.546 2.914.5.5 0 0 1 .908-.417A6 6 0 1 1 8 2v1z', fillRule: 'evenodd' }, + { d: 'M8 4.466V.534a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384L8.41 4.658A.25.25 0 0 1 8 4.466z' } + ]; + case 'arrow-up-circle': + return [ + { d: 'M1 8a7 7 0 1 0 14 0A7 7 0 0 0 1 8zm15 0A8 8 0 1 1 0 8a8 8 0 0 1 16 0zm-7.5 3.5a.5.5 0 0 1-1 0V5.707L5.354 7.854a.5.5 0 1 1-.708-.708l3-3a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1-.708.708L8.5 5.707V11.5z', fillRule: 'evenodd' } + ]; + case 'clock': + return [ + { d: 'M8 3.5a.5.5 0 0 1 .5.5v3.25l2.15 1.29a.5.5 0 0 1-.514.858l-2.4-1.44A.5.5 0 0 1 7.5 7.53V4a.5.5 0 0 1 .5-.5z' }, + { d: 'M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16zm0-1A7 7 0 1 1 8 1a7 7 0 0 1 0 14z' } + ]; + case 'cloud-upload-fill': + return [ + { d: 'M8 0a5.53 5.53 0 0 0-3.594 1.342c-.766.66-1.321 1.52-1.464 2.383C1.266 4.095 0 5.555 0 7.318 0 9.366 1.708 11 3.781 11H7.5V5.707L5.354 7.854a.5.5 0 1 1-.708-.708l3-3a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1-.708.708L8.5 5.707V11h4.188C14.502 11 16 9.57 16 7.773c0-1.636-1.242-2.969-2.834-3.194C12.923 1.999 10.69 0 8 0zm-.5 14.5V11h1v3.5a.5.5 0 0 1-1 0z', fillRule: 'evenodd' } + ]; + case 'eye': + return [ + { d: 'M16 8s-3-5.5-8-5.5S0 8 0 8s3 5.5 8 5.5S16 8 16 8zM1.173 8a13.133 13.133 0 0 1 1.66-2.043C4.12 4.668 5.88 3.5 8 3.5c2.12 0 3.879 1.168 5.168 2.457A13.133 13.133 0 0 1 14.828 8c-.058.087-.122.183-.195.288-.335.48-.83 1.12-1.465 1.755C11.879 11.332 10.119 12.5 8 12.5c-2.12 0-3.879-1.168-5.168-2.457A13.134 13.134 0 0 1 1.172 8z' }, + { d: 'M8 5.5a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5zM4.5 8a3.5 3.5 0 1 1 7 0 3.5 3.5 0 0 1-7 0z' } + ]; + case 'file-earmark': + return [ + { d: 'M14 4.5V14a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V2a2 2 0 0 1 2-2h5.5L14 4.5zm-3 0A1.5 1.5 0 0 1 9.5 3V1H4a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V4.5h-2z' } + ]; + case 'file-earmark-zip': + return [ + { d: 'M5 7.5a1 1 0 0 1 1-1h1a1 1 0 0 1 1 1v.938l.4 1.599a1 1 0 0 1-.416 1.074l-.93.62a1 1 0 0 1-1.11 0l-.929-.62a1 1 0 0 1-.415-1.074L5 8.438V7.5zm2 0H6v.938a1 1 0 0 1-.03.243l-.4 1.598.93.62.929-.62-.4-1.598A1 1 0 0 1 7 8.438V7.5z' }, + { d: 'M14 4.5V14a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V2a2 2 0 0 1 2-2h5.5L14 4.5zm-3 0A1.5 1.5 0 0 1 9.5 3V1h-2v1h-1v1h1v1h-1v1h1v1H6V5H5V4h1V3H5V2h1V1H4a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V4.5h-2z' } + ]; + case 'files': + return [ + { d: 'M13 0H6a2 2 0 0 0-2 2 2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h7a2 2 0 0 0 2-2 2 2 0 0 0 2-2V2a2 2 0 0 0-2-2zm0 13V4a2 2 0 0 0-2-2H5a1 1 0 0 1 1-1h7a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1zM3 4a1 1 0 0 1 1-1h7a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4z' } + ]; + case 'folder-check': + return [ + { d: 'm.5 3 .04.87a1.99 1.99 0 0 0-.342 1.311l.637 7A2 2 0 0 0 2.826 14H9v-1H2.826a1 1 0 0 1-.995-.91l-.637-7A1 1 0 0 1 2.19 4h11.62a1 1 0 0 1 .996 1.09L14.54 8h1.005l.256-2.819A2 2 0 0 0 13.81 3H9.828a2 2 0 0 1-1.414-.586l-.828-.828A2 2 0 0 0 6.172 1H2.5a2 2 0 0 0-2 2zm5.672-1a1 1 0 0 1 .707.293L7.586 3H2.19c-.24 0-.47.042-.683.12L1.5 2.98a1 1 0 0 1 1-.98h3.672z' }, + { d: 'M15.854 10.146a.5.5 0 0 1 0 .708l-3 3a.5.5 0 0 1-.707 0l-1.5-1.5a.5.5 0 0 1 .707-.708l1.146 1.147 2.646-2.647a.5.5 0 0 1 .708 0z' } + ]; + case 'folder-fill': + return [ + { d: 'M9.828 3h3.982a2 2 0 0 1 1.992 2.181l-.637 7A2 2 0 0 1 13.174 14H2.825a2 2 0 0 1-1.991-1.819l-.637-7a1.99 1.99 0 0 1 .342-1.31L.5 3a2 2 0 0 1 2-2h3.672a2 2 0 0 1 1.414.586l.828.828A2 2 0 0 0 9.828 3zm-8.322.12C1.72 3.042 1.95 3 2.19 3h5.396l-.707-.707A1 1 0 0 0 6.172 2H2.5a1 1 0 0 0-1 .981l.006.139z' } + ]; + case 'folder-plus': + return [ + { d: 'm.5 3 .04.87a1.99 1.99 0 0 0-.342 1.311l.637 7A2 2 0 0 0 2.826 14H9v-1H2.826a1 1 0 0 1-.995-.91l-.637-7A1 1 0 0 1 2.19 4h11.62a1 1 0 0 1 .996 1.09L14.54 8h1.005l.256-2.819A2 2 0 0 0 13.81 3H9.828a2 2 0 0 1-1.414-.586l-.828-.828A2 2 0 0 0 6.172 1H2.5a2 2 0 0 0-2 2zm5.672-1a1 1 0 0 1 .707.293L7.586 3H2.19c-.24 0-.47.042-.683.12L1.5 2.98a1 1 0 0 1 1-.98h3.672z' }, + { d: 'M13.5 10a.5.5 0 0 1 .5.5V12h1.5a.5.5 0 1 1 0 1H14v1.5a.5.5 0 1 1-1 0V13h-1.5a.5.5 0 0 1 0-1H13v-1.5a.5.5 0 0 1 .5-.5z' } + ]; + case 'geo-alt-fill': + return [ + { d: 'M8 16s6-5.686 6-10A6 6 0 0 0 2 6c0 4.314 6 10 6 10zm0-7a3 3 0 1 1 0-6 3 3 0 0 1 0 6z' } + ]; + case 'grid-3x3-gap': + return [ + { d: 'M4 2v2H2V2h2zm1 12v-2a1 1 0 0 0-1-1H2a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1zm0-5V7a1 1 0 0 0-1-1H2a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1zm0-5V2a1 1 0 0 0-1-1H2a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1zm5 10v-2a1 1 0 0 0-1-1H7a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1zm0-5V7a1 1 0 0 0-1-1H7a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1zm0-5V2a1 1 0 0 0-1-1H7a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1zM9 2v2H7V2h2zm5 0v2h-2V2h2zM4 7v2H2V7h2zm5 0v2H7V7h2zm5 0h-2v2h2V7zM4 12v2H2v-2h2zm5 0v2H7v-2h2zm5 0v2h-2v-2h2zM12 1a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V2a1 1 0 0 0-1-1h-2zm-1 6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1V7zm1 4a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-2z' } + ]; + case 'hdd-network-fill': + return [ + { d: 'M2 2a2 2 0 0 0-2 2v1a2 2 0 0 0 2 2h5.5v3A1.5 1.5 0 0 0 6 11.5H.5a.5.5 0 0 0 0 1H6A1.5 1.5 0 0 0 7.5 14h1a1.5 1.5 0 0 0 1.5-1.5h5.5a.5.5 0 0 0 0-1H10A1.5 1.5 0 0 0 8.5 10V7H14a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H2zm.5 3a.5.5 0 1 1 0-1 .5.5 0 0 1 0 1zm2 0a.5.5 0 1 1 0-1 .5.5 0 0 1 0 1z' } + ]; + case 'lightning-charge-fill': + return [ + { d: 'M11.251.068a.5.5 0 0 1 .227.58L9.677 6.5H13a.5.5 0 0 1 .364.843l-8 8.5a.5.5 0 0 1-.842-.49L6.323 9.5H3a.5.5 0 0 1-.364-.843l8-8.5a.5.5 0 0 1 .615-.09z' } + ]; + case 'pencil-square': + return [ + { d: 'M15.502 1.94a.5.5 0 0 1 0 .706L14.459 3.69l-2-2L13.502.646a.5.5 0 0 1 .707 0l1.293 1.293zm-1.75 2.456-2-2L4.939 9.21a.5.5 0 0 0-.121.196l-.805 2.414a.25.25 0 0 0 .316.316l2.414-.805a.5.5 0 0 0 .196-.12l6.813-6.814z' }, + { d: 'M1 13.5A1.5 1.5 0 0 0 2.5 15h11a1.5 1.5 0 0 0 1.5-1.5v-6a.5.5 0 0 0-1 0v6a.5.5 0 0 1-.5.5h-11a.5.5 0 0 1-.5-.5v-11a.5.5 0 0 1 .5-.5H9a.5.5 0 0 0 0-1H2.5A1.5 1.5 0 0 0 1 2.5v11z', fillRule: 'evenodd' } + ]; + case 'scissors': + return [ + { d: 'M3.5 3.5c.828 0 1.5.672 1.5 1.5S4.328 6.5 3.5 6.5 2 5.828 2 5s.672-1.5 1.5-1.5zM6 5c0-.813-.39-1.536-.993-1.993L8 0h2L5.993 4.007C5.39 4.464 5 5.187 5 6c0 .383.085.747.238 1.073L0 12.31V14h1.69l5.237-5.238A2.985 2.985 0 0 0 8 9c.813 0 1.536-.39 1.993-.993L14 12.014V10L9.993 5.993A2.985 2.985 0 0 0 10 6c0-.813-.39-1.536-.993-1.993L14 0h-2L7.073 4.927A2.985 2.985 0 0 0 6 5zm6.5 4.5c.828 0 1.5.672 1.5 1.5s-.672 1.5-1.5 1.5S11 11.828 11 11s.672-1.5 1.5-1.5z' } + ]; + case 'search': + return [ + { d: 'M11.742 10.344a6.5 6.5 0 1 0-1.397 1.398h-.001c.03.04.062.078.098.115l3.85 3.85a1 1 0 0 0 1.415-1.414l-3.85-3.85a1.007 1.007 0 0 0-.115-.1zM12 6.5a5.5 5.5 0 1 1-11 0 5.5 5.5 0 0 1 11 0z' } + ]; + case 'terminal': + return [ + { d: 'M6 9a.5.5 0 0 1 .5-.5h3a.5.5 0 0 1 0 1h-3A.5.5 0 0 1 6 9zM3.854 4.146a.5.5 0 1 0-.708.708L4.793 6.5 3.146 8.146a.5.5 0 1 0 .708.708l2-2a.5.5 0 0 0 0-.708l-2-2z' }, + { d: 'M2 1a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V3a2 2 0 0 0-2-2H2zm12 1a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1h12z' } + ]; + case 'trash': + return [ + { d: 'M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z' }, + { d: 'M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z', fillRule: 'evenodd' } + ]; + } +} +function isHiddenFileName(name) { + return !!name && name !== '..' && name.startsWith('.'); +} +function isImageFile(name) { + return /\.(png|jpe?g|gif|webp|bmp|svg|avif|ico|tiff?)$/i.test(name); +} +function getFileExtensionLower(name) { + const dotIndex = name.lastIndexOf('.'); + return dotIndex > 0 ? name.slice(dotIndex + 1).toLowerCase() : ''; +} +function compareImageGridImagesBy(a, b, key) { + switch (key) { + case 'size': + return getImageGridPixelArea(a) - getImageGridPixelArea(b); + case 'date': + return (a.item.rawModified || 0) - (b.item.rawModified || 0); + case 'type': + return IMAGE_GRID_SORT_COLLATOR.compare(getFileExtensionLower(a.item.name), getFileExtensionLower(b.item.name)); + case 'name': + default: + return IMAGE_GRID_SORT_COLLATOR.compare(a.item.name, b.item.name); + } +} +function getImageGridPixelArea(image) { + const width = image.originalWidth || image.width || 0; + const height = image.originalHeight || image.height || 0; + return width > 0 && height > 0 ? width * height : 0; +} +function sortImageGridImages(images, key, descending) { + images.sort((a, b) => { + let comparison = compareImageGridImagesBy(a, b, key); + if (comparison === 0 && key !== 'name') { + comparison = IMAGE_GRID_SORT_COLLATOR.compare(a.item.name, b.item.name); + } + return descending ? -comparison : comparison; + }); +} +function isArchiveFile(name) { + return /\.(zip|rar|7z|tar|tgz|tar\.gz|tar\.bz2|tbz2|tar\.xz|txz|tar\.zst|tzst|gz|bz2|xz)$/i.test(name); +} +function getMimeType(name) { + const lower = name.toLowerCase(); + if (lower.endsWith('.png')) { + return 'image/png'; + } + if (lower.endsWith('.jpg') || lower.endsWith('.jpeg')) { + return 'image/jpeg'; + } + if (lower.endsWith('.gif')) { + return 'image/gif'; + } + if (lower.endsWith('.webp')) { + return 'image/webp'; + } + if (lower.endsWith('.avif')) { + return 'image/avif'; + } + if (lower.endsWith('.ico')) { + return 'image/x-icon'; + } + if (lower.endsWith('.bmp')) { + return 'image/bmp'; + } + if (lower.endsWith('.svg')) { + return 'image/svg+xml'; + } + return 'text/plain'; +} +function clampNumber(value, min, max) { + if (!Number.isFinite(value)) { + return min; + } + return Math.min(max, Math.max(min, value)); +} +function quantizeNumber(value, step) { + const normalizedStep = Math.max(1, Math.round(step)); + if (!Number.isFinite(value)) { + return 0; + } + return Math.round(value / normalizedStep) * normalizedStep; +} +function formatSize(size) { + if (size < 1024) { + return `${Math.round(size)} B`; + } + const kb = size / 1024; + if (kb < 1024) { + return `${kb.toFixed(1)} KB`; + } + const mb = kb / 1024; + if (mb < 1024) { + return `${mb.toFixed(1)} MB`; + } + return `${(mb / 1024).toFixed(1)} GB`; +} +function trimTrailingSlash(path) { + return path.length > 1 ? path.replace(/[\\/]+$/, '') : path; +} +function formatDuration(milliseconds) { + const seconds = Math.max(0.1, Math.max(0, milliseconds) / 1000); + if (seconds < 60) { + return `${seconds.toFixed(1)}s`; + } + const minutes = Math.floor(seconds / 60); + const remainingSeconds = seconds - minutes * 60; + return `${minutes}m ${remainingSeconds.toFixed(1)}s`; +} +function formatThroughput(bytes, milliseconds) { + if (bytes <= 0 || milliseconds <= 0) { + return ''; + } + return `${formatTransferRate((bytes / milliseconds) * 1000)}/s`; +} +function formatTransferRate(bytesPerSecond) { + if (bytesPerSecond < 1000) { + return `${Math.round(bytesPerSecond)} B`; + } + const kb = bytesPerSecond / 1000; + if (kb < 1000) { + return `${kb.toFixed(1)} KB`; + } + const mb = kb / 1000; + if (mb < 1000) { + return `${mb.toFixed(1)} MB`; + } + return `${(mb / 1000).toFixed(1)} GB`; +} +function formatDate(timestamp) { + if (!timestamp) { + return ''; + } + const date = new Date(timestamp); + const pad = (value) => value.toString().padStart(2, '0'); + return `${date.getFullYear()}/${pad(date.getMonth() + 1)}/${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`; +} +//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoidHVyYm9maWxlRWRpdG9yLmpzIiwic291cmNlUm9vdCI6ImZpbGU6Ly8vRDovV29yay9Db2RlL1R1cmJvRmlsZS1XaW5kb3dzL3NyYy8iLCJzb3VyY2VzIjpbInZzL3dvcmtiZW5jaC9jb250cmliL3R1cmJvZmlsZS9icm93c2VyL3R1cmJvZmlsZUVkaXRvci50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFBQTs7O2dHQUdnRzs7Ozs7Ozs7Ozs7QUFFaEcsT0FBTyx1QkFBdUIsQ0FBQztBQUMvQixPQUFPLEVBQUUsYUFBYSxFQUFFLE1BQU0saUNBQWlDLENBQUM7QUFDaEUsT0FBTyxFQUFFLENBQUMsRUFBRSxTQUFTLEVBQWEsU0FBUyxFQUFnQixNQUFNLGlDQUFpQyxDQUFDO0FBQ25HLE9BQU8sRUFBRSxrQkFBa0IsRUFBRSxNQUFNLHdDQUF3QyxDQUFDO0FBQzVFLE9BQU8sRUFBRSxjQUFjLEVBQUUsTUFBTSwwREFBMEQsQ0FBQztBQUMxRixPQUFPLEVBQUUsWUFBWSxFQUFFLFlBQVksRUFBRSxRQUFRLEVBQUUsTUFBTSxtQ0FBbUMsQ0FBQztBQUN6RixPQUFPLEVBQUUsT0FBTyxFQUFFLE1BQU0scUNBQXFDLENBQUM7QUFDOUQsT0FBTyxFQUFFLFVBQVUsRUFBRSxNQUFNLHFEQUFxRCxDQUFDO0FBRWpGLE9BQU8sRUFBRSxPQUFPLEVBQUUsS0FBSyxFQUFFLE1BQU0sa0NBQWtDLENBQUM7QUFDbEUsT0FBTyxFQUFFLFVBQVUsRUFBRSxlQUFlLEVBQWUsTUFBTSxzQ0FBc0MsQ0FBQztBQUNoRyxPQUFPLEVBQUUsU0FBUyxFQUFFLE1BQU0sc0NBQXNDLENBQUM7QUFDakUsT0FBTyxFQUFFLEdBQUcsRUFBRSxNQUFNLGdDQUFnQyxDQUFDO0FBQ3JELE9BQU8sRUFBRSxRQUFRLEVBQUUsTUFBTSxzQ0FBc0MsQ0FBQztBQUNoRSxPQUFPLEVBQVcsU0FBUyxFQUFFLFFBQVEsRUFBRSxNQUFNLG9DQUFvQyxDQUFDO0FBQ2xGLE9BQU8sRUFBRSxjQUFjLEVBQUUs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 \ No newline at end of file diff --git a/out/vs/workbench/contrib/turbofile/common/turbofileDragDrop.js b/out/vs/workbench/contrib/turbofile/common/turbofileDragDrop.js new file mode 100644 index 00000000..f1d9994c --- /dev/null +++ b/out/vs/workbench/contrib/turbofile/common/turbofileDragDrop.js @@ -0,0 +1,14 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +export function getTurboFileRowDragEffectAllowed() { + return 'copyMove'; +} +export function shouldHandleTurboFilePaneDropAtPage(sourceSide, targetSide) { + return sourceSide === undefined || sourceSide !== targetSide; +} +export function getTurboFileDirectoryMoveRefreshPaths(sourceDir, targetDir) { + return sourceDir === targetDir ? [sourceDir] : [sourceDir, targetDir]; +} +//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoidHVyYm9maWxlRHJhZ0Ryb3AuanMiLCJzb3VyY2VSb290IjoiZmlsZTovLy9EOi9Xb3JrL0NvZGUvVHVyYm9GaWxlLVdpbmRvd3Mvc3JjLyIsInNvdXJjZXMiOlsidnMvd29ya2JlbmNoL2NvbnRyaWIvdHVyYm9maWxlL2NvbW1vbi90dXJib2ZpbGVEcmFnRHJvcC50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFBQTs7O2dHQUdnRztBQUloRyxNQUFNLFVBQVUsZ0NBQWdDO0lBQy9DLE9BQU8sVUFBVSxDQUFDO0FBQ25CLENBQUM7QUFFRCxNQUFNLFVBQVUsbUNBQW1DLENBQUMsVUFBeUMsRUFBRSxVQUE2QjtJQUMzSCxPQUFPLFVBQVUsS0FBSyxTQUFTLElBQUksVUFBVSxLQUFLLFVBQVUsQ0FBQztBQUM5RCxDQUFDO0FBRUQsTUFBTSxVQUFVLHFDQUFxQyxDQUFDLFNBQWlCLEVBQUUsU0FBaUI7SUFDekYsT0FBTyxTQUFTLEtBQUssU0FBUyxDQUFDLENBQUMsQ0FBQyxDQUFDLFNBQVMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLFNBQVMsRUFBRSxTQUFTLENBQUMsQ0FBQztBQUN2RSxDQUFDIn0= \ No newline at end of file diff --git a/out/vs/workbench/contrib/turbofile/common/turbofileFileSort.js b/out/vs/workbench/contrib/turbofile/common/turbofileFileSort.js new file mode 100644 index 00000000..3491aa71 --- /dev/null +++ b/out/vs/workbench/contrib/turbofile/common/turbofileFileSort.js @@ -0,0 +1,45 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +export function sortTurboFileItems(items, key, direction) { + return [...items].sort((left, right) => compareTurboFileItems(left, right, key, direction)); +} +function compareTurboFileItems(left, right, key, direction) { + if (!!left.parent !== !!right.parent) { + return left.parent ? -1 : 1; + } + if (!!left.directory !== !!right.directory) { + return left.directory ? -1 : 1; + } + const comparison = compareTurboFileItemsByKey(left, right, key); + const directed = direction === 'descending' ? -comparison : comparison; + return directed || compareNames(left.name, right.name); +} +function compareTurboFileItemsByKey(left, right, key) { + switch (key) { + case 'modified': + return compareNumbers(left.rawModified, right.rawModified); + case 'size': + return compareNumbers(left.rawSize, right.rawSize); + case 'type': + return compareNames(getTypeSortKey(left), getTypeSortKey(right)); + case 'name': + default: + return compareNames(left.name, right.name); + } +} +function compareNumbers(left, right) { + return (left || 0) - (right || 0); +} +function compareNames(left, right) { + return left.localeCompare(right, undefined, { numeric: true, sensitivity: 'base' }); +} +function getTypeSortKey(item) { + if (item.directory) { + return 'directory'; + } + const dotIndex = item.name.lastIndexOf('.'); + return dotIndex >= 0 && dotIndex < item.name.length - 1 ? item.name.substring(dotIndex + 1).toLowerCase() : item.type; +} +//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoidHVyYm9maWxlRmlsZVNvcnQuanMiLCJzb3VyY2VSb290IjoiZmlsZTovLy9EOi9Xb3JrL0NvZGUvVHVyYm9GaWxlLVdpbmRvd3Mvc3JjLyIsInNvdXJjZXMiOlsidnMvd29ya2JlbmNoL2NvbnRyaWIvdHVyYm9maWxlL2NvbW1vbi90dXJib2ZpbGVGaWxlU29ydC50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFBQTs7O2dHQUdnRztBQWdCaEcsTUFBTSxVQUFVLGtCQUFrQixDQUFrQyxLQUFtQixFQUFFLEdBQXFCLEVBQUUsU0FBaUM7SUFDaEosT0FBTyxDQUFDLEdBQUcsS0FBSyxDQUFDLENBQUMsSUFBSSxDQUFDLENBQUMsSUFBSSxFQUFFLEtBQUssRUFBRSxFQUFFLENBQUMscUJBQXFCLENBQUMsSUFBSSxFQUFFLEtBQUssRUFBRSxHQUFHLEVBQUUsU0FBUyxDQUFDLENBQUMsQ0FBQztBQUM3RixDQUFDO0FBRUQsU0FBUyxxQkFBcUIsQ0FBQyxJQUEyQixFQUFFLEtBQTRCLEVBQUUsR0FBcUIsRUFBRSxTQUFpQztJQUNqSixJQUFJLENBQUMsQ0FBQyxJQUFJLENBQUMsTUFBTSxLQUFLLENBQUMsQ0FBQyxLQUFLLENBQUMsTUFBTSxFQUFFLENBQUM7UUFDdEMsT0FBTyxJQUFJLENBQUMsTUFBTSxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDO0lBQzdCLENBQUM7SUFDRCxJQUFJLENBQUMsQ0FBQyxJQUFJLENBQUMsU0FBUyxLQUFLLENBQUMsQ0FBQyxLQUFLLENBQUMsU0FBUyxFQUFFLENBQUM7UUFDNUMsT0FBTyxJQUFJLENBQUMsU0FBUyxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDO0lBQ2hDLENBQUM7SUFFRCxNQUFNLFVBQVUsR0FBRywwQkFBMEIsQ0FBQyxJQUFJLEVBQUUsS0FBSyxFQUFFLEdBQUcsQ0FBQyxDQUFDO0lBQ2hFLE1BQU0sUUFBUSxHQUFHLFNBQVMsS0FBSyxZQUFZLENBQUMsQ0FBQyxDQUFDLENBQUMsVUFBVSxDQUFDLENBQUMsQ0FBQyxVQUFVLENBQUM7SUFDdkUsT0FBTyxRQUFRLElBQUksWUFBWSxDQUFDLElBQUksQ0FBQyxJQUFJLEVBQUUsS0FBSyxDQUFDLElBQUksQ0FBQyxDQUFDO0FBQ3hELENBQUM7QUFFRCxTQUFTLDBCQUEwQixDQUFDLElBQTJCLEVBQUUsS0FBNEIsRUFBRSxHQUFxQjtJQUNuSCxRQUFRLEdBQUcsRUFBRSxDQUFDO1FBQ2IsS0FBSyxVQUFVO1lBQ2QsT0FBTyxjQUFjLENBQUMsSUFBSSxDQUFDLFdBQVcsRUFBRSxLQUFLLENBQUMsV0FBVyxDQUFDLENBQUM7UUFDNUQsS0FBSyxNQUFNO1lBQ1YsT0FBTyxjQUFjLENBQUMsSUFBSSxDQUFDLE9BQU8sRUFBRSxLQUFLLENBQUMsT0FBTyxDQUFDLENBQUM7UUFDcEQsS0FBSyxNQUFNO1lBQ1YsT0FBTyxZQUFZLENBQUMsY0FBYyxDQUFDLElBQUksQ0FBQyxFQUFFLGNBQWMsQ0FBQyxLQUFLLENBQUMsQ0FBQyxDQUFDO1FBQ2xFLEtBQUssTUFBTSxDQUFDO1FBQ1o7WUFDQyxPQUFPLFlBQVksQ0FBQyxJQUFJLENBQUMsSUFBSSxFQUFFLEtBQUssQ0FBQyxJQUFJLENBQUMsQ0FBQztJQUM3QyxDQUFDO0FBQ0YsQ0FBQztBQUVELFNBQVMsY0FBYyxDQUFDLElBQVksRUFBRSxLQUFhO0lBQ2xELE9BQU8sQ0FBQyxJQUFJLElBQUksQ0FBQyxDQUFDLEdBQUcsQ0FBQyxLQUFLLElBQUksQ0FBQyxDQUFDLENBQUM7QUFDbkMsQ0FBQztBQUVELFNBQVMsWUFBWSxDQUFDLElBQVksRUFBRSxLQUFhO0lBQ2hELE9BQU8sSUFBSSxDQUFDLGFBQWEsQ0FBQyxLQUFLLEVBQUUsU0FBUyxFQUFFLEVBQUUsT0FBTyxFQUFFLElBQUksRUFBRSxXQUFXLEVBQUUsTUFBTSxFQUFFLENBQUMsQ0FBQztBQUNyRixDQUFDO0FBRUQsU0FBUyxjQUFjLENBQUMsSUFBMkI7SUFDbEQsSUFBSSxJQUFJLENBQUMsU0FBUyxFQUFFLENBQUM7UUFDcEIsT0FBTyxXQUFXLENBQUM7SUFDcEIsQ0FBQztJQUNELE1BQU0sUUFBUSxHQUFHLElBQUksQ0FBQyxJQUFJLENBQUMsV0FBVyxDQUFDLEdBQUcsQ0FBQyxDQUFDO0lBQzVDLE9BQU8sUUFBUSxJQUFJLENBQUMsSUFBSSxRQUFRLEdBQUcsSUFBSSxDQUFDLElBQUksQ0FBQyxNQUFNLEdBQUcsQ0FBQyxDQUFDLENBQUMsQ0FBQyxJQUFJLENBQUMsSUFBSSxDQUFDLFNBQVMsQ0FBQyxRQUFRLEdBQUcsQ0FBQyxDQUFDLENBQUMsV0FBVyxFQUFFLENBQUMsQ0FBQyxDQUFDLElBQUksQ0FBQyxJQUFJLENBQUM7QUFDdkgsQ0FBQyJ9 \ No newline at end of file diff --git a/out/vs/workbench/contrib/turbofile/common/turbofileImageDimensions.js b/out/vs/workbench/contrib/turbofile/common/turbofileImageDimensions.js new file mode 100644 index 00000000..858590e4 --- /dev/null +++ b/out/vs/workbench/contrib/turbofile/common/turbofileImageDimensions.js @@ -0,0 +1,28 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +export function getTurboFileImageDimensions(image) { + const originalWidth = toPositiveInteger(image.originalWidth); + const originalHeight = toPositiveInteger(image.originalHeight); + if (originalWidth !== undefined && originalHeight !== undefined) { + return { width: originalWidth, height: originalHeight }; + } + const width = toPositiveInteger(image.width); + const height = toPositiveInteger(image.height); + if (width !== undefined && height !== undefined) { + return { width, height }; + } + return undefined; +} +export function formatTurboFileImageDimensions(image, separator = ' × ') { + const dimensions = getTurboFileImageDimensions(image); + return dimensions ? `${dimensions.width}${separator}${dimensions.height}` : ''; +} +function toPositiveInteger(value) { + if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) { + return undefined; + } + return Math.max(1, Math.round(value)); +} +//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoidHVyYm9maWxlSW1hZ2VEaW1lbnNpb25zLmpzIiwic291cmNlUm9vdCI6ImZpbGU6Ly8vRDovV29yay9Db2RlL1R1cmJvRmlsZS1XaW5kb3dzL3NyYy8iLCJzb3VyY2VzIjpbInZzL3dvcmtiZW5jaC9jb250cmliL3R1cmJvZmlsZS9jb21tb24vdHVyYm9maWxlSW1hZ2VEaW1lbnNpb25zLnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBOzs7Z0dBR2dHO0FBU2hHLE1BQU0sVUFBVSwyQkFBMkIsQ0FBQyxLQUFnQztJQUMzRSxNQUFNLGFBQWEsR0FBRyxpQkFBaUIsQ0FBQyxLQUFLLENBQUMsYUFBYSxDQUFDLENBQUM7SUFDN0QsTUFBTSxjQUFjLEdBQUcsaUJBQWlCLENBQUMsS0FBSyxDQUFDLGNBQWMsQ0FBQyxDQUFDO0lBQy9ELElBQUksYUFBYSxLQUFLLFNBQVMsSUFBSSxjQUFjLEtBQUssU0FBUyxFQUFFLENBQUM7UUFDakUsT0FBTyxFQUFFLEtBQUssRUFBRSxhQUFhLEVBQUUsTUFBTSxFQUFFLGNBQWMsRUFBRSxDQUFDO0lBQ3pELENBQUM7SUFFRCxNQUFNLEtBQUssR0FBRyxpQkFBaUIsQ0FBQyxLQUFLLENBQUMsS0FBSyxDQUFDLENBQUM7SUFDN0MsTUFBTSxNQUFNLEdBQUcsaUJBQWlCLENBQUMsS0FBSyxDQUFDLE1BQU0sQ0FBQyxDQUFDO0lBQy9DLElBQUksS0FBSyxLQUFLLFNBQVMsSUFBSSxNQUFNLEtBQUssU0FBUyxFQUFFLENBQUM7UUFDakQsT0FBTyxFQUFFLEtBQUssRUFBRSxNQUFNLEVBQUUsQ0FBQztJQUMxQixDQUFDO0lBRUQsT0FBTyxTQUFTLENBQUM7QUFDbEIsQ0FBQztBQUVELE1BQU0sVUFBVSw4QkFBOEIsQ0FBQyxLQUFnQyxFQUFFLFNBQVMsR0FBRyxLQUFLO0lBQ2pHLE1BQU0sVUFBVSxHQUFHLDJCQUEyQixDQUFDLEtBQUssQ0FBQyxDQUFDO0lBQ3RELE9BQU8sVUFBVSxDQUFDLENBQUMsQ0FBQyxHQUFHLFVBQVUsQ0FBQyxLQUFLLEdBQUcsU0FBUyxHQUFHLFVBQVUsQ0FBQyxNQUFNLEVBQUUsQ0FBQyxDQUFDLENBQUMsRUFBRSxDQUFDO0FBQ2hGLENBQUM7QUFFRCxTQUFTLGlCQUFpQixDQUFDLEtBQXlCO0lBQ25ELElBQUksT0FBTyxLQUFLLEtBQUssUUFBUSxJQUFJLENBQUMsTUFBTSxDQUFDLFFBQVEsQ0FBQyxLQUFLLENBQUMsSUFBSSxLQUFLLElBQUksQ0FBQyxFQUFFLENBQUM7UUFDeEUsT0FBTyxTQUFTLENBQUM7SUFDbEIsQ0FBQztJQUNELE9BQU8sSUFBSSxDQUFDLEdBQUcsQ0FBQyxDQUFDLEVBQUUsSUFBSSxDQUFDLEtBQUssQ0FBQyxLQUFLLENBQUMsQ0FBQyxDQUFDO0FBQ3ZDLENBQUMifQ== \ No newline at end of file diff --git a/out/vs/workbench/contrib/turbofile/common/turbofileImageGridSizing.js b/out/vs/workbench/contrib/turbofile/common/turbofileImageGridSizing.js new file mode 100644 index 00000000..4e594037 --- /dev/null +++ b/out/vs/workbench/contrib/turbofile/common/turbofileImageGridSizing.js @@ -0,0 +1,52 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +// Clamp the aspect ratio used for laying out a card. Without this, an extreme-aspect image (e.g. a +// 1024x32 crop) makes its card either freakishly wide — which inflates the grid's column width and +// leaves big gaps between every other card — or, fitted into the square preview, a ~4px sliver that +// looks like a stray dot. Clamping keeps the card a sane shape; the image itself fills it via CSS +// object-fit: cover, so a very wide/tall picture shows a centered crop instead of a sliver. +const MAX_GRID_CARD_ASPECT = 2.2; +export function getTurboFileImageGridCardSize(source, scale, baseCardWidth, basePreviewHeight, cardVerticalChrome, cardInlineChrome) { + const normalizedScale = normalizePositiveNumber(scale, 1); + const baseWidth = Math.max(1, Math.round(baseCardWidth)); + const previewWidth = Math.max(1, baseWidth - Math.max(0, Math.round(cardInlineChrome))); + const previewHeight = Math.max(1, Math.round(basePreviewHeight)); + const rawWidth = normalizePositiveNumber(source.width, 0); + const rawHeight = normalizePositiveNumber(source.height, 0); + const hasSourceSize = rawWidth > 0 && rawHeight > 0; + let sourceWidth = rawWidth; + let sourceHeight = rawHeight; + if (hasSourceSize) { + const ratio = rawWidth / rawHeight; + if (ratio > MAX_GRID_CARD_ASPECT) { + sourceHeight = rawWidth / MAX_GRID_CARD_ASPECT; + } + else if (ratio < 1 / MAX_GRID_CARD_ASPECT) { + sourceWidth = rawHeight / MAX_GRID_CARD_ASPECT; + } + } + const baseImageScale = hasSourceSize ? Math.min(previewWidth / sourceWidth, previewHeight / sourceHeight, 1) : 1; + const baseImageWidth = hasSourceSize ? Math.max(1, Math.round(sourceWidth * baseImageScale)) : previewWidth; + const baseImageHeight = hasSourceSize ? Math.max(1, Math.round(sourceHeight * baseImageScale)) : previewHeight; + const imageWidth = Math.max(1, Math.round(baseImageWidth * normalizedScale)); + const imageHeight = Math.max(1, Math.round(baseImageHeight * normalizedScale)); + return { + imageWidth, + imageHeight, + cardWidth: Math.max(baseWidth, Math.round(imageWidth + Math.max(0, cardInlineChrome))), + rowHeight: Math.max(1, Math.round(Math.max(previewHeight, imageHeight) + Math.max(0, cardVerticalChrome))) + }; +} +export function getTurboFileImageGridEffectiveColumns(maxColumns, availableWidth, cardWidth, gap) { + const columns = Math.max(1, Math.round(maxColumns)); + const width = Math.max(1, Math.floor(availableWidth)); + const itemWidth = Math.max(1, Math.ceil(cardWidth)); + const itemGap = Math.max(0, Math.round(gap)); + return Math.max(1, Math.min(columns, Math.floor((width + itemGap) / (itemWidth + itemGap)))); +} +function normalizePositiveNumber(value, fallback) { + return Number.isFinite(value) && value !== undefined && value > 0 ? value : fallback; +} +//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoidHVyYm9maWxlSW1hZ2VHcmlkU2l6aW5nLmpzIiwic291cmNlUm9vdCI6ImZpbGU6Ly8vRDovV29yay9Db2RlL1R1cmJvRmlsZS1XaW5kb3dzL3NyYy8iLCJzb3VyY2VzIjpbInZzL3dvcmtiZW5jaC9jb250cmliL3R1cmJvZmlsZS9jb21tb24vdHVyYm9maWxlSW1hZ2VHcmlkU2l6aW5nLnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBOzs7Z0dBR2dHO0FBY2hHLG1HQUFtRztBQUNuRyxtR0FBbUc7QUFDbkcsb0dBQW9HO0FBQ3BHLGtHQUFrRztBQUNsRyw0RkFBNEY7QUFDNUYsTUFBTSxvQkFBb0IsR0FBRyxHQUFHLENBQUM7QUFFakMsTUFBTSxVQUFVLDZCQUE2QixDQUFDLE1BQXFDLEVBQUUsS0FBYSxFQUFFLGFBQXFCLEVBQUUsaUJBQXlCLEVBQUUsa0JBQTBCLEVBQUUsZ0JBQXdCO0lBQ3pNLE1BQU0sZUFBZSxHQUFHLHVCQUF1QixDQUFDLEtBQUssRUFBRSxDQUFDLENBQUMsQ0FBQztJQUMxRCxNQUFNLFNBQVMsR0FBRyxJQUFJLENBQUMsR0FBRyxDQUFDLENBQUMsRUFBRSxJQUFJLENBQUMsS0FBSyxDQUFDLGFBQWEsQ0FBQyxDQUFDLENBQUM7SUFDekQsTUFBTSxZQUFZLEdBQUcsSUFBSSxDQUFDLEdBQUcsQ0FBQyxDQUFDLEVBQUUsU0FBUyxHQUFHLElBQUksQ0FBQyxHQUFHLENBQUMsQ0FBQyxFQUFFLElBQUksQ0FBQyxLQUFLLENBQUMsZ0JBQWdCLENBQUMsQ0FBQyxDQUFDLENBQUM7SUFDeEYsTUFBTSxhQUFhLEdBQUcsSUFBSSxDQUFDLEdBQUcsQ0FBQyxDQUFDLEVBQUUsSUFBSSxDQUFDLEtBQUssQ0FBQyxpQkFBaUIsQ0FBQyxDQUFDLENBQUM7SUFDakUsTUFBTSxRQUFRLEdBQUcsdUJBQXVCLENBQUMsTUFBTSxDQUFDLEtBQUssRUFBRSxDQUFDLENBQUMsQ0FBQztJQUMxRCxNQUFNLFNBQVMsR0FBRyx1QkFBdUIsQ0FBQyxNQUFN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 \ No newline at end of file diff --git a/out/vs/workbench/contrib/turbofile/common/turbofileImagePreview.js b/out/vs/workbench/contrib/turbofile/common/turbofileImagePreview.js new file mode 100644 index 00000000..8de70fd6 --- /dev/null +++ b/out/vs/workbench/contrib/turbofile/common/turbofileImagePreview.js @@ -0,0 +1,47 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +/** + * Sources at or below this size are previewed as the true original bytes — fast enough to stream + * and keeps pixel-exact fidelity for the inspector/histogram. + */ +export const TURBOFILE_PREVIEW_ORIGINAL_MAX_BYTES = 8 * 1024 * 1024; +/** + * Upper bound (px) for the long edge of a downscaled preview of a very large source. Shrink-only, + * so anything already within the bound is returned untouched; only oversized images are reduced. + * 4096 keeps the preview crisp at any normal display/zoom while making the payload small. + */ +export const TURBOFILE_PREVIEW_LARGE_MAX_DIMENSION = 4096; +/** + * Builds the read options for the full-size image preview. + * + * Small/medium sources read at original size (forceOriginal) for pixel-exact fidelity. Very large + * sources (e.g. 4K+ dense PNGs) are downscaled remotely to a display-resolution bound instead: + * transferring tens of MB of original bytes over IPC was slow and intermittently failed ("No + * response from server"), while a remote ImageMagick downscale returns in a fraction of the time + * and stays visually identical at normal zoom. The shrink-only geometry means this never enlarges. + */ +export function createTurboFileImagePreviewReadOptions(sourceSize, sourceModified) { + if (Number.isFinite(sourceSize) && sourceSize > TURBOFILE_PREVIEW_ORIGINAL_MAX_BYTES) { + return { + encoding: 'base64', + width: TURBOFILE_PREVIEW_LARGE_MAX_DIMENSION, + height: TURBOFILE_PREVIEW_LARGE_MAX_DIMENSION, + quality: 92, + format: 'webp', + interpolation: 'lanczos', + maxBytes: 256 * 1024 * 1024, + sourceSize, + sourceModified + }; + } + return { + encoding: 'base64', + forceOriginal: true, + maxBytes: 128 * 1024 * 1024, + sourceSize, + sourceModified + }; +} +//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoidHVyYm9maWxlSW1hZ2VQcmV2aWV3LmpzIiwic291cmNlUm9vdCI6ImZpbGU6Ly8vRDovV29yay9Db2RlL1R1cmJvRmlsZS1XaW5kb3dzL3NyYy8iLCJzb3VyY2VzIjpbInZzL3dvcmtiZW5jaC9jb250cmliL3R1cmJvZmlsZS9jb21tb24vdHVyYm9maWxlSW1hZ2VQcmV2aWV3LnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBOzs7Z0dBR2dHO0FBSWhHOzs7R0FHRztBQUNILE1BQU0sQ0FBQyxNQUFNLG9DQUFvQyxHQUFHLENBQUMsR0FBRyxJQUFJLEdBQUcsSUFBSSxDQUFDO0FBRXBFOzs7O0dBSUc7QUFDSCxNQUFNLENBQUMsTUFBTSxxQ0FBcUMsR0FBRyxJQUFJLENBQUM7QUFFMUQ7Ozs7Ozs7O0dBUUc7QUFDSCxNQUFNLFVBQVUsc0NBQXNDLENBQUMsVUFBa0IsRUFBRSxjQUFzQjtJQUNoRyxJQUFJLE1BQU0sQ0FBQyxRQUFRLENBQUMsVUFBVSxDQUFDLElBQUksVUFBVSxHQUFHLG9DQUFvQyxFQUFFLENBQUM7UUFDdEYsT0FBTztZQUNOLFFBQVEsRUFBRSxRQUFRO1lBQ2xCLEtBQUssRUFBRSxxQ0FBcUM7WUFDNUMsTUFBTSxFQUFFLHFDQUFxQztZQUM3QyxPQUFPLEVBQUUsRUFBRTtZQUNYLE1BQU0sRUFBRSxNQUFNO1lBQ2QsYUFBYSxFQUFFLFNBQVM7WUFDeEIsUUFBUSxFQUFFLEdBQUcsR0FBRyxJQUFJLEdBQUcsSUFBSTtZQUMzQixVQUFVO1lBQ1YsY0FBYztTQUNkLENBQUM7SUFDSCxDQUFDO0lBQ0QsT0FBTztRQUNOLFFBQVEsRUFBRSxRQUFRO1FBQ2xCLGFBQWEsRUFBRSxJQUFJO1FBQ25CLFFBQVEsRUFBRSxHQUFHLEdBQUcsSUFBSSxHQUFHLElBQUk7UUFDM0IsVUFBVTtRQUNWLGNBQWM7S0FDZCxDQUFDO0FBQ0gsQ0FBQyJ9 \ No newline at end of file diff --git a/out/vs/workbench/contrib/turbofile/common/turbofileImageResource.js b/out/vs/workbench/contrib/turbofile/common/turbofileImageResource.js new file mode 100644 index 00000000..c617a535 --- /dev/null +++ b/out/vs/workbench/contrib/turbofile/common/turbofileImageResource.js @@ -0,0 +1,53 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +export const TURBOFILE_IMAGE_GRID_RESOURCE_QUALITY = 72; +export const TURBOFILE_IMAGE_PREVIEW_RESOURCE_QUALITY = 82; +export const TURBOFILE_IMAGE_PREVIEW_MAX_BYTES = 128 * 1024 * 1024; +/** + * Sources larger than this read as the true original for the preview. Above it, the original is + * almost always a high-megapixel image (e.g. super-resolution PNGs) that the renderer's + * decoder refuses to rasterize ("Failed to load image"), so it is downscaled to a display bound + * instead. The shrink-only geometry means smaller images keep their native size. + */ +export const TURBOFILE_IMAGE_PREVIEW_ORIGINAL_MAX_BYTES = 12 * 1024 * 1024; +export function createTurboFileImageGridResourceOptions(width, sourceSize, sourceModified) { + return { + width, + quality: TURBOFILE_IMAGE_GRID_RESOURCE_QUALITY, + // JPEG, not WebP: some servers' ImageMagick WebP delegate produced byte streams that passed a + // shallow magic-byte check but that this build's Chromium decoder rejected for certain + // sources, leaving grid cards on the generic file icon. JPEG decodes universally and is + // verifiable in the main process before it is served. + format: 'jpeg', + interpolation: 'lanczos', + sourceSize, + sourceModified + }; +} +export function createTurboFileImagePreviewResourceOptions(_isWindowsSsh, request, sourceSize, sourceModified) { + // Read the true original only when it is small enough to be both transferred quickly and + // rasterized by the renderer. Very large sources are still downscaled to a display bound so the + // preview always loads regardless of source dimensions. + const mustDownscale = Number.isFinite(sourceSize) && sourceSize > TURBOFILE_IMAGE_PREVIEW_ORIGINAL_MAX_BYTES; + if (!mustDownscale) { + return { + forceOriginal: true, + maxBytes: TURBOFILE_IMAGE_PREVIEW_MAX_BYTES, + sourceSize, + sourceModified + }; + } + return { + width: request.width, + height: request.height, + quality: request.quality, + format: 'jpeg', + interpolation: 'lanczos', + maxBytes: TURBOFILE_IMAGE_PREVIEW_MAX_BYTES, + sourceSize, + sourceModified + }; +} +//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoidHVyYm9maWxlSW1hZ2VSZXNvdXJjZS5qcyIsInNvdXJjZVJvb3QiOiJmaWxlOi8vL0Q6L1dvcmsvQ29kZS9UdXJib0ZpbGUtV2luZG93cy9zcmMvIiwic291cmNlcyI6WyJ2cy93b3JrYmVuY2gvY29udHJpYi90dXJib2ZpbGUvY29tbW9uL3R1cmJvZmlsZUltYWdlUmVzb3VyY2UudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBQUE7OztnR0FHZ0c7QUFJaEcsTUFBTSxDQUFDLE1BQU0scUNBQXFDLEdBQUcsRUFBRSxDQUFDO0FBQ3hELE1BQU0sQ0FBQyxNQUFNLHdDQUF3QyxHQUFHLEVBQUUsQ0FBQztBQUMzRCxNQUFNLENBQUMsTUFBTSxpQ0FBaUMsR0FBRyxHQUFHLEdBQUcsSUFBSSxHQUFHLElBQUksQ0FBQztBQUVuRTs7Ozs7R0FLRztBQUNILE1BQU0sQ0FBQyxNQUFNLDBDQUEwQyxHQUFHLEVBQUUsR0FBRyxJQUFJLEdBQUcsSUFBSSxDQUFDO0FBUTNFLE1BQU0sVUFBVSx1Q0FBdUMsQ0FBQyxLQUFhLEVBQUUsVUFBa0IsRUFBRSxjQUFzQjtJQUNoSCxPQUFPO1FBQ04sS0FBSztRQUNMLE9BQU8sRUFBRSxxQ0FBcUM7UUFDOUMsOEZBQThGO1FBQzlGLDZGQUE2RjtRQUM3Rix3RkFBd0Y7UUFDeEYsc0RBQXNEO1FBQ3RELE1BQU0sRUFBRSxNQUFNO1FBQ2QsYUFBYSxFQUFFLFNBQVM7UUFDeEIsVUFBVTtRQUNWLGNBQWM7S0FDZCxDQUFDO0FBQ0gsQ0FBQztBQUVELE1BQU0sVUFBVSwwQ0FBMEMsQ0FBQyxhQUFzQixFQUFFLE9BQTJDLEVBQUUsVUFBa0IsRUFBRSxjQUFzQjtJQUN6Syx5RkFBeUY7SUFDekYsZ0dBQWdHO0lBQ2hHLHdEQUF3RDtJQUN4RCxNQUFNLGFBQWEsR0FBRyxNQUFNLENBQUMsUUFBUSxDQUFDLFVBQVUsQ0FBQyxJQUFJLFVBQVUsR0FBRywwQ0FBMEMsQ0FBQztJQUM3RyxJQUFJLENBQUMsYUFBYSxFQUFFLENBQUM7UUFDcEIsT0FBTztZQUNOLGFBQWEsRUFBRSxJQUFJO1lBQ25CLFFBQVEsRUFBRSxpQ0FBaUM7WUFDM0MsVUFBVTtZQUNWLGNBQWM7U0FDZCxDQUFDO0lBQ0gsQ0FBQztJQUVELE9BQU87UUFDTixLQUFLLEVBQUUsT0FBTyxDQUFDLEtBQUs7UUFDcEIsTUFBTSxFQUFFLE9BQU8sQ0FBQyxNQUFNO1FBQ3RCLE9BQU8sRUFBRSxPQUFPLENBQUMsT0FBTztRQUN4QixNQUFNLEVBQUUsTUFBTTtRQUNkLGFBQWEsRUFBRSxTQUFTO1FBQ3hCLFFBQVEsRUFBRSxpQ0FBaUM7UUFDM0MsVUFBVTtRQUNWLGNBQWM7S0FDZCxDQUFDO0FBQ0gsQ0FBQyJ9 \ No newline at end of file diff --git a/out/vs/workbench/contrib/turbofile/common/turbofileWindowsLocation.js b/out/vs/workbench/contrib/turbofile/common/turbofileWindowsLocation.js new file mode 100644 index 00000000..5a171509 --- /dev/null +++ b/out/vs/workbench/contrib/turbofile/common/turbofileWindowsLocation.js @@ -0,0 +1,14 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +export function shouldRetargetWindowsDesktopPane(currentPath, previousDesktopPath, profileDesktopPath) { + return isSameWindowsPath(currentPath, previousDesktopPath) || isSameWindowsPath(currentPath, profileDesktopPath); +} +function isSameWindowsPath(left, right) { + return getWindowsPathKey(left) === getWindowsPathKey(right); +} +function getWindowsPathKey(path) { + return path.replace(/\//g, '\\').replace(/\\+$/, '').toLowerCase(); +} +//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoidHVyYm9maWxlV2luZG93c0xvY2F0aW9uLmpzIiwic291cmNlUm9vdCI6ImZpbGU6Ly8vRDovV29yay9Db2RlL1R1cmJvRmlsZS1XaW5kb3dzL3NyYy8iLCJzb3VyY2VzIjpbInZzL3dvcmtiZW5jaC9jb250cmliL3R1cmJvZmlsZS9jb21tb24vdHVyYm9maWxlV2luZG93c0xvY2F0aW9uLnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBOzs7Z0dBR2dHO0FBRWhHLE1BQU0sVUFBVSxnQ0FBZ0MsQ0FBQyxXQUFtQixFQUFFLG1CQUEyQixFQUFFLGtCQUEwQjtJQUM1SCxPQUFPLGlCQUFpQixDQUFDLFdBQVcsRUFBRSxtQkFBbUIsQ0FBQyxJQUFJLGlCQUFpQixDQUFDLFdBQVcsRUFBRSxrQkFBa0IsQ0FBQyxDQUFDO0FBQ2xILENBQUM7QUFFRCxTQUFTLGlCQUFpQixDQUFDLElBQVksRUFBRSxLQUFhO0lBQ3JELE9BQU8saUJBQWlCLENBQUMsSUFBSSxDQUFDLEtBQUssaUJBQWlCLENBQUMsS0FBSyxDQUFDLENBQUM7QUFDN0QsQ0FBQztBQUVELFNBQVMsaUJBQWlCLENBQUMsSUFBWTtJQUN0QyxPQUFPLElBQUksQ0FBQyxPQUFPLENBQUMsS0FBSyxFQUFFLElBQUksQ0FBQyxDQUFDLE9BQU8sQ0FBQyxNQUFNLEVBQUUsRUFBRSxDQUFDLENBQUMsV0FBVyxFQUFFLENBQUM7QUFDcEUsQ0FBQyJ9 \ No newline at end of file diff --git a/out/vs/workbench/contrib/turbofile/test/common/turbofileImageDimensions.test.js b/out/vs/workbench/contrib/turbofile/test/common/turbofileImageDimensions.test.js new file mode 100644 index 00000000..35e47c17 --- /dev/null +++ b/out/vs/workbench/contrib/turbofile/test/common/turbofileImageDimensions.test.js @@ -0,0 +1,23 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import * as assert from 'assert'; +import { formatTurboFileImageDimensions } from '../../common/turbofileImageDimensions.js'; +suite('TurboFileImageDimensions', () => { + test('prefers original dimensions over transformed preview dimensions', () => { + assert.strictEqual(formatTurboFileImageDimensions({ + width: 1920, + height: 360, + originalWidth: 2048, + originalHeight: 384 + }), '2048 × 384'); + }); + test('falls back to transformed dimensions when original dimensions are absent', () => { + assert.strictEqual(formatTurboFileImageDimensions({ + width: 1920, + height: 360 + }), '1920 × 360'); + }); +}); +//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoidHVyYm9maWxlSW1hZ2VEaW1lbnNpb25zLnRlc3QuanMiLCJzb3VyY2VSb290IjoiZmlsZTovLy9EOi9Xb3JrL0NvZGUvVHVyYm9GaWxlLVdpbmRvd3Mvc3JjLyIsInNvdXJjZXMiOlsidnMvd29ya2JlbmNoL2NvbnRyaWIvdHVyYm9maWxlL3Rlc3QvY29tbW9uL3R1cmJvZmlsZUltYWdlRGltZW5zaW9ucy50ZXN0LnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBOzs7Z0dBR2dHO0FBRWhHLE9BQU8sS0FBSyxNQUFNLE1BQU0sUUFBUSxDQUFDO0FBQ2pDLE9BQU8sRUFBRSw4QkFBOEIsRUFBRSxNQUFNLDBDQUEwQyxDQUFDO0FBRTFGLEtBQUssQ0FBQywwQkFBMEIsRUFBRSxHQUFHLEVBQUU7SUFDdEMsSUFBSSxDQUFDLGlFQUFpRSxFQUFFLEdBQUcsRUFBRTtRQUM1RSxNQUFNLENBQUMsV0FBVyxDQUFDLDhCQUE4QixDQUFDO1lBQ2pELEtBQUssRUFBRSxJQUFJO1lBQ1gsTUFBTSxFQUFFLEdBQUc7WUFDWCxhQUFhLEVBQUUsSUFBSTtZQUNuQixjQUFjLEVBQUUsR0FBRztTQUNuQixDQUFDLEVBQUUsWUFBWSxDQUFDLENBQUM7SUFDbkIsQ0FBQyxDQUFDLENBQUM7SUFFSCxJQUFJLENBQUMsMEVBQTBFLEVBQUUsR0FBRyxFQUFFO1FBQ3JGLE1BQU0sQ0FBQyxXQUFXLENBQUMsOEJBQThCLENBQUM7WUFDakQsS0FBSyxFQUFFLElBQUk7WUFDWCxNQUFNLEVBQUUsR0FBRztTQUNYLENBQUMsRUFBRSxZQUFZLENBQUMsQ0FBQztJQUNuQixDQUFDLENBQUMsQ0FBQztBQUNKLENBQUMsQ0FBQyxDQUFDIn0= \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 97d3cee8..997eae71 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "turbofile-windows", - "version": "1.119.0", + "version": "1.155.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "turbofile-windows", - "version": "1.119.0", + "version": "1.155.0", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -16,11 +16,6 @@ "@github/copilot-sdk": "^0.3.0", "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", - "@microsoft/dev-tunnels-connections": "^1.3.41", - "@microsoft/dev-tunnels-contracts": "^1.3.41", - "@microsoft/dev-tunnels-management": "^1.3.41", - "@microsoft/dev-tunnels-ssh": "^3.12.22", - "@microsoft/dev-tunnels-ssh-tcp": "^3.12.22", "@parcel/watcher": "^2.5.6", "@types/semver": "^7.5.8", "@vscode/codicons": "^0.0.46-6", @@ -1616,118 +1611,6 @@ "resolved": "https://registry.npmjs.org/@microsoft/applicationinsights-shims/-/applicationinsights-shims-2.0.2.tgz", "integrity": "sha512-PoHEgsnmcqruLNHZ/amACqdJ6YYQpED0KSRe6J7gIJTtpZC1FfFU9b1fmDKDKtFoUSrPzEh1qzO3kmRZP0betg==" }, - "node_modules/@microsoft/dev-tunnels-connections": { - "version": "1.3.41", - "resolved": "https://registry.npmjs.org/@microsoft/dev-tunnels-connections/-/dev-tunnels-connections-1.3.41.tgz", - "integrity": "sha512-6TcFQ0BE+lFYRFHJcAEkxyiQ7Y4rXH6jjGGYSjPNEkyiyC6r503m5gukHZGHJE9GOpbH3eVrSZYJ+7/gNULvzA==", - "license": "MIT", - "dependencies": { - "@microsoft/dev-tunnels-contracts": "1.3.41", - "@microsoft/dev-tunnels-management": "1.3.41", - "await-semaphore": "^0.1.3", - "buffer": "^5.2.1", - "debug": "^4.1.1", - "es5-ext": "0.10.64", - "uuid": "^3.3.3", - "vscode-jsonrpc": "^4.0.0", - "websocket": "^1.0.28" - }, - "peerDependencies": { - "@microsoft/dev-tunnels-ssh": "^3.12.22", - "@microsoft/dev-tunnels-ssh-tcp": "^3.12.22" - } - }, - "node_modules/@microsoft/dev-tunnels-connections/node_modules/uuid": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", - "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==", - "deprecated": "Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details.", - "license": "MIT", - "bin": { - "uuid": "bin/uuid" - } - }, - "node_modules/@microsoft/dev-tunnels-connections/node_modules/vscode-jsonrpc": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-4.0.0.tgz", - "integrity": "sha512-perEnXQdQOJMTDFNv+UF3h1Y0z4iSiaN9jIlb0OqIYgosPCZGYh/MCUlkFtV2668PL69lRDO32hmvL2yiidUYg==", - "license": "MIT", - "engines": { - "node": ">=8.0.0 || >=10.0.0" - } - }, - "node_modules/@microsoft/dev-tunnels-contracts": { - "version": "1.3.41", - "resolved": "https://registry.npmjs.org/@microsoft/dev-tunnels-contracts/-/dev-tunnels-contracts-1.3.41.tgz", - "integrity": "sha512-TpaIbXVLMS2kX6XmtLpGisy6om4lzI3c6uRsJDV2PrCeCy/unk2H4+cC9Yb2y50iQRTpAiviYoEiT594BOyyYA==", - "license": "MIT", - "dependencies": { - "buffer": "^5.2.1", - "debug": "^4.1.1", - "vscode-jsonrpc": "^4.0.0" - } - }, - "node_modules/@microsoft/dev-tunnels-contracts/node_modules/vscode-jsonrpc": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-4.0.0.tgz", - "integrity": "sha512-perEnXQdQOJMTDFNv+UF3h1Y0z4iSiaN9jIlb0OqIYgosPCZGYh/MCUlkFtV2668PL69lRDO32hmvL2yiidUYg==", - "license": "MIT", - "engines": { - "node": ">=8.0.0 || >=10.0.0" - } - }, - "node_modules/@microsoft/dev-tunnels-management": { - "version": "1.3.41", - "resolved": "https://registry.npmjs.org/@microsoft/dev-tunnels-management/-/dev-tunnels-management-1.3.41.tgz", - "integrity": "sha512-Xaj9l4ccUOLVcO4MBAC0L1bz8dHKXMqZ471jgKKQvX0n3cTvLEGVab3rS5qYMraNxUTSUTeDiGCh3ZsKZ23RsQ==", - "license": "MIT", - "dependencies": { - "@microsoft/dev-tunnels-contracts": "1.3.41", - "axios": "^1.8.4", - "buffer": "^5.2.1", - "debug": "^4.1.1", - "vscode-jsonrpc": "^4.0.0" - } - }, - "node_modules/@microsoft/dev-tunnels-management/node_modules/vscode-jsonrpc": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-4.0.0.tgz", - "integrity": "sha512-perEnXQdQOJMTDFNv+UF3h1Y0z4iSiaN9jIlb0OqIYgosPCZGYh/MCUlkFtV2668PL69lRDO32hmvL2yiidUYg==", - "license": "MIT", - "engines": { - "node": ">=8.0.0 || >=10.0.0" - } - }, - "node_modules/@microsoft/dev-tunnels-ssh": { - "version": "3.12.22", - "resolved": "https://registry.npmjs.org/@microsoft/dev-tunnels-ssh/-/dev-tunnels-ssh-3.12.22.tgz", - "integrity": "sha512-BiYjRiBAbvo306zeZmRaLgY5f3JuN8x0F108ZcssVwL11CUP5zvYwjyOUysMOSH42qfQG/zCG+1b56muLGW+5A==", - "license": "MIT", - "dependencies": { - "buffer": "^5.2.1", - "debug": "^4.1.1", - "diffie-hellman": "^5.0.3", - "vscode-jsonrpc": "^4.0.0" - } - }, - "node_modules/@microsoft/dev-tunnels-ssh-tcp": { - "version": "3.12.22", - "resolved": "https://registry.npmjs.org/@microsoft/dev-tunnels-ssh-tcp/-/dev-tunnels-ssh-tcp-3.12.22.tgz", - "integrity": "sha512-oZs7CLRv5V6Lo0+oFGsW2EwKFAzAdQ+sa+06yc9O4CyV0HAywP/E6o52vhjVJD4RpsZzCTsUf0ApoYYr05hDpw==", - "license": "MIT", - "dependencies": { - "@microsoft/dev-tunnels-ssh": "~3.12" - } - }, - "node_modules/@microsoft/dev-tunnels-ssh/node_modules/vscode-jsonrpc": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-4.0.0.tgz", - "integrity": "sha512-perEnXQdQOJMTDFNv+UF3h1Y0z4iSiaN9jIlb0OqIYgosPCZGYh/MCUlkFtV2668PL69lRDO32hmvL2yiidUYg==", - "license": "MIT", - "engines": { - "node": ">=8.0.0 || >=10.0.0" - } - }, "node_modules/@microsoft/dynamicproto-js": { "version": "1.1.9", "resolved": "https://registry.npmjs.org/@microsoft/dynamicproto-js/-/dynamicproto-js-1.1.9.tgz", @@ -4908,7 +4791,8 @@ "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k= sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k= sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true }, "node_modules/atob": { "version": "2.1.2", @@ -4938,32 +4822,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/await-semaphore": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/await-semaphore/-/await-semaphore-0.1.3.tgz", - "integrity": "sha512-d1W2aNSYcz/sxYO4pMGX9vq65qOTu0P800epMud+6cYYX0QcT7zyqcxec3VWzpgvdXo57UWmVbZpLMjX2m1I7Q==", - "license": "MIT" - }, - "node_modules/axios": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz", - "integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==", - "license": "MIT", - "dependencies": { - "follow-redirects": "^1.15.11", - "form-data": "^4.0.5", - "proxy-from-env": "^2.1.0" - } - }, - "node_modules/axios/node_modules/proxy-from-env": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", - "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", - "license": "MIT", - "engines": { - "node": ">=10" - } - }, "node_modules/b4a": { "version": "1.6.4", "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.6.4.tgz", @@ -5193,12 +5051,6 @@ "readable-stream": "^3.4.0" } }, - "node_modules/bn.js": { - "version": "4.12.3", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", - "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==", - "license": "MIT" - }, "node_modules/body-parser": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", @@ -5260,12 +5112,6 @@ "node": ">=8" } }, - "node_modules/brorand": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/brorand/-/brorand-1.1.0.tgz", - "integrity": "sha512-cKV8tMCEpQs4hK/ik71d6LrPOnpkpGBR0wzxqr68g2m/LB2GxVYQroAjMJZRVM1Y4BCjCKc3vAamxSzOY2RP+w==", - "license": "MIT" - }, "node_modules/browser-stdout": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz", @@ -5361,19 +5207,6 @@ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "dev": true }, - "node_modules/bufferutil": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.1.0.tgz", - "integrity": "sha512-ZMANVnAixE6AWWnPzlW2KpUrxhm9woycYvPOo67jWHyFowASTEd9s+QN1EIMsSDtwhIxN4sWE1jotpuDUIgyIw==", - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "node-gyp-build": "^4.3.0" - }, - "engines": { - "node": ">=6.14.2" - } - }, "node_modules/bundle-name": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", @@ -5506,6 +5339,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -5998,6 +5832,7 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, "dependencies": { "delayed-stream": "~1.0.0" }, @@ -6009,6 +5844,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk= sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, "engines": { "node": ">=0.4.0" } @@ -6361,6 +6197,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/d/-/d-1.0.1.tgz", "integrity": "sha512-m62ShEObQ39CfralilEQRjH6oAMtNCV1xJyEx5LpRYUVN+EviphDgUc/F3hnYbADmkiNs67Y+3ylmlG7Lnu+FA==", + "dev": true, "dependencies": { "es5-ext": "^0.10.50", "type": "^1.0.1" @@ -6778,17 +6615,6 @@ "node": ">=0.3.1" } }, - "node_modules/diffie-hellman": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/diffie-hellman/-/diffie-hellman-5.0.3.tgz", - "integrity": "sha512-kqag/Nl+f3GwyK25fhUMYj81BUOrZ9IuJsjIcDE5icNM9FJHAVm3VcUDxdLPoQtTuUylWm6ZIknYJwwaPxsUzg==", - "license": "MIT", - "dependencies": { - "bn.js": "^4.1.0", - "miller-rabin": "^4.0.0", - "randombytes": "^2.0.0" - } - }, "node_modules/doctrine": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", @@ -6862,6 +6688,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.1", @@ -7151,6 +6978,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -7160,6 +6988,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, "engines": { "node": ">= 0.4" } @@ -7174,6 +7003,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -7186,6 +7016,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -7232,6 +7063,7 @@ "version": "0.10.64", "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.64.tgz", "integrity": "sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg==", + "dev": true, "hasInstallScript": true, "license": "ISC", "dependencies": { @@ -7255,6 +7087,7 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.3.tgz", "integrity": "sha1-p96IkUGgWpSwhUQDstCg+/qY87c= sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g==", + "dev": true, "dependencies": { "d": "1", "es5-ext": "^0.10.35", @@ -7265,6 +7098,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.3.tgz", "integrity": "sha512-NJ6Yn3FuDinBaBRWl/q5X/s4koRHBrgKAu+yGI6JCBeiu3qrcbJhwT2GeR/EXVfylRk8dpQVJoLEFhK+Mu31NA==", + "dev": true, "dependencies": { "d": "^1.0.1", "ext": "^1.1.2" @@ -7611,6 +7445,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/esniff/-/esniff-2.0.1.tgz", "integrity": "sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg==", + "dev": true, "dependencies": { "d": "^1.0.1", "es5-ext": "^0.10.62", @@ -7624,7 +7459,8 @@ "node_modules/esniff/node_modules/type": { "version": "2.7.2", "resolved": "https://registry.npmjs.org/type/-/type-2.7.2.tgz", - "integrity": "sha512-dzlvlNlt6AXU7EBSfpAscydQ7gXB+pPGsPnfJnZpiNJBDj7IaJzQlBZYGdEi4R9HmPdBv2XmWJ6YUtoTa7lmCw==" + "integrity": "sha512-dzlvlNlt6AXU7EBSfpAscydQ7gXB+pPGsPnfJnZpiNJBDj7IaJzQlBZYGdEi4R9HmPdBv2XmWJ6YUtoTa7lmCw==", + "dev": true }, "node_modules/espree": { "version": "10.4.0", @@ -7713,6 +7549,7 @@ "version": "0.3.5", "resolved": "https://registry.npmjs.org/event-emitter/-/event-emitter-0.3.5.tgz", "integrity": "sha1-34xp7vFkeSPHFXuc6DhAYQsCzDk= sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==", + "dev": true, "dependencies": { "d": "1", "es5-ext": "~0.10.14" @@ -8054,6 +7891,7 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/ext/-/ext-1.4.0.tgz", "integrity": "sha512-Key5NIsUxdqKg3vIsdw9dSuXpPCQ297y6wBjL30edxwPgt2E44WcWBZey/ZvUc6sERLTxKdyCu4gZFmUbk1Q7A==", + "dev": true, "dependencies": { "type": "^2.0.0" } @@ -8061,7 +7899,8 @@ "node_modules/ext/node_modules/type": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/type/-/type-2.1.0.tgz", - "integrity": "sha512-G9absDWvhAWCV2gmF1zKud3OyC61nZDwWvBL2DApaVFogI07CprggiQAOOjvp2NRjYWFzPyu7vwtDrQFq8jeSA==" + "integrity": "sha512-G9absDWvhAWCV2gmF1zKud3OyC61nZDwWvBL2DApaVFogI07CprggiQAOOjvp2NRjYWFzPyu7vwtDrQFq8jeSA==", + "dev": true }, "node_modules/extend": { "version": "3.0.2", @@ -8624,26 +8463,6 @@ "util-deprecate": "~1.0.1" } }, - "node_modules/follow-redirects": { - "version": "1.16.0", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", - "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], - "license": "MIT", - "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } - } - }, "node_modules/for-each": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", @@ -8701,6 +8520,7 @@ "version": "4.0.5", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dev": true, "license": "MIT", "dependencies": { "asynckit": "^0.4.0", @@ -8841,6 +8661,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -8908,6 +8729,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -8932,6 +8754,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, "license": "MIT", "dependencies": { "dunder-proto": "^1.0.1", @@ -9514,6 +9337,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -10910,6 +10734,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -10922,6 +10747,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, "license": "MIT", "dependencies": { "has-symbols": "^1.0.3" @@ -11000,6 +10826,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, "dependencies": { "function-bind": "^1.1.2" }, @@ -12038,12 +11865,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-typedarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", - "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==", - "license": "MIT" - }, "node_modules/is-unc-path": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-unc-path/-/is-unc-path-1.0.0.tgz", @@ -13261,6 +13082,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -13372,19 +13194,6 @@ "node": ">=8.6" } }, - "node_modules/miller-rabin": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/miller-rabin/-/miller-rabin-4.0.1.tgz", - "integrity": "sha512-115fLhvZVqWwHPbClyntxEVfVDfl9DLLTuJvq3g2O/Oxi8AiNouAHvDSzHS0viUJc+V5vm3eq91Xwqn9dp4jRA==", - "license": "MIT", - "dependencies": { - "bn.js": "^4.0.0", - "brorand": "^1.0.1" - }, - "bin": { - "miller-rabin": "bin/miller-rabin" - } - }, "node_modules/mime": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", @@ -13401,6 +13210,7 @@ "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -13410,6 +13220,7 @@ "version": "2.1.35", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, "license": "MIT", "dependencies": { "mime-db": "1.52.0" @@ -13880,7 +13691,8 @@ "node_modules/next-tick": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.1.0.tgz", - "integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==" + "integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==", + "dev": true }, "node_modules/nise": { "version": "5.1.0", @@ -13979,17 +13791,6 @@ } } }, - "node_modules/node-gyp-build": { - "version": "4.8.1", - "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.1.tgz", - "integrity": "sha512-OSs33Z9yWr148JZcbZd5WiAXhh/n9z8TxQcdMhIOlpN9AhWpLfvVFO73+m77bBABQMaY9XSvIa+qk0jlI7Gcaw==", - "license": "MIT", - "bin": { - "node-gyp-build": "bin.js", - "node-gyp-build-optional": "optional.js", - "node-gyp-build-test": "build-test.js" - } - }, "node_modules/node-html-markdown": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/node-html-markdown/-/node-html-markdown-1.3.0.tgz", @@ -15512,15 +15313,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/randombytes": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", - "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", - "license": "MIT", - "dependencies": { - "safe-buffer": "^5.1.0" - } - }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -17251,6 +17043,9 @@ "nan": "^2.23.0" } }, + "node_modules/ssh2/node_modules/cpu-features": { + "optional": true + }, "node_modules/stable": { "version": "0.1.8", "resolved": "https://registry.npmjs.org/stable/-/stable-0.1.8.tgz", @@ -18442,7 +18237,8 @@ "node_modules/type": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/type/-/type-1.2.0.tgz", - "integrity": "sha512-+5nt5AAniqsCnu2cEQQdpzCAh33kVx8n0VoFidKpB1dVVLAN/F+bgVOqOJqOnEnrhp222clB5p3vUlD+1QAnfg==" + "integrity": "sha512-+5nt5AAniqsCnu2cEQQdpzCAh33kVx8n0VoFidKpB1dVVLAN/F+bgVOqOJqOnEnrhp222clB5p3vUlD+1QAnfg==", + "dev": true }, "node_modules/type-check": { "version": "0.4.0", @@ -18604,15 +18400,6 @@ "integrity": "sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c= sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", "dev": true }, - "node_modules/typedarray-to-buffer": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz", - "integrity": "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==", - "license": "MIT", - "dependencies": { - "is-typedarray": "^1.0.0" - } - }, "node_modules/typescript": { "version": "6.0.0-dev.20260416", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.0-dev.20260416.tgz", @@ -18939,19 +18726,6 @@ "node": ">=0.10.0" } }, - "node_modules/utf-8-validate": { - "version": "5.0.10", - "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz", - "integrity": "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==", - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "node-gyp-build": "^4.3.0" - }, - "engines": { - "node": ">=6.14.2" - } - }, "node_modules/util": { "version": "0.12.5", "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz", @@ -19243,38 +19017,6 @@ "integrity": "sha1-JFNCdeKnvGvnvIZhHMFq4KVlSHE= sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", "dev": true }, - "node_modules/websocket": { - "version": "1.0.35", - "resolved": "https://registry.npmjs.org/websocket/-/websocket-1.0.35.tgz", - "integrity": "sha512-/REy6amwPZl44DDzvRCkaI1q1bIiQB0mEFQLUrhz3z2EK91cp3n72rAjUlrTP0zV22HJIUOVHQGPxhFRjxjt+Q==", - "license": "Apache-2.0", - "dependencies": { - "bufferutil": "^4.0.1", - "debug": "^2.2.0", - "es5-ext": "^0.10.63", - "typedarray-to-buffer": "^3.1.5", - "utf-8-validate": "^5.0.2", - "yaeti": "^0.0.6" - }, - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/websocket/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/websocket/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" - }, "node_modules/whatwg-url": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", @@ -19586,16 +19328,6 @@ "node": ">=10" } }, - "node_modules/yaeti": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/yaeti/-/yaeti-0.0.6.tgz", - "integrity": "sha512-MvQa//+KcZCUkBTIC9blM+CU9J2GzuTytsOUwf2lidtvkx/6gnEp1QvJv34t9vdjhFmha/mUiNDbN0D0mJWdug==", - "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", - "license": "MIT", - "engines": { - "node": ">=0.10.32" - } - }, "node_modules/yallist": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/yallist/-/yallist-2.1.2.tgz", diff --git a/package.json b/package.json index baa3ebf7..d0d5e749 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "turbofile-windows", - "version": "1.119.0", + "version": "1.155.0", "distro": "03ddc71f37189e8ced0ef924fe9b9600c402b67f", "author": { "name": "Microsoft Corporation" @@ -16,6 +16,7 @@ "test-node": "mocha test/unit/node/index.js --delay --ui=tdd --timeout=5000 --exit", "test-extension": "vscode-test", "test-build-scripts": "cd build && npm run test", + "turbofile:update-manifest": "node scripts/turbofile-update-manifest.mjs", "check-cyclic-dependencies": "node build/lib/checkCyclicDependencies.ts out", "preinstall": "node build/npm/preinstall.ts", "postinstall": "node build/npm/postinstall.ts", @@ -95,11 +96,6 @@ "@github/copilot-sdk": "^0.3.0", "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", - "@microsoft/dev-tunnels-connections": "^1.3.41", - "@microsoft/dev-tunnels-contracts": "^1.3.41", - "@microsoft/dev-tunnels-management": "^1.3.41", - "@microsoft/dev-tunnels-ssh": "^3.12.22", - "@microsoft/dev-tunnels-ssh-tcp": "^3.12.22", "@parcel/watcher": "^2.5.6", "@types/semver": "^7.5.8", "@vscode/codicons": "^0.0.46-6", diff --git a/product.json b/product.json index ead98596..78ca7d77 100644 --- a/product.json +++ b/product.json @@ -1,12 +1,25 @@ { "nameShort": "极速传", "nameLong": "极速传", + "quality": "stable", "applicationName": "turbofile", "dataFolderName": ".turbofile", "sharedDataFolderName": ".turbofile-shared", "win32MutexName": "turbofile", "licenseName": "MIT", "licenseUrl": "https://github.com/microsoft/vscode/blob/main/LICENSE.txt", + "downloadUrl": "https://github.com/DMwarrior/TurboFile-Windows/releases/latest", + "updateUrl": "https://api.github.com/repos/DMwarrior/TurboFile-Windows/releases/latest", + "turbofileUpdates": { + "owner": "DMwarrior", + "repo": "TurboFile-Windows", + "initialCheckDelayMs": 10000, + "checkIntervalMs": 60000, + "manifestUrls": [ + "https://github.com/DMwarrior/TurboFile-Windows/releases/download/updater/update-proxy.json", + "https://github.com/DMwarrior/TurboFile-Windows/releases/download/updater/update.json" + ] + }, "serverLicenseUrl": "https://github.com/microsoft/vscode/blob/main/LICENSE.txt", "serverGreeting": [], "serverLicense": [], diff --git a/resources/turbofile/drag-ext/TurboFileDragSource.cpp b/resources/turbofile/drag-ext/TurboFileDragSource.cpp new file mode 100644 index 00000000..ec9115d0 --- /dev/null +++ b/resources/turbofile/drag-ext/TurboFileDragSource.cpp @@ -0,0 +1,110 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +#include +#include +#include +#include + +class TurboFileDropSource final : public IDropSource { +public: + TurboFileDropSource() : references(1) { } + + HRESULT STDMETHODCALLTYPE QueryInterface(REFIID iid, void **object) override { + if (!object) { + return E_POINTER; + } + if (iid == IID_IUnknown || iid == IID_IDropSource) { + *object = static_cast(this); + AddRef(); + return S_OK; + } + *object = nullptr; + return E_NOINTERFACE; + } + + ULONG STDMETHODCALLTYPE AddRef() override { + return InterlockedIncrement(&references); + } + + ULONG STDMETHODCALLTYPE Release() override { + const ULONG remaining = InterlockedDecrement(&references); + if (!remaining) { + delete this; + } + return remaining; + } + + HRESULT STDMETHODCALLTYPE QueryContinueDrag(BOOL escapePressed, DWORD keyState) override { + if (escapePressed) { + return DRAGDROP_S_CANCEL; + } + if ((keyState & MK_LBUTTON) == 0) { + return DRAGDROP_S_DROP; + } + return S_OK; + } + + HRESULT STDMETHODCALLTYPE GiveFeedback(DWORD) override { + return DRAGDROP_S_USEDEFAULTCURSORS; + } + +private: + ~TurboFileDropSource() = default; + LONG references; +}; + +int WINAPI wWinMain(HINSTANCE, HINSTANCE, PWSTR, int) { + int argumentCount = 0; + LPWSTR *arguments = CommandLineToArgvW(GetCommandLineW(), &argumentCount); + if (!arguments || argumentCount != 2) { + if (arguments) { + LocalFree(arguments); + } + return 1; + } + + const HRESULT oleResult = OleInitialize(nullptr); + if (FAILED(oleResult)) { + LocalFree(arguments); + return 1; + } + + PIDLIST_ABSOLUTE itemIdList = ILCreateFromPathW(arguments[1]); + LocalFree(arguments); + if (!itemIdList) { + OleUninitialize(); + return 1; + } + + IShellFolder *parentFolder = nullptr; + PCUITEMID_CHILD childId = nullptr; + IDataObject *dataObject = nullptr; + HRESULT result = SHBindToParent(itemIdList, IID_PPV_ARGS(&parentFolder), &childId); + if (SUCCEEDED(result)) { + result = parentFolder->GetUIObjectOf(nullptr, 1, &childId, IID_IDataObject, nullptr, reinterpret_cast(&dataObject)); + } + + DWORD effect = DROPEFFECT_NONE; + if (SUCCEEDED(result) && dataObject) { + TurboFileDropSource *dropSource = new TurboFileDropSource(); + result = DoDragDrop(dataObject, dropSource, DROPEFFECT_COPY, &effect); + dropSource->Release(); + } + + if (dataObject) { + dataObject->Release(); + } + if (parentFolder) { + parentFolder->Release(); + } + CoTaskMemFree(itemIdList); + OleUninitialize(); + + if (result == DRAGDROP_S_CANCEL) { + return 2; + } + return SUCCEEDED(result) && effect != DROPEFFECT_NONE ? 0 : 1; +} diff --git a/resources/turbofile/drag-ext/x64/TurboFileDragSource.exe b/resources/turbofile/drag-ext/x64/TurboFileDragSource.exe new file mode 100644 index 0000000000000000000000000000000000000000..497a2f9944b8e8af24065d8710ba19a000fbe9db GIT binary patch literal 102400 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 YfH}#D8K}Qei@C-^GwQ#Oe_;*$7b11w4*&oF literal 0 HcmV?d00001 diff --git a/scripts/build-turbofile-drag-ext.ps1 b/scripts/build-turbofile-drag-ext.ps1 index 51b87d13..61181797 100644 --- a/scripts/build-turbofile-drag-ext.ps1 +++ b/scripts/build-turbofile-drag-ext.ps1 @@ -1,18 +1,25 @@ param( - [string] $Configuration = 'Release' + [string] $Configuration = 'Release', + [switch] $DragSourceOnly ) $ErrorActionPreference = 'Stop' $repoRoot = Split-Path -Parent $PSScriptRoot $source = Join-Path $repoRoot 'resources\turbofile\drag-ext\TurboFileDragExt.cpp' +$dragSource = Join-Path $repoRoot 'resources\turbofile\drag-ext\TurboFileDragSource.cpp' $outDir = Join-Path $repoRoot 'resources\turbofile\drag-ext\x64' $dll = Join-Path $outDir 'TurboFileDragExt.dll' +$exe = Join-Path $outDir 'TurboFileDragSource.exe' $obj = Join-Path $outDir 'TurboFileDragExt.obj' +$dragSourceObj = Join-Path $outDir 'TurboFileDragSource.obj' if (!(Test-Path $source)) { throw "Missing source file: $source" } +if (!(Test-Path $dragSource)) { + throw "Missing source file: $dragSource" +} New-Item -ItemType Directory -Force -Path $outDir | Out-Null @@ -32,16 +39,29 @@ if (!(Test-Path $vcvars)) { } $optimization = if ($Configuration -ieq 'Debug') { '/Od /Zi' } else { '/O2' } -$command = "`"$vcvars`" -arch=x64 -host_arch=x64 >nul && cl.exe /nologo /LD /EHsc $optimization /DUNICODE /D_UNICODE /DWIN32_LEAN_AND_MEAN `"$source`" /Fe:`"$dll`" /Fo:`"$obj`" /link /NOLOGO /EXPORT:DllGetClassObject,PRIVATE /EXPORT:DllCanUnloadNow,PRIVATE shell32.lib ole32.lib uuid.lib" +if (!$DragSourceOnly) { + $command = "`"$vcvars`" -arch=x64 -host_arch=x64 >nul && cl.exe /nologo /LD /EHsc $optimization /DUNICODE /D_UNICODE /DWIN32_LEAN_AND_MEAN `"$source`" /Fe:`"$dll`" /Fo:`"$obj`" /link /NOLOGO /EXPORT:DllGetClassObject,PRIVATE /EXPORT:DllCanUnloadNow,PRIVATE shell32.lib ole32.lib uuid.lib" + + cmd.exe /d /s /c $command + if ($LASTEXITCODE -ne 0) { + throw "TurboFile drag extension build failed with exit code $LASTEXITCODE." + } +} -cmd.exe /d /s /c $command +$dragSourceCommand = "`"$vcvars`" -arch=x64 -host_arch=x64 >nul && cl.exe /nologo /EHsc $optimization /DUNICODE /D_UNICODE /DWIN32_LEAN_AND_MEAN `"$dragSource`" /Fe:`"$exe`" /Fo:`"$dragSourceObj`" /link /NOLOGO /SUBSYSTEM:WINDOWS shell32.lib ole32.lib uuid.lib" +cmd.exe /d /s /c $dragSourceCommand if ($LASTEXITCODE -ne 0) { - throw "TurboFile drag extension build failed with exit code $LASTEXITCODE." + throw "TurboFile drag source helper build failed with exit code $LASTEXITCODE." } Remove-Item -Force -ErrorAction SilentlyContinue ` (Join-Path $outDir 'TurboFileDragExt.obj'), ` (Join-Path $outDir 'TurboFileDragExt.exp'), ` - (Join-Path $outDir 'TurboFileDragExt.lib') + (Join-Path $outDir 'TurboFileDragExt.lib'), ` + $dragSourceObj -Write-Host "Built $dll" +if ($DragSourceOnly) { + Write-Host "Built $exe" +} else { + Write-Host "Built $dll and $exe" +} diff --git a/scripts/turbofile-update-manifest.mjs b/scripts/turbofile-update-manifest.mjs new file mode 100644 index 00000000..c566e9de --- /dev/null +++ b/scripts/turbofile-update-manifest.mjs @@ -0,0 +1,247 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { createHash } from 'node:crypto'; +import { createReadStream } from 'node:fs'; + +const repoSlug = process.env.GITHUB_REPOSITORY ?? 'DMwarrior/TurboFile-Windows'; +const [owner, repo] = repoSlug.split('/'); +const token = process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN; +const apiBase = process.env.GITHUB_API_URL ?? 'https://api.github.com'; +const updaterTag = process.env.TURBOFILE_UPDATER_TAG ?? 'updater'; +const proxyPrefix = process.env.TURBOFILE_UPDATE_PROXY_PREFIX ?? ''; +const assetPattern = new RegExp(process.env.TURBOFILE_UPDATE_ASSET_PATTERN ?? '^TurboFileSetup-win32-x64-(\\d+\\.\\d+\\.\\d+(?:-[0-9A-Za-z.-]+)?)\\.exe$', 'i'); + +if (!owner || !repo) { + throw new Error(`Invalid GITHUB_REPOSITORY value: ${repoSlug}`); +} +if (!token) { + throw new Error('Missing GITHUB_TOKEN or GH_TOKEN'); +} + +const VERSION_PATTERN = /(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/; + +const sourceRelease = await findLatestInstallerRelease(); +const installerAsset = findInstallerAsset(sourceRelease); +if (!installerAsset?.browser_download_url) { + throw new Error(`No Windows x64 installer asset found in release ${sourceRelease.tag_name}`); +} + +const version = normalizeVersion(sourceRelease.tag_name) ?? normalizeVersion(sourceRelease.name) ?? normalizeVersion(installerAsset.name); +if (!version) { + throw new Error(`Could not resolve version from release ${sourceRelease.tag_name}`); +} + +const sha256 = await resolveSha256(installerAsset); +const manifest = createManifest(sourceRelease, installerAsset, version, sha256); +const proxyManifest = proxyPrefix ? createProxiedManifest(manifest, proxyPrefix) : manifest; +const updaterRelease = await getOrCreateUpdaterRelease(); + +await replaceReleaseAsset(updaterRelease, 'update.json', manifest); +await replaceReleaseAsset(updaterRelease, 'update-proxy.json', proxyManifest); + +console.log(`Published TurboFile update manifest for ${version}`); +console.log(`Installer: ${installerAsset.name}`); +console.log(`URL: ${installerAsset.browser_download_url}`); +if (sha256) { + console.log(`SHA256: ${sha256}`); +} + +async function findLatestInstallerRelease() { + const releases = await github(`/repos/${owner}/${repo}/releases?per_page=100`); + const candidates = releases + .filter(release => !release.draft && release.tag_name !== updaterTag && findInstallerAsset(release)) + .map(release => ({ + release, + version: normalizeVersion(release.tag_name) ?? normalizeVersion(release.name) ?? normalizeVersion(findInstallerAsset(release)?.name), + })) + .filter(candidate => candidate.version) + .sort((left, right) => compareVersions(right.version, left.version)); + + if (!candidates.length) { + throw new Error('No published release with a Windows x64 installer was found'); + } + + return candidates[0].release; +} + +function findInstallerAsset(release) { + return release.assets?.find(asset => assetPattern.test(asset.name ?? '')); +} + +function createManifest(release, asset, version, sha256) { + const platform = { + url: asset.browser_download_url, + }; + if (sha256) { + platform.sha256 = sha256; + } + + return { + version, + name: release.name || `TurboFile Windows ${version}`, + notes: release.body || '', + pub_date: release.published_at || release.created_at || new Date().toISOString(), + platforms: { + 'windows-x64': platform, + }, + }; +} + +function createProxiedManifest(manifest, prefix) { + const result = JSON.parse(JSON.stringify(manifest)); + for (const platform of Object.values(result.platforms ?? {})) { + if (platform.url && !platform.url.startsWith(prefix)) { + platform.url = `${prefix}${platform.url}`; + } + } + + return result; +} + +async function resolveSha256(asset) { + if (process.env.TURBOFILE_UPDATE_SHA256) { + return process.env.TURBOFILE_UPDATE_SHA256.toLowerCase(); + } + + if (process.env.TURBOFILE_UPDATE_LOCAL_FILE) { + return (await sha256File(process.env.TURBOFILE_UPDATE_LOCAL_FILE)).toLowerCase(); + } + + if (typeof asset.digest === 'string' && asset.digest.toLowerCase().startsWith('sha256:')) { + return asset.digest.slice('sha256:'.length).toLowerCase(); + } + + return undefined; +} + +async function sha256File(filePath) { + const hash = createHash('sha256'); + await new Promise((resolve, reject) => { + createReadStream(filePath) + .on('error', reject) + .on('data', chunk => hash.update(chunk)) + .on('end', resolve); + }); + return hash.digest('hex'); +} + +async function getOrCreateUpdaterRelease() { + const existing = await github(`/repos/${owner}/${repo}/releases/tags/${encodeURIComponent(updaterTag)}`, { allow404: true }); + if (existing) { + return existing; + } + + return github(`/repos/${owner}/${repo}/releases`, { + method: 'POST', + body: { + tag_name: updaterTag, + name: 'TurboFile update manifest', + body: 'Static update metadata consumed by TurboFile.', + draft: false, + prerelease: false, + }, + }); +} + +async function replaceReleaseAsset(release, name, data) { + const existing = release.assets?.find(asset => asset.name === name); + if (existing) { + await github(`/repos/${owner}/${repo}/releases/assets/${existing.id}`, { method: 'DELETE' }); + } + + const uploadBaseUrl = release.upload_url.replace(/\{.*$/, ''); + const uploadUrl = `${uploadBaseUrl}?name=${encodeURIComponent(name)}`; + const body = `${JSON.stringify(data, null, 2)}\n`; + const response = await fetch(uploadUrl, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + Accept: 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + 'Content-Type': 'application/json', + 'Content-Length': String(Buffer.byteLength(body)), + }, + body, + }); + + if (!response.ok) { + throw new Error(`GitHub upload failed for ${name}: ${response.status} ${await response.text()}`); + } +} + +async function github(path, options = {}) { + const response = await fetch(`${apiBase}${path}`, { + method: options.method ?? 'GET', + headers: { + Authorization: `Bearer ${token}`, + Accept: 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + ...(options.body ? { 'Content-Type': 'application/json' } : {}), + ...(options.headers ?? {}), + }, + body: options.body ? JSON.stringify(options.body) : undefined, + }); + + if (options.allow404 && response.status === 404) { + return undefined; + } + + if (!response.ok) { + throw new Error(`GitHub request failed: ${response.status} ${await response.text()}`); + } + + if (response.status === 204) { + return undefined; + } + + return response.json(); +} + +function normalizeVersion(value) { + if (!value) { + return undefined; + } + + const match = VERSION_PATTERN.exec(value); + return match ? `${Number(match[1])}.${Number(match[2])}.${Number(match[3])}${match[4] ? `-${match[4]}` : ''}` : undefined; +} + +function compareVersions(left, right) { + const parsedLeft = parseVersion(left); + const parsedRight = parseVersion(right); + for (const key of ['major', 'minor', 'patch']) { + const diff = parsedLeft[key] - parsedRight[key]; + if (diff !== 0) { + return diff; + } + } + + if (!parsedLeft.prerelease && !parsedRight.prerelease) { + return 0; + } + if (!parsedLeft.prerelease) { + return 1; + } + if (!parsedRight.prerelease) { + return -1; + } + + return parsedLeft.prerelease.localeCompare(parsedRight.prerelease); +} + +function parseVersion(version) { + const match = VERSION_PATTERN.exec(version); + if (!match) { + throw new Error(`Invalid version: ${version}`); + } + + return { + major: Number(match[1]), + minor: Number(match[2]), + patch: Number(match[3]), + prerelease: match[4], + }; +} diff --git a/src/tsec.exemptions.json b/src/tsec.exemptions.json index e290af37..83691e2d 100644 --- a/src/tsec.exemptions.json +++ b/src/tsec.exemptions.json @@ -13,7 +13,6 @@ ], "ban-trustedtypes-createpolicy": [ "vs/code/electron-browser/workbench/workbench.ts", - "vs/sessions/electron-browser/sessions.ts", "vs/amdX.ts", "vs/base/browser/trustedTypes.ts", "vs/workbench/contrib/notebook/browser/view/renderers/webviewPreloads.ts" @@ -38,7 +37,6 @@ "**/*.ts" ], "ban-script-content-assignments": [ - "vs/code/electron-browser/workbench/workbench.ts", - "vs/sessions/electron-browser/sessions.ts" + "vs/code/electron-browser/workbench/workbench.ts" ] } diff --git a/src/vs/base/common/product.ts b/src/vs/base/common/product.ts index bc7c8f68..71068efb 100644 --- a/src/vs/base/common/product.ts +++ b/src/vs/base/common/product.ts @@ -94,6 +94,17 @@ export interface IProductConfiguration { readonly downloadUrl?: string; readonly updateUrl?: string; + readonly turbofileUpdates?: { + readonly owner?: string; + readonly repo?: string; + readonly manifestUrl?: string; + readonly manifestUrls?: readonly string[]; + readonly latestReleaseUrl?: string; + readonly assetNamePattern?: string; + readonly allowPrerelease?: boolean; + readonly initialCheckDelayMs?: number; + readonly checkIntervalMs?: number; + }; readonly webUrl?: string; readonly webEndpointUrlTemplate?: string; readonly webviewContentExternalBaseUrlTemplate?: string; diff --git a/src/vs/code/electron-browser/workbench/workbench-dev.html b/src/vs/code/electron-browser/workbench/workbench-dev.html index 4160d848..eaa0b991 100644 --- a/src/vs/code/electron-browser/workbench/workbench-dev.html +++ b/src/vs/code/electron-browser/workbench/workbench-dev.html @@ -13,6 +13,7 @@ 'self' data: blob: + turbofile-image: vscode-remote-resource: vscode-managed-remote-resource: https: diff --git a/src/vs/code/electron-browser/workbench/workbench.html b/src/vs/code/electron-browser/workbench/workbench.html index ce51984c..794a53d3 100644 --- a/src/vs/code/electron-browser/workbench/workbench.html +++ b/src/vs/code/electron-browser/workbench/workbench.html @@ -13,6 +13,7 @@ 'self' data: blob: + turbofile-image: vscode-remote-resource: vscode-managed-remote-resource: https: diff --git a/src/vs/code/electron-main/app.ts b/src/vs/code/electron-main/app.ts index cc3f0288..78caf2c5 100644 --- a/src/vs/code/electron-main/app.ts +++ b/src/vs/code/electron-main/app.ts @@ -39,10 +39,6 @@ import { IEncryptionMainService } from '../../platform/encryption/common/encrypt import { EncryptionMainService } from '../../platform/encryption/electron-main/encryptionMainService.js'; import { ITurboFileSshService } from '../../platform/turbofile/common/turbofile.js'; import { TurboFileSshMainService } from '../../platform/turbofile/electron-main/turbofileSshMainService.js'; -import { ipcBrowserViewChannelName } from '../../platform/browserView/common/browserView.js'; -import { ipcBrowserViewGroupChannelName } from '../../platform/browserView/common/browserViewGroup.js'; -import { BrowserViewMainService, IBrowserViewMainService } from '../../platform/browserView/electron-main/browserViewMainService.js'; -import { BrowserViewGroupMainService, IBrowserViewGroupMainService } from '../../platform/browserView/electron-main/browserViewGroupMainService.js'; import { NativeParsedArgs } from '../../platform/environment/common/argv.js'; import { IEnvironmentMainService } from '../../platform/environment/electron-main/environmentMainService.js'; import { isLaunchedFromCli } from '../../platform/environment/node/argvHelper.js'; @@ -129,20 +125,12 @@ import { ipcUtilityProcessWorkerChannelName } from '../../platform/utilityProces import { ILocalPtyService, LocalReconnectConstants, TerminalIpcChannels, TerminalSettingId } from '../../platform/terminal/common/terminal.js'; import { ElectronPtyHostStarter } from '../../platform/terminal/electron-main/electronPtyHostStarter.js'; import { PtyHostService } from '../../platform/terminal/node/ptyHostService.js'; -import { ElectronAgentHostStarter } from '../../platform/agentHost/electron-main/electronAgentHostStarter.js'; -import { AgentHostProcessManager } from '../../platform/agentHost/node/agentHostService.js'; -import { AgentHostEnabledSettingId } from '../../platform/agentHost/common/agentService.js'; import { NODE_REMOTE_RESOURCE_CHANNEL_NAME, NODE_REMOTE_RESOURCE_IPC_METHOD_NAME, NodeRemoteResourceResponse, NodeRemoteResourceRouter } from '../../platform/remote/common/electronRemoteResources.js'; import { Lazy } from '../../base/common/lazy.js'; import { IAuxiliaryWindowsMainService } from '../../platform/auxiliaryWindow/electron-main/auxiliaryWindows.js'; import { AuxiliaryWindowsMainService } from '../../platform/auxiliaryWindow/electron-main/auxiliaryWindowsMainService.js'; import { normalizeNFC } from '../../base/common/normalization.js'; import { ICSSDevelopmentService, CSSDevelopmentService } from '../../platform/cssDev/node/cssDevService.js'; -import { INativeMcpDiscoveryHelperService, NativeMcpDiscoveryHelperChannelName } from '../../platform/mcp/common/nativeMcpDiscoveryHelper.js'; -import { NativeMcpDiscoveryHelperService } from '../../platform/mcp/node/nativeMcpDiscoveryHelperService.js'; -import { IMcpGatewayService, McpGatewayChannelName } from '../../platform/mcp/common/mcpGateway.js'; -import { McpGatewayService } from '../../platform/mcp/node/mcpGatewayService.js'; -import { McpGatewayChannel } from '../../platform/mcp/node/mcpGatewayChannel.js'; import { IWebContentExtractorService } from '../../platform/webContentExtractor/common/webContentExtractor.js'; import { NativeWebContentExtractorService } from '../../platform/webContentExtractor/electron-main/webContentExtractorService.js'; import { AgentNetworkFilterService, IAgentNetworkFilterService } from '../../platform/networkFilter/common/networkFilterService.js'; @@ -440,10 +428,6 @@ export class CodeApplication extends Disposable { // Handle any in-page navigation contents.on('will-navigate', event => { - if (BrowserViewMainService.isBrowserViewWebContents(contents)) { - return; // Allow navigation in integrated browser views - } - this.logService.error('webContents#will-navigate: Prevented webcontent navigation'); event.preventDefault(); // Prevent any in-page navigation @@ -1101,10 +1085,6 @@ export class CodeApplication extends Disposable { // Cross-app IPC services.set(ICrossAppIPCService, new SyncDescriptor(CrossAppIPCService)); - // Browser View - services.set(IBrowserViewMainService, new SyncDescriptor(BrowserViewMainService, undefined, false /* proxied to other processes */)); - services.set(IBrowserViewGroupMainService, new SyncDescriptor(BrowserViewGroupMainService, undefined, false /* proxied to other processes */)); - // Keyboard Layout services.set(IKeyboardLayoutMainService, new SyncDescriptor(KeyboardLayoutMainService)); @@ -1147,12 +1127,6 @@ export class CodeApplication extends Disposable { ); services.set(ILocalPtyService, ptyHostService); - // Agent Host - if (this.configurationService.getValue(AgentHostEnabledSettingId)) { - const agentHostStarter = new ElectronAgentHostStarter(this.configurationService, this.environmentMainService, this.lifecycleMainService, this.logService); - this._register(new AgentHostProcessManager(agentHostStarter, this.logService, this.loggerService)); - } - // External terminal if (isWindows) { services.set(IExternalTerminalMainService, new SyncDescriptor(WindowsExternalTerminalService)); @@ -1200,10 +1174,6 @@ export class CodeApplication extends Disposable { // Proxy Auth services.set(IProxyAuthService, new SyncDescriptor(ProxyAuthService)); - // MCP - services.set(INativeMcpDiscoveryHelperService, new SyncDescriptor(NativeMcpDiscoveryHelperService)); - services.set(IMcpGatewayService, new SyncDescriptor(McpGatewayService)); - // Dev Only: CSS service (for ESM) services.set(ICSSDevelopmentService, new SyncDescriptor(CSSDevelopmentService, undefined, true)); @@ -1306,16 +1276,6 @@ export class CodeApplication extends Disposable { const turboFileSshChannel = ProxyChannel.fromService(accessor.get(ITurboFileSshService), disposables); mainProcessElectronServer.registerChannel('turbofile:ssh', turboFileSshChannel); - // Browser View - const browserViewChannel = ProxyChannel.fromService(accessor.get(IBrowserViewMainService), disposables); - mainProcessElectronServer.registerChannel(ipcBrowserViewChannelName, browserViewChannel); - sharedProcessClient.then(client => client.registerChannel(ipcBrowserViewChannelName, browserViewChannel)); - - // Browser View Group - const browserViewGroupChannel = ProxyChannel.fromService(accessor.get(IBrowserViewGroupMainService), disposables); - mainProcessElectronServer.registerChannel(ipcBrowserViewGroupChannelName, browserViewGroupChannel); - sharedProcessClient.then(client => client.registerChannel(ipcBrowserViewGroupChannelName, browserViewGroupChannel)); - // Signing const signChannel = ProxyChannel.fromService(accessor.get(ISignService), disposables); mainProcessElectronServer.registerChannel('sign', signChannel); @@ -1371,12 +1331,6 @@ export class CodeApplication extends Disposable { const sandboxHelperChannel = ProxyChannel.fromService(accessor.get(ISandboxHelperMainService), disposables); mainProcessElectronServer.registerChannel('sandboxHelper', sandboxHelperChannel); - // MCP - const mcpDiscoveryChannel = ProxyChannel.fromService(accessor.get(INativeMcpDiscoveryHelperService), disposables); - mainProcessElectronServer.registerChannel(NativeMcpDiscoveryHelperChannelName, mcpDiscoveryChannel); - const mcpGatewayChannel = this._register(new McpGatewayChannel(mainProcessElectronServer, accessor.get(IMcpGatewayService), accessor.get(ILoggerMainService))); - mainProcessElectronServer.registerChannel(McpGatewayChannelName, mcpGatewayChannel); - // Logger const loggerChannel = new LoggerChannel(accessor.get(ILoggerMainService),); mainProcessElectronServer.registerChannel('logger', loggerChannel); diff --git a/src/vs/code/electron-utility/sharedProcess/sharedProcessMain.ts b/src/vs/code/electron-utility/sharedProcess/sharedProcessMain.ts index 5857aecb..b6611d68 100644 --- a/src/vs/code/electron-utility/sharedProcess/sharedProcessMain.ts +++ b/src/vs/code/electron-utility/sharedProcess/sharedProcessMain.ts @@ -87,11 +87,6 @@ import { InspectProfilingService as V8InspectProfilingService } from '../../../p import { IV8InspectProfilingService } from '../../../platform/profiling/common/profiling.js'; import { IExtensionsScannerService } from '../../../platform/extensionManagement/common/extensionsScannerService.js'; import { ExtensionsScannerService } from '../../../platform/extensionManagement/node/extensionsScannerService.js'; -import { ISSHRemoteAgentHostMainService, SSH_REMOTE_AGENT_HOST_CHANNEL } from '../../../platform/agentHost/common/sshRemoteAgentHost.js'; -import { SSHRemoteAgentHostMainService } from '../../../platform/agentHost/node/sshRemoteAgentHostService.js'; -import { ITunnelAgentHostMainService, ITunnelAgentHostHostingService, TUNNEL_AGENT_HOST_CHANNEL, TUNNEL_HOST_CHANNEL } from '../../../platform/agentHost/common/tunnelAgentHost.js'; -import { TunnelAgentHostMainService } from '../../../platform/agentHost/node/tunnelAgentHostService.js'; -import { TunnelHostMainService } from '../../../platform/agentHost/node/tunnelHostMainService.js'; import { IUserDataProfilesService } from '../../../platform/userDataProfile/common/userDataProfile.js'; import { IExtensionsProfileScannerService } from '../../../platform/extensionManagement/common/extensionsProfileScannerService.js'; import { PolicyChannelClient } from '../../../platform/policy/common/policyIpc.js'; @@ -129,19 +124,8 @@ import { IExtensionGalleryManifestService } from '../../../platform/extensionMan import { ExtensionGalleryManifestIPCService } from '../../../platform/extensionManagement/common/extensionGalleryManifestServiceIpc.js'; import { ISharedWebContentExtractorService } from '../../../platform/webContentExtractor/common/webContentExtractor.js'; import { SharedWebContentExtractorService } from '../../../platform/webContentExtractor/node/sharedWebContentExtractorService.js'; -import { McpManagementService } from '../../../platform/mcp/node/mcpManagementService.js'; -import { IAllowedMcpServersService, IMcpGalleryService, IMcpManagementService } from '../../../platform/mcp/common/mcpManagement.js'; -import { IMcpResourceScannerService, McpResourceScannerService } from '../../../platform/mcp/common/mcpResourceScannerService.js'; -import { McpGalleryService } from '../../../platform/mcp/common/mcpGalleryService.js'; -import { McpManagementChannel } from '../../../platform/mcp/common/mcpManagementIpc.js'; -import { AllowedMcpServersService } from '../../../platform/mcp/common/allowedMcpServersService.js'; -import { IMcpGalleryManifestService } from '../../../platform/mcp/common/mcpGalleryManifest.js'; -import { McpGalleryManifestIPCService } from '../../../platform/mcp/common/mcpGalleryManifestServiceIpc.js'; import { IMeteredConnectionService } from '../../../platform/meteredConnection/common/meteredConnection.js'; import { MeteredConnectionChannelClient, METERED_CONNECTION_CHANNEL } from '../../../platform/meteredConnection/common/meteredConnectionIpc.js'; -import { PlaywrightChannel } from '../../../platform/browserView/node/playwrightChannel.js'; -import { AgentNetworkFilterService } from '../../../platform/networkFilter/common/networkFilterService.js'; -import { NullTerminalSandboxService } from '../../../platform/sandbox/common/terminalSandboxService.js'; import { ILocalGitService } from '../../../platform/git/common/localGitService.js'; import { LocalGitService } from '../../../platform/git/node/localGitService.js'; @@ -359,13 +343,6 @@ class SharedProcessMain extends Disposable implements IClientConnectionFilter { services.set(IAllowedExtensionsService, new SyncDescriptor(AllowedExtensionsService, undefined, true)); services.set(INativeServerExtensionManagementService, new SyncDescriptor(ExtensionManagementService, undefined, true)); - // MCP Management - services.set(IAllowedMcpServersService, new SyncDescriptor(AllowedMcpServersService, undefined, true)); - services.set(IMcpGalleryManifestService, new McpGalleryManifestIPCService(this.server)); - services.set(IMcpGalleryService, new SyncDescriptor(McpGalleryService, undefined, true)); - services.set(IMcpResourceScannerService, new SyncDescriptor(McpResourceScannerService, undefined, true)); - services.set(IMcpManagementService, new SyncDescriptor(McpManagementService, undefined, true)); - // Extension Gallery services.set(IExtensionGalleryManifestService, new ExtensionGalleryManifestIPCService(this.server, productService)); services.set(IExtensionGalleryService, new SyncDescriptor(ExtensionGalleryService, undefined, true)); @@ -414,15 +391,6 @@ class SharedProcessMain extends Disposable implements IClientConnectionFilter { // Local Git services.set(ILocalGitService, new SyncDescriptor(LocalGitService, undefined, false /* proxied to other processes */)); - // SSH Remote Agent Host - services.set(ISSHRemoteAgentHostMainService, new SyncDescriptor(SSHRemoteAgentHostMainService, undefined, true)); - - // Tunnel Agent Host - services.set(ITunnelAgentHostMainService, new SyncDescriptor(TunnelAgentHostMainService, undefined, true)); - - // Tunnel Host (hosting local agent host for remote connections) - services.set(ITunnelAgentHostHostingService, new SyncDescriptor(TunnelHostMainService, undefined, true)); - return new InstantiationService(services); } @@ -432,10 +400,6 @@ class SharedProcessMain extends Disposable implements IClientConnectionFilter { const channel = new ExtensionManagementChannel(accessor.get(IExtensionManagementService), () => null); this.server.registerChannel('extensions', channel); - // Mcp Management - const mcpManagementChannel = new McpManagementChannel(accessor.get(IMcpManagementService), () => null); - this.server.registerChannel('mcpManagement', mcpManagementChannel); - // Language Packs const languagePacksChannel = ProxyChannel.fromService(accessor.get(ILanguagePackService), this._store); this.server.registerChannel('languagePacks', languagePacksChannel); @@ -490,26 +454,10 @@ class SharedProcessMain extends Disposable implements IClientConnectionFilter { const webContentExtractorChannel = ProxyChannel.fromService(accessor.get(ISharedWebContentExtractorService), this._store); this.server.registerChannel('sharedWebContentExtractor', webContentExtractorChannel); - // Playwright - const agentNetworkFilterService = this._register(new AgentNetworkFilterService(accessor.get(IConfigurationService), new NullTerminalSandboxService())); - const playwrightChannel = this._register(new PlaywrightChannel(this.server, accessor.get(IMainProcessService), accessor.get(ILogService), agentNetworkFilterService)); - this.server.registerChannel('playwright', playwrightChannel); - // Local Git const localGitChannel = ProxyChannel.fromService(accessor.get(ILocalGitService), this._store); this.server.registerChannel('localGit', localGitChannel); - // SSH Remote Agent Host - const sshRemoteAgentHostChannel = ProxyChannel.fromService(accessor.get(ISSHRemoteAgentHostMainService), this._store); - this.server.registerChannel(SSH_REMOTE_AGENT_HOST_CHANNEL, sshRemoteAgentHostChannel); - - // Tunnel Agent Host - const tunnelAgentHostChannel = ProxyChannel.fromService(accessor.get(ITunnelAgentHostMainService), this._store); - this.server.registerChannel(TUNNEL_AGENT_HOST_CHANNEL, tunnelAgentHostChannel); - - // Tunnel Host - const tunnelHostChannel = ProxyChannel.fromService(accessor.get(ITunnelAgentHostHostingService), this._store); - this.server.registerChannel(TUNNEL_HOST_CHANNEL, tunnelHostChannel); } private registerErrorHandler(logService: ILogService): void { diff --git a/src/vs/platform/agentHost/browser/nullAgentHostService.ts b/src/vs/platform/agentHost/browser/nullAgentHostService.ts deleted file mode 100644 index 184572bb..00000000 --- a/src/vs/platform/agentHost/browser/nullAgentHostService.ts +++ /dev/null @@ -1,58 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { Event } from '../../../base/common/event.js'; -import { IReference } from '../../../base/common/lifecycle.js'; -import { constObservable, IObservable } from '../../../base/common/observable.js'; -import { URI } from '../../../base/common/uri.js'; -import type { IAgentCreateSessionConfig, IAgentHostInspectInfo, IAgentHostService, IAgentHostSocketInfo, IAgentResolveSessionConfigParams, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, AuthenticateParams, AuthenticateResult } from '../common/agentService.js'; -import type { IAgentSubscription } from '../common/state/agentSubscription.js'; -import type { CreateTerminalParams, ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../common/state/protocol/commands.js'; -import type { ActionEnvelope, INotification, IRootConfigChangedAction, SessionAction, TerminalAction } from '../common/state/sessionActions.js'; -import type { ResourceCopyParams, ResourceCopyResult, ResourceDeleteParams, ResourceDeleteResult, ResourceListResult, ResourceMoveParams, ResourceMoveResult, ResourceReadResult, ResourceWriteParams, ResourceWriteResult } from '../common/state/sessionProtocol.js'; -import type { ComponentToState, RootState, StateComponents } from '../common/state/sessionState.js'; - -const notSupported = () => { throw new Error('Local agent host is not supported in the browser.'); }; - -/** - * Null implementation of {@link IAgentHostService} for browser contexts - * where a local agent host process is not available. - */ -export class NullAgentHostService implements IAgentHostService { - declare readonly _serviceBrand: undefined; - - readonly clientId = ''; - readonly onAgentHostExit = Event.None; - readonly onAgentHostStart = Event.None; - readonly onDidNotification: Event = Event.None; - readonly onDidAction: Event = Event.None; - - readonly authenticationPending: IObservable = constObservable(false); - setAuthenticationPending(_pending: boolean): void { /* no-op */ } - - get rootState(): IAgentSubscription { return notSupported(); } - - getSubscription(_kind: T, _resource: URI): IReference> { return notSupported(); } - getSubscriptionUnmanaged(_kind: T, _resource: URI): IAgentSubscription | undefined { return undefined; } - dispatch(_action: SessionAction | TerminalAction | IRootConfigChangedAction): void { notSupported(); } - - async restartAgentHost(): Promise { notSupported(); } - async authenticate(_params: AuthenticateParams): Promise { return notSupported(); } - async listSessions(): Promise { return []; } - async createSession(_config?: IAgentCreateSessionConfig): Promise { return notSupported(); } - async resolveSessionConfig(_params: IAgentResolveSessionConfigParams): Promise { return notSupported(); } - async sessionConfigCompletions(_params: IAgentSessionConfigCompletionsParams): Promise { return notSupported(); } - async startWebSocketServer(): Promise { return notSupported(); } - async getInspectInfo(_tryEnable: boolean): Promise { return undefined; } - async disposeSession(_session: URI): Promise { } - async createTerminal(_params: CreateTerminalParams): Promise { notSupported(); } - async disposeTerminal(_terminal: URI): Promise { } - async resourceList(_uri: URI): Promise { return notSupported(); } - async resourceRead(_uri: URI): Promise { return notSupported(); } - async resourceWrite(_params: ResourceWriteParams): Promise { return notSupported(); } - async resourceCopy(_params: ResourceCopyParams): Promise { return notSupported(); } - async resourceDelete(_params: ResourceDeleteParams): Promise { return notSupported(); } - async resourceMove(_params: ResourceMoveParams): Promise { return notSupported(); } -} diff --git a/src/vs/platform/agentHost/browser/nullSshRemoteAgentHostService.ts b/src/vs/platform/agentHost/browser/nullSshRemoteAgentHostService.ts deleted file mode 100644 index 70119aeb..00000000 --- a/src/vs/platform/agentHost/browser/nullSshRemoteAgentHostService.ts +++ /dev/null @@ -1,45 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { Event } from '../../../base/common/event.js'; -import { URI } from '../../../base/common/uri.js'; -import type { ISSHRemoteAgentHostService, ISSHAgentHostConnection, ISSHAgentHostConfig, ISSHConnectProgress, ISSHResolvedConfig } from '../common/sshRemoteAgentHost.js'; - -/** - * Null implementation of {@link ISSHRemoteAgentHostService} for browser contexts - * where SSH is not available. - */ -export class NullSSHRemoteAgentHostService implements ISSHRemoteAgentHostService { - declare readonly _serviceBrand: undefined; - readonly onDidChangeConnections = Event.None; - readonly onDidReportConnectProgress: Event = Event.None; - readonly connections: readonly ISSHAgentHostConnection[] = []; - - async connect(_config: ISSHAgentHostConfig): Promise { - throw new Error('SSH connections are not supported in the browser.'); - } - - async disconnect(_host: string): Promise { } - - async listSSHConfigHosts(): Promise { - return []; - } - - async ensureUserSSHConfig(): Promise { - throw new Error('SSH is not supported in the browser.'); - } - - async listSSHConfigFiles(): Promise { - return []; - } - - async resolveSSHConfig(_host: string): Promise { - throw new Error('SSH is not supported in the browser.'); - } - - async reconnect(_sshConfigHost: string, _name: string): Promise { - throw new Error('SSH connections are not supported in the browser.'); - } -} diff --git a/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts b/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts deleted file mode 100644 index cb6366cf..00000000 --- a/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts +++ /dev/null @@ -1,528 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -// Protocol client for communicating with a remote agent host process. -// Wraps WebSocketClientTransport and SessionClientState to provide a -// higher-level API matching IAgentService. - -import { DeferredPromise } from '../../../base/common/async.js'; -import { Emitter } from '../../../base/common/event.js'; -import { Disposable, IReference } from '../../../base/common/lifecycle.js'; -import { Schemas } from '../../../base/common/network.js'; -import { hasKey } from '../../../base/common/types.js'; -import { URI } from '../../../base/common/uri.js'; -import { generateUuid } from '../../../base/common/uuid.js'; -import { ILogService } from '../../log/common/log.js'; -import { FileSystemProviderErrorCode, IFileService, toFileSystemProviderErrorCode } from '../../files/common/files.js'; -import { AgentSession, IAgentConnection, IAgentCreateSessionConfig, IAgentResolveSessionConfigParams, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, AuthenticateParams, AuthenticateResult } from '../common/agentService.js'; -import { AgentSubscriptionManager, type IAgentSubscription } from '../common/state/agentSubscription.js'; -import { agentHostAuthority, fromAgentHostUri, toAgentHostUri } from '../common/agentHostUri.js'; -import type { ClientNotificationMap, CommandMap, JsonRpcErrorResponse, JsonRpcRequest } from '../common/state/protocol/messages.js'; -import type { ActionEnvelope, INotification, IRootConfigChangedAction, SessionAction, TerminalAction } from '../common/state/sessionActions.js'; -import { SessionSummary, SessionStatus, ROOT_STATE_URI, StateComponents, type RootState } from '../common/state/sessionState.js'; -import { PROTOCOL_VERSION } from '../common/state/sessionCapabilities.js'; -import { isJsonRpcNotification, isJsonRpcRequest, isJsonRpcResponse, type ProtocolMessage, type IStateSnapshot } from '../common/state/sessionProtocol.js'; -import { isClientTransport, type IProtocolTransport } from '../common/state/sessionTransport.js'; -import { AhpErrorCodes } from '../common/state/protocol/errors.js'; -import { ContentEncoding, type CreateTerminalParams, type ResolveSessionConfigResult, type SessionConfigCompletionsResult } from '../common/state/protocol/commands.js'; -import { decodeBase64, encodeBase64, VSBuffer } from '../../../base/common/buffer.js'; - -const AHP_CLIENT_CONNECTION_CLOSED = -32000; - -export class RemoteAgentHostProtocolError extends Error { - - readonly code: number; - readonly data: unknown | undefined; - - constructor(error: JsonRpcErrorResponse['error']) { - super(error.message); - this.code = error.code; - this.data = error.data; - } - - static connectionClosed(address: string): RemoteAgentHostProtocolError { - return new RemoteAgentHostProtocolError({ code: AHP_CLIENT_CONNECTION_CLOSED, message: `Connection closed: ${address}` }); - } - - static disposed(address: string): RemoteAgentHostProtocolError { - return new RemoteAgentHostProtocolError({ code: AHP_CLIENT_CONNECTION_CLOSED, message: `Connection disposed: ${address}` }); - } -} - -interface IRemoteAgentHostExtensionCommandMap { - 'shutdown': { params: undefined; result: void }; -} - -/** - * A protocol-level client for a single remote agent host connection. - * Manages the WebSocket transport, handshake, subscriptions, action dispatch, - * and command/response correlation. - * - * Implements {@link IAgentConnection} so consumers can program against - * a single interface regardless of whether the agent host is local or remote. - */ -export class RemoteAgentHostProtocolClient extends Disposable implements IAgentConnection { - - declare readonly _serviceBrand: undefined; - - private readonly _clientId = generateUuid(); - private readonly _address: string; - private readonly _transport: IProtocolTransport; - private readonly _connectionAuthority: string; - private _serverSeq = 0; - private _nextClientSeq = 1; - private _defaultDirectory: string | undefined; - private readonly _subscriptionManager: AgentSubscriptionManager; - - private readonly _onDidAction = this._register(new Emitter()); - readonly onDidAction = this._onDidAction.event; - - private readonly _onDidNotification = this._register(new Emitter()); - readonly onDidNotification = this._onDidNotification.event; - - private readonly _onDidClose = this._register(new Emitter()); - readonly onDidClose = this._onDidClose.event; - - /** Pending JSON-RPC requests keyed by request id. */ - private readonly _pendingRequests = new Map>(); - private _nextRequestId = 1; - private _isClosed = false; - private _closeError: RemoteAgentHostProtocolError | undefined; - - get clientId(): string { - return this._clientId; - } - - get address(): string { - return this._address; - } - - get defaultDirectory(): string | undefined { - return this._defaultDirectory; - } - - constructor( - address: string, - transport: IProtocolTransport, - @ILogService private readonly _logService: ILogService, - @IFileService private readonly _fileService: IFileService, - ) { - super(); - this._address = address; - this._connectionAuthority = agentHostAuthority(address); - this._transport = transport; - this._register(this._transport); - this._register(this._transport.onMessage(msg => this._handleMessage(msg))); - this._register(this._transport.onClose(() => this._handleClose(RemoteAgentHostProtocolError.connectionClosed(this._address)))); - - this._subscriptionManager = this._register(new AgentSubscriptionManager( - this._clientId, - () => this.nextClientSeq(), - msg => this._logService.warn(`[RemoteAgentHostProtocolClient] ${msg}`), - resource => this.subscribe(resource), - resource => this.unsubscribe(resource), - )); - - // Forward action envelopes from the transport to the subscription manager - this._register(this.onDidAction(envelope => { - this._subscriptionManager.receiveEnvelope(envelope); - })); - } - - override dispose(): void { - this._handleClose(RemoteAgentHostProtocolError.disposed(this._address)); - super.dispose(); - } - - /** - * Connect to the remote agent host and perform the protocol handshake. - */ - async connect(): Promise { - if (isClientTransport(this._transport)) { - await this._raceClose(this._transport.connect()); - } - - const result = await this._sendRequest('initialize', { - protocolVersion: PROTOCOL_VERSION, - clientId: this._clientId, - initialSubscriptions: [ROOT_STATE_URI], - }); - this._serverSeq = result.serverSeq; - - // Hydrate root state from the initial snapshot - for (const snapshot of result.snapshots ?? []) { - if (snapshot.resource === ROOT_STATE_URI) { - this._subscriptionManager.handleRootSnapshot(snapshot.state as RootState, snapshot.fromSeq); - } - } - - if (result.defaultDirectory) { - const dir = result.defaultDirectory; - if (typeof dir === 'string') { - this._defaultDirectory = URI.parse(dir).path; - } else { - this._defaultDirectory = URI.revive(dir).path; - } - } - } - - // ---- IAgentConnection subscription API ---------------------------------- - - get rootState(): IAgentSubscription { - return this._subscriptionManager.rootState; - } - - getSubscription(kind: StateComponents, resource: URI): IReference> { - return this._subscriptionManager.getSubscription(kind, resource); - } - - getSubscriptionUnmanaged(_kind: StateComponents, resource: URI): IAgentSubscription | undefined { - return this._subscriptionManager.getSubscriptionUnmanaged(resource); - } - - dispatch(action: SessionAction | TerminalAction | IRootConfigChangedAction): void { - const seq = this._subscriptionManager.dispatchOptimistic(action); - this.dispatchAction(action, this._clientId, seq); - } - - /** - * Subscribe to state at a URI. Returns the current state snapshot. - */ - async subscribe(resource: URI): Promise { - const result = await this._sendRequest('subscribe', { resource: resource.toString() }); - return result.snapshot; - } - - /** - * Unsubscribe from state at a URI. - */ - unsubscribe(resource: URI): void { - this._sendNotification('unsubscribe', { resource: resource.toString() }); - } - - /** - * Dispatch a client action to the server. Returns the clientSeq used. - */ - dispatchAction(action: SessionAction | TerminalAction | IRootConfigChangedAction, _clientId: string, clientSeq: number): void { - this._sendNotification('dispatchAction', { clientSeq, action }); - } - - /** - * Create a new session on the remote agent host. - */ - async createSession(config?: IAgentCreateSessionConfig): Promise { - const provider = config?.provider; - if (!provider) { - throw new Error('Cannot create remote agent host session without a provider.'); - } - const session = config?.session ?? AgentSession.uri(provider, generateUuid()); - await this._sendRequest('createSession', { - session: session.toString(), - provider, - model: config?.model, - workingDirectory: config?.workingDirectory ? fromAgentHostUri(config.workingDirectory).toString() : undefined, - config: config?.config, - activeClient: config?.activeClient, - }); - return session; - } - - async resolveSessionConfig(params: IAgentResolveSessionConfigParams): Promise { - return this._sendRequest('resolveSessionConfig', { - provider: params.provider, - workingDirectory: params.workingDirectory ? fromAgentHostUri(params.workingDirectory).toString() : undefined, - config: params.config, - }); - } - - async sessionConfigCompletions(params: IAgentSessionConfigCompletionsParams): Promise { - return this._sendRequest('sessionConfigCompletions', { - provider: params.provider, - workingDirectory: params.workingDirectory ? fromAgentHostUri(params.workingDirectory).toString() : undefined, - config: params.config, - property: params.property, - query: params.query, - }); - } - - /** - * Authenticate with the remote agent host using a specific scheme. - */ - async authenticate(params: AuthenticateParams): Promise { - await this._sendRequest('authenticate', params); - return { authenticated: true }; - } - - /** - * Gracefully shut down all sessions on the remote host. - */ - async shutdown(): Promise { - await this._sendExtensionRequest('shutdown'); - } - - /** - * Dispose a session on the remote agent host. - */ - async disposeSession(session: URI): Promise { - await this._sendRequest('disposeSession', { session: session.toString() }); - } - - /** - * Create a new terminal on the remote agent host. - */ - async createTerminal(params: CreateTerminalParams): Promise { - await this._sendRequest('createTerminal', params); - } - - /** - * Dispose a terminal on the remote agent host. - */ - async disposeTerminal(terminal: URI): Promise { - await this._sendRequest('disposeTerminal', { terminal: terminal.toString() }); - } - - /** - * List all sessions from the remote agent host. - */ - async listSessions(): Promise { - const result = await this._sendRequest('listSessions', {}); - return result.items.map((s: SessionSummary) => ({ - session: URI.parse(s.resource), - startTime: s.createdAt, - modifiedTime: s.modifiedAt, - ...(s.project ? { - project: { - uri: this._toLocalProjectUri(URI.parse(s.project.uri)), - displayName: s.project.displayName, - } - } : {}), - summary: s.title, - status: s.status, - activity: s.activity, - workingDirectory: typeof s.workingDirectory === 'string' ? toAgentHostUri(URI.parse(s.workingDirectory), this._connectionAuthority) : undefined, - isRead: !!(s.status & SessionStatus.IsRead), - isArchived: !!(s.status & SessionStatus.IsArchived), - diffs: s.diffs, - })); - } - - private _toLocalProjectUri(uri: URI): URI { - return uri.scheme === Schemas.file ? toAgentHostUri(uri, this._connectionAuthority) : uri; - } - - /** - * List the contents of a directory on the remote host's filesystem. - */ - async resourceList(uri: URI): Promise { - return await this._sendRequest('resourceList', { uri: uri.toString() }); - } - - /** - * Read the content of a resource on the remote host. - */ - async resourceRead(uri: URI): Promise { - return this._sendRequest('resourceRead', { uri: uri.toString() }); - } - - async resourceWrite(params: CommandMap['resourceWrite']['params']): Promise { - return this._sendRequest('resourceWrite', params); - } - - async resourceCopy(params: CommandMap['resourceCopy']['params']): Promise { - return this._sendRequest('resourceCopy', params); - } - - async resourceDelete(params: CommandMap['resourceDelete']['params']): Promise { - return this._sendRequest('resourceDelete', params); - } - - async resourceMove(params: CommandMap['resourceMove']['params']): Promise { - return this._sendRequest('resourceMove', params); - } - - private _handleMessage(msg: ProtocolMessage): void { - if (isJsonRpcRequest(msg)) { - this._handleReverseRequest(msg.id, msg.method, msg.params); - } else if (isJsonRpcResponse(msg)) { - const pending = this._pendingRequests.get(msg.id); - if (pending) { - this._pendingRequests.delete(msg.id); - if (hasKey(msg, { error: true })) { - this._logService.warn(`[RemoteAgentHostProtocol] Request ${msg.id} failed:`, msg.error); - pending.error(this._toProtocolError(msg.error)); - } else { - pending.complete(msg.result); - } - } else { - this._logService.warn(`[RemoteAgentHostProtocol] Received response for unknown request id ${msg.id}`); - } - } else if (isJsonRpcNotification(msg)) { - switch (msg.method) { - case 'action': { - // Protocol envelope → VS Code envelope (superset of action types) - const envelope = msg.params; - this._serverSeq = Math.max(this._serverSeq, envelope.serverSeq); - this._onDidAction.fire(envelope); - break; - } - case 'notification': { - const notification = msg.params.notification; - this._logService.trace(`[RemoteAgentHostProtocol] Notification: ${notification.type}`); - this._onDidNotification.fire(notification); - break; - } - default: - this._logService.trace(`[RemoteAgentHostProtocol] Unhandled method: ${msg.method}`); - break; - } - } else { - this._logService.warn(`[RemoteAgentHostProtocol] Unrecognized message:`, JSON.stringify(msg)); - } - } - - private _handleClose(error: RemoteAgentHostProtocolError): void { - if (this._isClosed) { - return; - } - - this._isClosed = true; - this._closeError = error; - this._rejectPendingRequests(error); - this._onDidClose.fire(); - } - - private async _raceClose(promise: Promise): Promise { - if (this._closeError) { - return Promise.reject(this._closeError); - } - - let closeListener = Disposable.None; - const closePromise = new Promise((_resolve, reject) => { - closeListener = this.onDidClose(() => reject(this._closeError)); - }); - - try { - return await Promise.race([promise, closePromise]); - } finally { - closeListener.dispose(); - } - } - - /** - * Handles reverse RPC requests from the server (e.g. resourceList, - * resourceRead). Reads from the local file service and sends a response. - */ - private _handleReverseRequest(id: number, method: string, params: unknown): void { - const sendResult = (result: unknown) => { - this._transport.send({ jsonrpc: '2.0', id, result }); - }; - const sendError = (err: unknown) => { - const fsCode = toFileSystemProviderErrorCode(err instanceof Error ? err : undefined); - let code = -32000; - switch (fsCode) { - case FileSystemProviderErrorCode.FileNotFound: code = AhpErrorCodes.NotFound; break; - case FileSystemProviderErrorCode.NoPermissions: code = AhpErrorCodes.PermissionDenied; break; - case FileSystemProviderErrorCode.FileExists: code = AhpErrorCodes.AlreadyExists; break; - } - this._transport.send({ jsonrpc: '2.0', id, error: { code, message: err instanceof Error ? err.message : String(err) } }); - }; - const handle = (fn: () => Promise) => { - fn().then(sendResult, sendError); - }; - - const p = params as Record; - switch (method) { - case 'resourceList': - if (!p.uri) { sendError(new Error('Missing uri')); return; } - return handle(async () => { - const stat = await this._fileService.resolve(URI.parse(p.uri as string)); - return { entries: (stat.children ?? []).map(c => ({ name: c.name, type: c.isDirectory ? 'directory' as const : 'file' as const })) }; - }); - case 'resourceRead': - if (!p.uri) { sendError(new Error('Missing uri')); return; } - return handle(async () => { - const content = await this._fileService.readFile(URI.parse(p.uri as string)); - return { data: encodeBase64(content.value), encoding: ContentEncoding.Base64 }; - }); - case 'resourceWrite': - if (!p.uri || !p.data) { sendError(new Error('Missing uri or data')); return; } - return handle(async () => { - const writeUri = URI.parse(p.uri as string); - const buf = p.encoding === ContentEncoding.Base64 - ? decodeBase64(p.data as string) - : VSBuffer.fromString(p.data as string); - if (p.createOnly) { - await this._fileService.createFile(writeUri, buf, { overwrite: false }); - } else { - await this._fileService.writeFile(writeUri, buf); - } - return {}; - }); - case 'resourceDelete': - if (!p.uri) { sendError(new Error('Missing uri')); return; } - return handle(() => this._fileService.del(URI.parse(p.uri as string), { recursive: !!p.recursive }).then(() => ({}))); - case 'resourceMove': - if (!p.source || !p.destination) { sendError(new Error('Missing source or destination')); return; } - return handle(() => this._fileService.move(URI.parse(p.source as string), URI.parse(p.destination as string), !p.failIfExists).then(() => ({}))); - default: - this._logService.warn(`[RemoteAgentHostProtocol] Unhandled reverse request: ${method}`); - sendError(new Error(`Unknown method: ${method}`)); - } - } - - /** Send a typed JSON-RPC notification for a protocol-defined method. */ - private _sendNotification(method: M, params: ClientNotificationMap[M]['params']): void { - // Generic M can't satisfy the distributive AhpNotification union directly - // eslint-disable-next-line local/code-no-dangerous-type-assertions - this._transport.send({ jsonrpc: '2.0' as const, method, params } as ProtocolMessage); - } - - /** Send a typed JSON-RPC request for a protocol-defined method. */ - private _sendRequest(method: M, params: CommandMap[M]['params']): Promise { - if (this._closeError) { - return Promise.reject(this._closeError); - } - - const id = this._nextRequestId++; - const deferred = new DeferredPromise(); - this._pendingRequests.set(id, deferred); - // Generic M can't satisfy the distributive AhpRequest union directly - // eslint-disable-next-line local/code-no-dangerous-type-assertions - this._transport.send({ jsonrpc: '2.0' as const, id, method, params } as ProtocolMessage); - return deferred.p as Promise; - } - - /** Send a JSON-RPC request for a VS Code extension method (not in the protocol spec). */ - private _sendExtensionRequest(method: M, params?: IRemoteAgentHostExtensionCommandMap[M]['params']): Promise { - if (this._closeError) { - return Promise.reject(this._closeError); - } - - const id = this._nextRequestId++; - const deferred = new DeferredPromise(); - this._pendingRequests.set(id, deferred); - const request: JsonRpcRequest = { jsonrpc: '2.0', id, method, params }; - this._transport.send(request); - return deferred.p as Promise; - } - - private _toProtocolError(error: JsonRpcErrorResponse['error']): RemoteAgentHostProtocolError { - return new RemoteAgentHostProtocolError(error); - } - - private _rejectPendingRequests(error: RemoteAgentHostProtocolError): void { - for (const pending of this._pendingRequests.values()) { - pending.error(error); - } - this._pendingRequests.clear(); - } - - /** - * Get the next client sequence number for optimistic dispatch. - */ - nextClientSeq(): number { - return this._nextClientSeq++; - } -} diff --git a/src/vs/platform/agentHost/browser/remoteAgentHostServiceImpl.ts b/src/vs/platform/agentHost/browser/remoteAgentHostServiceImpl.ts deleted file mode 100644 index 3070a174..00000000 --- a/src/vs/platform/agentHost/browser/remoteAgentHostServiceImpl.ts +++ /dev/null @@ -1,565 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -// Service implementation that manages WebSocket connections to remote agent -// host processes. Reads addresses from the `chat.remoteAgentHosts` setting -// and maintains connections, reconnecting as the setting changes. - -import { Emitter } from '../../../base/common/event.js'; -import { Disposable, DisposableStore, IDisposable } from '../../../base/common/lifecycle.js'; -import { DeferredPromise, raceTimeout } from '../../../base/common/async.js'; -import { ConfigurationTarget, IConfigurationService } from '../../configuration/common/configuration.js'; -import { IInstantiationService } from '../../instantiation/common/instantiation.js'; -import { ILogService } from '../../log/common/log.js'; - -import type { IAgentConnection } from '../common/agentService.js'; -import { - IRemoteAgentHostService, - RemoteAgentHostConnectionStatus, - RemoteAgentHostEntryType, - RemoteAgentHostsEnabledSettingId, - RemoteAgentHostsSettingId, - entryToRawEntry, - getEntryAddress, - rawEntryToEntry, - type IRawRemoteAgentHostEntry, - type IRemoteAgentHostConnectionInfo, - type IRemoteAgentHostEntry, -} from '../common/remoteAgentHostService.js'; -import { RemoteAgentHostProtocolClient } from './remoteAgentHostProtocolClient.js'; -import { WebSocketClientTransport } from './webSocketClientTransport.js'; -import { normalizeRemoteAgentHostAddress } from '../common/agentHostUri.js'; -import { isDefined } from '../../../base/common/types.js'; - -/** Tracks a single remote connection through its lifecycle. */ -interface IConnectionEntry { - readonly store: DisposableStore; - readonly client: RemoteAgentHostProtocolClient; - connected: boolean; - /** Current connection status for UI display. */ - status: RemoteAgentHostConnectionStatus; -} - -export class RemoteAgentHostService extends Disposable implements IRemoteAgentHostService { - private static readonly ConnectionWaitTimeout = 10000; - /** Initial reconnect delay in milliseconds. */ - private static readonly ReconnectInitialDelay = 1000; - /** Maximum reconnect delay in milliseconds. */ - private static readonly ReconnectMaxDelay = 30000; - - declare readonly _serviceBrand: undefined; - - private readonly _onDidChangeConnections = this._register(new Emitter()); - readonly onDidChangeConnections = this._onDidChangeConnections.event; - - private readonly _entries = new Map(); - private readonly _names = new Map(); - private readonly _tokens = new Map(); - /** - * Stores the original {@link IRemoteAgentHostEntry} for connections - * registered via {@link addManagedConnection}. This is needed because - * tunnel entries are not persisted to settings and therefore don't - * appear in {@link configuredEntries}. - */ - private readonly _registeredEntries = new Map(); - private readonly _pendingConnectionWaits = new Map>(); - /** Pending reconnect timeouts, keyed by normalized address. */ - private readonly _reconnectTimeouts = new Map>(); - /** Current reconnect attempt count per address for exponential backoff. */ - private readonly _reconnectAttempts = new Map(); - - constructor( - @IConfigurationService private readonly _configurationService: IConfigurationService, - @IInstantiationService private readonly _instantiationService: IInstantiationService, - @ILogService private readonly _logService: ILogService, - ) { - super(); - - // React to setting changes - this._register(this._configurationService.onDidChangeConfiguration(e => { - if (e.affectsConfiguration(RemoteAgentHostsSettingId) || e.affectsConfiguration(RemoteAgentHostsEnabledSettingId)) { - this._reconcileConnections(); - } - })); - - // Initial connection - this._reconcileConnections(); - } - - get connections(): readonly IRemoteAgentHostConnectionInfo[] { - const result: IRemoteAgentHostConnectionInfo[] = []; - for (const [address, entry] of this._entries) { - result.push({ - address, - name: this._names.get(address) ?? address, - clientId: entry.client.clientId, - defaultDirectory: entry.client.defaultDirectory, - status: entry.status, - }); - } - return result; - } - - get configuredEntries(): readonly IRemoteAgentHostEntry[] { - return this._getConfiguredEntries().map(e => { - if (e.connection.type === RemoteAgentHostEntryType.Tunnel) { - return e; - } - return { ...e, connection: { ...e.connection, address: normalizeRemoteAgentHostAddress(e.connection.address) } }; - }); - } - - getConnection(address: string): IAgentConnection | undefined { - const normalized = normalizeRemoteAgentHostAddress(address); - const entry = this._entries.get(normalized); - return entry?.connected ? entry.client : undefined; - } - - getEntryByAddress(address: string): IRemoteAgentHostEntry | undefined { - const normalized = normalizeRemoteAgentHostAddress(address); - // Check dynamically registered entries first (e.g. tunnel connections - // that are not persisted to settings). - const registered = this._registeredEntries.get(normalized); - if (registered) { - return registered; - } - // Fall back to configured entries from settings. - return this.configuredEntries.find( - e => normalizeRemoteAgentHostAddress(getEntryAddress(e)) === normalized - ); - } - - reconnect(address: string): void { - const normalized = normalizeRemoteAgentHostAddress(address); - - // SSH/tunnel entries are reconnected by their respective services - const configuredEntry = this._getConfiguredEntries().find( - e => normalizeRemoteAgentHostAddress(getEntryAddress(e)) === normalized - ); - if (configuredEntry && configuredEntry.connection.type !== RemoteAgentHostEntryType.WebSocket) { - return; - } - - const token = this._tokens.get(normalized); - - // Cancel any pending reconnect - this._cancelReconnect(normalized); - this._reconnectAttempts.delete(normalized); - - // Tear down existing connection if present - const entry = this._entries.get(normalized); - if (entry) { - this._entries.delete(normalized); - entry.store.dispose(); - } - - // Start fresh connection attempt - this._connectTo(normalized, token); - } - - async addRemoteAgentHost(input: IRemoteAgentHostEntry): Promise { - if (!this._configurationService.getValue(RemoteAgentHostsEnabledSettingId)) { - throw new Error('Remote agent host connections are not enabled.'); - } - - const entry: IRemoteAgentHostEntry = input.connection.type === RemoteAgentHostEntryType.Tunnel - ? input - : { ...input, connection: { ...input.connection, address: normalizeRemoteAgentHostAddress(input.connection.address) } }; - const address = getEntryAddress(entry); - const existingConnection = this._getConnectionInfo(address); - await this._storeConfiguredEntries(this._upsertConfiguredEntry(entry)); - - if (existingConnection) { - return { - ...existingConnection, - name: entry.name, - }; - } - - // SSH entries are connected externally — just persist - // the entry and return a disconnected placeholder. The connection - // will be established by the SSH contribution. - if (entry.connection.type === RemoteAgentHostEntryType.SSH) { - return { - address, - name: entry.name, - clientId: '', - status: RemoteAgentHostConnectionStatus.Disconnected, - }; - } - - const connectedConnection = this._getConnectionInfo(address); - if (connectedConnection) { - return connectedConnection; - } - - const wait = this._getOrCreateConnectionWait(address); - const connection = await raceTimeout(wait.p, RemoteAgentHostService.ConnectionWaitTimeout, () => { - this._pendingConnectionWaits.delete(address); - }); - if (!connection) { - throw new Error(`Timed out connecting to ${address}`); - } - - return connection; - } - - async addManagedConnection(entry: IRemoteAgentHostEntry, connection: IAgentConnection, transportDisposable?: IDisposable): Promise { - const address = getEntryAddress(entry); - - // Dispose any existing entry for this address to avoid leaking - // old protocol clients and relay transports on reconnect. - const existingEntry = this._entries.get(address); - if (existingEntry) { - this._entries.delete(address); - existingEntry.store.dispose(); - } - - const store = new DisposableStore(); - - // Create a connection entry wrapping the pre-connected client - const protocolClient = connection as RemoteAgentHostProtocolClient; - store.add(protocolClient); - // Tear the underlying transport (e.g. SSH/tunnel relay) down with - // the entry. This is what makes "Remove Remote" actually close the - // shared-process tunnel and stop the remote agent host process. - if (transportDisposable) { - store.add(transportDisposable); - } - const connEntry: IConnectionEntry = { store, client: protocolClient, connected: true, status: RemoteAgentHostConnectionStatus.Connected }; - this._entries.set(address, connEntry); - this._names.set(address, entry.name); - this._registeredEntries.set(address, entry); - if (entry.connectionToken) { - this._tokens.set(address, entry.connectionToken); - } - - store.add(protocolClient.onDidClose(() => { - if (this._entries.get(address) === connEntry) { - connEntry.connected = false; - connEntry.status = RemoteAgentHostConnectionStatus.Disconnected; - this._onDidChangeConnections.fire(); - } - })); - - // Persist entries — await so that the config is written before - // onDidChangeConnections fires, ensuring _reconcile creates the provider. - // Tunnel entries are filtered out by _storeConfiguredEntries automatically. - await this._storeConfiguredEntries(this._upsertConfiguredEntry(entry)); - - this._onDidChangeConnections.fire(); - - return { - address, - name: entry.name, - clientId: protocolClient.clientId, - defaultDirectory: protocolClient.defaultDirectory, - status: RemoteAgentHostConnectionStatus.Connected, - }; - } - - async removeRemoteAgentHost(address: string): Promise { - const normalized = normalizeRemoteAgentHostAddress(address); - // This setting is only used in the sessions app (user scope), so we - // don't need to inspect per-scope values like _upsertConfiguredEntry does. - const entries = this._getConfiguredEntries().filter( - e => normalizeRemoteAgentHostAddress(getEntryAddress(e)) !== normalized - ); - await this._storeConfiguredEntries(entries); - - // Eagerly clear in-memory state so the UI updates immediately - // (the config change listener will reconcile, but this is instant). - this._names.delete(normalized); - this._tokens.delete(normalized); - this._registeredEntries.delete(normalized); - this._cancelReconnect(normalized); - this._reconnectAttempts.delete(normalized); - this._removeConnection(normalized); - } - - private _removeConnection(address: string): void { - const entry = this._entries.get(address); - if (entry) { - this._entries.delete(address); - entry.store.dispose(); - this._rejectPendingConnectionWait(address, new Error(`Connection closed: ${address}`)); - this._onDidChangeConnections.fire(); - } - } - - private _reconcileConnections(): void { - if (!this._configurationService.getValue(RemoteAgentHostsEnabledSettingId)) { - // Disconnect all when disabled - for (const address of [...this._entries.keys()]) { - this._cancelReconnect(address); - this._removeConnection(address); - } - this._names.clear(); - this._tokens.clear(); - this._reconnectAttempts.clear(); - return; - } - - const rawEntries = (this._configurationService.getValue(RemoteAgentHostsSettingId) ?? []).map(rawEntryToEntry).filter(isDefined); - const entriesWithAddress = rawEntries.map(e => ({ entry: e, address: normalizeRemoteAgentHostAddress(getEntryAddress(e)) })); - const desired = new Set(entriesWithAddress.map(e => e.address)); - - this._logService.info(`[RemoteAgentHost] Reconciling: desired=[${[...desired].join(', ')}], current=[${[...this._entries.keys()].map(a => `${a}(${this._entries.get(a)!.connected ? 'connected' : 'pending'})`).join(', ')}]`); - - // Update name map and detect name changes for existing connections - let namesChanged = false; - const oldNames = new Map(this._names); - this._names.clear(); - this._tokens.clear(); - for (const { entry, address } of entriesWithAddress) { - this._names.set(address, entry.name); - this._tokens.set(address, entry.connectionToken); - if (this._entries.has(address) && oldNames.get(address) !== entry.name) { - namesChanged = true; - } - } - - // Remove connections no longer in the setting - for (const address of [...this._entries.keys()]) { - if (!desired.has(address)) { - this._logService.info(`[RemoteAgentHost] Disconnecting from ${address}`); - this._cancelReconnect(address); - this._reconnectAttempts.delete(address); - this._removeConnection(address); - } - } - - // Add new connections (skip SSH entries — those are handled by ISSHRemoteAgentHostService, - // and skip tunnel entries — those are handled by ITunnelAgentHostService) - for (const { entry, address } of entriesWithAddress) { - if (!this._entries.has(address) && entry.connection.type === RemoteAgentHostEntryType.WebSocket) { - this._connectTo(address, entry.connectionToken); - } - } - - // If only names changed (no add/remove), notify so the UI updates - if (namesChanged) { - this._onDidChangeConnections.fire(); - } - } - - private _connectTo(address: string, connectionToken?: string): void { - // Dispose any existing entry for this address before creating a new one - // to avoid leaking disposables on reconnect. - const existingEntry = this._entries.get(address); - if (existingEntry) { - this._entries.delete(address); - existingEntry.store.dispose(); - } - - const store = new DisposableStore(); - const transport = store.add(new WebSocketClientTransport(address, connectionToken)); - const client = store.add(this._instantiationService.createInstance(RemoteAgentHostProtocolClient, address, transport)); - const entry: IConnectionEntry = { store, client, connected: false, status: RemoteAgentHostConnectionStatus.Connecting }; - this._entries.set(address, entry); - - // Guard against stale callbacks: only act if the - // current entry for this address is still the one we created. - const isCurrentEntry = () => this._entries.get(address) === entry; - - store.add(client.onDidClose(() => { - if (!isCurrentEntry()) { - return; - } - this._logService.warn(`[RemoteAgentHost] Connection closed: ${address}`); - entry.connected = false; - entry.status = RemoteAgentHostConnectionStatus.Disconnected; - this._onDidChangeConnections.fire(); - // Schedule reconnect if the address is still configured - this._scheduleReconnect(address, connectionToken); - })); - - this._logService.info(`[RemoteAgentHost] Connecting to ${address}`); - this._onDidChangeConnections.fire(); - client.connect().then(() => { - if (store.isDisposed) { - return; // removed before connect resolved - } - this._logService.info(`[RemoteAgentHost] Connected to ${address}`); - entry.connected = true; - entry.status = RemoteAgentHostConnectionStatus.Connected; - this._reconnectAttempts.delete(address); - this._resolvePendingConnectionWait(address); - this._onDidChangeConnections.fire(); - }).catch((err: unknown) => { - if (!isCurrentEntry()) { - return; - } - this._logService.error(`[RemoteAgentHost] Failed to connect to ${address}. Verify address and connectionToken`, err); - entry.status = RemoteAgentHostConnectionStatus.Disconnected; - // Clean up the failed entry - this._entries.delete(address); - entry.store.dispose(); - this._rejectPendingConnectionWait(address, err); - this._onDidChangeConnections.fire(); - // Schedule reconnect if the address is still configured - this._scheduleReconnect(address, connectionToken); - }); - } - - /** - * Schedule a reconnect attempt with exponential backoff. - * Only reconnects if the address is still in the configured entries. - */ - private _scheduleReconnect(address: string, connectionToken?: string): void { - // Don't reconnect if the address was removed from settings - if (!this._isAddressConfigured(address)) { - this._logService.info(`[RemoteAgentHost] Not reconnecting to ${address}: no longer configured`); - return; - } - - const attempt = (this._reconnectAttempts.get(address) ?? 0) + 1; - this._reconnectAttempts.set(address, attempt); - const delay = Math.min( - RemoteAgentHostService.ReconnectInitialDelay * Math.pow(2, attempt - 1), - RemoteAgentHostService.ReconnectMaxDelay, - ); - - this._logService.info(`[RemoteAgentHost] Scheduling reconnect to ${address} in ${delay}ms (attempt ${attempt})`); - - this._cancelReconnect(address); - const timeout = setTimeout(() => { - this._reconnectTimeouts.delete(address); - if (this._isAddressConfigured(address)) { - this._connectTo(address, connectionToken ?? this._tokens.get(address)); - } - }, delay); - this._reconnectTimeouts.set(address, timeout); - } - - /** Cancel a pending reconnect timeout for the given address. */ - private _cancelReconnect(address: string): void { - const timeout = this._reconnectTimeouts.get(address); - if (timeout !== undefined) { - clearTimeout(timeout); - this._reconnectTimeouts.delete(address); - } - } - - /** Check whether the given normalized address is still in the configured entries. */ - private _isAddressConfigured(address: string): boolean { - const entries = this._getConfiguredEntries(); - return entries.some(e => normalizeRemoteAgentHostAddress(getEntryAddress(e)) === address); - } - - private _getConnectionInfo(address: string): IRemoteAgentHostConnectionInfo | undefined { - return this.connections.find(connection => connection.address === address && connection.status === RemoteAgentHostConnectionStatus.Connected); - } - - private _getConfiguredEntries(): IRemoteAgentHostEntry[] { - return (this._configurationService.getValue(RemoteAgentHostsSettingId) ?? []).map(rawEntryToEntry).filter(isDefined); - } - - private _upsertConfiguredEntry(entry: IRemoteAgentHostEntry): IRemoteAgentHostEntry[] { - // Read from the same scope we'll write to, so we don't accidentally - // merge entries from an overriding scope (e.g. workspace) into the - // user scope and then lose them on the next read. - const target = this._getConfigurationTarget(); - const inspected = this._configurationService.inspect(RemoteAgentHostsSettingId); - let configuredRaw: readonly IRawRemoteAgentHostEntry[]; - switch (target) { - case ConfigurationTarget.USER_LOCAL: - configuredRaw = inspected.userLocalValue ?? []; - break; - case ConfigurationTarget.USER_REMOTE: - configuredRaw = inspected.userRemoteValue ?? []; - break; - default: - configuredRaw = inspected.userValue ?? []; - break; - } - - const configuredEntries = configuredRaw.map(rawEntryToEntry).filter((e): e is IRemoteAgentHostEntry => e !== undefined); - const normalizedAddress = normalizeRemoteAgentHostAddress(getEntryAddress(entry)); - const existingIndex = configuredEntries.findIndex(e => normalizeRemoteAgentHostAddress(getEntryAddress(e)) === normalizedAddress); - if (existingIndex === -1) { - return [...configuredEntries, entry]; - } - - return configuredEntries.map((e, index) => index === existingIndex ? entry : e); - } - - private _getConfigurationTarget(): ConfigurationTarget { - const inspected = this._configurationService.inspect(RemoteAgentHostsSettingId); - if (inspected.userLocalValue !== undefined) { - return ConfigurationTarget.USER_LOCAL; - } - if (inspected.userRemoteValue !== undefined) { - return ConfigurationTarget.USER_REMOTE; - } - if (inspected.userValue !== undefined) { - return ConfigurationTarget.USER; - } - return ConfigurationTarget.USER; - } - - private async _storeConfiguredEntries(entries: IRemoteAgentHostEntry[]): Promise { - const raw = entries.map(entryToRawEntry).filter(isDefined); - await this._configurationService.updateValue(RemoteAgentHostsSettingId, raw, this._getConfigurationTarget()); - } - - private _getOrCreateConnectionWait(address: string): DeferredPromise { - let wait = this._pendingConnectionWaits.get(address); - if (wait) { - return wait; - } - - // If the connection is already available (fast connect resolved before - // the caller called us), return an immediately-completed wait. - const existingConnection = this._getConnectionInfo(address); - if (existingConnection) { - const immediateWait = new DeferredPromise(); - immediateWait.complete(existingConnection); - return immediateWait; - } - - wait = new DeferredPromise(); - this._pendingConnectionWaits.set(address, wait); - return wait; - } - - private _resolvePendingConnectionWait(address: string): void { - const wait = this._pendingConnectionWaits.get(address); - const connection = this._getConnectionInfo(address); - if (!wait || !connection) { - return; - } - - this._pendingConnectionWaits.delete(address); - void wait.complete(connection); - } - - private _rejectPendingConnectionWait(address: string, err: unknown): void { - const wait = this._pendingConnectionWaits.get(address); - if (!wait) { - return; - } - - this._pendingConnectionWaits.delete(address); - void wait.error(err); - } - - override dispose(): void { - for (const timeout of this._reconnectTimeouts.values()) { - clearTimeout(timeout); - } - this._reconnectTimeouts.clear(); - this._reconnectAttempts.clear(); - for (const [address, wait] of this._pendingConnectionWaits) { - void wait.error(new Error(`Remote agent host service disposed before connecting to ${address}`)); - } - this._pendingConnectionWaits.clear(); - for (const entry of this._entries.values()) { - entry.store.dispose(); - } - this._entries.clear(); - super.dispose(); - } -} diff --git a/src/vs/platform/agentHost/browser/webSocketClientTransport.ts b/src/vs/platform/agentHost/browser/webSocketClientTransport.ts deleted file mode 100644 index 6b0c0212..00000000 --- a/src/vs/platform/agentHost/browser/webSocketClientTransport.ts +++ /dev/null @@ -1,189 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -// WebSocket client transport for connecting to remote agent host processes. -// Uses plain JSON serialization — URIs are string-typed in the protocol. - -import { Emitter } from '../../../base/common/event.js'; -import { Disposable } from '../../../base/common/lifecycle.js'; -import { connectionTokenQueryName } from '../../../base/common/network.js'; -import type { AhpServerNotification, JsonRpcResponse, ProtocolMessage } from '../common/state/sessionProtocol.js'; -import type { IClientTransport } from '../common/state/sessionTransport.js'; -import { MALFORMED_FRAMES_FORCE_CLOSE_THRESHOLD, MALFORMED_FRAMES_LOG_CAP } from '../common/transportConstants.js'; - -// ---- Client transport ------------------------------------------------------- - -/** - * A WebSocket client transport that connects to a remote agent host server. - * Uses the native browser WebSocket API (available in Electron renderer). - * Implements {@link IClientTransport} with JSON serialization and URI revival. - */ -export class WebSocketClientTransport extends Disposable implements IClientTransport { - - private readonly _onMessage = this._register(new Emitter()); - readonly onMessage = this._onMessage.event; - - private readonly _onClose = this._register(new Emitter()); - readonly onClose = this._onClose.event; - - private readonly _onOpen = this._register(new Emitter()); - readonly onOpen = this._onOpen.event; - - private _ws: WebSocket | undefined; - private _malformedFrames = 0; - - /** Guards against firing onClose more than once. */ - private _closeFired = false; - - get isOpen(): boolean { - return this._ws?.readyState === WebSocket.OPEN; - } - - constructor( - private readonly _address: string, - private readonly _connectionToken?: string, - ) { - // TODO: @osortega remove console.logs - super(); - } - - /** - * Initiate the WebSocket connection. Resolves when the connection - * is open, or rejects on error/timeout. - */ - connect(): Promise { - return new Promise((resolve, reject) => { - if (this._store.isDisposed) { - reject(new Error('Transport is disposed')); - return; - } - - let url = this._address.startsWith('ws://') || this._address.startsWith('wss://') - ? this._address - : `ws://${this._address}`; - - if (this._connectionToken) { - const separator = url.includes('?') ? '&' : '?'; - url += `${separator}${connectionTokenQueryName}=${encodeURIComponent(this._connectionToken)}`; - } - - const ws = new WebSocket(url); - this._ws = ws; - - const onOpen = () => { - cleanup(); - this._onOpen.fire(); - resolve(); - }; - - const onError = () => { - cleanup(); - reject(new Error(`WebSocket connection failed: ${this._address}`)); - }; - - const onClose = () => { - cleanup(); - reject(new Error(`WebSocket closed before connection was established: ${this._address}`)); - }; - - const cleanup = () => { - ws.removeEventListener('open', onOpen); - ws.removeEventListener('error', onError); - ws.removeEventListener('close', onClose); - }; - - ws.addEventListener('open', onOpen); - ws.addEventListener('error', onError); - ws.addEventListener('close', onClose); - - // Wire up long-lived listeners after connection - ws.addEventListener('message', (event: MessageEvent) => { - if (typeof event.data !== 'string') { - this._malformedFrames++; - if (this._malformedFrames <= MALFORMED_FRAMES_LOG_CAP) { - const dataType = event.data instanceof ArrayBuffer ? 'ArrayBuffer' : event.data instanceof Blob ? 'Blob' : typeof event.data; - const byteLen = event.data instanceof ArrayBuffer ? event.data.byteLength : event.data instanceof Blob ? event.data.size : 0; - console.warn( - `[WebSocketClientTransport] Non-string frame #${this._malformedFrames} (type=${dataType}, bytes=${byteLen})` - ); - } - if (this._malformedFrames > MALFORMED_FRAMES_FORCE_CLOSE_THRESHOLD) { - console.warn( - `[WebSocketClientTransport] Malformed frame threshold exceeded; forcing close of ${this._address}.` - ); - this._ws?.close(4002, 'malformed-frames'); - } - return; - } - const text = event.data; - let message: ProtocolMessage; - try { - message = JSON.parse(text) as ProtocolMessage; - } catch (err) { - this._malformedFrames++; - if (this._malformedFrames <= MALFORMED_FRAMES_LOG_CAP) { - const preview = text.length > 80 ? text.slice(0, 80) + '…' : text; - console.warn( - `[WebSocketClientTransport] Malformed frame #${this._malformedFrames} (len=${text.length}): ${preview}`, - err instanceof Error ? err.message : String(err) - ); - } - if (this._malformedFrames > MALFORMED_FRAMES_FORCE_CLOSE_THRESHOLD) { - console.warn( - `[WebSocketClientTransport] Malformed frame threshold exceeded; forcing close of ${this._address}.` - ); - this._ws?.close(4002, 'malformed-frames'); - } - return; - } - this._onMessage.fire(message); - }); - - ws.addEventListener('close', () => { - if (!this._closeFired) { - this._closeFired = true; - this._onClose.fire(); - } - }); - - ws.addEventListener('error', () => { - // Error always precedes close - closing is handled in the close handler. - // Only fire if close hasn't already been fired (e.g. from send failure). - if (!this._closeFired) { - this._closeFired = true; - this._onClose.fire(); - } - }); - }); - } - - /** - * Send a message to the remote end. Returns `true` if the message was - * sent, `false` if it was dropped (socket not open). On failure, the - * transport is force-closed so reconnection is triggered immediately - * rather than silently losing messages. - */ - send(message: ProtocolMessage | AhpServerNotification | JsonRpcResponse): boolean { - if (this._ws?.readyState === WebSocket.OPEN) { - this._ws.send(JSON.stringify(message)); - return true; - } - console.warn( - `[WebSocketClientTransport] Message dropped: readyState=${this._ws?.readyState ?? 'no-socket'}` - ); - // Force-close and fire onClose exactly once to trigger reconnection - this._ws?.close(4001, 'send-on-dead-socket'); - if (!this._closeFired) { - this._closeFired = true; - this._onClose.fire(); - } - return false; - } - - override dispose(): void { - this._ws?.close(); - super.dispose(); - } -} diff --git a/src/vs/platform/agentHost/electron-browser/agentHostService.ts b/src/vs/platform/agentHost/electron-browser/agentHostService.ts deleted file mode 100644 index d426f2b2..00000000 --- a/src/vs/platform/agentHost/electron-browser/agentHostService.ts +++ /dev/null @@ -1,229 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { DeferredPromise } from '../../../base/common/async.js'; -import { Emitter } from '../../../base/common/event.js'; -import { Disposable, DisposableStore, IReference } from '../../../base/common/lifecycle.js'; -import { IObservable, ISettableObservable, observableValue } from '../../../base/common/observable.js'; -import { generateUuid } from '../../../base/common/uuid.js'; -import { getDelayedChannel, ProxyChannel } from '../../../base/parts/ipc/common/ipc.js'; -import { Client as MessagePortClient } from '../../../base/parts/ipc/common/ipc.mp.js'; -import { acquirePort } from '../../../base/parts/ipc/electron-browser/ipc.mp.js'; -import { InstantiationType, registerSingleton } from '../../instantiation/common/extensions.js'; -import { IConfigurationService } from '../../configuration/common/configuration.js'; -import { ILogService } from '../../log/common/log.js'; -import { AgentHostEnabledSettingId, AgentHostIpcChannels, IAgentCreateSessionConfig, IAgentHostInspectInfo, IAgentHostService, IAgentResolveSessionConfigParams, IAgentService, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, AuthenticateParams, AuthenticateResult, IAgentHostSocketInfo, IConnectionTrackerService } from '../common/agentService.js'; -import { AgentSubscriptionManager, type IAgentSubscription } from '../common/state/agentSubscription.js'; -import type { CreateTerminalParams, ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../common/state/protocol/commands.js'; -import type { ActionEnvelope, INotification, IRootConfigChangedAction, SessionAction, TerminalAction } from '../common/state/sessionActions.js'; -import type { ResourceCopyParams, ResourceCopyResult, ResourceDeleteParams, ResourceDeleteResult, ResourceListResult, ResourceMoveParams, ResourceMoveResult, ResourceReadResult, ResourceWriteParams, ResourceWriteResult, IStateSnapshot } from '../common/state/sessionProtocol.js'; -import { StateComponents, ROOT_STATE_URI, type RootState } from '../common/state/sessionState.js'; -import { revive } from '../../../base/common/marshalling.js'; -import { URI } from '../../../base/common/uri.js'; -import { IFileService } from '../../files/common/files.js'; -import { AGENT_HOST_CLIENT_RESOURCE_CHANNEL, AgentHostClientResourceChannel } from '../common/agentHostClientResourceChannel.js'; - -/** - * Renderer-side implementation of {@link IAgentHostService} that connects - * directly to the agent host utility process via MessagePort, bypassing - * the main process relay. Uses the same `getDelayedChannel` pattern as - * the pty host so the proxy is usable immediately while the port is acquired. - */ -class AgentHostServiceClient extends Disposable implements IAgentHostService { - declare readonly _serviceBrand: undefined; - - /** Unique identifier for this window, used in action envelope origin tracking. */ - readonly clientId = generateUuid(); - - private readonly _clientEventually = new DeferredPromise(); - private readonly _proxy: IAgentService; - private readonly _connectionTracker: IConnectionTrackerService; - private readonly _subscriptionManager: AgentSubscriptionManager; - - private readonly _onAgentHostExit = this._register(new Emitter()); - readonly onAgentHostExit = this._onAgentHostExit.event; - private readonly _onAgentHostStart = this._register(new Emitter()); - readonly onAgentHostStart = this._onAgentHostStart.event; - - private readonly _onDidAction = this._register(new Emitter()); - readonly onDidAction = this._onDidAction.event; - - private readonly _onDidNotification = this._register(new Emitter()); - readonly onDidNotification = this._onDidNotification.event; - - private readonly _authenticationPending: ISettableObservable = observableValue('authenticationPending', true); - readonly authenticationPending: IObservable = this._authenticationPending; - private _authenticationSettled = false; - - setAuthenticationPending(pending: boolean): void { - // Sticky: once the first authentication pass settles, never surface - // pending again. Subsequent re-auths (account/session changes, reconnect) - // happen silently in the background and should not flicker the UI. - if (this._authenticationSettled) { - return; - } - if (!pending) { - this._authenticationSettled = true; - } - this._authenticationPending.set(pending, undefined); - } - - constructor( - @ILogService private readonly _logService: ILogService, - @IConfigurationService configurationService: IConfigurationService, - @IFileService private readonly _fileService: IFileService, - ) { - super(); - - // Create a proxy backed by a delayed channel - usable immediately, - // calls queue until the MessagePort connection is established. - this._proxy = ProxyChannel.toService( - getDelayedChannel(this._clientEventually.p.then(client => client.getChannel(AgentHostIpcChannels.AgentHost))) - ); - - this._connectionTracker = ProxyChannel.toService( - getDelayedChannel(this._clientEventually.p.then(client => client.getChannel(AgentHostIpcChannels.ConnectionTracker))) - ); - - this._subscriptionManager = this._register(new AgentSubscriptionManager( - this.clientId, - () => this.nextClientSeq(), - msg => this._logService.warn(`[AgentHost:renderer] ${msg}`), - resource => this.subscribe(resource), - resource => this.unsubscribe(resource), - )); - - if (configurationService.getValue(AgentHostEnabledSettingId)) { - this._connect(); - } - } - - private async _connect(): Promise { - this._logService.info('[AgentHost:renderer] Acquiring MessagePort to agent host...'); - const port = await acquirePort('vscode:createAgentHostMessageChannel', 'vscode:createAgentHostMessageChannelResult'); - this._logService.info('[AgentHost:renderer] MessagePort acquired, creating client...'); - - const store = this._register(new DisposableStore()); - // Use clientId as the IPC ctx so the agent host can route reverse-RPC - // calls (vscode-agent-client filesystem reads) back to this renderer - // via `IPCServer.getChannel(name, c => c.ctx === clientId)`. - const client = store.add(new MessagePortClient(port, this.clientId)); - // Serve filesystem reverse-RPCs from the local file service. The - // agent host registers an authority on its - // AgentHostClientFileSystemProvider that calls back through this channel. - client.registerChannel(AGENT_HOST_CLIENT_RESOURCE_CHANNEL, new AgentHostClientResourceChannel(this._fileService)); - this._clientEventually.complete(client); - - store.add(this._proxy.onDidAction(e => { - const revived = revive(e) as ActionEnvelope; - this._subscriptionManager.receiveEnvelope(revived); - this._onDidAction.fire(revived); - })); - store.add(this._proxy.onDidNotification(e => { - this._onDidNotification.fire(revive(e)); - })); - this._logService.info('[AgentHost:renderer] Direct MessagePort connection established'); - this._onAgentHostStart.fire(); - - // Subscribe to root state - this.subscribe(URI.parse(ROOT_STATE_URI)).then(snapshot => { - this._subscriptionManager.handleRootSnapshot(snapshot.state as RootState, snapshot.fromSeq); - }).catch(err => { - this._logService.error('[AgentHost:renderer] Failed to subscribe to root state', err); - }); - } - - // ---- IAgentService forwarding (no await needed, delayed channel handles queuing) ---- - - authenticate(params: AuthenticateParams): Promise { - return this._proxy.authenticate(params); - } - listSessions(): Promise { - return this._proxy.listSessions(); - } - createSession(config?: IAgentCreateSessionConfig): Promise { - return this._proxy.createSession(config); - } - resolveSessionConfig(params: IAgentResolveSessionConfigParams): Promise { - return this._proxy.resolveSessionConfig(params); - } - sessionConfigCompletions(params: IAgentSessionConfigCompletionsParams): Promise { - return this._proxy.sessionConfigCompletions(params); - } - disposeSession(session: URI): Promise { - return this._proxy.disposeSession(session); - } - createTerminal(params: CreateTerminalParams): Promise { - return this._proxy.createTerminal(params); - } - disposeTerminal(terminal: URI): Promise { - return this._proxy.disposeTerminal(terminal); - } - shutdown(): Promise { - return this._proxy.shutdown(); - } - private subscribe(resource: URI): Promise { - return this._proxy.subscribe(resource, this.clientId); - } - private unsubscribe(resource: URI): void { - this._proxy.unsubscribe(resource, this.clientId); - } - dispatchAction(action: SessionAction | TerminalAction | IRootConfigChangedAction, clientId: string, clientSeq: number): void { - this._proxy.dispatchAction(action, clientId, clientSeq); - } - private _nextSeq = 1; - nextClientSeq(): number { - return this._nextSeq++; - } - - get rootState(): IAgentSubscription { - return this._subscriptionManager.rootState; - } - - getSubscription(kind: StateComponents, resource: URI): IReference> { - return this._subscriptionManager.getSubscription(kind, resource); - } - - getSubscriptionUnmanaged(_kind: StateComponents, resource: URI): IAgentSubscription | undefined { - return this._subscriptionManager.getSubscriptionUnmanaged(resource); - } - - dispatch(action: SessionAction | TerminalAction | IRootConfigChangedAction): void { - const seq = this._subscriptionManager.dispatchOptimistic(action); - this.dispatchAction(action, this.clientId, seq); - } - - resourceList(uri: URI): Promise { - return this._proxy.resourceList(uri); - } - resourceRead(uri: URI): Promise { - return this._proxy.resourceRead(uri); - } - resourceWrite(params: ResourceWriteParams): Promise { - return this._proxy.resourceWrite(params); - } - resourceCopy(params: ResourceCopyParams): Promise { - return this._proxy.resourceCopy(params); - } - resourceDelete(params: ResourceDeleteParams): Promise { - return this._proxy.resourceDelete(params); - } - resourceMove(params: ResourceMoveParams): Promise { - return this._proxy.resourceMove(params); - } - async restartAgentHost(): Promise { - // Restart is handled by the main process side - } - - startWebSocketServer(): Promise { - return this._connectionTracker.startWebSocketServer(); - } - - getInspectInfo(tryEnable: boolean): Promise { - return this._connectionTracker.getInspectInfo(tryEnable); - } -} - -registerSingleton(IAgentHostService, AgentHostServiceClient, InstantiationType.Delayed); diff --git a/src/vs/platform/agentHost/electron-browser/remoteAgentHostService.ts b/src/vs/platform/agentHost/electron-browser/remoteAgentHostService.ts deleted file mode 100644 index e9c9c56a..00000000 --- a/src/vs/platform/agentHost/electron-browser/remoteAgentHostService.ts +++ /dev/null @@ -1,10 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { InstantiationType, registerSingleton } from '../../instantiation/common/extensions.js'; -import { IRemoteAgentHostService } from '../common/remoteAgentHostService.js'; -import { RemoteAgentHostService } from '../browser/remoteAgentHostServiceImpl.js'; - -registerSingleton(IRemoteAgentHostService, RemoteAgentHostService, InstantiationType.Delayed); diff --git a/src/vs/platform/agentHost/electron-browser/sshRelayTransport.ts b/src/vs/platform/agentHost/electron-browser/sshRelayTransport.ts deleted file mode 100644 index 68549cc6..00000000 --- a/src/vs/platform/agentHost/electron-browser/sshRelayTransport.ts +++ /dev/null @@ -1,58 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { Emitter } from '../../../base/common/event.js'; -import { Disposable } from '../../../base/common/lifecycle.js'; -import type { AhpServerNotification, JsonRpcResponse, ProtocolMessage } from '../common/state/sessionProtocol.js'; -import type { IProtocolTransport } from '../common/state/sessionTransport.js'; -import type { ISSHRelayMessage, ISSHRemoteAgentHostMainService } from '../common/sshRemoteAgentHost.js'; - -/** - * A protocol transport that relays messages through the shared process - * SSH tunnel via IPC, instead of using a direct WebSocket connection. - * - * The shared process manages the actual WebSocket-over-SSH connection - * and forwards messages bidirectionally through this IPC channel. - */ -export class SSHRelayTransport extends Disposable implements IProtocolTransport { - - private readonly _onMessage = this._register(new Emitter()); - readonly onMessage = this._onMessage.event; - - private readonly _onClose = this._register(new Emitter()); - readonly onClose = this._onClose.event; - - constructor( - private readonly _connectionId: string, - private readonly _sshService: ISSHRemoteAgentHostMainService, - ) { - super(); - - // Listen for relay messages from the shared process - this._register(this._sshService.onDidRelayMessage((msg: ISSHRelayMessage) => { - if (msg.connectionId === this._connectionId) { - try { - const parsed = JSON.parse(msg.data) as ProtocolMessage; - this._onMessage.fire(parsed); - } catch { - // Malformed message — drop - } - } - })); - - // Listen for relay close - this._register(this._sshService.onDidRelayClose((closedId: string) => { - if (closedId === this._connectionId) { - this._onClose.fire(); - } - })); - } - - send(message: ProtocolMessage | AhpServerNotification | JsonRpcResponse): void { - this._sshService.relaySend(this._connectionId, JSON.stringify(message)).catch(() => { - // Send failed — connection probably closed - }); - } -} diff --git a/src/vs/platform/agentHost/electron-browser/sshRemoteAgentHostService.ts b/src/vs/platform/agentHost/electron-browser/sshRemoteAgentHostService.ts deleted file mode 100644 index 9e1930a9..00000000 --- a/src/vs/platform/agentHost/electron-browser/sshRemoteAgentHostService.ts +++ /dev/null @@ -1,10 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { InstantiationType, registerSingleton } from '../../instantiation/common/extensions.js'; -import { ISSHRemoteAgentHostService } from '../common/sshRemoteAgentHost.js'; -import { SSHRemoteAgentHostService } from './sshRemoteAgentHostServiceImpl.js'; - -registerSingleton(ISSHRemoteAgentHostService, SSHRemoteAgentHostService, InstantiationType.Delayed); diff --git a/src/vs/platform/agentHost/electron-browser/sshRemoteAgentHostServiceImpl.ts b/src/vs/platform/agentHost/electron-browser/sshRemoteAgentHostServiceImpl.ts deleted file mode 100644 index 3f0c5987..00000000 --- a/src/vs/platform/agentHost/electron-browser/sshRemoteAgentHostServiceImpl.ts +++ /dev/null @@ -1,261 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { Emitter, Event } from '../../../base/common/event.js'; -import { Disposable, IDisposable, toDisposable } from '../../../base/common/lifecycle.js'; -import { URI } from '../../../base/common/uri.js'; -import { ILogService } from '../../log/common/log.js'; -import { IConfigurationService } from '../../configuration/common/configuration.js'; -import { ISharedProcessService } from '../../ipc/electron-browser/services.js'; -import { ProxyChannel } from '../../../base/parts/ipc/common/ipc.js'; -import { IRemoteAgentHostService, RemoteAgentHostEntryType } from '../common/remoteAgentHostService.js'; -import { IInstantiationService } from '../../instantiation/common/instantiation.js'; -import { SSHRelayTransport } from './sshRelayTransport.js'; -import { RemoteAgentHostProtocolClient } from '../browser/remoteAgentHostProtocolClient.js'; -import { - ISSHRemoteAgentHostService, - SSH_REMOTE_AGENT_HOST_CHANNEL, - type ISSHAgentHostConfig, - type ISSHAgentHostConnection, - type ISSHConnectResult, - type ISSHRemoteAgentHostMainService, - type ISSHResolvedConfig, - type ISSHConnectProgress, -} from '../common/sshRemoteAgentHost.js'; - -/** - * Renderer-side implementation of {@link ISSHRemoteAgentHostService} that - * delegates the actual SSH work to the main process via IPC, then registers - * the resulting connection with the renderer-local {@link IRemoteAgentHostService}. - */ -export class SSHRemoteAgentHostService extends Disposable implements ISSHRemoteAgentHostService { - declare readonly _serviceBrand: undefined; - - private readonly _mainService: ISSHRemoteAgentHostMainService; - - private readonly _onDidChangeConnections = this._register(new Emitter()); - readonly onDidChangeConnections: Event = this._onDidChangeConnections.event; - - readonly onDidReportConnectProgress: Event; - - private readonly _connections = new Map(); - - constructor( - @ISharedProcessService sharedProcessService: ISharedProcessService, - @IRemoteAgentHostService private readonly _remoteAgentHostService: IRemoteAgentHostService, - @ILogService private readonly _logService: ILogService, - @IInstantiationService private readonly _instantiationService: IInstantiationService, - @IConfigurationService private readonly _configurationService: IConfigurationService, - ) { - super(); - - this._mainService = ProxyChannel.toService( - sharedProcessService.getChannel(SSH_REMOTE_AGENT_HOST_CHANNEL), - ); - - this.onDidReportConnectProgress = this._mainService.onDidReportConnectProgress; - - // When shared process fires onDidCloseConnection, clean up the renderer-side handle. - // Do NOT remove the configured entry — it stays in settings so startup reconnect - // can re-establish the SSH tunnel on next launch. - this._register(this._mainService.onDidCloseConnection(connectionId => { - const handle = this._connections.get(connectionId); - if (handle) { - this._connections.delete(connectionId); - handle.fireClose(); - handle.dispose(); - this._onDidChangeConnections.fire(); - } - })); - - } - - get connections(): readonly ISSHAgentHostConnection[] { - return [...this._connections.values()]; - } - - async connect(config: ISSHAgentHostConfig): Promise { - const augmentedConfig = this._augmentConfig(config); - this._logService.info(`[SSHRemoteAgentHost] Connecting to ${config.host}`); - const result = await this._mainService.connect(augmentedConfig); - this._logService.trace('[SSHRemoteAgentHost] SSH tunnel established, connectionId=' + result.connectionId); - return this._setupConnection(result); - } - - async disconnect(host: string): Promise { - await this._mainService.disconnect(host); - } - - async listSSHConfigHosts(): Promise { - return this._mainService.listSSHConfigHosts(); - } - - async ensureUserSSHConfig(): Promise { - return this._mainService.ensureUserSSHConfig(); - } - - async listSSHConfigFiles(): Promise { - return this._mainService.listSSHConfigFiles(); - } - - async resolveSSHConfig(host: string): Promise { - return this._mainService.resolveSSHConfig(host); - } - - async reconnect(sshConfigHost: string, name: string): Promise { - const commandOverride = this._getRemoteAgentHostCommand(); - const agentForward = this._isSSHAgentForwardingEnabled(); - this._logService.info(`[SSHRemoteAgentHost] Reconnecting to ${sshConfigHost}`); - const result = await this._mainService.reconnect(sshConfigHost, name, commandOverride, agentForward); - return this._setupConnection(result); - } - - /** - * Build the renderer-side handle, do the protocol handshake, and register - * with IRemoteAgentHostService. Any failure after the shared-process tunnel - * was established tears it back down so we don't leak it. - */ - private async _setupConnection(result: ISSHConnectResult): Promise { - const existing = this._connections.get(result.connectionId); - if (existing) { - this._logService.trace('[SSHRemoteAgentHost] Returning existing connection handle'); - return existing; - } - - let protocolClient: RemoteAgentHostProtocolClient | undefined; - let handle: SSHAgentHostConnectionHandle | undefined; - let registeredHandle = false; - try { - protocolClient = this._createRelayClient(result); - await protocolClient.connect(); - this._logService.trace('[SSHRemoteAgentHost] Protocol handshake completed'); - - handle = new SSHAgentHostConnectionHandle( - result.config, - result.address, - result.name, - () => this._mainService.disconnect(result.connectionId), - ); - - this._connections.set(result.connectionId, handle); - registeredHandle = true; - this._onDidChangeConnections.fire(); - - await this._remoteAgentHostService.addManagedConnection({ - name: result.name, - connectionToken: result.connectionToken, - connection: { - type: RemoteAgentHostEntryType.SSH, - address: result.address, - sshConfigHost: result.sshConfigHost, - hostName: result.config.host, - user: result.config.username || undefined, - port: result.config.port, - }, - }, protocolClient, this._createTransportDisposable(result.connectionId, handle)); - - return handle; - } catch (err) { - this._logService.error('[SSHRemoteAgentHost] Connection setup failed', err); - if (registeredHandle && this._connections.get(result.connectionId) === handle) { - this._connections.delete(result.connectionId); - this._onDidChangeConnections.fire(); - } - handle?.dispose(); - protocolClient?.dispose(); - this._mainService.disconnect(result.connectionId).catch(() => { /* best effort */ }); - throw err; - } - } - - /** - * Build a disposable that the {@link IRemoteAgentHostService} will own - * for the lifetime of this entry. When the entry is removed (either by - * the user via "Remove Remote" or by config reconciliation), this runs - * and tears down the renderer-side handle and the shared-process SSH - * tunnel together. Without this hookup, the SSH tunnel would leak and - * the next `connect()` would silently reuse it. - */ - private _createTransportDisposable(connectionId: string, handle: SSHAgentHostConnectionHandle): IDisposable { - return toDisposable(() => { - // Drop the renderer-side handle map entry first so a concurrent - // `connect()` for the same key doesn't latch onto a being-torn-down - // connection. - if (this._connections.get(connectionId) === handle) { - this._connections.delete(connectionId); - this._onDidChangeConnections.fire(); - } - // Mark the handle as already closed-from-main so disposing it - // doesn't kick off a redundant second disconnect IPC. The actual - // disconnect is initiated below. - handle.fireClose(); - handle.dispose(); - this._mainService.disconnect(connectionId).catch(() => { /* best effort */ }); - }); - } - - private _createRelayClient(result: { connectionId: string; address: string }): RemoteAgentHostProtocolClient { - const transport = new SSHRelayTransport(result.connectionId, this._mainService); - return this._instantiationService.createInstance( - RemoteAgentHostProtocolClient, result.address, transport, - ); - } - - private _augmentConfig(config: ISSHAgentHostConfig): ISSHAgentHostConfig { - const result = { ...config }; - const commandOverride = this._getRemoteAgentHostCommand(); - if (commandOverride) { - result.remoteAgentHostCommand = commandOverride; - } - // Agent forwarding requires both the global setting (security opt-in) - // and the per-host SSH config `ForwardAgent yes` to be enabled. - if (this._isSSHAgentForwardingEnabled() && config.agentForward) { - result.agentForward = true; - } - return result; - } - - private _getRemoteAgentHostCommand(): string | undefined { - return this._configurationService.getValue('chat.sshRemoteAgentHostCommand') || undefined; - } - - private _isSSHAgentForwardingEnabled(): boolean | undefined { - return this._configurationService.getValue('chat.agentHost.forwardSSHAgent') || undefined; - } -} - -/** - * Lightweight renderer-side handle that represents a connection - * managed by the main process. - */ -class SSHAgentHostConnectionHandle extends Disposable implements ISSHAgentHostConnection { - private readonly _onDidClose = this._register(new Emitter()); - readonly onDidClose = this._onDidClose.event; - - private _closedByMain = false; - - constructor( - readonly config: ISSHAgentHostConnection['config'], - readonly localAddress: string, - readonly name: string, - disconnectFn: () => Promise, - ) { - super(); - - // When this handle is disposed, tear down the main-process tunnel - // (skip if already closed from the main process side) - this._register(toDisposable(() => { - if (!this._closedByMain) { - disconnectFn().catch(() => { /* best effort */ }); - } - })); - } - - /** Called by the service when the main process signals connection closure. */ - fireClose(): void { - this._closedByMain = true; - this._onDidClose.fire(); - } -} diff --git a/src/vs/platform/agentHost/electron-browser/tunnelRelayTransport.ts b/src/vs/platform/agentHost/electron-browser/tunnelRelayTransport.ts deleted file mode 100644 index 0043e908..00000000 --- a/src/vs/platform/agentHost/electron-browser/tunnelRelayTransport.ts +++ /dev/null @@ -1,81 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { Emitter } from '../../../base/common/event.js'; -import { Disposable } from '../../../base/common/lifecycle.js'; -import type { AhpServerNotification, JsonRpcResponse, ProtocolMessage } from '../common/state/sessionProtocol.js'; -import type { IProtocolTransport } from '../common/state/sessionTransport.js'; -import type { ITunnelAgentHostMainService, ITunnelRelayMessage } from '../common/tunnelAgentHost.js'; -import { MALFORMED_FRAMES_FORCE_CLOSE_THRESHOLD, MALFORMED_FRAMES_LOG_CAP } from '../common/transportConstants.js'; - -/** - * A protocol transport that relays messages through the shared process - * tunnel relay via IPC, instead of using a direct WebSocket connection. - * - * The shared process manages the actual dev tunnel relay connection - * and forwards messages bidirectionally through this IPC channel. - */ -export class TunnelRelayTransport extends Disposable implements IProtocolTransport { - - private readonly _onMessage = this._register(new Emitter()); - readonly onMessage = this._onMessage.event; - - private readonly _onClose = this._register(new Emitter()); - readonly onClose = this._onClose.event; - - private _malformedFrames = 0; - - constructor( - private readonly _connectionId: string, - private readonly _tunnelService: ITunnelAgentHostMainService, - ) { - super(); - - // Listen for relay messages from the shared process - this._register(this._tunnelService.onDidRelayMessage((msg: ITunnelRelayMessage) => { - if (msg.connectionId !== this._connectionId) { - return; - } - let parsed: ProtocolMessage; - try { - parsed = JSON.parse(msg.data) as ProtocolMessage; - } catch (err) { - this._malformedFrames++; - if (this._malformedFrames <= MALFORMED_FRAMES_LOG_CAP) { - const preview = msg.data.length > 80 ? msg.data.slice(0, 80) + '…' : msg.data; - console.warn( - `[TunnelRelayTransport] Malformed frame #${this._malformedFrames} (len=${msg.data.length}): ${preview}`, - err instanceof Error ? err.message : String(err) - ); - } - if (this._malformedFrames > MALFORMED_FRAMES_FORCE_CLOSE_THRESHOLD) { - console.warn('[TunnelRelayTransport] Malformed frame threshold exceeded; closing relay.'); - this._tunnelService.disconnect(this._connectionId).catch(() => { /* best effort */ }); - } - return; - } - this._onMessage.fire(parsed); - })); - - // Listen for relay close - this._register(this._tunnelService.onDidRelayClose((closedId: string) => { - if (closedId === this._connectionId) { - this._onClose.fire(); - } - })); - } - - override dispose(): void { - // Tear down the shared-process relay connection - this._tunnelService.disconnect(this._connectionId).catch(() => { /* best effort */ }); - super.dispose(); - } - - send(message: ProtocolMessage | AhpServerNotification | JsonRpcResponse): void { - this._tunnelService.relaySend(this._connectionId, JSON.stringify(message)).catch(() => { - // Send failed — connection probably closed - }); - } -} diff --git a/src/vs/platform/agentHost/electron-main/electronAgentHostStarter.ts b/src/vs/platform/agentHost/electron-main/electronAgentHostStarter.ts deleted file mode 100644 index a51c4033..00000000 --- a/src/vs/platform/agentHost/electron-main/electronAgentHostStarter.ts +++ /dev/null @@ -1,149 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { Disposable, DisposableStore, toDisposable } from '../../../base/common/lifecycle.js'; -import { DeferredPromise } from '../../../base/common/async.js'; -import { Emitter } from '../../../base/common/event.js'; -import { IpcMainEvent } from 'electron'; -import { validatedIpcMain } from '../../../base/parts/ipc/electron-main/ipcMain.js'; -import { Client as MessagePortClient } from '../../../base/parts/ipc/electron-main/ipc.mp.js'; -import { IConfigurationService } from '../../configuration/common/configuration.js'; -import { IEnvironmentMainService } from '../../environment/electron-main/environmentMainService.js'; -import { parseAgentHostDebugPort } from '../../environment/node/environmentService.js'; -import { ILifecycleMainService } from '../../lifecycle/electron-main/lifecycleMainService.js'; -import { ILogService } from '../../log/common/log.js'; -import { Schemas } from '../../../base/common/network.js'; -import { getResolvedShellEnv } from '../../shell/node/shellEnv.js'; -import { NullTelemetryService } from '../../telemetry/common/telemetryUtils.js'; -import { UtilityProcess } from '../../utilityProcess/electron-main/utilityProcess.js'; -import { IAgentHostConnection, IAgentHostStarter } from '../common/agent.js'; -import { deepClone } from '../../../base/common/objects.js'; - -export class ElectronAgentHostStarter extends Disposable implements IAgentHostStarter { - - private utilityProcess: UtilityProcess | undefined = undefined; - private utilityProcessStarted: DeferredPromise | undefined = undefined; - - private readonly _onRequestConnection = this._register(new Emitter()); - readonly onRequestConnection = this._onRequestConnection.event; - private readonly _onWillShutdown = this._register(new Emitter()); - readonly onWillShutdown = this._onWillShutdown.event; - - constructor( - @IConfigurationService private readonly _configurationService: IConfigurationService, - @IEnvironmentMainService private readonly _environmentMainService: IEnvironmentMainService, - @ILifecycleMainService private readonly _lifecycleMainService: ILifecycleMainService, - @ILogService private readonly _logService: ILogService, - ) { - super(); - - this._register(this._lifecycleMainService.onWillShutdown(() => this._onWillShutdown.fire())); - - // Listen for new windows to establish a direct MessagePort connection to the agent host - const onWindowConnection = (e: IpcMainEvent, nonce: string) => this._onWindowConnection(e, nonce); - validatedIpcMain.on('vscode:createAgentHostMessageChannel', onWindowConnection); - this._register(toDisposable(() => { - validatedIpcMain.removeListener('vscode:createAgentHostMessageChannel', onWindowConnection); - })); - } - - async start(): Promise { - this.utilityProcess = new UtilityProcess(this._logService, NullTelemetryService, this._lifecycleMainService); - this.utilityProcessStarted = new DeferredPromise(); - - const inspectParams = parseAgentHostDebugPort(this._environmentMainService.args, this._environmentMainService.isBuilt); - const execArgv = inspectParams.port ? [ - '--nolazy', - `--inspect${inspectParams.break ? '-brk' : ''}=${inspectParams.port}` - ] : undefined; - - // Resolve user shell environment so spawned tools/terminals inherit - // PATH and other vars from the user's login shell (macOS/Linux GUI launches). - const shellEnv = await this._resolveShellEnv(); - - this.utilityProcess.start({ - type: 'agentHost', - name: 'agent-host', - entryPoint: 'vs/platform/agentHost/node/agentHostMain', - execArgv, - args: ['--logsPath', this._environmentMainService.logsHome.with({ scheme: Schemas.file }).fsPath], - env: { - ...deepClone(process.env), - ...shellEnv, - VSCODE_ESM_ENTRYPOINT: 'vs/platform/agentHost/node/agentHostMain', - VSCODE_PIPE_LOGGING: 'true', - VSCODE_VERBOSE_LOGGING: 'true', - } - }); - - this.utilityProcessStarted.complete(); - - const port = this.utilityProcess.connect(); - const client = new MessagePortClient(port, 'agentHost'); - - const store = new DisposableStore(); - store.add(client); - store.add(this.utilityProcess.onStderr(data => { - if (this._isExpectedStderr(data)) { - return; - } - this._logService.error(`[AgentHost:stderr] ${data}`); - })); - store.add(toDisposable(() => { - this.utilityProcess?.kill(); - this.utilityProcess?.dispose(); - this.utilityProcess = undefined; - this.utilityProcessStarted = undefined; - })); - - return { - client, - store, - onDidProcessExit: this.utilityProcess.onExit, - }; - } - - private async _resolveShellEnv(): Promise { - try { - return await getResolvedShellEnv(this._configurationService, this._logService, this._environmentMainService.args, process.env); - } catch (error) { - this._logService.error('AgentHostStarter was unable to resolve shell environment', error); - return {}; - } - } - - private async _onWindowConnection(e: IpcMainEvent, nonce: string): Promise { - this._onRequestConnection.fire(); - - // Wait for utilityProcess.start() to actually run before calling connect(), - // otherwise the MessagePort posted via connect() is silently dropped. - await this.utilityProcessStarted?.p; - - if (!this.utilityProcess) { - this._logService.error('AgentHostStarter: cannot create window connection, agent host process is not running'); - return; - } - - const port = this.utilityProcess.connect(); - - if (e.sender.isDestroyed()) { - port.close(); - return; - } - - e.sender.postMessage('vscode:createAgentHostMessageChannelResult', nonce, [port]); - } - - private static readonly _expectedStderrPatterns = [ - 'Most NODE_OPTIONs are not supported in packaged apps', - 'Debugger listening on ws://', - 'For help, see: https://nodejs.org/en/docs/inspector', - 'ExperimentalWarning: SQLite is an experimental feature', - ]; - - private _isExpectedStderr(data: string): boolean { - return ElectronAgentHostStarter._expectedStderrPatterns.some(pattern => data.includes(pattern)); - } -} diff --git a/src/vs/platform/agentHost/node/agentConfigurationService.ts b/src/vs/platform/agentHost/node/agentConfigurationService.ts deleted file mode 100644 index d32ca5fc..00000000 --- a/src/vs/platform/agentHost/node/agentConfigurationService.ts +++ /dev/null @@ -1,264 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import * as fs from 'fs'; -import { Emitter, Event } from '../../../base/common/event.js'; -import { Disposable } from '../../../base/common/lifecycle.js'; -import { dirname } from '../../../base/common/path.js'; -import { hasKey } from '../../../base/common/types.js'; -import { URI } from '../../../base/common/uri.js'; -import { createDecorator } from '../../instantiation/common/instantiation.js'; -import { ILogService } from '../../log/common/log.js'; -import { AgentHostConfigKey, agentHostCustomizationConfigSchema, defaultAgentHostCustomizationConfigValues } from '../common/agentHostCustomizationConfig.js'; -import type { ISchema, SchemaDefinition, SchemaValue } from '../common/agentHostSchema.js'; -import { ProtocolError } from '../common/state/sessionProtocol.js'; -import { ActionType } from '../common/state/sessionActions.js'; -import { parseSubagentSessionUri, type URI as ProtocolURI } from '../common/state/sessionState.js'; -import { AgentHostStateManager } from './agentHostStateManager.js'; - -export const IAgentConfigurationService = createDecorator('agentConfigurationService'); - -/** - * Cohesive read/write surface for agent-host configuration. - * - * All platform-layer consumers (tool auto-approval, side effects, future - * host-config editors) should read and mutate config values through this - * service rather than reaching into raw session state. The service owns - * the `session → parent session → host` inheritance chain so that - * host-level defaults, subagent inheritance, and per-session overrides - * compose the same way everywhere. - * - * Reads go through a caller-supplied {@link ISchema}: each raw value is - * validated against the property's schema before being returned, so a - * malformed value in one layer transparently falls back to the next. - */ -export interface IAgentConfigurationService { - readonly _serviceBrand: undefined; - - /** - * Fires whenever a {@link ActionType.RootConfigChanged} action is - * processed by the state manager, signalling that callers should - * re-read any root config values they depend on. - */ - readonly onDidRootConfigChange: Event; - - /** - * Returns the effective value of `key` for `session`, walking the - * `session → parent session → host` chain and returning the first - * layer that provides a value which validates against - * `schema.definition[key]`. Layers that provide a malformed value - * are logged and skipped. Returns `undefined` when no layer provides - * a valid value. - */ - getEffectiveValue( - session: ProtocolURI, - schema: ISchema, - key: K, - ): SchemaValue | undefined; - - /** - * Returns the effective working directory for a session, falling back - * to the parent (subagent) session's working directory when the - * session itself does not have one set. The host layer does not carry - * a working directory. - */ - getEffectiveWorkingDirectory(session: ProtocolURI): string | undefined; - - /** - * Merges a partial config patch into a session's values via a - * {@link ActionType.SessionConfigChanged} action. Keys not present in - * `patch` are left untouched. The patch is applied atomically through - * the state manager's reducer. - */ - updateSessionConfig(session: ProtocolURI, patch: Record): void; - - /** - * Returns the host-level value for `key`, validating it against - * `schema.definition[key]`. Invalid persisted values are logged and treated - * as missing. - */ - getRootValue( - schema: ISchema, - key: K, - ): SchemaValue | undefined; - - /** - * Merges a partial config patch into the host-level value bag and persists - * the updated values for future agent-host lifetimes. - */ - updateRootConfig(patch: Record, replace?: boolean): void; - - /** - * Persists the current host-level value bag without mutating it. - */ - persistRootConfig(): void; -} - -export class AgentConfigurationService extends Disposable implements IAgentConfigurationService { - declare readonly _serviceBrand: undefined; - private _rootConfigWrite = Promise.resolve(); - - private readonly _onDidRootConfigChange = this._register(new Emitter()); - readonly onDidRootConfigChange: Event = this._onDidRootConfigChange.event; - - constructor( - private readonly _stateManager: AgentHostStateManager, - @ILogService private readonly _logService: ILogService, - private readonly _rootConfigResource?: URI, - ) { - super(); - // Merge our customization schema/values into the existing root config - // (which already carries platform properties like permissions) rather - // than replacing it. - const existing = this._stateManager.rootState.config; - const ownSchema = agentHostCustomizationConfigSchema.toProtocol(); - this._stateManager.rootState.config = { - schema: { - type: 'object', - properties: { ...existing?.schema.properties, ...ownSchema.properties }, - }, - values: { ...existing?.values, ...this._loadPersistedRootConfig() }, - }; - - this._register(this._stateManager.onDidEmitEnvelope(envelope => { - if (envelope.action.type === ActionType.RootConfigChanged) { - this._onDidRootConfigChange.fire(); - } - })); - } - - getEffectiveValue( - session: ProtocolURI, - schema: ISchema, - key: K, - ): SchemaValue | undefined { - for (const values of this._effectiveChain(session)) { - const raw = values[key]; - if (raw === undefined) { - continue; - } - try { - schema.assertValid(key, raw); - return raw; - } catch (err) { - const reason = err instanceof ProtocolError ? err.message : String(err); - this._logService.warn(`[AgentConfigurationService] Value for '${key}' on ${session} failed schema validation, falling back: ${reason}`); - } - } - return undefined; - } - - getEffectiveWorkingDirectory(session: ProtocolURI): string | undefined { - const own = this._stateManager.getSessionState(session)?.summary.workingDirectory; - if (own !== undefined) { - return own; - } - const parentInfo = parseSubagentSessionUri(session); - if (parentInfo) { - return this._stateManager.getSessionState(parentInfo.parentSession)?.summary.workingDirectory; - } - return undefined; - } - - updateSessionConfig(session: ProtocolURI, patch: Record): void { - this._stateManager.dispatchServerAction({ - type: ActionType.SessionConfigChanged, - session, - config: patch, - }); - } - - getRootValue( - schema: ISchema, - key: K, - ): SchemaValue | undefined { - const root = this._stateManager.rootState.config?.values; - const raw = root?.[key]; - if (raw === undefined) { - return undefined; - } - try { - schema.assertValid(key, raw); - return raw; - } catch (err) { - const reason = err instanceof ProtocolError ? err.message : String(err); - this._logService.warn(`[AgentConfigurationService] Host value for '${key}' failed schema validation, ignoring: ${reason}`); - return undefined; - } - } - - updateRootConfig(patch: Record, replace = false): void { - this._stateManager.dispatchServerAction({ - type: ActionType.RootConfigChanged, - config: patch, - replace, - }); - this.persistRootConfig(); - } - - persistRootConfig(): void { - if (!this._rootConfigResource) { - return; - } - - const values = this._stateManager.rootState.config?.values ?? { [AgentHostConfigKey.Customizations]: [] }; - const content = JSON.stringify(values, undefined, '\t'); - const resource = this._rootConfigResource; - - this._rootConfigWrite = this._rootConfigWrite - .catch(err => { - this._logService.warn('[AgentConfigurationService] Previous host config write failed', err); - }) - .then(async () => { - await fs.promises.mkdir(dirname(resource.fsPath), { recursive: true }); - await fs.promises.writeFile(resource.fsPath, `${content}\n`, 'utf8'); - }) - .catch(err => { - this._logService.error(`[AgentConfigurationService] Failed to persist host config to ${resource.fsPath}`, err); - }); - } - - /** - * Yields the raw value bags that contribute to the effective config - * for `session`, in precedence order: session, parent subagent - * session (if any), host. - */ - private *_effectiveChain(session: ProtocolURI): Iterable> { - const own = this._stateManager.getSessionState(session)?.config?.values; - if (own) { - yield own; - } - const parentInfo = parseSubagentSessionUri(session); - if (parentInfo) { - const parent = this._stateManager.getSessionState(parentInfo.parentSession)?.config?.values; - if (parent) { - yield parent; - } - } - const host = this._stateManager.rootState.config?.values; - if (host) { - yield host; - } - } - - private _loadPersistedRootConfig(): Record { - const defaults = defaultAgentHostCustomizationConfigValues; - if (!this._rootConfigResource) { - return { ...defaults }; - } - - try { - const raw = fs.readFileSync(this._rootConfigResource.fsPath, 'utf8'); - const parsed = JSON.parse(raw) as Record; - return agentHostCustomizationConfigSchema.validateOrDefault(parsed, defaults); - } catch (err) { - const code = err && typeof err === 'object' && hasKey(err, { code: true }) ? String(err.code) : undefined; - if (code !== 'ENOENT') { - this._logService.warn(`[AgentConfigurationService] Failed to read host config from ${this._rootConfigResource.fsPath}: ${err instanceof Error ? err.message : String(err)}`); - } - return { ...defaults }; - } - } -} diff --git a/src/vs/platform/agentHost/node/agentHostGitService.ts b/src/vs/platform/agentHost/node/agentHostGitService.ts deleted file mode 100644 index c55a9cd5..00000000 --- a/src/vs/platform/agentHost/node/agentHostGitService.ts +++ /dev/null @@ -1,606 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import * as cp from 'child_process'; -import { URI } from '../../../base/common/uri.js'; -import { VSBuffer } from '../../../base/common/buffer.js'; -import { generateUuid } from '../../../base/common/uuid.js'; -import { INativeEnvironmentService } from '../../environment/common/environment.js'; -import { IFileService } from '../../files/common/files.js'; -import { createDecorator } from '../../instantiation/common/instantiation.js'; -import { FileEditKind, type ISessionFileDiff, type ISessionGitState } from '../common/state/sessionState.js'; -import { buildGitBlobUri } from './gitDiffContent.js'; - -export const IAgentHostGitService = createDecorator('agentHostGitService'); - -export interface IAgentHostGitService { - readonly _serviceBrand: undefined; - isInsideWorkTree(workingDirectory: URI): Promise; - getCurrentBranch(workingDirectory: URI): Promise; - getDefaultBranch(workingDirectory: URI): Promise; - getBranches(workingDirectory: URI, options?: { readonly query?: string; readonly limit?: number }): Promise; - getRepositoryRoot(workingDirectory: URI): Promise; - getWorktreeRoots(workingDirectory: URI): Promise; - addWorktree(repositoryRoot: URI, worktree: URI, branchName: string, startPoint: string): Promise; - /** - * Adds a worktree for an existing branch (no `-b`). Used when restoring - * a worktree whose branch was preserved (e.g. unarchiving a session - * whose worktree was previously cleaned up on archive). - */ - addExistingWorktree(repositoryRoot: URI, worktree: URI, branchName: string): Promise; - removeWorktree(repositoryRoot: URI, worktree: URI): Promise; - /** - * Returns true when the named branch exists in the repository - * (`refs/heads/` resolves). Used by archive cleanup to - * confirm the branch is preserved before deleting the worktree, and by - * the unarchive path to confirm the branch is still around before - * recreating the worktree. - */ - branchExists(repositoryRoot: URI, branchName: string): Promise; - /** - * Returns true when the working tree has any tracked, staged, or - * untracked changes. Used by archive cleanup to skip removing a - * worktree that still contains uncommitted work. - */ - hasUncommittedChanges(workingDirectory: URI): Promise; - /** - * Computes the {@link ISessionGitState} for the working directory by - * shelling out to `git`. Returns undefined if the directory is not a - * git work tree. Called on session open and after each turn completes - * so the UI always reflects current branch/remote/change state. - */ - getSessionGitState(workingDirectory: URI): Promise; - - /** - * Computes per-file diffs for the session by shelling out to `git - * diff --raw --numstat --diff-filter=ADMR -z` against the merge base of - * the current branch and {@link IComputeSessionFileDiffsOptions.baseBranch} - * (or `HEAD` if no base branch is available). When the working tree has - * untracked files, the diff is computed via a temp index so the - * untracked content is included. - * - * Returns `undefined` when {@link workingDirectory} is not a git work - * tree, so callers can fall back to other diff sources. - * - * Each returned {@link ISessionFileDiff} has its `before.content` set to - * a `git-blob:` URI ({@link buildGitBlobUri}); `after.content` is a - * `file:` URI on the working-tree path. Adds and deletes drop the - * missing side. - */ - computeSessionFileDiffs(workingDirectory: URI, options: IComputeSessionFileDiffsOptions): Promise; - - /** - * Reads a single git blob via `git show :` from - * the given working directory. Returns `undefined` when the blob does - * not exist or the directory is not a git work tree. - */ - showBlob(workingDirectory: URI, sha: string, repoRelativePath: string): Promise; -} - -/** - * Provider-agnostic session-database metadata key under which agents - * persist the branch they want git-driven diffs anchored to. Read by - * {@link AgentSideEffects} when computing per-session file diffs; absent - * value means the diff falls back to anchoring at HEAD. - */ -export const META_DIFF_BASE_BRANCH = 'agentHost.diffBaseBranch'; - -/** Options for {@link IAgentHostGitService.computeSessionFileDiffs}. */ -export interface IComputeSessionFileDiffsOptions { - /** - * The session URI, used as the authority of the produced - * `git-blob:` URIs so the resolver can find the session's working - * directory. - */ - readonly sessionUri: string; - /** - * The branch to diff against. Typically the worktree's start-point - * branch (for worktree sessions) or the repository's default branch. - * When undefined or unresolvable, the diff is taken against `HEAD`, - * which surfaces uncommitted work but no committed-on-branch work. - */ - readonly baseBranch?: string; -} - -function getCommonBranchPriority(branch: string): number { - if (branch === 'main') { - return 0; - } - if (branch === 'master') { - return 1; - } - return 2; -} - -export function getBranchCompletions(branches: readonly string[], options?: { readonly query?: string; readonly limit?: number }): string[] { - const normalizedQuery = options?.query?.toLowerCase(); - const filtered = normalizedQuery - ? branches.filter(branch => branch.toLowerCase().includes(normalizedQuery)) - : [...branches]; - - filtered.sort((a, b) => getCommonBranchPriority(a) - getCommonBranchPriority(b)); - return options?.limit ? filtered.slice(0, options.limit) : filtered; -} - -export class AgentHostGitService implements IAgentHostGitService { - declare readonly _serviceBrand: undefined; - - constructor( - @IFileService private readonly _fileService: IFileService, - @INativeEnvironmentService private readonly _environmentService: INativeEnvironmentService, - ) { } - - async isInsideWorkTree(workingDirectory: URI): Promise { - return (await this._runGit(workingDirectory, ['rev-parse', '--is-inside-work-tree']))?.trim() === 'true'; - } - - async getCurrentBranch(workingDirectory: URI): Promise { - return (await this._runGit(workingDirectory, ['branch', '--show-current']))?.trim() - || (await this._runGit(workingDirectory, ['rev-parse', '--short', 'HEAD']))?.trim() - || undefined; - } - - async getDefaultBranch(workingDirectory: URI): Promise { - // Try to read the default branch from the remote HEAD reference - const remoteRef = (await this._runGit(workingDirectory, ['symbolic-ref', 'refs/remotes/origin/HEAD']))?.trim(); - if (remoteRef) { - if (!remoteRef.startsWith('refs/remotes/origin/')) { - return remoteRef; - } - - const branch = remoteRef.substring('refs/remotes/origin/'.length); - // Check whether a local branch exists; if not, use the remote-tracking ref - // so that 'git worktree add ... ' resolves correctly. - const hasLocalBranch = (await this._runGit(workingDirectory, ['show-ref', '--verify', '--quiet', `refs/heads/${branch}`])) !== undefined; - return hasLocalBranch ? branch : `origin/${branch}`; - } - return undefined; - } - - async getBranches(workingDirectory: URI, options?: { readonly query?: string; readonly limit?: number }): Promise { - const args = ['for-each-ref', '--format=%(refname:short)', '--sort=-committerdate']; - args.push('refs/heads'); - - const output = await this._runGit(workingDirectory, args); - if (!output) { - return []; - } - const branches = output.split(/\r?\n/g).map(line => line.trim()).filter(branch => branch.length > 0); - return getBranchCompletions(branches, options); - } - - async getRepositoryRoot(workingDirectory: URI): Promise { - const repositoryRootPath = (await this._runGit(workingDirectory, ['rev-parse', '--show-toplevel']))?.trim(); - return repositoryRootPath ? URI.file(repositoryRootPath) : undefined; - } - - async getWorktreeRoots(workingDirectory: URI): Promise { - const output = await this._runGit(workingDirectory, ['worktree', 'list', '--porcelain']); - if (!output) { - return []; - } - return output.split(/\r?\n/g) - .filter(line => line.startsWith('worktree ')) - .map(line => URI.file(line.substring('worktree '.length))); - } - - async addWorktree(repositoryRoot: URI, worktree: URI, branchName: string, startPoint: string): Promise { - await this._runGit(repositoryRoot, ['worktree', 'add', '-b', branchName, worktree.fsPath, startPoint], { timeout: 30_000, throwOnError: true }); - } - - async addExistingWorktree(repositoryRoot: URI, worktree: URI, branchName: string): Promise { - await this._runGit(repositoryRoot, ['worktree', 'add', worktree.fsPath, branchName], { timeout: 30_000, throwOnError: true }); - } - - async removeWorktree(repositoryRoot: URI, worktree: URI): Promise { - await this._runGit(repositoryRoot, ['worktree', 'remove', '--force', worktree.fsPath], { timeout: 30_000, throwOnError: true }); - } - - async branchExists(repositoryRoot: URI, branchName: string): Promise { - // `show-ref --verify --quiet` exits 0 when the ref exists and 1 otherwise. - // `_runGit` returns undefined on non-zero exit, so `!== undefined` is the existence signal. - const output = await this._runGit(repositoryRoot, ['show-ref', '--verify', '--quiet', `refs/heads/${branchName}`]); - return output !== undefined; - } - - async hasUncommittedChanges(workingDirectory: URI): Promise { - const output = await this._runGit(workingDirectory, ['status', '--porcelain']); - return !!output && output.trim().length > 0; - } - - async computeSessionFileDiffs(workingDirectory: URI, options: IComputeSessionFileDiffsOptions): Promise { - // Bail fast if not inside a git work tree so callers can fall back - // to other diff sources. - const inside = await this._runGit(workingDirectory, ['rev-parse', '--is-inside-work-tree']); - if (inside?.trim() !== 'true') { - return undefined; - } - - // All git invocations run from the working tree's repository root so - // `--raw` paths are repo-relative — that's what `git show :` - // expects when we resolve `git-blob:` URIs later. - const repositoryRootPath = (await this._runGit(workingDirectory, ['rev-parse', '--show-toplevel']))?.trim(); - if (!repositoryRootPath) { - return undefined; - } - const repositoryRoot = URI.file(repositoryRootPath); - - // Resolve the merge-base commit. With a base branch, this is - // `merge-base HEAD ` so the diff stays anchored even when the - // base branch advances. Without one, fall back to HEAD itself, which - // surfaces uncommitted work but no committed-on-branch work — the - // best we can do without context. For empty repos with no HEAD, fall - // back to the well-known empty-tree object. - let mergeBaseCommit: string | undefined; - if (options.baseBranch) { - mergeBaseCommit = (await this._runGit(repositoryRoot, ['merge-base', 'HEAD', options.baseBranch]))?.trim(); - } - if (!mergeBaseCommit) { - mergeBaseCommit = (await this._runGit(repositoryRoot, ['rev-parse', 'HEAD']))?.trim(); - } - if (!mergeBaseCommit) { - mergeBaseCommit = EMPTY_TREE_OBJECT; - } - - // Detect whether the working tree has any untracked files. If so we - // have to use the temp-index trick so the untracked content is - // included in `--cached --raw` output; otherwise a plain `git diff` - // is sufficient and avoids the temp-dir overhead. - const statusOut = await this._runGit(repositoryRoot, ['status', '--porcelain=v1', '-z', '--untracked-files=all']); - const untracked = parseUntrackedPaths(statusOut); - - let rawDiffOutput: string | undefined; - if (untracked.length === 0) { - rawDiffOutput = await this._runGit(repositoryRoot, ['diff', '--raw', '--numstat', '--diff-filter=ADMR', '-z', mergeBaseCommit, '--']); - } else { - rawDiffOutput = await this._runWithTempIndex(repositoryRoot, mergeBaseCommit); - } - - if (rawDiffOutput === undefined) { - return undefined; - } - - return parseGitDiffRawNumstat(rawDiffOutput, repositoryRoot, options.sessionUri, mergeBaseCommit); - } - - private async _runWithTempIndex(repositoryRoot: URI, mergeBaseCommit: string): Promise { - // Build a throwaway index so we can stage the entire working tree - // (including untracked files) without disturbing the user's real - // index. `read-tree HEAD` seeds it; in empty repos that fails so we - // fall back to the empty tree, leaving everything as "added". - const tempDir = URI.joinPath(this._environmentService.tmpDir, `agent-host-git-diff-${generateUuid()}`); - await this._fileService.createFolder(tempDir); - // `GIT_INDEX_FILE` is consumed by the `git` subprocess so it must be - // a real OS path string, not a URI. - const indexFile = URI.joinPath(tempDir, 'index').fsPath; - const env: Record = { GIT_INDEX_FILE: indexFile }; - // GVFS (Virtual File System) repos use a hook that acquires a lock around - // git commands. Setting COMMAND_HOOK_LOCK=1 prevents the temp-index - // operations from blocking the main working-tree lock. This mirrors what - // the extension's `buildTempIndexEnv` does for the same reason. - env.COMMAND_HOOK_LOCK = '1'; - try { - const seeded = await this._runGit(repositoryRoot, ['read-tree', 'HEAD'], { env }); - if (seeded === undefined) { - // Empty repo (no HEAD yet) - `read-tree` of the empty tree always succeeds. - await this._runGit(repositoryRoot, ['read-tree', EMPTY_TREE_OBJECT], { env }); - } - // Stage every change in the working tree (modified, deleted, - // untracked, renamed). `add -A` plus an explicit `:/` pathspec - // covers the entire repo from any cwd. - await this._runGit(repositoryRoot, ['add', '-A', '--', ':/'], { env }); - return await this._runGit(repositoryRoot, ['diff', '--cached', '--raw', '--numstat', '--diff-filter=ADMR', '-z', mergeBaseCommit, '--'], { env }); - } finally { - try { await this._fileService.del(tempDir, { recursive: true, useTrash: false }); } catch { /* best-effort */ } - } - } - - async showBlob(workingDirectory: URI, sha: string, repoRelativePath: string): Promise { - // Validate sha before passing it to git. `git show :` parses - // its argument as a revision, so an attacker-controlled sha that starts - // with `-` could inject options, and a non-hex value could resolve to - // commit could resolve to surprising refs. Object names are 4-64 lowercase hex chars. - if (!/^[0-9a-f]{4,64}$/.test(sha)) { - return undefined; - } - const inside = await this._runGit(workingDirectory, ['rev-parse', '--is-inside-work-tree']); - if (inside?.trim() !== 'true') { - return undefined; - } - // `git show` exits non-zero when the path didn't exist at that - // commit; `_runGit` swallows that into `undefined` which is exactly - // the contract callers want. - return new Promise((resolve) => { - cp.execFile('git', ['show', `${sha}:${repoRelativePath}`], { cwd: workingDirectory.fsPath, timeout: 5000, encoding: 'buffer', maxBuffer: 32 * 1024 * 1024 }, (error, stdout) => { - if (error) { - resolve(undefined); - return; - } - resolve(VSBuffer.wrap(stdout as Buffer)); - }); - }); - } - - async getSessionGitState(workingDirectory: URI): Promise { - return this._computeSessionGitState(workingDirectory); - } - - private async _computeSessionGitState(workingDirectory: URI): Promise { - // Bail fast if not inside a git work tree. - const inside = await this._runGit(workingDirectory, ['rev-parse', '--is-inside-work-tree']); - if (inside?.trim() !== 'true') { - return undefined; - } - - // Run all probes in parallel. Each handles its own errors and returns - // undefined on failure so we can populate fields independently. - const [ - statusOutput, - remotesOutput, - defaultBranchRef, - ] = await Promise.all([ - this._runGit(workingDirectory, ['status', '-b', '--porcelain=v2']), - this._runGit(workingDirectory, ['remote', '-v']), - this._runGit(workingDirectory, ['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD']), - ]); - - const status = parseGitStatusV2(statusOutput); - const hasGitHubRemote = parseHasGitHubRemote(remotesOutput); - const baseBranchName = parseDefaultBranchRef(defaultBranchRef); - - // `git status -b --porcelain=v2` only emits ahead/behind counts when the - // branch has an upstream tracking ref. For agent-host worktrees the - // branch is typically created locally with no upstream, so the user can - // have committed work that we'd otherwise report as 0 outgoing changes - // and the "Create PR" button would never appear. Fall back to counting - // commits relative to the base branch — that matches what the user - // actually cares about for "is there work to PR?". - let outgoingChanges = status.outgoingChanges; - if (outgoingChanges === undefined && baseBranchName && status.branchName && status.branchName !== baseBranchName) { - const ahead = await this._runGit(workingDirectory, ['rev-list', '--count', `${baseBranchName}..HEAD`]); - const parsed = ahead === undefined ? NaN : Number(ahead.trim()); - if (Number.isFinite(parsed)) { - outgoingChanges = parsed; - } - } - - const result: ISessionGitState = { - hasGitHubRemote, - branchName: status.branchName, - baseBranchName, - upstreamBranchName: status.upstreamBranchName, - incomingChanges: status.incomingChanges, - outgoingChanges, - uncommittedChanges: status.uncommittedChanges, - }; - // Strip undefined fields so the resulting object is the same regardless - // of which probes succeeded — easier to compare in tests. - return stripUndefined(result); - } - - private _runGit(workingDirectory: URI, args: readonly string[], options?: { readonly timeout?: number; readonly throwOnError?: boolean; readonly env?: Record; readonly maxBuffer?: number }): Promise { - return new Promise((resolve, reject) => { - const env = options?.env ? { ...process.env, ...options.env } : undefined; - // Default maxBuffer is 32MB — Node's default is ~1MB, which is - // easy to exceed for diff output in large repos. Exceeding it - // causes execFile to error and we'd silently drop the diff. - cp.execFile('git', [...args], { cwd: workingDirectory.fsPath, timeout: options?.timeout ?? 5000, env, maxBuffer: options?.maxBuffer ?? 32 * 1024 * 1024 }, (error, stdout, stderr) => { - if (error) { - if (options?.throwOnError) { - reject(new Error(stderr || error.message)); - return; - } - resolve(undefined); - return; - } - resolve(stdout); - }); - }); - } -} - -/** - * The well-known SHA-1 of git's empty tree, used as a fallback when a - * repository has no commits (no `HEAD` to read into the temp index). - */ -export const EMPTY_TREE_OBJECT = '4b825dc642cb6eb9a060e54bf8d69288fbee4904'; - -/** - * Parses NUL-separated `git status --porcelain=v1 -z --untracked-files=all` - * output and returns the repo-relative paths of untracked entries (status - * `??`). Other entries are ignored; we only need to know whether any - * untracked files exist to decide whether to use the temp-index path. - * - * Exported for tests. - */ -export function parseUntrackedPaths(output: string | undefined): string[] { - if (!output) { - return []; - } - const result: string[] = []; - const segments = output.split('\x00'); - for (let i = 0; i < segments.length; i++) { - const seg = segments[i]; - if (!seg) { continue; } - // Each entry is "XY "; for renames v1 emits a second NUL-separated - // "from" path that we have to skip. We only care about untracked here. - const status = seg.substring(0, 2); - const path = seg.substring(3); - if (status === '??') { - result.push(path); - } else if (status[0] === 'R' || status[0] === 'C') { - // Skip the "from" path for renames/copies. - i++; - } - } - return result; -} - -/** - * Parses combined `--raw --numstat -z` output produced by - * {@link IAgentHostGitService.computeSessionFileDiffs} and converts each - * change into an {@link ISessionFileDiff} ready for the protocol. - * - * The combined NUL-separated stream alternates between `--raw` segments - * (start with `:`) and `--numstat` segments. For renames the raw segment - * is followed by two extra path segments (old, new); the numstat segment - * has an empty path field followed by old/new path segments. - * - * Exported for tests. - */ -export function parseGitDiffRawNumstat(output: string, repositoryRoot: URI, sessionUri: string, mergeBaseCommit: string): ISessionFileDiff[] { - const segments = output.split('\x00'); - const changes: { kind: FileEditKind; oldPath?: string; newPath?: string }[] = []; - const numStats = new Map(); - - let i = 0; - while (i < segments.length) { - const segment = segments[i++]; - if (!segment) { continue; } - - if (segment.startsWith(':')) { - // Raw line: ": " - // followed by NUL-separated path(s). - const fields = segment.split(' '); - const status = fields[4] ?? ''; - const path1 = segments[i++]; - if (!path1) { continue; } - - switch (status[0]) { - case 'A': - changes.push({ kind: FileEditKind.Create, newPath: path1 }); - break; - case 'M': - changes.push({ kind: FileEditKind.Edit, oldPath: path1, newPath: path1 }); - break; - case 'D': - changes.push({ kind: FileEditKind.Delete, oldPath: path1 }); - break; - case 'R': { - const path2 = segments[i++]; - if (!path2) { continue; } - changes.push({ kind: FileEditKind.Rename, oldPath: path1, newPath: path2 }); - break; - } - default: - break; - } - } else { - // Numstat line: "\t\t" or, for renames, - // "\t\t" followed by NUL-separated old/new paths. - const [addedStr, removedStr, filePath] = segment.split('\t'); - let key: string; - if (filePath === '' || filePath === undefined) { - const oldPath = segments[i++]; - const newPath = segments[i++]; - key = newPath ?? oldPath ?? ''; - } else { - key = filePath; - } - if (!key) { continue; } - numStats.set(key, { - added: addedStr === '-' ? 0 : Number(addedStr) || 0, - removed: removedStr === '-' ? 0 : Number(removedStr) || 0, - }); - } - } - - return changes.map(change => { - const stats = numStats.get(change.newPath ?? change.oldPath ?? ''); - const hasBefore = change.kind !== FileEditKind.Create; - const hasAfter = change.kind !== FileEditKind.Delete; - return { - ...(hasBefore && change.oldPath ? { - before: { - uri: URI.joinPath(repositoryRoot, change.oldPath).toString(), - content: { uri: buildGitBlobUri(sessionUri, mergeBaseCommit, change.oldPath) }, - }, - } : {}), - ...(hasAfter && change.newPath ? { - after: { - uri: URI.joinPath(repositoryRoot, change.newPath).toString(), - content: { uri: URI.joinPath(repositoryRoot, change.newPath).toString() }, - }, - } : {}), - diff: { added: stats?.added ?? 0, removed: stats?.removed ?? 0 }, - }; - }); -} - -/** - * Parses output of `git status -b --porcelain=v2`. The format is documented - * at https://git-scm.com/docs/git-status. We care about a few header lines: - * - * # branch.head - * # branch.upstream - * # branch.ab + - - * - * and the count of non-header lines (one per changed entry). - * - * Exported for tests. - */ -export function parseGitStatusV2(output: string | undefined): { - branchName?: string; - upstreamBranchName?: string; - outgoingChanges?: number; - incomingChanges?: number; - uncommittedChanges?: number; -} { - if (!output) { - return {}; - } - let branchName: string | undefined; - let upstreamBranchName: string | undefined; - let outgoingChanges: number | undefined; - let incomingChanges: number | undefined; - let uncommittedChanges = 0; - for (const rawLine of output.split(/\r?\n/g)) { - const line = rawLine.trimEnd(); - if (!line) { continue; } - if (line.startsWith('# branch.head ')) { - const head = line.substring('# branch.head '.length).trim(); - // `(detached)` is what git emits for a detached HEAD. Treat as no branch. - branchName = head === '(detached)' ? undefined : head; - } else if (line.startsWith('# branch.upstream ')) { - upstreamBranchName = line.substring('# branch.upstream '.length).trim(); - } else if (line.startsWith('# branch.ab ')) { - const m = /^# branch\.ab \+(\d+) -(\d+)$/.exec(line); - if (m) { - outgoingChanges = Number(m[1]); - incomingChanges = Number(m[2]); - } - } else if (!line.startsWith('#')) { - uncommittedChanges++; - } - } - return { branchName, upstreamBranchName, outgoingChanges, incomingChanges, uncommittedChanges }; -} - -/** Exported for tests. */ -export function parseHasGitHubRemote(remotesOutput: string | undefined): boolean | undefined { - if (remotesOutput === undefined) { - return undefined; - } - if (!remotesOutput.trim()) { - return false; - } - return /github\.com[:\/]/i.test(remotesOutput); -} - -/** Exported for tests. */ -export function parseDefaultBranchRef(symbolicRefOutput: string | undefined): string | undefined { - const ref = symbolicRefOutput?.trim(); - if (!ref) { return undefined; } - const prefix = 'refs/remotes/origin/'; - return ref.startsWith(prefix) ? ref.substring(prefix.length) : ref; -} - -function stripUndefined(obj: T): T { - const out: Record = {}; - for (const [k, v] of Object.entries(obj)) { - if (v !== undefined) { out[k] = v; } - } - return out as T; -} diff --git a/src/vs/platform/agentHost/node/agentHostMain.ts b/src/vs/platform/agentHost/node/agentHostMain.ts deleted file mode 100644 index 44bc9893..00000000 --- a/src/vs/platform/agentHost/node/agentHostMain.ts +++ /dev/null @@ -1,305 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { ProxyChannel } from '../../../base/parts/ipc/common/ipc.js'; -import { Server as ChildProcessServer } from '../../../base/parts/ipc/node/ipc.cp.js'; -import { Server as UtilityProcessServer } from '../../../base/parts/ipc/node/ipc.mp.js'; -import { isUtilityProcess } from '../../../base/parts/sandbox/node/electronTypes.js'; -import { Emitter } from '../../../base/common/event.js'; -import { DisposableStore, IDisposable } from '../../../base/common/lifecycle.js'; -import { joinPath } from '../../../base/common/resources.js'; -import { isWindows } from '../../../base/common/platform.js'; -import { URI } from '../../../base/common/uri.js'; -import { generateUuid } from '../../../base/common/uuid.js'; -import * as os from 'os'; -import * as inspector from 'inspector'; -import { AgentHostIpcChannels, IAgentHostInspectInfo, IAgentHostSocketInfo, IConnectionTrackerService } from '../common/agentService.js'; -import { AgentService } from './agentService.js'; -import { IAgentConfigurationService } from './agentConfigurationService.js'; -import { IAgentHostTerminalManager } from './agentHostTerminalManager.js'; -import { CopilotAgent } from './copilot/copilotAgent.js'; -import { CopilotApiService, ICopilotApiService } from './shared/copilotApiService.js'; -import { ClaudeProxyService, IClaudeProxyService } from './claude/claudeProxyService.js'; -import { ProtocolServerHandler } from './protocolServerHandler.js'; -import { WebSocketProtocolServer } from './webSocketTransport.js'; -import { INativeEnvironmentService } from '../../environment/common/environment.js'; -import { NativeEnvironmentService } from '../../environment/node/environmentService.js'; -import { parseArgs, OPTIONS } from '../../environment/node/argv.js'; -import { getLogLevel, ILogService } from '../../log/common/log.js'; -import { LogService } from '../../log/common/logService.js'; -import { LoggerService } from '../../log/node/loggerService.js'; -import { LoggerChannel } from '../../log/common/logIpc.js'; -import { DefaultURITransformer } from '../../../base/common/uriIpc.js'; -import product from '../../product/common/product.js'; -import { IProductService } from '../../product/common/productService.js'; -import { localize } from '../../../nls.js'; -import { FileService } from '../../files/common/fileService.js'; -import { IFileService } from '../../files/common/files.js'; -import { DiskFileSystemProvider } from '../../files/node/diskFileSystemProvider.js'; -import { Schemas } from '../../../base/common/network.js'; -import { InstantiationService } from '../../instantiation/common/instantiationService.js'; -import { ServiceCollection } from '../../instantiation/common/serviceCollection.js'; -import { SessionDataService } from './sessionDataService.js'; -import { ISessionDataService } from '../common/sessionDataService.js'; -import { IDiffComputeService } from '../common/diffComputeService.js'; -import { NodeWorkerDiffComputeService } from './diffComputeService.js'; -import { AgentHostClientFileSystemProvider } from '../common/agentHostClientFileSystemProvider.js'; -import { AGENT_CLIENT_SCHEME } from '../common/agentClientUri.js'; -import { AGENT_HOST_CLIENT_RESOURCE_CHANNEL, createAgentHostClientResourceConnection } from '../common/agentHostClientResourceChannel.js'; -import { IAgentPluginManager } from '../common/agentPluginManager.js'; -import { AgentPluginManager } from './agentPluginManager.js'; -import { AgentHostGitService, IAgentHostGitService } from './agentHostGitService.js'; -import { registerPendingEditContentProvider } from './copilot/pendingEditContentStore.js'; -import { join } from '../../../base/common/path.js'; - -// Entry point for the agent host utility process. -// Sets up IPC, logging, and registers agent providers (Copilot). -// When VSCODE_AGENT_HOST_PORT or VSCODE_AGENT_HOST_SOCKET_PATH env vars -// are set, also starts a WebSocket server for external clients. - -startAgentHost(); - -function startAgentHost(): void { - // Setup RPC - supports both Electron utility process and Node child process - let server: ChildProcessServer | UtilityProcessServer; - if (isUtilityProcess(process)) { - server = new UtilityProcessServer(); - } else { - server = new ChildProcessServer(AgentHostIpcChannels.AgentHost); - } - - const disposables = new DisposableStore(); - - // Services - const productService: IProductService = { _serviceBrand: undefined, ...product }; - const environmentService = new NativeEnvironmentService(parseArgs(process.argv, OPTIONS), productService); - const loggerService = new LoggerService(getLogLevel(environmentService), environmentService.logsHome); - server.registerChannel(AgentHostIpcChannels.Logger, new LoggerChannel(loggerService, () => DefaultURITransformer)); - const logger = loggerService.createLogger('agenthost', { name: localize('agentHost', "Agent Host") }); - const logService = new LogService(logger); - logService.info('Agent Host process started successfully'); - - // File service - const fileService = disposables.add(new FileService(logService)); - disposables.add(fileService.registerProvider(Schemas.file, disposables.add(new DiskFileSystemProvider(logService)))); - // In-memory filesystem backing transient file-edit previews shown during - // tool-call confirmations. - disposables.add(registerPendingEditContentProvider(fileService)); - - // Session data service - const sessionDataService = new SessionDataService(URI.file(environmentService.userDataPath), fileService, logService); - const rootConfigResource = joinPath(environmentService.appSettingsHome, 'globalStorage', 'agent-host-config.json'); - - // Create the real service implementation that lives in this process - let agentService: AgentService; - try { - // Build the DI container early so the git service can be created via - // `createInstance` (it needs IFileService + INativeEnvironmentService). - const diServices = new ServiceCollection(); - diServices.set(INativeEnvironmentService, environmentService); - diServices.set(ILogService, logService); - diServices.set(IFileService, fileService); - diServices.set(ISessionDataService, sessionDataService); - diServices.set(IProductService, productService); - const instantiationService = new InstantiationService(diServices); - const gitService = instantiationService.createInstance(AgentHostGitService); - diServices.set(IAgentHostGitService, gitService); - const copilotApiService = instantiationService.createInstance(CopilotApiService, undefined); - diServices.set(ICopilotApiService, copilotApiService); - const claudeProxyService = disposables.add(instantiationService.createInstance(ClaudeProxyService)); - diServices.set(IClaudeProxyService, claudeProxyService); - agentService = new AgentService(logService, fileService, sessionDataService, productService, gitService, rootConfigResource); - const pluginManager = new AgentPluginManager(URI.file(environmentService.userDataPath), fileService, logService); - diServices.set(IAgentPluginManager, pluginManager); - const diffComputeService = disposables.add(new NodeWorkerDiffComputeService(logService)); - diServices.set(IDiffComputeService, diffComputeService); - - diServices.set(IAgentHostTerminalManager, agentService.terminalManager); - diServices.set(IAgentConfigurationService, agentService.configurationService); - agentService.registerProvider(instantiationService.createInstance(CopilotAgent)); - } catch (err) { - logService.error('Failed to create AgentService', err); - throw err; - } - const agentChannel = ProxyChannel.fromService(agentService, disposables); - server.registerChannel(AgentHostIpcChannels.AgentHost, agentChannel); - - // Single shared `vscode-agent-client` filesystem provider. Per-client - // authorities are added either by ProtocolServerHandler (for WebSocket - // transports) or by the IPC connection lifecycle below (for the local - // in-process renderer-to-utility-process MessagePort transport). - const clientFileSystemProvider = disposables.add(new AgentHostClientFileSystemProvider()); - disposables.add(fileService.registerProvider(AGENT_CLIENT_SCHEME, clientFileSystemProvider)); - - // Wire reverse-RPC for in-process renderer connections. The renderer's - // `MessagePortClient` ctx is its `clientId`, and it exposes - // `AGENT_HOST_CLIENT_RESOURCE_CHANNEL` for filesystem reads. - if (server instanceof UtilityProcessServer) { - const authorityRegistrations = new Map(); - disposables.add(server.onDidAddConnection(connection => { - const clientId = connection.ctx; - if (typeof clientId !== 'string' || !clientId) { - return; - } - const channel = server.getChannel(AGENT_HOST_CLIENT_RESOURCE_CHANNEL, c => c.ctx === clientId); - const fsConnection = createAgentHostClientResourceConnection(channel); - authorityRegistrations.set(connection, clientFileSystemProvider.registerAuthority(clientId, fsConnection)); - })); - disposables.add(server.onDidRemoveConnection(connection => { - const reg = authorityRegistrations.get(connection); - if (reg) { - reg.dispose(); - authorityRegistrations.delete(connection); - } - })); - } - - // Expose the WebSocket client connection count to the parent process via IPC. - // This is NOT part of the agent host protocol -- it is only used by the - // server process to manage the agent host process lifetime. - const connectionCountEmitter = disposables.add(new Emitter()); - let dynamicSocketInfo: IAgentHostSocketInfo | undefined; - const connectionTrackerService: IConnectionTrackerService = { - onDidChangeConnectionCount: connectionCountEmitter.event, - async startWebSocketServer(): Promise { - if (dynamicSocketInfo) { - return dynamicSocketInfo; - } - - const socketPath = isWindows - ? `\\\\.\\pipe\\vscode-agent-host-${generateUuid().replace(/-/g, '')}` - : join(os.tmpdir(), `vscode-agent-host-${generateUuid().replace(/-/g, '')}.sock`); - - const wsServer = disposables.add(await WebSocketProtocolServer.create( - { socketPath }, - logService, - )); - - const protocolHandler = disposables.add(new ProtocolServerHandler( - agentService, - agentService.stateManager, - wsServer, - { defaultDirectory: URI.file(os.homedir()).toString() }, - clientFileSystemProvider, - logService, - )); - disposables.add(protocolHandler.onDidChangeConnectionCount(count => connectionCountEmitter.fire(count))); - - logService.info(`[AgentHost] Dynamic WebSocket server listening on ${socketPath}`); - dynamicSocketInfo = { socketPath }; - return dynamicSocketInfo; - }, - async getInspectInfo(tryEnable: boolean): Promise { - let url = inspector.url(); - if (!url && tryEnable) { - try { - inspector.open(0, '127.0.0.1', false); - } catch (err) { - logService.error('[AgentHost] Failed to open inspector', err); - return undefined; - } - url = inspector.url(); - } - if (!url) { - return undefined; - } - // Inspector URL looks like: ws://host:port/uuid (host may be IPv6 in brackets) - try { - const parsedUrl = new URL(url); - if (parsedUrl.protocol !== 'ws:') { - logService.warn(`[AgentHost] Unexpected inspector URL: ${url}`); - return undefined; - } - - const port = Number(parsedUrl.port); - const auth = parsedUrl.pathname.replace(/^\/+/, ''); - if (!Number.isInteger(port) || !auth) { - logService.warn(`[AgentHost] Unexpected inspector URL: ${url}`); - return undefined; - } - - const host = parsedUrl.hostname === '0.0.0.0' - ? '127.0.0.1' - : parsedUrl.hostname === '::' - ? '::1' - : parsedUrl.hostname; - const devtoolsHost = host.includes(':') ? `[${host}]` : host; - - return { - host, - port, - devtoolsUrl: `devtools://devtools/bundled/js_app.html?v8only=true&ws=${devtoolsHost}:${parsedUrl.port}/${auth}`, - }; - } catch { - logService.warn(`[AgentHost] Unexpected inspector URL: ${url}`); - return undefined; - } - }, - }; - const connectionTrackerChannel = ProxyChannel.fromService(connectionTrackerService, disposables); - server.registerChannel(AgentHostIpcChannels.ConnectionTracker, connectionTrackerChannel); - - // Start WebSocket server for external clients if configured (env-var flow for CLI/server) - startWebSocketServer(agentService, clientFileSystemProvider, logService, disposables, count => connectionCountEmitter.fire(count)).catch(err => { - logService.error('Failed to start WebSocket server', err); - }); - - process.once('exit', () => { - agentService.dispose(); - logService.dispose(); - disposables.dispose(); - }); -} - -/** - * When the parent process passes WebSocket configuration via environment - * variables, start a protocol server that external clients can connect to. - * This reuses the same {@link AgentService} and {@link AgentHostStateManager} - * that the IPC channel uses, so both IPC and WebSocket clients share state. - */ -async function startWebSocketServer(agentService: AgentService, clientFileSystemProvider: AgentHostClientFileSystemProvider, logService: ILogService, disposables: DisposableStore, onConnectionCountChanged: (count: number) => void): Promise { - const port = process.env['VSCODE_AGENT_HOST_PORT']; - const socketPath = process.env['VSCODE_AGENT_HOST_SOCKET_PATH']; - - if (!port && !socketPath) { - return; - } - - const connectionToken = process.env['VSCODE_AGENT_HOST_CONNECTION_TOKEN']; - const host = process.env['VSCODE_AGENT_HOST_HOST'] || 'localhost'; - - const wsServer = disposables.add(await WebSocketProtocolServer.create( - socketPath - ? { - socketPath, - connectionTokenValidate: connectionToken - ? (token) => token === connectionToken - : undefined, - } - : { - port: parseInt(port!, 10), - host, - connectionTokenValidate: connectionToken - ? (token) => token === connectionToken - : undefined, - }, - logService, - )); - - const protocolHandler = disposables.add(new ProtocolServerHandler( - agentService, - agentService.stateManager, - wsServer, - { defaultDirectory: URI.file(os.homedir()).toString() }, - clientFileSystemProvider, - logService, - )); - disposables.add(protocolHandler.onDidChangeConnectionCount(onConnectionCountChanged)); - - const listenTarget = socketPath ?? `${host}:${port}`; - logService.info(`[AgentHost] WebSocket server listening on ${listenTarget}`); - // Do not change this line. The CLI looks for this in the output. - console.log(`Agent host server listening on ${listenTarget}`); -} diff --git a/src/vs/platform/agentHost/node/agentHostServerMain.ts b/src/vs/platform/agentHost/node/agentHostServerMain.ts deleted file mode 100644 index 10d4dd4c..00000000 --- a/src/vs/platform/agentHost/node/agentHostServerMain.ts +++ /dev/null @@ -1,298 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -// Standalone agent host server with WebSocket protocol transport. -// Start with: node out/vs/platform/agentHost/node/agentHostServerMain.js [--port ] [--host ] [--connection-token ] [--connection-token-file ] [--without-connection-token] [--enable-mock-agent] [--quiet] [--log ] - -import { fileURLToPath } from 'url'; - -// This standalone process isn't bootstrapped via bootstrap-esm.ts, so we must -// set _VSCODE_FILE_ROOT ourselves so that FileAccess can resolve module paths. -// This file lives at out/vs/platform/agentHost/node/ - the root is `out/`. -globalThis._VSCODE_FILE_ROOT = fileURLToPath(new URL('../../../..', import.meta.url)); - -import * as fs from 'fs'; -import * as os from 'os'; -import { DisposableStore } from '../../../base/common/lifecycle.js'; -import { raceTimeout } from '../../../base/common/async.js'; -import { joinPath } from '../../../base/common/resources.js'; -import { URI } from '../../../base/common/uri.js'; -import { generateUuid } from '../../../base/common/uuid.js'; -import { localize } from '../../../nls.js'; -import { NativeEnvironmentService } from '../../environment/node/environmentService.js'; -import { INativeEnvironmentService } from '../../environment/common/environment.js'; -import { parseArgs, OPTIONS } from '../../environment/node/argv.js'; -import { getLogLevel, ILogService, NullLogService } from '../../log/common/log.js'; -import { LogService } from '../../log/common/logService.js'; -import { LoggerService } from '../../log/node/loggerService.js'; -import product from '../../product/common/product.js'; -import { IProductService } from '../../product/common/productService.js'; -import { InstantiationService } from '../../instantiation/common/instantiationService.js'; -import { ServiceCollection } from '../../instantiation/common/serviceCollection.js'; -import { CopilotAgent } from './copilot/copilotAgent.js'; -import { AgentService } from './agentService.js'; -import { IAgentConfigurationService } from './agentConfigurationService.js'; -import { IAgentHostTerminalManager } from './agentHostTerminalManager.js'; -import { WebSocketProtocolServer } from './webSocketTransport.js'; -import { ProtocolServerHandler } from './protocolServerHandler.js'; -import { FileService } from '../../files/common/fileService.js'; -import { IFileService } from '../../files/common/files.js'; -import { DiskFileSystemProvider } from '../../files/node/diskFileSystemProvider.js'; -import { Schemas } from '../../../base/common/network.js'; -import { ISessionDataService } from '../common/sessionDataService.js'; -import { IDiffComputeService } from '../common/diffComputeService.js'; -import { NodeWorkerDiffComputeService } from './diffComputeService.js'; -import { SessionDataService } from './sessionDataService.js'; -import { AgentHostClientFileSystemProvider } from '../common/agentHostClientFileSystemProvider.js'; -import { AGENT_CLIENT_SCHEME } from '../common/agentClientUri.js'; -import { resolveServerUrls } from './serverUrls.js'; -import { AgentPluginManager } from './agentPluginManager.js'; -import { IAgentPluginManager } from '../common/agentPluginManager.js'; -import { registerPendingEditContentProvider } from './copilot/pendingEditContentStore.js'; -import { AgentHostGitService, IAgentHostGitService } from './agentHostGitService.js'; - -/** Log to stderr so messages appear in the terminal alongside the process. */ -function log(msg: string): void { - process.stderr.write(`[AgentHostServer] ${msg}\n`); -} - -// ---- Options ---------------------------------------------------------------- - -const connectionTokenRegex = /^[0-9A-Za-z_-]+$/; - -interface IServerOptions { - readonly port: number; - readonly host: string | undefined; - readonly enableMockAgent: boolean; - readonly quiet: boolean; - /** Connection token string, or `undefined` when `--without-connection-token`. */ - readonly connectionToken: string | undefined; -} - -function parseServerOptions(): IServerOptions { - const argv = process.argv.slice(2); - const envPort = parseInt(process.env['VSCODE_AGENT_HOST_PORT'] ?? '8081', 10); - const portIdx = argv.indexOf('--port'); - const port = portIdx >= 0 ? parseInt(argv[portIdx + 1], 10) : envPort; - const hostIdx = argv.indexOf('--host'); - const host = hostIdx >= 0 ? argv[hostIdx + 1] : undefined; - const enableMockAgent = argv.includes('--enable-mock-agent'); - const quiet = argv.includes('--quiet'); - - // Connection token - const withoutConnectionToken = argv.includes('--without-connection-token'); - const connectionTokenIdx = argv.indexOf('--connection-token'); - const connectionTokenFileIdx = argv.indexOf('--connection-token-file'); - const rawToken = connectionTokenIdx >= 0 ? argv[connectionTokenIdx + 1] : undefined; - const tokenFilePath = connectionTokenFileIdx >= 0 ? argv[connectionTokenFileIdx + 1] : undefined; - - let connectionToken: string | undefined; - if (withoutConnectionToken) { - if (rawToken !== undefined || tokenFilePath !== undefined) { - log('Error: --without-connection-token cannot be used with --connection-token or --connection-token-file'); - process.exit(1); - } - connectionToken = undefined; - } else if (tokenFilePath !== undefined) { - if (rawToken !== undefined) { - log('Error: --connection-token cannot be used with --connection-token-file'); - process.exit(1); - } - try { - connectionToken = fs.readFileSync(tokenFilePath).toString().replace(/\r?\n$/, ''); - } catch { - log(`Error: Unable to read connection token file at '${tokenFilePath}'`); - process.exit(1); - } - if (!connectionTokenRegex.test(connectionToken!)) { - log(`Error: The connection token in '${tokenFilePath}' does not adhere to the characters 0-9, a-z, A-Z, _, or -.`); - process.exit(1); - } - } else if (rawToken !== undefined) { - if (!connectionTokenRegex.test(rawToken)) { - log(`Error: The connection token '${rawToken}' does not adhere to the characters 0-9, a-z, A-Z, _, or -.`); - process.exit(1); - } - connectionToken = rawToken; - } else { - // Default: generate a random token (secure by default) - connectionToken = generateUuid(); - } - - return { port, host, enableMockAgent, quiet, connectionToken }; -} - -// ---- Main ------------------------------------------------------------------- - -async function main(): Promise { - const options = parseServerOptions(); - const disposables = new DisposableStore(); - - // Services - const productService: IProductService = { _serviceBrand: undefined, ...product }; - const args = parseArgs(process.argv.slice(2), OPTIONS); - const environmentService = new NativeEnvironmentService(args, productService); - - // Logging — production logging unless --quiet - let logService: ILogService; - let loggerService: LoggerService | undefined; - - if (options.quiet) { - logService = new NullLogService(); - } else { - const services = new ServiceCollection(); - services.set(IProductService, productService); - services.set(INativeEnvironmentService, environmentService); - loggerService = new LoggerService(getLogLevel(environmentService), environmentService.logsHome); - const logger = loggerService.createLogger('agenthost-server', { name: localize('agentHostServer', "Agent Host Server") }); - logService = disposables.add(new LogService(logger)); - services.set(ILogService, logService); - log('Starting standalone agent host server'); - } - - logService.info('[AgentHostServer] Starting standalone agent host server'); - - // File service - const fileService = disposables.add(new FileService(logService)); - disposables.add(fileService.registerProvider(Schemas.file, disposables.add(new DiskFileSystemProvider(logService)))); - // In-memory filesystem backing transient file-edit previews shown during - // tool-call confirmations. - disposables.add(registerPendingEditContentProvider(fileService)); - - // Session data service - const sessionDataService = new SessionDataService(URI.file(environmentService.userDataPath), fileService, logService); - const rootConfigResource = joinPath(environmentService.appSettingsHome, 'globalStorage', 'agent-host-config.json'); - - // Build the DI container early so the git service can be created via - // `createInstance` (it needs IFileService + INativeEnvironmentService). - // The git service is shared by AgentService (for diff computation + - // showBlob) and the production agent registration path. - const diServices = new ServiceCollection(); - diServices.set(IProductService, productService); - diServices.set(INativeEnvironmentService, environmentService); - diServices.set(ILogService, logService); - diServices.set(IFileService, fileService); - diServices.set(ISessionDataService, sessionDataService); - const instantiationService = new InstantiationService(diServices); - const gitService = instantiationService.createInstance(AgentHostGitService); - - // Create the agent service (owns AgentHostStateManager + AgentSideEffects internally) - const agentService = new AgentService(logService, fileService, sessionDataService, productService, gitService, rootConfigResource); - disposables.add(agentService); - - // Register agents - if (!options.quiet) { - // Production agents (require DI) - const pluginManager = new AgentPluginManager(URI.file(environmentService.userDataPath), fileService, logService); - diServices.set(IAgentPluginManager, pluginManager); - diServices.set(IDiffComputeService, disposables.add(new NodeWorkerDiffComputeService(logService))); - diServices.set(IAgentHostTerminalManager, agentService.terminalManager); - diServices.set(IAgentConfigurationService, agentService.configurationService); - diServices.set(IAgentHostGitService, gitService); - const copilotAgent = disposables.add(instantiationService.createInstance(CopilotAgent)); - agentService.registerProvider(copilotAgent); - log('CopilotAgent registered'); - } - - if (options.enableMockAgent) { - // Dynamic import to avoid bundling test code in production - import('../test/node/mockAgent.js').then(({ ScriptedMockAgent }) => { - const mockAgent = disposables.add(new ScriptedMockAgent()); - agentService.registerProvider(mockAgent); - }).catch(err => { - logService.error('[AgentHostServer] Failed to load mock agent', err); - }); - } - - // WebSocket server - const wsServer = disposables.add(await WebSocketProtocolServer.create({ - port: options.port, - host: options.host, - connectionTokenValidate: options.connectionToken - ? token => token === options.connectionToken - : undefined, - }, logService)); - - - const clientFileSystemProvider = disposables.add(new AgentHostClientFileSystemProvider()); - disposables.add(fileService.registerProvider(AGENT_CLIENT_SCHEME, clientFileSystemProvider)); - - // Wire up protocol handler - disposables.add(new ProtocolServerHandler( - agentService, - agentService.stateManager, - wsServer, - { defaultDirectory: URI.file(os.homedir()).toString() }, - clientFileSystemProvider, - logService, - )); - - // Report ready - function reportReady(addr: string): void { - const listeningPort = Number(addr.split(':').pop()); - process.stdout.write(`READY:${listeningPort}\n`); - - const urls = resolveServerUrls(options.host, listeningPort); - for (const url of urls.local) { - log(` Local: ${url}`); - logService.info(`[AgentHostServer] Local: ${url}`); - } - for (const url of urls.network) { - log(` Network: ${url}`); - logService.info(`[AgentHostServer] Network: ${url}`); - } - if (urls.network.length === 0 && options.host === undefined) { - log(' Network: use --host to expose'); - logService.info('[AgentHostServer] Network: use --host to expose'); - } - } - - const address = wsServer.address; - if (address) { - reportReady(address); - } else { - const interval = setInterval(() => { - const addr = wsServer.address; - if (addr) { - clearInterval(interval); - reportReady(addr); - } - }, 10); - } - - // Keep alive until stdin closes or signal - process.stdin.resume(); - process.stdin.on('end', () => { void shutdown(); }); - process.on('SIGTERM', () => { void shutdown(); }); - process.on('SIGINT', () => { void shutdown(); }); - - let shuttingDown = false; - async function shutdown(): Promise { - if (shuttingDown) { - return; - } - shuttingDown = true; - logService.info('[AgentHostServer] Shutting down...'); - // Close the WebSocket server first so no further actions can be - // dispatched while we wait for in-flight writes to flush — otherwise - // a late-arriving action could keep queuing DB writes and either - // undermine the flush or push us past the timeout. - wsServer.dispose(); - // Wait for in-flight persistence writes to flush to the per-session - // SQLite databases. Without this, a SIGTERM arriving while a - // `setMetadata` write (configValues, customTitle, isRead, isDone, - // diffs) is in flight can drop the latest value — see the - // "Session Config persistence across restarts" integration test. - // Capped so a stuck write cannot hang shutdown indefinitely. - await raceTimeout(sessionDataService.whenIdle(), 3000, () => { - logService.warn('[AgentHostServer] Timed out waiting for session database writes to flush; exiting anyway.'); - }); - disposables.dispose(); - loggerService?.dispose(); - process.exit(0); - } -} - -main(); diff --git a/src/vs/platform/agentHost/node/agentHostService.ts b/src/vs/platform/agentHost/node/agentHostService.ts deleted file mode 100644 index b0a43fbd..00000000 --- a/src/vs/platform/agentHost/node/agentHostService.ts +++ /dev/null @@ -1,90 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { Event } from '../../../base/common/event.js'; -import { Disposable, toDisposable } from '../../../base/common/lifecycle.js'; -import { ILogService, ILoggerService } from '../../log/common/log.js'; -import { RemoteLoggerChannelClient } from '../../log/common/logIpc.js'; -import { IAgentHostStarter } from '../common/agent.js'; -import { AgentHostIpcChannels } from '../common/agentService.js'; - -enum Constants { - MaxRestarts = 5, -} - -/** - * Main-process service that manages the agent host utility process lifecycle - * (lazy start, crash recovery, logger forwarding). The renderer communicates - * with the utility process directly via MessagePort - this class does not - * relay any agent service calls. - */ -export class AgentHostProcessManager extends Disposable { - - private _started = false; - private _wasQuitRequested = false; - private _restartCount = 0; - - constructor( - private readonly _starter: IAgentHostStarter, - @ILogService private readonly _logService: ILogService, - @ILoggerService private readonly _loggerService: ILoggerService, - ) { - super(); - - this._register(this._starter); - - // Start lazily when the first window asks for a connection - if (this._starter.onRequestConnection) { - this._register(Event.once(this._starter.onRequestConnection)(() => this._ensureStarted())); - } - - if (this._starter.onWillShutdown) { - this._register(this._starter.onWillShutdown(() => this._wasQuitRequested = true)); - } - } - - private _ensureStarted(): void { - if (!this._started) { - this._start(); - } - } - - private async _start(): Promise { - this._started = true; - try { - const connection = await this._starter.start(); - - if (this._store.isDisposed) { - connection.store.dispose(); - return; - } - - this._logService.info('AgentHostProcessManager: agent host started'); - - // Connect logger channel so agent host logs appear in the output channel - this._register(new RemoteLoggerChannelClient(this._loggerService, connection.client.getChannel(AgentHostIpcChannels.Logger))); - - // Handle unexpected exit - this._register(connection.onDidProcessExit(e => { - if (!this._wasQuitRequested && !this._store.isDisposed) { - if (this._restartCount <= Constants.MaxRestarts) { - this._logService.error(`AgentHostProcessManager: agent host terminated unexpectedly with code ${e.code}`); - this._restartCount++; - this._started = false; - connection.store.dispose(); - this._start(); - } else { - this._logService.error(`AgentHostProcessManager: agent host terminated with code ${e.code}, giving up after ${Constants.MaxRestarts} restarts`); - } - } - })); - - this._register(toDisposable(() => connection.store.dispose())); - } catch (error) { - this._started = false; - this._logService.error('AgentHostProcessManager: failed to start agent host', error); - } - } -} diff --git a/src/vs/platform/agentHost/node/agentHostStateManager.ts b/src/vs/platform/agentHost/node/agentHostStateManager.ts deleted file mode 100644 index d883c8e6..00000000 --- a/src/vs/platform/agentHost/node/agentHostStateManager.ts +++ /dev/null @@ -1,356 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { RunOnceScheduler } from '../../../base/common/async.js'; -import { Emitter, Event } from '../../../base/common/event.js'; -import { Disposable } from '../../../base/common/lifecycle.js'; -import { ILogService } from '../../log/common/log.js'; -import { ActionType, NotificationType, ActionEnvelope, ActionOrigin, INotification, IRootConfigChangedAction, SessionAction, RootAction, StateAction, TerminalAction, isRootAction, isSessionAction } from '../common/state/sessionActions.js'; -import type { IStateSnapshot } from '../common/state/sessionProtocol.js'; -import { rootReducer, sessionReducer } from '../common/state/sessionReducers.js'; -import { createRootState, createSessionState, SessionLifecycle, type RootState, type SessionMeta, type SessionState, type SessionSummary, type Turn, type URI, ROOT_STATE_URI } from '../common/state/sessionState.js'; -import { IPermissionsValue, platformRootSchema } from '../common/agentHostSchema.js'; -import { SessionConfigKey } from '../common/sessionConfigKeys.js'; - -/** - * Server-side state manager for the sessions process protocol. - * - * Maintains the authoritative state tree (root + per-session), applies actions - * through pure reducers, assigns monotonic sequence numbers, and emits - * {@link ActionEnvelope}s for subscribed clients. - */ -export class AgentHostStateManager extends Disposable { - - private _serverSeq = 0; - - private _rootState: RootState; - private readonly _sessionStates = new Map(); - - /** Tracks which session URI each active turn belongs to, keyed by turnId. */ - private readonly _activeTurnToSession = new Map(); - - /** Last summary sent to clients (via sessionAdded or sessionSummaryChanged). */ - private readonly _lastNotifiedSummaries = new Map(); - /** Sessions whose summary changed since the last flush. */ - private readonly _dirtySummaries = new Set(); - private readonly _summaryNotifyScheduler = this._register(new RunOnceScheduler(() => this._flushSummaryNotifications(), 100)); - - private readonly _onDidEmitEnvelope = this._register(new Emitter()); - readonly onDidEmitEnvelope: Event = this._onDidEmitEnvelope.event; - - private readonly _onDidEmitNotification = this._register(new Emitter()); - readonly onDidEmitNotification: Event = this._onDidEmitNotification.event; - - constructor( - @ILogService private readonly _logService: ILogService, - ) { - super(); - this._rootState = createRootState(); - // Seed the host-level configuration schema + default values so that - // RootConfigChanged actions can merge into it, and clients see the - // schema immediately upon subscribing to `agenthost:/root`. See - // `platformRootSchema` for the set of platform-owned properties. - this._rootState = { - ...this._rootState, - config: { - schema: platformRootSchema.toProtocol(), - values: platformRootSchema.validateOrDefault({}, { - [SessionConfigKey.Permissions]: { allow: [], deny: [] } satisfies IPermissionsValue, - }), - }, - }; - } - private readonly _log = (msg: string) => this._logService.warn(`[AgentHostStateManager] ${msg}`); - - get hasActiveSessions(): boolean { - return this._activeTurnToSession.size > 0; - } - - // ---- State accessors ---------------------------------------------------- - - get rootState(): RootState { - return this._rootState; - } - - getSessionState(session: URI): SessionState | undefined { - return this._sessionStates.get(session); - } - - get serverSeq(): number { - return this._serverSeq; - } - - getSessionUris(): string[] { - return [...this._sessionStates.keys()]; - } - - /** - * Returns all session URIs whose keys start with the given prefix. - * Used to discover subagent sessions for a given parent. - */ - getSessionUrisWithPrefix(prefix: string): string[] { - const result: string[] = []; - for (const key of this._sessionStates.keys()) { - if (key.startsWith(prefix)) { - result.push(key); - } - } - return result; - } - - // ---- Snapshots ---------------------------------------------------------- - - /** - * Returns a state snapshot for a given resource URI. - * The `fromSeq` in the snapshot is the current serverSeq at snapshot time; - * the client should process subsequent envelopes with serverSeq > fromSeq. - */ - getSnapshot(resource: URI): IStateSnapshot | undefined { - if (resource === ROOT_STATE_URI) { - return { - resource, - state: this._rootState, - fromSeq: this._serverSeq, - }; - } - - const sessionState = this._sessionStates.get(resource); - if (!sessionState) { - return undefined; - } - - return { - resource, - state: sessionState, - fromSeq: this._serverSeq, - }; - } - - // ---- Session lifecycle -------------------------------------------------- - - /** - * Creates a new session in state with `lifecycle: 'creating'`. - * Returns the initial session state. - */ - createSession(summary: SessionSummary): SessionState { - const key = summary.resource; - if (this._sessionStates.has(key)) { - this._logService.warn(`[AgentHostStateManager] Session already exists: ${key}`); - return this._sessionStates.get(key)!; - } - - const state = createSessionState(summary); - this._sessionStates.set(key, state); - - this._logService.trace(`[AgentHostStateManager] Created session: ${key}`); - - this._lastNotifiedSummaries.set(key, summary); - this._onDidEmitNotification.fire({ - type: NotificationType.SessionAdded, - summary, - }); - - return state; - } - - /** - * Restores a session from a previous server lifetime into the state manager - * with pre-populated turns. The session is created in `ready` lifecycle - * state since it already exists on the backend. - * - * Unlike {@link createSession}, this does NOT emit a `sessionAdded` - * notification because the session is already known to clients via - * `listSessions`. - */ - restoreSession(summary: SessionSummary, turns: Turn[]): SessionState { - const key = summary.resource; - if (this._sessionStates.has(key)) { - this._logService.warn(`[AgentHostStateManager] Session already exists (restore): ${key}`); - return this._sessionStates.get(key)!; - } - - const state: SessionState = { - ...createSessionState(summary), - lifecycle: SessionLifecycle.Ready, - turns, - }; - this._sessionStates.set(key, state); - this._lastNotifiedSummaries.set(key, summary); - - this._logService.trace(`[AgentHostStateManager] Restored session: ${key} (${turns.length} turns)`); - - return state; - } - - /** - * Removes a session from in-memory state without emitting a notification. - * Use {@link deleteSession} when the session is being permanently deleted - * and clients need to be notified. - */ - removeSession(session: URI): void { - const state = this._sessionStates.get(session); - if (!state) { - return; - } - - // Clean up active turn tracking - if (state.activeTurn) { - this._activeTurnToSession.delete(state.activeTurn.id); - } - - this._sessionStates.delete(session); - this._lastNotifiedSummaries.delete(session); - this._dirtySummaries.delete(session); - this._logService.trace(`[AgentHostStateManager] Removed session: ${session}`); - } - - /** - * Permanently deletes a session from state and emits a - * {@link NotificationType.SessionRemoved} notification so that clients - * know the session is no longer accessible. - */ - deleteSession(session: URI): void { - this.removeSession(session); - this._onDidEmitNotification.fire({ - type: NotificationType.SessionRemoved, - session, - }); - } - - // ---- Session meta ------------------------------------------------------- - - /** - * Replaces `state._meta` on a session by dispatching a - * {@link ActionType.SessionMetaChanged} action so the change flows - * through the action envelope (and thus to all live subscribers). - * - * The full `_meta` object is replaced (not merged) so callers stay in - * control of the convention for their own keys; use the `withSessionXxx` - * helpers in `sessionState.ts` to combine slots. - */ - setSessionMeta(session: URI, meta: SessionMeta | undefined): void { - this.dispatchServerAction({ type: ActionType.SessionMetaChanged, session, _meta: meta }); - } - - // ---- Turn tracking ------------------------------------------------------ - - /** - * Registers a mapping from turnId to session URI so that incoming - * provider events (which carry only session URI) can be associated - * with the correct active turn. - */ - getActiveTurnId(session: URI): string | undefined { - const state = this._sessionStates.get(session); - return state?.activeTurn?.id; - } - - // ---- Action dispatch ---------------------------------------------------- - - /** - * Dispatch a server-originated action (from the agent backend). - * The action is applied to state via the reducer and emitted as an - * envelope with no origin (server-produced). - */ - dispatchServerAction(action: StateAction): void { - this._applyAndEmit(action, undefined); - } - - /** - * Dispatch a client-originated action (write-ahead from a renderer). - * The action is applied to state and emitted with the client's origin - * so the originating client can reconcile. - */ - dispatchClientAction(action: SessionAction | TerminalAction | IRootConfigChangedAction, origin: ActionOrigin): unknown { - return this._applyAndEmit(action, origin); - } - - // ---- Internal ----------------------------------------------------------- - - private _applyAndEmit(action: StateAction, origin: ActionOrigin | undefined): unknown { - let resultingState: unknown = undefined; - // Apply to state - if (isRootAction(action)) { - this._rootState = rootReducer(this._rootState, action as RootAction, this._log); - resultingState = this._rootState; - } - - if (isSessionAction(action)) { - const sessionAction = action as SessionAction; - const key = sessionAction.session; - const state = this._sessionStates.get(key); - if (state) { - const newState = sessionReducer(state, sessionAction, this._log); - this._sessionStates.set(key, newState); - - // Detect summary changes for notification - if (state.summary !== newState.summary) { - this._dirtySummaries.add(key); - this._summaryNotifyScheduler.schedule(); - } - - // Track active turn for turn lifecycle - if (sessionAction.type === ActionType.SessionTurnStarted) { - this._activeTurnToSession.set(sessionAction.turnId, key); - this.dispatchServerAction({ type: ActionType.RootActiveSessionsChanged, activeSessions: this._activeTurnToSession.size }); - } else if ( - sessionAction.type === ActionType.SessionTurnComplete || - sessionAction.type === ActionType.SessionTurnCancelled || - sessionAction.type === ActionType.SessionError - ) { - this._activeTurnToSession.delete(sessionAction.turnId); - this.dispatchServerAction({ type: ActionType.RootActiveSessionsChanged, activeSessions: this._activeTurnToSession.size }); - } - - resultingState = newState; - } else { - this._logService.warn(`[AgentHostStateManager] Action for unknown session: ${key}, type=${action.type}`); - } - } - - // Emit envelope - const envelope: ActionEnvelope = { - action, - serverSeq: ++this._serverSeq, - origin, - }; - - this._logService.trace(`[AgentHostStateManager] Emitting envelope: seq=${envelope.serverSeq}, type=${action.type}${origin ? `, origin=${origin.clientId}:${origin.clientSeq}` : ''}`); - this._onDidEmitEnvelope.fire(envelope); - - return resultingState; - } - - private _flushSummaryNotifications(): void { - for (const session of this._dirtySummaries) { - const state = this._sessionStates.get(session); - const lastNotified = this._lastNotifiedSummaries.get(session); - if (!state || !lastNotified || state.summary === lastNotified) { - continue; - } - - const current = state.summary; - const changes: Partial = {}; - if (current.title !== lastNotified.title) { changes.title = current.title; } - if (current.status !== lastNotified.status) { changes.status = current.status; } - if (current.activity !== lastNotified.activity) { changes.activity = current.activity; } - if (current.modifiedAt !== lastNotified.modifiedAt) { changes.modifiedAt = current.modifiedAt; } - if (current.project !== lastNotified.project) { changes.project = current.project; } - if (current.model !== lastNotified.model) { changes.model = current.model; } - if (current.workingDirectory !== lastNotified.workingDirectory) { changes.workingDirectory = current.workingDirectory; } - if (current.diffs !== lastNotified.diffs) { changes.diffs = current.diffs; } - - this._lastNotifiedSummaries.set(session, current); - - if (Object.keys(changes).length > 0) { - this._onDidEmitNotification.fire({ - type: NotificationType.SessionSummaryChanged, - session, - changes, - }); - } - } - this._dirtySummaries.clear(); - } -} diff --git a/src/vs/platform/agentHost/node/agentHostTerminalManager.ts b/src/vs/platform/agentHost/node/agentHostTerminalManager.ts deleted file mode 100644 index 72d83b73..00000000 --- a/src/vs/platform/agentHost/node/agentHostTerminalManager.ts +++ /dev/null @@ -1,721 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import * as fs from 'fs'; -import { DeferredPromise, raceCancellablePromises, timeout } from '../../../base/common/async.js'; -import { Emitter } from '../../../base/common/event.js'; -import { Disposable, DisposableStore, IDisposable, toDisposable } from '../../../base/common/lifecycle.js'; -import { dirname, parse as pathParse } from '../../../base/common/path.js'; -import * as platform from '../../../base/common/platform.js'; -import { getSystemShell } from '../../../base/node/shell.js'; -import { URI } from '../../../base/common/uri.js'; -import { generateUuid } from '../../../base/common/uuid.js'; -import { createDecorator } from '../../instantiation/common/instantiation.js'; -import { ILogService } from '../../log/common/log.js'; -import { IProductService } from '../../product/common/productService.js'; -import { getShellIntegrationInjection } from '../../terminal/node/terminalEnvironment.js'; -import { ActionType } from '../common/state/protocol/actions.js'; -import type { CreateTerminalParams } from '../common/state/protocol/commands.js'; -import { TerminalClaim, TerminalContentPart, TerminalInfo, TerminalState, TerminalClaimKind } from '../common/state/protocol/state.js'; -import { isTerminalAction } from '../common/state/sessionActions.js'; -import type { AgentHostStateManager } from './agentHostStateManager.js'; -import { Osc633Event, Osc633EventType, Osc633Parser } from './osc633Parser.js'; - -const WAIT_FOR_PROMPT_TIMEOUT = 10_000; - -export const IAgentHostTerminalManager = createDecorator('agentHostTerminalManager'); - -export interface ICommandFinishedEvent { - commandId: string; - exitCode: number | undefined; - command: string; - output: string; -} - -/** - * Service interface for terminal management in the agent host. - */ -export interface IAgentHostTerminalManager { - readonly _serviceBrand: undefined; - createTerminal(params: CreateTerminalParams, options?: { shell?: string; preventShellHistory?: boolean; nonInteractive?: boolean }): Promise; - writeInput(uri: string, data: string): void; - onData(uri: string, cb: (data: string) => void): IDisposable; - onExit(uri: string, cb: (exitCode: number) => void): IDisposable; - onClaimChanged(uri: string, cb: (claim: TerminalClaim) => void): IDisposable; - onCommandFinished(uri: string, cb: (event: ICommandFinishedEvent) => void): IDisposable; - getContent(uri: string): string | undefined; - getClaim(uri: string): TerminalClaim | undefined; - hasTerminal(uri: string): boolean; - getExitCode(uri: string): number | undefined; - supportsCommandDetection(uri: string): boolean; - disposeTerminal(uri: string): void; - getTerminalInfos(): TerminalInfo[]; - getTerminalState(uri: string): TerminalState | undefined; -} - -// node-pty is loaded dynamically to avoid bundling issues in non-node environments -let nodePtyModule: typeof import('node-pty') | undefined; -async function getNodePty(): Promise { - if (!nodePtyModule) { - nodePtyModule = await import('node-pty'); - } - return nodePtyModule; -} - -/** Per-terminal command detection tracking state. */ -interface ICommandTracker { - readonly parser: Osc633Parser; - readonly nonce: string; - commandCounter: number; - detectionAvailableEmitted: boolean; - pendingCommandLine?: string; - activeCommandId?: string; - activeCommandTimestamp?: number; -} - -/** Represents a single managed terminal with its PTY process. */ -interface IManagedTerminal { - readonly uri: string; - readonly store: DisposableStore; - readonly pty: import('node-pty').IPty; - readonly onDataEmitter: Emitter; - readonly onExitEmitter: Emitter; - readonly onClaimChangedEmitter: Emitter; - readonly onCommandFinishedEmitter: Emitter; - title: string; - cwd: string; - cols: number; - rows: number; - content: TerminalContentPart[]; - contentSize: number; - claim: TerminalClaim; - exitCode?: number; - commandTracker?: ICommandTracker; -} - -/** - * Manages terminal processes for the agent host. Each terminal is backed by - * a node-pty instance and identified by a protocol URI. - * - * Listens to the {@link AgentHostStateManager} for client-dispatched terminal - * actions (input, resize, claim changes) and dispatches server-originated - * PTY output back through the state manager. - */ -export class AgentHostTerminalManager extends Disposable implements IAgentHostTerminalManager { - declare readonly _serviceBrand: undefined; - - private readonly _terminals = new Map(); - - constructor( - private readonly _stateManager: AgentHostStateManager, - @ILogService private readonly _logService: ILogService, - @IProductService private readonly _productService: IProductService, - ) { - super(); - - // React to client-dispatched terminal actions flowing through the state manager - this._register(this._stateManager.onDidEmitEnvelope(envelope => { - const action = envelope.action; - if (!isTerminalAction(action)) { - return; - } - switch (action.type) { - case ActionType.TerminalInput: - this._writeInput(action.terminal, action.data); - break; - case ActionType.TerminalResized: - this._resize(action.terminal, action.cols, action.rows); - break; - case ActionType.TerminalClaimed: - this._setClaim(action.terminal, action.claim); - break; - case ActionType.TerminalTitleChanged: - this._setTitle(action.terminal, action.title); - break; - case ActionType.TerminalCleared: - this._clearContent(action.terminal); - break; - } - })); - } - - /** Get metadata for all active terminals (for root state). */ - getTerminalInfos(): TerminalInfo[] { - return [...this._terminals.values()].map(t => ({ - resource: t.uri, - title: t.title, - claim: t.claim, - exitCode: t.exitCode, - })); - } - - /** Get the full state for a terminal (for subscribe snapshots). */ - getTerminalState(uri: string): TerminalState | undefined { - const terminal = this._terminals.get(uri); - if (!terminal) { - return undefined; - } - return { - title: terminal.title, - cwd: terminal.cwd, - cols: terminal.cols, - rows: terminal.rows, - content: terminal.content, - exitCode: terminal.exitCode, - claim: terminal.claim, - supportsCommandDetection: terminal.commandTracker?.detectionAvailableEmitted, - }; - } - - /** - * Create a new terminal backed by node-pty. - * Spawns the user's default shell. - */ - async createTerminal(params: CreateTerminalParams, options?: { shell?: string; preventShellHistory?: boolean; nonInteractive?: boolean }): Promise { - const uri = params.terminal; - if (this._terminals.has(uri)) { - throw new Error(`Terminal already exists: ${uri}`); - } - - const nodePty = await getNodePty(); - - const cwd = await this._resolveCwd(params.cwd, uri); - const cols = params.cols ?? 80; - const rows = params.rows ?? 24; - - const shell = options?.shell ?? await this._getDefaultShell(); - const name = platform.isWindows ? 'cmd' : 'xterm-256color'; - - this._logService.info(`[TerminalManager] Creating terminal ${uri}: shell=${shell}, cwd=${cwd}, cols=${cols}, rows=${rows}`); - - // Shell integration — inject scripts so the shell emits OSC 633 sequences - const nonce = generateUuid(); - const env: Record = { ...process.env as Record }; - if (options?.preventShellHistory) { - // Picked up by the shell integration scripts to set HISTCONTROL=ignorespace - // (bash) / HIST_IGNORE_SPACE (zsh), or suppress PSReadLine history (pwsh). - // Combined with the leading-space prefix applied at command-write time, this - // prevents agent-executed commands from polluting the user's shell history. - env['VSCODE_PREVENT_SHELL_HISTORY'] = '1'; - } - if (options?.nonInteractive) { - // Suppress paging and interactive prompts so that tool-spawned - // terminals produce clean, machine-friendly output. An empty - // string disables paging in git, less, and most CLI tools and - // is safe on all platforms (unlike 'cat' which isn't on Windows PATH). - env['LC_ALL'] = 'C.UTF-8'; - env['PAGER'] = ''; - env['GIT_PAGER'] = ''; - env['GH_PAGER'] = ''; - env['GIT_TERMINAL_PROMPT'] = '0'; - env['DEBIAN_FRONTEND'] = 'noninteractive'; - } - let shellArgs: string[] = []; - if (platform.isMacintosh) { - const shellName = pathParse(shell).name; - if (shellName.match(/(zsh|bash)/)) { - shellArgs = ['--login']; - } - } - - const injection = await getShellIntegrationInjection( - { executable: shell, args: shellArgs, forceShellIntegration: true }, - { - shellIntegration: { enabled: true, suggestEnabled: false, nonce }, - windowsUseConptyDll: false, - environmentVariableCollections: undefined, - workspaceFolder: undefined, - isScreenReaderOptimized: false, - }, - undefined, - this._logService, - this._productService, - ); - - let commandTracker: ICommandTracker | undefined; - - if (injection.type === 'injection') { - this._logService.info(`[TerminalManager] Shell integration injected for ${uri}`); - if (injection.envMixin) { - for (const [key, value] of Object.entries(injection.envMixin)) { - if (value !== undefined) { - env[key] = value; - } - } - } - if (injection.newArgs) { - shellArgs = injection.newArgs; - } - if (injection.filesToCopy) { - for (const f of injection.filesToCopy) { - try { - await fs.promises.mkdir(dirname(f.dest), { recursive: true }); - await fs.promises.copyFile(f.source, f.dest); - } catch { - // Swallow — another process may be using the same temp dir - } - } - } - commandTracker = { - parser: new Osc633Parser(), - nonce, - commandCounter: 0, - detectionAvailableEmitted: false, - }; - } else { - this._logService.info(`[TerminalManager] Shell integration not available for ${uri}: ${injection.reason}`); - } - - const ptyProcess = nodePty.spawn(shell, shellArgs, { - name, - cwd, - env, - cols, - rows, - }); - - const store = new DisposableStore(); - const claim: TerminalClaim = params.claim ?? { kind: TerminalClaimKind.Client, clientId: '' }; - - const onDataEmitter = store.add(new Emitter()); - const onExitEmitter = store.add(new Emitter()); - const onClaimChangedEmitter = store.add(new Emitter()); - const onCommandFinishedEmitter = store.add(new Emitter()); - - const managed: IManagedTerminal = { - uri, - store, - pty: ptyProcess, - onDataEmitter, - onExitEmitter, - onClaimChangedEmitter, - onCommandFinishedEmitter, - title: params.name ?? shell, - cwd, - cols, - rows, - content: [], - contentSize: 0, - claim, - commandTracker, - }; - - this._terminals.set(uri, managed); - - // Wire PTY events → protocol events - store.add(toDisposable(() => { - try { ptyProcess.kill(); } catch { /* already dead */ } - })); - - const onFirstData = new DeferredPromise(); - const dataListener = ptyProcess.onData(rawData => { - this._handlePtyData(managed, rawData); - onFirstData.complete(); - }); - store.add(toDisposable(() => dataListener.dispose())); - - const exitListener = ptyProcess.onExit(e => { - managed.exitCode = e.exitCode; - managed.onExitEmitter.fire(e.exitCode); - onFirstData.complete(); - this._stateManager.dispatchServerAction({ - type: ActionType.TerminalExited, - terminal: uri, - exitCode: e.exitCode, - }); - this._broadcastTerminalList(); - }); - store.add(toDisposable(() => exitListener.dispose())); - - // Poll for title changes (non-Windows) - if (!platform.isWindows) { - const titleInterval = setInterval(() => { - const newTitle = ptyProcess.process; - if (newTitle && newTitle !== managed.title) { - managed.title = newTitle; - this._stateManager.dispatchServerAction({ - type: ActionType.TerminalTitleChanged, - terminal: uri, - title: newTitle, - }); - this._broadcastTerminalList(); - } - }, 200); - store.add(toDisposable(() => clearInterval(titleInterval))); - } - - await raceCancellablePromises([onFirstData.p, timeout(WAIT_FOR_PROMPT_TIMEOUT)]); - - this._broadcastTerminalList(); - } - - /** Send input data to a terminal's PTY process (from client-dispatched actions). */ - private _writeInput(uri: string, data: string): void { - this.writeInput(uri, data); - } - - /** Send input data to a terminal's PTY process. */ - writeInput(uri: string, data: string): void { - const terminal = this._terminals.get(uri); - if (terminal && terminal.exitCode === undefined) { - terminal.pty.write(data); - } - } - - /** Register a callback for PTY data events on a terminal. */ - onData(uri: string, cb: (data: string) => void): IDisposable { - const terminal = this._terminals.get(uri); - if (!terminal) { - return toDisposable(() => { }); - } - return terminal.onDataEmitter.event(cb); - } - - /** Register a callback for PTY exit events on a terminal. */ - onExit(uri: string, cb: (exitCode: number) => void): IDisposable { - const terminal = this._terminals.get(uri); - if (!terminal) { - return toDisposable(() => { }); - } - return terminal.onExitEmitter.event(cb); - } - - /** Register a callback for terminal claim changes. */ - onClaimChanged(uri: string, cb: (claim: TerminalClaim) => void): IDisposable { - const terminal = this._terminals.get(uri); - if (!terminal) { - return toDisposable(() => { }); - } - return terminal.onClaimChangedEmitter.event(cb); - } - - /** Register a callback for command completion events (requires shell integration). */ - onCommandFinished(uri: string, cb: (event: ICommandFinishedEvent) => void): IDisposable { - const terminal = this._terminals.get(uri); - if (!terminal) { - return toDisposable(() => { }); - } - return terminal.onCommandFinishedEmitter.event(cb); - } - - /** Get accumulated scrollback content for a terminal as raw text. */ - getContent(uri: string): string | undefined { - const terminal = this._terminals.get(uri); - if (!terminal) { - return undefined; - } - return terminal.content.map(p => p.type === 'command' ? p.output : p.value).join(''); - } - - /** Get the current claim for a terminal. */ - getClaim(uri: string): TerminalClaim | undefined { - return this._terminals.get(uri)?.claim; - } - - /** Check whether a terminal exists. */ - hasTerminal(uri: string): boolean { - return this._terminals.has(uri); - } - - /** Whether the terminal has shell integration active for command detection. */ - supportsCommandDetection(uri: string): boolean { - const terminal = this._terminals.get(uri); - return terminal?.commandTracker?.detectionAvailableEmitted ?? false; - } - - /** Get the exit code for a terminal, or undefined if still running. */ - getExitCode(uri: string): number | undefined { - return this._terminals.get(uri)?.exitCode; - } - - /** Resize a terminal. */ - private _resize(uri: string, cols: number, rows: number): void { - const terminal = this._terminals.get(uri); - if (terminal && terminal.exitCode === undefined) { - terminal.cols = cols; - terminal.rows = rows; - terminal.pty.resize(cols, rows); - } - } - - /** Update a terminal's claim. */ - private _setClaim(uri: string, claim: TerminalClaim): void { - const terminal = this._terminals.get(uri); - if (terminal) { - terminal.claim = claim; - terminal.onClaimChangedEmitter.fire(claim); - this._broadcastTerminalList(); - } - } - - /** Update a terminal's title. */ - private _setTitle(uri: string, title: string): void { - const terminal = this._terminals.get(uri); - if (terminal) { - terminal.title = title; - this._broadcastTerminalList(); - } - } - - /** Clear a terminal's scrollback buffer. */ - private _clearContent(uri: string): void { - const terminal = this._terminals.get(uri); - if (terminal) { - terminal.content = []; - terminal.contentSize = 0; - } - } - - /** Process raw PTY output: parse OSC 633 sequences, dispatch actions, track content. */ - private _handlePtyData(managed: IManagedTerminal, rawData: string): void { - const tracker = managed.commandTracker; - let cleanedData: string; - - if (tracker) { - const parseResult = tracker.parser.parse(rawData); - cleanedData = parseResult.cleanedData; - - for (const event of parseResult.events) { - this._handleOsc633Event(managed, tracker, event); - } - } else { - cleanedData = rawData; - } - - // Append to structured content - if (cleanedData.length > 0) { - this._appendToContent(managed, cleanedData); - } - - // Trim content if too large - this._trimContent(managed); - - // Fire data event and dispatch to protocol (cleaned, without OSC 633) - if (cleanedData.length > 0) { - managed.onDataEmitter.fire(cleanedData); - this._stateManager.dispatchServerAction({ - type: ActionType.TerminalData, - terminal: managed.uri, - data: cleanedData, - }); - } - } - - /** Handle a parsed OSC 633 event by dispatching the appropriate protocol actions. */ - private _handleOsc633Event(managed: IManagedTerminal, tracker: ICommandTracker, event: Osc633Event): void { - // Emit TerminalCommandDetectionAvailable on first sequence - if (!tracker.detectionAvailableEmitted) { - tracker.detectionAvailableEmitted = true; - this._stateManager.dispatchServerAction({ - type: ActionType.TerminalCommandDetectionAvailable, - terminal: managed.uri, - }); - } - - switch (event.type) { - case Osc633EventType.CommandLine: { - // Only trust command lines with a valid nonce - if (event.nonce === tracker.nonce) { - tracker.pendingCommandLine = event.commandLine; - } - break; - } - - case Osc633EventType.CommandExecuted: { - const commandId = `cmd-${++tracker.commandCounter}`; - const commandLine = tracker.pendingCommandLine ?? ''; - const timestamp = Date.now(); - tracker.pendingCommandLine = undefined; - tracker.activeCommandId = commandId; - tracker.activeCommandTimestamp = timestamp; - - // Push a new command content part - managed.content.push({ - type: 'command', - commandId, - commandLine, - output: '', - timestamp, - isComplete: false, - }); - - this._stateManager.dispatchServerAction({ - type: ActionType.TerminalCommandExecuted, - terminal: managed.uri, - commandId, - commandLine, - timestamp, - }); - break; - } - - case Osc633EventType.CommandFinished: { - const finishedCommandId = tracker.activeCommandId; - if (!finishedCommandId) { - break; - } - const durationMs = tracker.activeCommandTimestamp !== undefined - ? Date.now() - tracker.activeCommandTimestamp - : undefined; - - // Mark the command content part as complete and collect output - let commandLine = ''; - let commandOutput = ''; - for (const part of managed.content) { - if (part.type === 'command' && part.commandId === finishedCommandId) { - part.isComplete = true; - part.exitCode = event.exitCode; - part.durationMs = durationMs; - commandLine = part.commandLine; - commandOutput = part.output; - break; - } - } - - tracker.activeCommandId = undefined; - tracker.activeCommandTimestamp = undefined; - - managed.onCommandFinishedEmitter.fire({ - commandId: finishedCommandId, - exitCode: event.exitCode, - command: commandLine, - output: commandOutput, - }); - - this._stateManager.dispatchServerAction({ - type: ActionType.TerminalCommandFinished, - terminal: managed.uri, - commandId: finishedCommandId, - exitCode: event.exitCode, - durationMs, - }); - break; - } - - case Osc633EventType.Property: { - if (event.key === 'Cwd') { - managed.cwd = event.value; - this._stateManager.dispatchServerAction({ - type: ActionType.TerminalCwdChanged, - terminal: managed.uri, - cwd: event.value, - }); - } - break; - } - } - } - - /** Append cleaned data to the terminal's structured content array. */ - private _appendToContent(managed: IManagedTerminal, data: string): void { - const tail = managed.content.length > 0 ? managed.content[managed.content.length - 1] : undefined; - - if (tail && tail.type === 'command' && !tail.isComplete) { - // Active command — append to its output - tail.output += data; - managed.contentSize += data.length; - } else if (tail && tail.type === 'unclassified') { - // Extend the existing unclassified part - tail.value += data; - managed.contentSize += data.length; - } else { - // Start a new unclassified part - managed.content.push({ type: 'unclassified', value: data }); - managed.contentSize += data.length; - } - } - - private _getContentPartSize(part: TerminalContentPart): number { - return part.type === 'command' ? part.output.length : part.value.length; - } - - /** Trim content parts to stay within the rolling buffer limit. */ - private _trimContent(managed: IManagedTerminal): void { - const maxSize = 100_000; - const targetSize = 80_000; - if (managed.contentSize <= maxSize) { - return; - } - // Drop whole parts from the front while possible - while (managed.contentSize > targetSize && managed.content.length > 1) { - const removed = managed.content.shift()!; - managed.contentSize -= this._getContentPartSize(removed); - } - // If the single remaining (or first) part is still over budget, trim its text - if (managed.contentSize > targetSize && managed.content.length > 0) { - const head = managed.content[0]; - const excess = managed.contentSize - targetSize; - if (head.type === 'command') { - head.output = head.output.slice(excess); - } else { - head.value = head.value.slice(excess); - } - managed.contentSize -= excess; - } - } - - /** Dispose a terminal: kill the process and remove it. */ - disposeTerminal(uri: string): void { - const terminal = this._terminals.get(uri); - if (terminal) { - this._terminals.delete(uri); - terminal.store.dispose(); - this._broadcastTerminalList(); - } - } - - private _getDefaultShell(): Promise { - return getSystemShell(platform.OS, process.env); - } - - /** - * Resolves the cwd string from {@link CreateTerminalParams} to an - * accessible filesystem path, falling back to $HOME if the requested - * directory is missing (otherwise node-pty exits silently with code 1). - * Accepts either a `file://` URI string or a raw absolute filesystem path. - */ - private async _resolveCwd(cwd: string | undefined, terminalURI: string): Promise { - let resolved = cwd; - if (cwd) { - const parsed = URI.parse(cwd); - if (parsed.scheme === 'file' && parsed.fsPath && parsed.fsPath !== '/') { - resolved = parsed.fsPath; - } else { - this._logService.warn(`[TerminalManager] Ignoring non-file cwd for ${terminalURI}: ${cwd}`); - } - } - - try { - if (resolved) { - const stat = await fs.promises.stat(resolved); - if (stat.isDirectory()) { - return resolved; - } - } - } catch { - // fall through to fallback - } - - const fallback = process.env['HOME'] || process.env['USERPROFILE'] || process.cwd(); - this._logService.warn(`[TerminalManager] cwd '${resolved}' is not accessible, falling back to ${fallback}`); - return fallback; - } - - /** Dispatch root/terminalsChanged with the current terminal list. */ - private _broadcastTerminalList(): void { - this._stateManager.dispatchServerAction({ - type: ActionType.RootTerminalsChanged, - terminals: this.getTerminalInfos(), - }); - } - - override dispose(): void { - for (const terminal of this._terminals.values()) { - terminal.store.dispose(); - } - this._terminals.clear(); - super.dispose(); - } -} diff --git a/src/vs/platform/agentHost/node/agentPluginManager.ts b/src/vs/platform/agentHost/node/agentPluginManager.ts deleted file mode 100644 index 07ad50cd..00000000 --- a/src/vs/platform/agentHost/node/agentPluginManager.ts +++ /dev/null @@ -1,209 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { VSBuffer } from '../../../base/common/buffer.js'; -import { SequencerByKey } from '../../../base/common/async.js'; -import { URI } from '../../../base/common/uri.js'; -import { IFileService } from '../../files/common/files.js'; -import { ILogService } from '../../log/common/log.js'; -import { IAgentPluginManager, type ISyncedCustomization } from '../common/agentPluginManager.js'; -import { CustomizationStatus, type CustomizationRef, type SessionCustomization } from '../common/state/sessionState.js'; -import { toAgentClientUri } from '../common/agentClientUri.js'; - -const DEFAULT_MAX_PLUGINS = 20; - -/** On-disk cache entry format. */ -interface ICacheEntry { - readonly uri: string; - readonly nonce: string; -} - -/** - * Implementation of {@link IAgentPluginManager}. - * - * Syncs plugin directories to local storage under - * `{userDataPath}/agentPlugins/{key}/`. Uses a {@link SequencerByKey} - * per plugin URI so that concurrent syncs of the same plugin are - * serialized and cannot clobber each other. - * - * The nonce cache and LRU order are persisted to a JSON file in the - * base path so they survive process restarts. - */ -export class AgentPluginManager implements IAgentPluginManager { - declare readonly _serviceBrand: undefined; - - private readonly _basePath: URI; - private readonly _cachePath: URI; - private readonly _maxPlugins: number; - - /** Serializes concurrent sync operations per plugin URI. */ - private readonly _sequencer = new SequencerByKey(); - - /** Nonces for plugins on disk, keyed by original customization URI string. */ - private readonly _cachedNonces = new Map(); - - /** LRU order: most recently used original customization URI strings at the end. */ - private readonly _lruOrder: string[] = []; - - /** Whether the on-disk cache has been loaded. */ - private _cacheLoaded = false; - - constructor( - userDataPath: URI, - @IFileService private readonly _fileService: IFileService, - @ILogService private readonly _logService: ILogService, - maxPlugins: number = DEFAULT_MAX_PLUGINS, - ) { - this._basePath = URI.joinPath(userDataPath, 'agentPlugins'); - this._cachePath = URI.joinPath(this._basePath, 'cache.json'); - this._maxPlugins = maxPlugins; - } - - async syncCustomizations( - clientId: string, - customizations: CustomizationRef[], - progress?: (status: SessionCustomization[]) => void, - ): Promise { - await this._ensureCacheLoaded(); - - // Build initial loading status and fire it immediately via progress - const statuses: SessionCustomization[] = customizations.map(c => ({ - customization: c, - enabled: true, - status: CustomizationStatus.Loading, - })); - progress?.([...statuses]); - - // Sync each customization in parallel, serialized per URI - const results = await Promise.all(customizations.map((ref, i) => - this._sequencer.queue(ref.uri, async (): Promise => { - try { - const pluginDir = await this._syncPlugin(clientId, ref); - statuses[i] = { customization: ref, enabled: true, status: CustomizationStatus.Loaded }; - progress?.([...statuses]); - return { customization: statuses[i], pluginDir }; - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - this._logService.error(`[AgentPluginManager] Failed to sync plugin ${ref.uri}: ${message}`); - statuses[i] = { customization: ref, enabled: true, status: CustomizationStatus.Error, statusMessage: message }; - progress?.([...statuses]); - return { customization: statuses[i] }; - } - }) - )); - - return results; - } - - // ---- plugin storage logic ----------------------------------------------- - - /** - * Syncs a single plugin to local storage. Skips the copy when the - * nonce matches the cached value. Returns the local directory URI. - */ - private async _syncPlugin(clientId: string, ref: CustomizationRef): Promise { - const pluginUri = toAgentClientUri(URI.parse(ref.uri), clientId); - const key = this._keyForUri(ref.uri); - const destDir = URI.joinPath(this._basePath, key); - - // Nonce cache hit — skip copy - if (ref.nonce && this._cachedNonces.get(ref.uri) === ref.nonce) { - this._touchLru(ref.uri); - this._logService.trace(`[AgentPluginManager] Nonce match for ${ref.uri}, skipping copy`); - return destDir; - } - - this._logService.info(`[AgentPluginManager] Syncing plugin: ${ref.uri} → ${destDir.toString()}`); - - await this._fileService.copy(pluginUri, destDir, true); - - if (ref.nonce) { - this._cachedNonces.set(ref.uri, ref.nonce); - } - this._touchLru(ref.uri); - await this._evictIfNeeded(); - await this._persistCache(); - - return destDir; - } - - private _keyForUri(uri: string): string { - return uri.replace(/[^a-zA-Z0-9]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '').substring(0, 128); - } - - private _touchLru(uri: string): void { - const idx = this._lruOrder.indexOf(uri); - if (idx !== -1) { - this._lruOrder.splice(idx, 1); - } - this._lruOrder.push(uri); - } - - private async _evictIfNeeded(): Promise { - while (this._lruOrder.length > this._maxPlugins) { - const evictUri = this._lruOrder.shift(); - if (!evictUri) { - break; - } - this._cachedNonces.delete(evictUri); - const evictKey = this._keyForUri(evictUri); - const evictDir = URI.joinPath(this._basePath, evictKey); - this._logService.info(`[AgentPluginManager] Evicting plugin: ${evictUri}`); - try { - await this._fileService.del(evictDir, { recursive: true }); - } catch (err) { - this._logService.warn(`[AgentPluginManager] Failed to evict plugin: ${evictUri}`, err); - } - } - } - - // ---- cache persistence -------------------------------------------------- - - private async _ensureCacheLoaded(): Promise { - if (this._cacheLoaded) { - return; - } - this._cacheLoaded = true; - - try { - if (!await this._fileService.exists(this._cachePath)) { - return; - } - const content = await this._fileService.readFile(this._cachePath); - const entries: ICacheEntry[] = JSON.parse(content.value.toString()); - if (!Array.isArray(entries)) { - return; - } - - // Entries are stored in LRU order (oldest first) - for (const entry of entries) { - if (typeof entry.uri === 'string' && typeof entry.nonce === 'string') { - this._cachedNonces.set(entry.uri, entry.nonce); - this._lruOrder.push(entry.uri); - } - } - this._logService.trace(`[AgentPluginManager] Loaded ${entries.length} cache entries from disk`); - } catch (err) { - this._logService.warn('[AgentPluginManager] Failed to load cache from disk', err); - } - } - - private async _persistCache(): Promise { - try { - // Write entries in LRU order (oldest first) - const entries: ICacheEntry[] = []; - for (const uri of this._lruOrder) { - const nonce = this._cachedNonces.get(uri); - if (nonce) { - entries.push({ uri, nonce }); - } - } - await this._fileService.createFolder(this._basePath); - await this._fileService.writeFile(this._cachePath, VSBuffer.fromString(JSON.stringify(entries))); - } catch (err) { - this._logService.warn('[AgentPluginManager] Failed to persist cache to disk', err); - } - } -} diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts deleted file mode 100644 index 6a7dc566..00000000 --- a/src/vs/platform/agentHost/node/agentService.ts +++ /dev/null @@ -1,995 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { decodeBase64, VSBuffer } from '../../../base/common/buffer.js'; -import { toErrorMessage } from '../../../base/common/errorMessage.js'; -import { Emitter } from '../../../base/common/event.js'; -import { Disposable, DisposableStore } from '../../../base/common/lifecycle.js'; -import { ResourceMap } from '../../../base/common/map.js'; -import { equals as objectEquals } from '../../../base/common/objects.js'; -import { observableValue } from '../../../base/common/observable.js'; -import { URI } from '../../../base/common/uri.js'; -import { generateUuid } from '../../../base/common/uuid.js'; -import { FileSystemProviderErrorCode, IFileService, toFileSystemProviderErrorCode } from '../../files/common/files.js'; -import { InstantiationService } from '../../instantiation/common/instantiationService.js'; -import { ServiceCollection } from '../../instantiation/common/serviceCollection.js'; -import { ILogService } from '../../log/common/log.js'; -import { AgentProvider, AgentSession, IAgent, IAgentCreateSessionConfig, IAgentResolveSessionConfigParams, IAgentService, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, AuthenticateParams, AuthenticateResult } from '../common/agentService.js'; -import { ISessionDataService } from '../common/sessionDataService.js'; -import { ActionType, ActionEnvelope, INotification, type IRootConfigChangedAction, type SessionAction, type TerminalAction } from '../common/state/sessionActions.js'; -import type { CreateTerminalParams, ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../common/state/protocol/commands.js'; -import { AhpErrorCodes, AHP_SESSION_NOT_FOUND, ContentEncoding, JSON_RPC_INTERNAL_ERROR, ProtocolError, type DirectoryEntry, type ResourceCopyParams, type ResourceCopyResult, type ResourceDeleteParams, type ResourceDeleteResult, type ResourceListResult, type ResourceMoveParams, type ResourceMoveResult, type ResourceReadResult, type ResourceWriteParams, type ResourceWriteResult, type IStateSnapshot } from '../common/state/sessionProtocol.js'; -import { ResponsePartKind, SessionStatus, ToolCallStatus, ToolResultContentType, parseSubagentSessionUri, readSessionGitState, withSessionGitState, type SessionConfigState, type ISessionFileDiff, type SessionSummary, type ToolResultSubagentContent, type Turn } from '../common/state/sessionState.js'; -import { IProductService } from '../../product/common/productService.js'; -import { AgentConfigurationService, IAgentConfigurationService } from './agentConfigurationService.js'; -import { AgentSideEffects } from './agentSideEffects.js'; -import { AgentHostTerminalManager, type IAgentHostTerminalManager } from './agentHostTerminalManager.js'; -import { ISessionDbUriFields, parseSessionDbUri } from './copilot/fileEditTracker.js'; -import { IGitBlobUriFields, parseGitBlobUri } from './gitDiffContent.js'; -import { AgentHostStateManager } from './agentHostStateManager.js'; -import { IAgentHostGitService } from './agentHostGitService.js'; - -/** - * The agent service implementation that runs inside the agent-host utility - * process. Dispatches to registered {@link IAgent} instances based - * on the provider identifier in the session configuration. - */ -export class AgentService extends Disposable implements IAgentService { - declare readonly _serviceBrand: undefined; - - /** Protocol: fires when state is mutated by an action. */ - private readonly _onDidAction = this._register(new Emitter()); - readonly onDidAction = this._onDidAction.event; - - /** Protocol: fires for ephemeral notifications (sessionAdded/Removed). */ - private readonly _onDidNotification = this._register(new Emitter()); - readonly onDidNotification = this._onDidNotification.event; - - /** Authoritative state manager for the sessions process protocol. */ - private readonly _stateManager: AgentHostStateManager; - - /** Exposes the state manager for co-hosting a WebSocket protocol server. */ - get stateManager(): AgentHostStateManager { return this._stateManager; } - - /** Exposes the configuration service so agent providers can share root config plumbing. */ - get configurationService(): IAgentConfigurationService { return this._configurationService; } - - /** Registered providers keyed by their {@link AgentProvider} id. */ - private readonly _providers = new Map(); - /** Maps each active session URI (toString) to its owning provider. */ - private readonly _sessionToProvider = new Map(); - /** Subscriptions to provider progress events; cleared when providers change. */ - private readonly _providerSubscriptions = this._register(new DisposableStore()); - /** Default provider used when no explicit provider is specified. */ - private _defaultProvider: AgentProvider | undefined; - /** Observable registered agents, drives `root/agentsChanged` via {@link AgentSideEffects}. */ - private readonly _agents = observableValue('agents', []); - /** Shared side-effect handler for action dispatch and session lifecycle. */ - private readonly _sideEffects: AgentSideEffects; - /** Manages PTY-backed terminals for the agent host protocol. */ - private readonly _terminalManager: AgentHostTerminalManager; - private readonly _configurationService: IAgentConfigurationService; - - /** - * Authoritative server-side per-resource subscription refcount, keyed by - * resource URI string and valued by the set of subscribed protocol - * client IDs. Populated by {@link subscribe} (or {@link addSubscriber} - * for handshake fast-paths) and drained by {@link unsubscribe}. When a - * resource's set becomes empty, the resource is dropped from the map and - * {@link _maybeEvictIdleSession} is invoked to release any cached state - * for it. - */ - private readonly _resourceSubscribers = new ResourceMap>(); - - /** Exposes the terminal manager for use by agent providers. */ - get terminalManager(): IAgentHostTerminalManager { return this._terminalManager; } - - constructor( - private readonly _logService: ILogService, - private readonly _fileService: IFileService, - private readonly _sessionDataService: ISessionDataService, - private readonly _productService: IProductService, - private readonly _gitService: IAgentHostGitService, - private readonly _rootConfigResource?: URI, - ) { - super(); - this._logService.info('AgentService initialized'); - this._stateManager = this._register(new AgentHostStateManager(_logService)); - this._register(this._stateManager.onDidEmitEnvelope(e => this._onDidAction.fire(e))); - this._register(this._stateManager.onDidEmitNotification(e => this._onDidNotification.fire(e))); - - // Build a local instantiation scope so downstream components can - // consume {@link IAgentConfigurationService} (and later {@link ILogService}) - // via DI rather than being plumbed plain-class references. - const configurationService: IAgentConfigurationService = this._register(new AgentConfigurationService(this._stateManager, this._logService, this._rootConfigResource)); - this._configurationService = configurationService; - const services = new ServiceCollection( - [ILogService, this._logService], - [IAgentConfigurationService, configurationService], - [IAgentHostGitService, this._gitService], - ); - const instantiationService = this._register(new InstantiationService(services, /*strict*/ true)); - - this._sideEffects = this._register(instantiationService.createInstance(AgentSideEffects, this._stateManager, { - getAgent: session => this._findProviderForSession(session), - sessionDataService: this._sessionDataService, - agents: this._agents, - onTurnComplete: session => { - const workingDirStr = this._stateManager.getSessionState(session)?.summary.workingDirectory; - this._attachGitState(URI.parse(session), workingDirStr ? URI.parse(workingDirStr) : undefined); - }, - })); - - // Terminal management — the terminal manager listens to the state - // manager's action stream and dispatches PTY output back through it. - this._terminalManager = this._register(new AgentHostTerminalManager(this._stateManager, this._logService, this._productService)); - } - - // ---- provider registration ---------------------------------------------- - - registerProvider(provider: IAgent): void { - if (this._providers.has(provider.id)) { - throw new Error(`Agent provider already registered: ${provider.id}`); - } - this._logService.info(`Registering agent provider: ${provider.id}`); - this._providers.set(provider.id, provider); - this._providerSubscriptions.add(this._sideEffects.registerProgressListener(provider)); - if (!this._defaultProvider) { - this._defaultProvider = provider.id; - } - - // Update root state with current agents list - this._updateAgents(); - } - - // ---- auth --------------------------------------------------------------- - - async authenticate(params: AuthenticateParams): Promise { - this._logService.trace(`[AgentService] authenticate called: resource=${params.resource}`); - for (const provider of this._providers.values()) { - const resources = provider.getProtectedResources(); - if (resources.some(r => r.resource === params.resource)) { - const accepted = await provider.authenticate(params.resource, params.token); - if (accepted) { - return { authenticated: true }; - } - } - } - return { authenticated: false }; - } - - // ---- session management ------------------------------------------------- - - async listSessions(): Promise { - this._logService.trace('[AgentService] listSessions called'); - const results = await Promise.all( - [...this._providers.values()].map(p => p.listSessions()) - ); - const flat = results.flat(); - - // Overlay persisted custom titles from per-session databases. - const result = await Promise.all(flat.map(async s => { - try { - const ref = await this._sessionDataService.tryOpenDatabase(s.session); - if (!ref) { - return s; - } - try { - const m = await ref.object.getMetadataObject({ customTitle: true, isRead: true, isArchived: true, isDone: true, diffs: true }); - let updated = s; - if (m.customTitle) { - updated = { ...updated, summary: m.customTitle }; - } - if (m.isRead !== undefined) { - updated = { ...updated, isRead: m.isRead === 'true' }; - } - if (m.isArchived !== undefined) { - updated = { ...updated, isArchived: m.isArchived === 'true' }; - } else if (m.isDone !== undefined) { - updated = { ...updated, isArchived: m.isDone === 'true' }; - } - if (m.diffs) { - try { updated = { ...updated, diffs: JSON.parse(m.diffs) }; } catch { /* ignore malformed */ } - } - return updated; - } finally { - ref.dispose(); - } - } catch (e) { - this._logService.warn(`[AgentService] Failed to read session metadata overlay for ${s.session}`, e); - } - return s; - })); - - // Overlay live session state from the state manager. - // For the title, prefer the state manager's value when it is - // non-empty, so SDK-sourced titles are not overwritten by the - // initial empty placeholder. - const withStatus = result.map(s => { - const liveState = this._stateManager.getSessionState(s.session.toString()); - if (liveState) { - return { - ...s, - summary: liveState.summary.title || s.summary, - status: liveState.summary.status, - activity: liveState.summary.activity, - model: liveState.summary.model ?? s.model, - }; - } - return s; - }); - - this._logService.trace(`[AgentService] listSessions returned ${withStatus.length} sessions`); - return withStatus; - } - - async createSession(config?: IAgentCreateSessionConfig): Promise { - const providerId = config?.provider ?? this._defaultProvider; - const provider = providerId ? this._providers.get(providerId) : undefined; - if (!provider) { - throw new Error(`No agent provider registered for: ${providerId ?? '(none)'}`); - } - - // When forking, build the old→new turn ID mapping before creating the - // session so the agent can use it to remap per-turn data. - if (config?.fork) { - const sourceState = this._stateManager.getSessionState(config.fork.session.toString()); - if (sourceState) { - const sourceTurns = sourceState.turns.slice(0, config.fork.turnIndex + 1); - const turnIdMapping = new Map(); - for (const t of sourceTurns) { - turnIdMapping.set(t.id, generateUuid()); - } - config = { - ...config, - fork: { ...config.fork, turnIdMapping }, - }; - } - } - - // Ensure the command auto-approver is ready before any session events - // can arrive. This makes shell command auto-approval fully synchronous. - // Safe to run in parallel with createSession since no events flow until - // sendMessage() is called. - this._logService.trace(`[AgentService] createSession: initializing auto-approver and creating session...`); - const [, created] = await Promise.all([ - this._sideEffects.initialize(), - provider.createSession(config), - ]); - const session = created.session; - this._logService.trace(`[AgentService] createSession: initialization complete`); - - this._logService.trace(`[AgentService] createSession: provider=${provider.id} model=${config?.model?.id ?? '(default)'}`); - this._sessionToProvider.set(session.toString(), provider.id); - this._logService.trace(`[AgentService] createSession returned: ${session.toString()}`); - - const sessionConfig = await this._resolveCreatedSessionConfig(provider, config); - - // When forking, populate the new session's protocol state with - // the source session's turns so the client sees the forked history. - if (config?.fork) { - const sourceState = this._stateManager.getSessionState(config.fork.session.toString()); - let sourceTurns: Turn[] = []; - if (sourceState && config.fork.turnIdMapping) { - sourceTurns = sourceState.turns.slice(0, config.fork.turnIndex + 1) - .map(t => ({ ...t, id: config!.fork!.turnIdMapping!.get(t.id) ?? generateUuid() })); - } - - const summary: SessionSummary = { - resource: session.toString(), - provider: provider.id, - title: sourceState?.summary.title ?? 'Forked Session', - status: SessionStatus.Idle, - createdAt: Date.now(), - modifiedAt: Date.now(), - ...(created.project ? { project: { uri: created.project.uri.toString(), displayName: created.project.displayName } } : {}), - model: config?.model, - workingDirectory: (created.workingDirectory ?? config.workingDirectory)?.toString(), - }; - const state = this._stateManager.createSession(summary); - state.config = sessionConfig; - state.turns = sourceTurns; - state.activeClient = config.activeClient; - } else { - // Create empty state for new sessions - const summary: SessionSummary = { - resource: session.toString(), - provider: provider.id, - title: '', - status: SessionStatus.Idle, - createdAt: Date.now(), - modifiedAt: Date.now(), - ...(created.project ? { project: { uri: created.project.uri.toString(), displayName: created.project.displayName } } : {}), - model: config?.model, - workingDirectory: (created.workingDirectory ?? config?.workingDirectory)?.toString(), - }; - const state = this._stateManager.createSession(summary); - state.config = sessionConfig; - state.activeClient = config?.activeClient; - } - // Persist initial config values so a subsequent `restoreSession` can - // re-hydrate them. We persist the full resolved values (not just the - // user's input) so clients can render them on restore without having - // to re-resolve. Mid-session changes are persisted by `AgentSideEffects` - // when handling `SessionConfigChanged`. - if (sessionConfig?.values && Object.keys(sessionConfig.values).length > 0) { - this._persistConfigValues(session, sessionConfig.values); - } - this._stateManager.dispatchServerAction({ type: ActionType.SessionReady, session: session.toString() }); - - // Lazily compute git state for sessions with a working directory; - // attaches under `state._meta.git` once ready. - this._attachGitState(session, created.workingDirectory ?? config?.workingDirectory); - - return session; - } - - /** - * Fire-and-forget probe that resolves the session's git state for its - * working directory (if any) and merges it into `state._meta.git` via - * the state manager. Failures are logged; sessions simply remain without - * git state. - */ - private _attachGitState(session: URI, workingDirectory: URI | undefined): void { - if (!workingDirectory) { - return; - } - this._gitService.getSessionGitState(workingDirectory).then( - gitState => { - if (!gitState) { - return; - } - const sessionKey = session.toString(); - const current = this._stateManager.getSessionState(sessionKey)?._meta; - // Skip the action if the computed git state hasn't changed; this is - // called after every turn, so deduping avoids needless action churn. - if (objectEquals(readSessionGitState(current), gitState)) { - return; - } - const next = withSessionGitState(current, gitState); - this._stateManager.setSessionMeta(sessionKey, next); - }, - e => { - this._logService.warn(`[AgentService] Failed to compute git state for ${session}`, e); - }, - ); - } - - private _persistConfigValues(session: URI, values: Record): void { - let ref; - try { - ref = this._sessionDataService.openDatabase(session); - } catch (err) { - this._logService.warn(`[AgentService] Failed to open session database to persist configValues for ${session.toString()}: ${toErrorMessage(err)}`); - return; - } - ref.object.setMetadata('configValues', JSON.stringify(values)).catch(err => { - this._logService.warn(`[AgentService] Failed to persist configValues for ${session.toString()}: ${toErrorMessage(err)}`); - }).finally(() => { - ref.dispose(); - }); - } - - private async _resolveCreatedSessionConfig(provider: IAgent, config: IAgentCreateSessionConfig | undefined): Promise { - if (!config?.config && !config?.workingDirectory) { - return undefined; - } - try { - const resolved = await provider.resolveSessionConfig({ - provider: provider.id, - workingDirectory: config.workingDirectory, - config: config.config, - }); - return { schema: resolved.schema, values: resolved.values }; - } catch (error) { - this._logService.error(`[AgentService] Failed to resolve created session config for provider ${provider.id}`, error); - return config.config ? { schema: { type: 'object', properties: {} }, values: config.config } : undefined; - } - } - - async resolveSessionConfig(params: IAgentResolveSessionConfigParams): Promise { - const providerId = params.provider ?? this._defaultProvider; - const provider = providerId ? this._providers.get(providerId) : undefined; - if (!provider) { - throw new Error(`No agent provider registered for: ${providerId ?? '(none)'}`); - } - return provider.resolveSessionConfig(params); - } - - async sessionConfigCompletions(params: IAgentSessionConfigCompletionsParams): Promise { - const providerId = params.provider ?? this._defaultProvider; - const provider = providerId ? this._providers.get(providerId) : undefined; - if (!provider) { - throw new Error(`No agent provider registered for: ${providerId ?? '(none)'}`); - } - return provider.sessionConfigCompletions(params); - } - - async disposeSession(session: URI): Promise { - this._logService.trace(`[AgentService] disposeSession: ${session.toString()}`); - const provider = this._findProviderForSession(session); - if (provider) { - await provider.disposeSession(session); - this._sessionToProvider.delete(session.toString()); - } - // Remove all subagent sessions for this parent - this._sideEffects.removeSubagentSessions(session.toString()); - this._stateManager.deleteSession(session.toString()); - } - - // ---- Protocol methods --------------------------------------------------- - - async createTerminal(params: CreateTerminalParams): Promise { - await this._terminalManager.createTerminal(params); - } - - async disposeTerminal(terminal: URI): Promise { - this._terminalManager.disposeTerminal(terminal.toString()); - } - - async subscribe(resource: URI, clientId: string): Promise { - this._logService.trace(`[AgentService] subscribe: ${resource.toString()}`); - const resourceStr = resource.toString(); - // Register the subscriber up front so a concurrent unsubscribe cannot - // evict the session state while we are awaiting restore. On any failure - // path below we must roll the registration back, otherwise the leaked - // refcount would permanently pin (or block eviction of) the resource. - this.addSubscriber(resource, clientId); - try { - // Check for terminal state - const terminalState = this._terminalManager.getTerminalState(resourceStr); - if (terminalState) { - return { resource: resourceStr, state: terminalState, fromSeq: this._stateManager.serverSeq }; - } - - let snapshot = this._stateManager.getSnapshot(resourceStr); - if (!snapshot) { - // Try subagent restore before regular session restore - const parsed = parseSubagentSessionUri(resourceStr); - if (parsed) { - await this._restoreSubagentSession(resourceStr, parsed.parentSession, parsed.toolCallId); - } else { - await this.restoreSession(resource); - } - snapshot = this._stateManager.getSnapshot(resourceStr); - } - if (!snapshot) { - throw new Error(`Cannot subscribe to unknown resource: ${resourceStr}`); - } - - // Ensure git state has been computed for this session. When the snapshot - // already existed (e.g. seeded by list query, or restored earlier), the - // restore path that normally calls `_attachGitState` is skipped — so - // trigger it lazily here for the first subscriber. `_attachGitState` - // is async and updates `_meta.git` once ready, which clients see via - // the normal state-update stream. - const sessionState = this._stateManager.getSessionState(resourceStr); - if (sessionState && readSessionGitState(sessionState._meta) === undefined) { - const wd = sessionState.summary?.workingDirectory; - this._attachGitState(resource, wd ? URI.parse(wd) : undefined); - } - - return snapshot; - } catch (err) { - this.unsubscribe(resource, clientId); - throw err; - } - } - - addSubscriber(resource: URI, clientId: string): void { - let set = this._resourceSubscribers.get(resource); - if (!set) { - set = new Set(); - this._resourceSubscribers.set(resource, set); - } - set.add(clientId); - } - - unsubscribe(resource: URI, clientId: string): void { - const set = this._resourceSubscribers.get(resource); - if (!set) { - return; - } - set.delete(clientId); - if (set.size > 0) { - return; - } - this._resourceSubscribers.delete(resource); - this._maybeEvictIdleSession(resource); - } - - /** - * If `resource` names an idle session and no client is still subscribed to - * it (or, for a subagent URI, no sibling subagent under the same parent is - * still subscribed), drop its cached state from the state manager. Subagent - * URIs evict the parent session entry; the parent owns the materialized - * turn tree that backs every subagent view. The next subscribe will - * rehydrate the session via {@link restoreSession}. - */ - private _maybeEvictIdleSession(resource: URI): void { - const key = resource.toString(); - if (this._resourceSubscribers.has(resource)) { - return; - } - const parsed = parseSubagentSessionUri(key); - let evictionTarget: string; - if (parsed) { - evictionTarget = parsed.parentSession; - if (this._resourceSubscribers.has(URI.parse(evictionTarget))) { - return; - } - const parentPrefix = parsed.parentSession + '/subagent/'; - for (const subscribedUri of this._resourceSubscribers.keys()) { - if (subscribedUri.toString().startsWith(parentPrefix)) { - return; - } - } - } else { - evictionTarget = key; - const subagentPrefix = key + '/subagent/'; - for (const subscribedUri of this._resourceSubscribers.keys()) { - if (subscribedUri.toString().startsWith(subagentPrefix)) { - return; - } - } - } - const targetState = this._stateManager.getSessionState(evictionTarget); - if (!targetState || targetState.activeTurn !== undefined) { - return; - } - this._logService.trace(`[AgentService] Evicting idle session: ${evictionTarget} (triggered by unsubscribe of ${key})`); - // Also evict any sibling subagent entries cached under the parent: their - // authoritative state is the parent's turn tree, and dropping the parent - // would leave them orphaned. - const subagentPrefix = evictionTarget + '/subagent/'; - for (const cachedKey of this._stateManager.getSessionUrisWithPrefix(subagentPrefix)) { - this._stateManager.removeSession(cachedKey); - } - this._stateManager.removeSession(evictionTarget); - } - - dispatchAction(action: SessionAction | TerminalAction | IRootConfigChangedAction, clientId: string, clientSeq: number): void { - this._logService.trace(`[AgentService] dispatchAction: type=${action.type}, clientId=${clientId}, clientSeq=${clientSeq}`, action); - - const origin = { clientId, clientSeq }; - this._stateManager.dispatchClientAction(action, origin); - if (action.type === ActionType.RootConfigChanged) { - this._configurationService.persistRootConfig(); - } - this._sideEffects.handleAction(action); - } - - async resourceList(uri: URI): Promise { - let stat; - try { - stat = await this._fileService.resolve(uri); - } catch { - throw new ProtocolError(AhpErrorCodes.NotFound, `Directory not found: ${uri.toString()}`); - } - - if (!stat.isDirectory) { - throw new ProtocolError(AhpErrorCodes.NotFound, `Not a directory: ${uri.toString()}`); - } - - const entries: DirectoryEntry[] = (stat.children ?? []).map(child => ({ - name: child.name, - type: child.isDirectory ? 'directory' : 'file', - })); - return { entries }; - } - - async restoreSession(session: URI): Promise { - const sessionStr = session.toString(); - - // Already in state manager - nothing to do. - if (this._stateManager.getSessionState(sessionStr)) { - return; - } - - const agent = this._findProviderForSession(session); - if (!agent) { - throw new ProtocolError(AHP_SESSION_NOT_FOUND, `No agent for session: ${sessionStr}`); - } - - // Verify the session actually exists on the backend to avoid - // creating phantom sessions for made-up URIs. - let allSessions; - try { - allSessions = await agent.listSessions(); - } catch (err) { - if (err instanceof ProtocolError) { - throw err; - } - const message = err instanceof Error ? err.message : String(err); - throw new ProtocolError(JSON_RPC_INTERNAL_ERROR, `Failed to list sessions for ${sessionStr}: ${message}`); - } - const meta = allSessions.find(s => s.session.toString() === sessionStr); - if (!meta) { - throw new ProtocolError(AHP_SESSION_NOT_FOUND, `Session not found on backend: ${sessionStr}`); - } - - let turns: readonly Turn[]; - try { - turns = await agent.getSessionMessages(session); - } catch (err) { - if (err instanceof ProtocolError) { - throw err; - } - const message = err instanceof Error ? err.message : String(err); - throw new ProtocolError(JSON_RPC_INTERNAL_ERROR, `Failed to restore session ${sessionStr}: ${message}`); - } - - // Check for persisted metadata in the session database - let title = meta.summary ?? 'Session'; - let isRead: boolean | undefined; - let isArchived: boolean | undefined; - let diffs: ISessionFileDiff[] | undefined; - let persistedConfigValues: Record | undefined; - const ref = this._sessionDataService.tryOpenDatabase?.(session); - if (ref) { - try { - const db = await ref; - if (db) { - try { - const m = await db.object.getMetadataObject({ customTitle: true, isRead: true, isArchived: true, isDone: true, diffs: true, configValues: true }); - if (m.customTitle) { - title = m.customTitle; - } - if (m.isRead !== undefined) { - isRead = m.isRead === 'true'; - } - if (m.isArchived !== undefined) { - isArchived = m.isArchived === 'true'; - } else if (m.isDone !== undefined) { - isArchived = m.isDone === 'true'; - } - if (m.diffs) { - try { diffs = JSON.parse(m.diffs); } catch { /* ignore malformed */ } - } - if (m.configValues) { - try { - persistedConfigValues = JSON.parse(m.configValues); - } catch (err) { - this._logService.warn(`[AgentService] Failed to parse persisted configValues for ${sessionStr}: ${toErrorMessage(err)}`); - } - } - } finally { - db.dispose(); - } - } - } catch { - // Best-effort: fall back to agent-provided metadata - } - } - - // Encode isRead/isArchived as status bitmask flags - let status: SessionStatus = SessionStatus.Idle; - if (isRead) { - status |= SessionStatus.IsRead; - } - if (isArchived) { - status |= SessionStatus.IsArchived; - } - - const summary: SessionSummary = { - resource: sessionStr, - provider: agent.id, - title, - status, - createdAt: meta.startTime, - modifiedAt: meta.modifiedTime, - ...(meta.project ? { project: { uri: meta.project.uri.toString(), displayName: meta.project.displayName } } : {}), - model: meta.model, - workingDirectory: meta.workingDirectory?.toString(), - diffs, - }; - - this._stateManager.restoreSession(summary, [...turns]); - - // Restore persisted `_meta` (e.g. git state) onto the new session - // state. This dispatches a SessionMetaChanged action. - if (meta._meta) { - this._stateManager.setSessionMeta(sessionStr, meta._meta); - } - - // Resolve the session config so clients (e.g. the running-session - // auto-approve picker) can render session-mutable properties for - // sessions that were not created in the current process lifetime. - // Overlay any values the user previously selected (persisted via - // `SessionConfigChanged`) on top of the provider's resolved defaults. - const restoredConfig = await this._resolveCreatedSessionConfig(agent, { - workingDirectory: meta.workingDirectory, - config: persistedConfigValues, - }); - if (restoredConfig) { - const restoredState = this._stateManager.getSessionState(sessionStr); - if (restoredState) { - restoredState.config = restoredConfig; - } - } - - this._logService.info(`[AgentService] Restored session ${sessionStr} with ${turns.length} turns`); - - // Lazily compute git state for sessions with a working directory; - // attaches under `state._meta.git` once ready. - this._attachGitState(session, meta.workingDirectory); - } - - async resourceRead(uri: URI): Promise { - // Handle session-db: URIs that reference file-edit content stored - // in a per-session SQLite database. - const dbFields = parseSessionDbUri(uri.toString()); - if (dbFields) { - return this._fetchSessionDbContent(dbFields); - } - - // Handle git-blob: URIs that reference file content at a specific - // git commit (the merge-base used as diff baseline). The URI - // encodes the session it belongs to so we can find the right - // working directory to run `git show` from. - const blobFields = parseGitBlobUri(uri.toString()); - if (blobFields) { - return this._fetchGitBlobContent(blobFields); - } - - try { - const content = await this._fileService.readFile(uri); - return { - data: content.value.toString(), - encoding: ContentEncoding.Utf8, - contentType: 'text/plain', - }; - } catch (_e) { - throw new ProtocolError(AhpErrorCodes.NotFound, `Content not found: ${uri.toString()}`); - } - } - - async resourceWrite(params: ResourceWriteParams): Promise { - const fileUri = typeof params.uri === 'string' ? URI.parse(params.uri) : URI.revive(params.uri); - let content: VSBuffer; - if (params.encoding === ContentEncoding.Base64) { - content = decodeBase64(params.data); - } else { - content = VSBuffer.fromString(params.data); - } - try { - if (params.createOnly) { - await this._fileService.createFile(fileUri, content, { overwrite: false }); - } else { - await this._fileService.writeFile(fileUri, content); - } - return {}; - } catch (e) { - const code = toFileSystemProviderErrorCode(e as Error); - if (code === FileSystemProviderErrorCode.FileExists) { - throw new ProtocolError(AhpErrorCodes.AlreadyExists, `File already exists: ${fileUri.toString()}`); - } - if (code === FileSystemProviderErrorCode.NoPermissions) { - throw new ProtocolError(AhpErrorCodes.PermissionDenied, `Permission denied: ${fileUri.toString()}`); - } - throw new ProtocolError(AhpErrorCodes.NotFound, `Failed to write file: ${fileUri.toString()}`); - } - } - - async resourceCopy(params: ResourceCopyParams): Promise { - const source = URI.parse(params.source); - const destination = URI.parse(params.destination); - try { - await this._fileService.copy(source, destination, !params.failIfExists); - return {}; - } catch (e) { - const code = toFileSystemProviderErrorCode(e as Error); - if (code === FileSystemProviderErrorCode.FileExists) { - throw new ProtocolError(AhpErrorCodes.AlreadyExists, `Destination already exists: ${destination.toString()}`); - } - if (code === FileSystemProviderErrorCode.NoPermissions) { - throw new ProtocolError(AhpErrorCodes.PermissionDenied, `Permission denied: ${source.toString()}`); - } - throw new ProtocolError(AhpErrorCodes.NotFound, `Source not found: ${source.toString()}`); - } - } - - async resourceDelete(params: ResourceDeleteParams): Promise { - const fileUri = URI.parse(params.uri); - try { - await this._fileService.del(fileUri, { recursive: params.recursive }); - return {}; - } catch (e) { - const code = toFileSystemProviderErrorCode(e as Error); - if (code === FileSystemProviderErrorCode.NoPermissions) { - throw new ProtocolError(AhpErrorCodes.PermissionDenied, `Permission denied: ${fileUri.toString()}`); - } - throw new ProtocolError(AhpErrorCodes.NotFound, `Resource not found: ${fileUri.toString()}`); - } - } - - async resourceMove(params: ResourceMoveParams): Promise { - const source = URI.parse(params.source); - const destination = URI.parse(params.destination); - try { - await this._fileService.move(source, destination, !params.failIfExists); - return {}; - } catch (e) { - const code = toFileSystemProviderErrorCode(e as Error); - if (code === FileSystemProviderErrorCode.FileExists) { - throw new ProtocolError(AhpErrorCodes.AlreadyExists, `Destination already exists: ${destination.toString()}`); - } - if (code === FileSystemProviderErrorCode.NoPermissions) { - throw new ProtocolError(AhpErrorCodes.PermissionDenied, `Permission denied: ${source.toString()}`); - } - throw new ProtocolError(AhpErrorCodes.NotFound, `Source not found: ${source.toString()}`); - } - } - - async shutdown(): Promise { - this._logService.info('AgentService: shutting down all providers...'); - const promises: Promise[] = []; - for (const provider of this._providers.values()) { - promises.push(provider.shutdown()); - } - await Promise.all(promises); - this._sessionToProvider.clear(); - } - - // ---- helpers ------------------------------------------------------------ - - private async _fetchSessionDbContent(fields: ISessionDbUriFields): Promise { - const sessionUri = URI.parse(fields.sessionUri); - const ref = this._sessionDataService.openDatabase(sessionUri); - try { - const content = await ref.object.readFileEditContent(fields.toolCallId, fields.filePath); - if (!content) { - throw new ProtocolError(AhpErrorCodes.NotFound, `File edit not found: toolCallId=${fields.toolCallId}, filePath=${fields.filePath}`); - } - const bytes = fields.part === 'before' ? content.beforeContent : content.afterContent; - if (!bytes) { - throw new ProtocolError(AhpErrorCodes.NotFound, `No ${fields.part} content for: toolCallId=${fields.toolCallId}, filePath=${fields.filePath}`); - } - return { - data: new TextDecoder().decode(bytes), - encoding: ContentEncoding.Utf8, - contentType: 'text/plain', - }; - } finally { - ref.dispose(); - } - } - - private async _fetchGitBlobContent(fields: IGitBlobUriFields): Promise { - if (!this._gitService) { - throw new ProtocolError(AhpErrorCodes.NotFound, `git service unavailable for: ${fields.repoRelativePath}`); - } - const workingDirectory = this._stateManager.getSessionState(fields.sessionUri)?.summary.workingDirectory; - if (!workingDirectory) { - throw new ProtocolError(AhpErrorCodes.NotFound, `Session has no working directory for git-blob URI: ${fields.sessionUri}`); - } - const blob = await this._gitService.showBlob(URI.parse(workingDirectory), fields.sha, fields.repoRelativePath); - if (!blob) { - throw new ProtocolError(AhpErrorCodes.NotFound, `git blob not found: ${fields.sha}:${fields.repoRelativePath}`); - } - return { - data: blob.toString(), - encoding: ContentEncoding.Utf8, - contentType: 'text/plain', - }; - } - - /** - * Restores a subagent session from its parent session's event history. - * Loads the parent's raw messages, filters for events belonging to - * the subagent (by `parentToolCallId`), and builds the child session's - * turns from those events. - */ - private async _restoreSubagentSession(subagentUri: string, parentSession: string, toolCallId: string): Promise { - // Ensure the parent session is loaded first - const parentUri = URI.parse(parentSession); - if (!this._stateManager.getSessionState(parentSession)) { - try { - await this.restoreSession(parentUri); - } catch { - this._logService.warn(`[AgentService] Cannot restore parent session for subagent: ${parentSession}`); - return; - } - } - - const parentState = this._stateManager.getSessionState(parentSession); - if (!parentState) { - return; - } - - // Search completed turns and active turn for the subagent content metadata - const allTurns = [...parentState.turns]; - if (parentState.activeTurn) { - allTurns.push(parentState.activeTurn as Turn); - } - - let subagentContent: ToolResultSubagentContent | undefined; - for (const turn of allTurns) { - for (const part of turn.responseParts) { - if (part.kind === ResponsePartKind.ToolCall) { - const tc = part.toolCall; - // Check both completed and running tool calls — running - // tool calls receive subagent content via ContentChanged - const content = tc.status === ToolCallStatus.Completed - ? tc.content - : (tc.status === ToolCallStatus.Running ? tc.content : undefined); - if (content) { - for (const c of content) { - if (c.type === ToolResultContentType.Subagent && c.resource === subagentUri) { - subagentContent = c; - break; - } - } - } - } - } - if (subagentContent) { - break; - } - } - - // Load the subagent's turns from the agent (which knows how to - // extract them from the parent session's event log). - let childTurns: readonly Turn[] = []; - const agent = this._findProviderForSession(parentUri); - if (agent) { - try { - childTurns = await agent.getSessionMessages(URI.parse(subagentUri)); - } catch (err) { - this._logService.warn(`[AgentService] Failed to load subagent turns for ${subagentUri}`, err); - } - } - - // Use metadata from subagent content if available, otherwise synthesize - const title = subagentContent?.title ?? 'Subagent'; - - this._stateManager.restoreSession( - { - resource: subagentUri, - provider: 'subagent', - title, - status: SessionStatus.Idle, - createdAt: Date.now(), - modifiedAt: Date.now(), - ...(parentState?.summary.project ? { project: parentState.summary.project } : {}), - }, - [...childTurns], - ); - this._logService.info(`[AgentService] Restored subagent session: ${subagentUri} with ${childTurns.length} turn(s)`); - } - - private _findProviderForSession(session: URI | string): IAgent | undefined { - const key = typeof session === 'string' ? session : session.toString(); - const providerId = this._sessionToProvider.get(key); - if (providerId) { - return this._providers.get(providerId); - } - const schemeProvider = AgentSession.provider(session); - if (schemeProvider) { - return this._providers.get(schemeProvider); - } - // Fallback: try the default provider (handles resumed sessions not yet tracked) - if (this._defaultProvider) { - return this._providers.get(this._defaultProvider); - } - return undefined; - } - - /** - * Sets the agents observable to trigger model re-fetch and - * `root/agentsChanged` via the autorun in {@link AgentSideEffects}. - */ - private _updateAgents(): void { - this._agents.set([...this._providers.values()], undefined); - } - - override dispose(): void { - for (const provider of this._providers.values()) { - provider.dispose(); - } - this._providers.clear(); - super.dispose(); - } -} diff --git a/src/vs/platform/agentHost/node/agentSideEffects.ts b/src/vs/platform/agentHost/node/agentSideEffects.ts deleted file mode 100644 index b172fbbc..00000000 --- a/src/vs/platform/agentHost/node/agentSideEffects.ts +++ /dev/null @@ -1,1063 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { disposableTimeout, SequencerByKey } from '../../../base/common/async.js'; -import { Disposable, DisposableMap, DisposableStore, IDisposable } from '../../../base/common/lifecycle.js'; -import { equals } from '../../../base/common/objects.js'; -import { autorun, IObservable, IReader } from '../../../base/common/observable.js'; -import { hasKey } from '../../../base/common/types.js'; -import { URI } from '../../../base/common/uri.js'; -import { generateUuid } from '../../../base/common/uuid.js'; -import { ILogService } from '../../log/common/log.js'; -import { IInstantiationService } from '../../instantiation/common/instantiation.js'; -import { AgentSignal, IAgent, IAgentAttachment, IAgentToolPendingConfirmationSignal } from '../common/agentService.js'; -import { IDiffComputeService } from '../common/diffComputeService.js'; -import { ISessionDatabase, ISessionDataService } from '../common/sessionDataService.js'; -import type { AgentInfo } from '../common/state/protocol/state.js'; -import { ActionType, isSessionAction, StateAction, type SessionToolCallCompleteAction } from '../common/state/sessionActions.js'; -import { - PendingMessageKind, - ResponsePartKind, - SessionStatus, - ToolCallStatus, - ToolResultContentType, - buildSubagentSessionUri, - getToolFileEdits, - type SessionState, - type ToolResultContent, - type ISessionFileDiff, - type URI as ProtocolURI, -} from '../common/state/sessionState.js'; -import { AgentHostStateManager } from './agentHostStateManager.js'; -import { IAgentHostGitService, META_DIFF_BASE_BRANCH } from './agentHostGitService.js'; -import { NodeWorkerDiffComputeService } from './diffComputeService.js'; -import { computeSessionDiffs, type IIncrementalDiffOptions } from './sessionDiffAggregator.js'; -import { SessionPermissionManager } from './sessionPermissions.js'; - -/** - * Options for constructing an {@link AgentSideEffects} instance. - */ -export interface IAgentSideEffectsOptions { - /** Resolve the agent responsible for a given session URI. */ - readonly getAgent: (session: ProtocolURI) => IAgent | undefined; - /** Observable set of registered agents. Triggers `root/agentsChanged` when it changes. */ - readonly agents: IObservable; - /** Session data service for cleaning up per-session data on disposal. */ - readonly sessionDataService: ISessionDataService; - /** - * Called after each top-level session turn completes so git state can be - * refreshed and published via `SessionMetaChanged`. Subagent turns are - * excluded — only the parent session URI is passed. - */ - readonly onTurnComplete: (session: ProtocolURI) => void; -} - -/** A signal that was deferred because its subagent session does not exist yet. */ -interface IPendingSubagentSignal { - readonly signal: AgentSignal; - readonly agent: IAgent; -} - -/** - * Shared implementation of agent side-effect handling. - * - * Routes client-dispatched actions to the correct agent backend, - * restores sessions from previous lifetimes, handles filesystem - * operations (browse/fetch/write), tracks pending permission requests, - * and wires up agent progress events to the state manager. - * - * Session create/dispose/list and auth are handled by {@link AgentService}. - */ -export class AgentSideEffects extends Disposable { - - /** Maps tool call IDs to the agent that owns them, for routing confirmations. */ - private readonly _toolCallAgents = new Map(); - /** Shared diff compute service for calculating line-level diffs in a worker thread. */ - private readonly _diffComputeService: IDiffComputeService; - /** Serializes per-session diff computations to avoid races with stale previousDiffs. */ - private readonly _diffComputationSequencer = new SequencerByKey(); - private _lastAgentInfos: readonly AgentInfo[] = []; - /** Per-session debounce timers for mid-turn diff computation. */ - private readonly _debouncedDiffTimers = this._register(new DisposableMap()); - private static readonly _DIFF_DEBOUNCE_MS = 5000; - - private readonly _permissionManager: SessionPermissionManager; - - /** - * Maps `parentSession:toolCallId` → subagent session URI. - * Used to route signals with `parentToolCallId` to the correct subagent. - */ - private readonly _subagentSessions = new Map(); - - /** - * Buffers signals whose `parentToolCallId` references a subagent - * whose `subagent_started` signal has not yet been processed. The SDK is - * not strict about ordering: an inner `tool_start` can arrive before the - * `subagent_started` that creates the child session. Without buffering, - * those signals would be dispatched against the parent session and the - * UI would render the inner tool calls flat at the top level rather than - * grouping them under the subagent. Drained by `_handleSubagentStarted`. - * - * Key: `${parentSession}:${parentToolCallId}`. - */ - private readonly _pendingSubagentSignals = new Map(); - - constructor( - private readonly _stateManager: AgentHostStateManager, - private readonly _options: IAgentSideEffectsOptions, - @IInstantiationService instantiationService: IInstantiationService, - @ILogService private readonly _logService: ILogService, - @IAgentHostGitService private readonly _gitService: IAgentHostGitService, - ) { - super(); - this._diffComputeService = this._register(new NodeWorkerDiffComputeService(this._logService)); - this._permissionManager = this._register(instantiationService.createInstance(SessionPermissionManager, this._stateManager)); - - // Whenever the agents observable changes, publish to root state. - this._register(autorun(reader => { - const agents = this._options.agents.read(reader); - this._publishAgentInfos(agents, reader); - })); - - // Server-dispatched SessionToolCallComplete actions (e.g. from - // the disconnect timeout in ProtocolServerHandler) bypass - // handleAction, so the agent's SDK deferred never resolves. - // Listen for these envelopes and notify the agent directly. - this._register(this._stateManager.onDidEmitEnvelope(envelope => { - if (!envelope.origin && envelope.action.type === ActionType.SessionToolCallComplete) { - const action = envelope.action; - const agent = this._options.getAgent(action.session); - agent?.onClientToolCallComplete(URI.parse(action.session), action.toolCallId, action.result); - } - })); - } - - /** - * Publishes agent descriptors using the last known model lists. - */ - private _publishAgentInfos(agents: readonly IAgent[], reader?: IReader): void { - const infos: AgentInfo[] = agents.map(a => { - const d = a.getDescriptor(); - const protectedResources = a.getProtectedResources(); - const models = reader ? a.models.read(reader) : a.models.get(); - const customizations = a.getCustomizations?.(); - return { - provider: d.provider, displayName: d.displayName, description: d.description, models: models.map(m => ({ - id: m.id, - provider: m.provider, - name: m.name, - maxContextWindow: m.maxContextWindow, - supportsVision: m.supportsVision, - policyState: m.policyState, - configSchema: m.configSchema, - })), - customizations: customizations?.length ? [...customizations] : undefined, - protectedResources: protectedResources.length > 0 ? protectedResources : undefined, - }; - }); - if (equals(this._lastAgentInfos, infos)) { - return; - } - this._lastAgentInfos = infos; - this._stateManager.dispatchServerAction({ type: ActionType.RootAgentsChanged, agents: infos }); - } - - private async _publishSessionCustomizations(agent: IAgent, session: ProtocolURI): Promise { - if (!agent.getSessionCustomizations) { - return; - } - - const customizations = await agent.getSessionCustomizations(URI.parse(session)); - this._stateManager.dispatchServerAction({ - type: ActionType.SessionCustomizationsChanged, - session, - customizations: [...customizations], - }); - } - - private _publishSessionCustomizationsSoon(agent: IAgent, session: ProtocolURI): void { - void this._publishSessionCustomizations(agent, session).catch(err => { - this._logService.error('[AgentSideEffects] getSessionCustomizations failed', err); - }); - } - - private _publishSessionCustomizationsForAgent(agent: IAgent): void { - for (const session of this._stateManager.getSessionUris()) { - if (this._options.getAgent(session) === agent) { - this._publishSessionCustomizationsSoon(agent, session); - } - } - } - - private _publishAllSessionCustomizations(): void { - for (const session of this._stateManager.getSessionUris()) { - const agent = this._options.getAgent(session); - if (agent) { - this._publishSessionCustomizationsSoon(agent, session); - } - } - } - - // ---- Initialization ---------------------------------------------------- - - /** - * Initializes async resources (tree-sitter WASM) used for command - * auto-approval. Await this before any session events can arrive to - * guarantee that auto-approval checks are fully synchronous. - */ - initialize(): Promise { - return this._permissionManager.initialize(); - } - - // ---- Agent registration ------------------------------------------------- - - /** - * Registers a progress-signal listener on the given agent so that - * {@link AgentSignal}s are routed/dispatched through the state manager. - * Returns a disposable that removes the listener. - */ - registerProgressListener(agent: IAgent): IDisposable { - const disposables = new DisposableStore(); - disposables.add(agent.onDidSessionProgress(signal => { - this._handleAgentSignal(agent, signal); - })); - if (agent.onDidCustomizationsChange) { - disposables.add(agent.onDidCustomizationsChange(() => { - this._publishAgentInfos(this._options.agents.get()); - this._publishSessionCustomizationsForAgent(agent); - })); - } - return disposables; - } - - /** - * Routes a single signal from `agent` to the correct session. - * - * Action signals with a `parentToolCallId` are routed to the matching - * subagent session. If the subagent session does not exist yet (the SDK - * can emit an inner `tool_start` before its `subagent_started`), the - * signal is buffered in {@link _pendingSubagentSignals} and replayed - * once the `subagent_started` arrives. - */ - private _handleAgentSignal(agent: IAgent, signal: AgentSignal): void { - const sessionKey = signal.session.toString(); - - // Track tool calls so handleAction can route confirmations. Defer - // registration for inner subagent tool calls until we know which - // subagent session they belong to — otherwise we'd register them - // under the parent session key and a later `pending_confirmation` - // (which lacks - // `parentToolCallId`) could be routed against the wrong session. - if (signal.kind === 'action' - && signal.action.type === ActionType.SessionToolCallStart - && !signal.parentToolCallId - ) { - this._toolCallAgents.set(`${sessionKey}:${signal.action.toolCallId}`, agent.id); - } - - if (signal.kind === 'subagent_started') { - this._handleSubagentStarted(sessionKey, signal.toolCallId, signal.agentName, signal.agentDisplayName, signal.agentDescription); - this._drainPendingSubagentSignals(sessionKey, signal.toolCallId); - return; - } - - if (signal.kind === 'steering_consumed') { - this._stateManager.dispatchServerAction({ - type: ActionType.SessionPendingMessageRemoved, - session: sessionKey, - kind: PendingMessageKind.Steering, - id: signal.id, - }); - return; - } - - // Route signals with parentToolCallId to the subagent session. - // Both action signals and pending_confirmation signals can carry - // a parentToolCallId — for client tools inside a subagent the - // permission flow fires `pending_confirmation` for an inner tool - // call, and that signal must be routed to the subagent session - // (otherwise the resulting SessionToolCallReady would land on the - // parent session, which has no matching SessionToolCallStart). - const parentToolCallId = signal.kind === 'action' || signal.kind === 'pending_confirmation' - ? signal.parentToolCallId - : undefined; - if (parentToolCallId) { - const subagentKey = `${sessionKey}:${parentToolCallId}`; - const subagentSession = this._subagentSessions.get(subagentKey); - if (subagentSession) { - // Track tool calls in subagent context for confirmation routing. - if (signal.kind === 'action' && signal.action.type === ActionType.SessionToolCallStart) { - this._toolCallAgents.set(`${subagentSession}:${signal.action.toolCallId}`, agent.id); - } - const subTurnId = this._stateManager.getActiveTurnId(subagentSession); - if (subTurnId) { - this._dispatchActionForSession(signal, subagentSession, subTurnId, agent); - } - return; - } - - // Subagent session does not exist yet — buffer the signal so we can - // replay it after `subagent_started` arrives. - this._logService.trace(`[AgentSideEffects] Buffering ${this._describeSignal(signal)} for pending subagent ${subagentKey}`); - let buffer = this._pendingSubagentSignals.get(subagentKey); - if (!buffer) { - buffer = []; - this._pendingSubagentSignals.set(subagentKey, buffer); - } - buffer.push({ signal, agent }); - return; - } - - // Route pending_confirmation signals for tools inside subagent sessions - // (legacy path for signals without an explicit parentToolCallId — the - // tool was previously registered under its subagent session key in - // _toolCallAgents). - if (signal.kind === 'pending_confirmation') { - const subagentSession = this._findSubagentSessionForToolCall(sessionKey, signal.state.toolCallId); - if (subagentSession) { - const subTurnId = this._stateManager.getActiveTurnId(subagentSession); - if (subTurnId) { - this._handleToolReady(signal, subagentSession, subTurnId, agent); - } - return; - } - } - - const turnId = this._stateManager.getActiveTurnId(sessionKey); - if (turnId) { - this._dispatchActionForSession(signal, sessionKey, turnId, agent); - return; - } - - // No active turn on the session. Most signals are silently dropped, - // but a `SessionTurnComplete` (idle) still needs to drive its - // post-turn side effects — flushing pending diff computation, - // recomputing diffs, and notifying the host. Tests routinely fire - // `idle` without first dispatching the matching `SessionTurnStarted` - // through the state manager. - if (signal.kind === 'action' && signal.action.type === ActionType.SessionTurnComplete) { - this._runTurnCompleteSideEffects(sessionKey, undefined); - } - } - - /** - * Dispatches a signal against a resolved session+turn. Performs the - * subagent-content merge for tool_complete and the related side effects. - */ - private _dispatchActionForSession(signal: AgentSignal, sessionKey: ProtocolURI, turnId: string, agent?: IAgent): void { - if (signal.kind === 'pending_confirmation') { - if (agent) { - this._handleToolReady(signal, sessionKey, turnId, agent); - } - return; - } - if (signal.kind !== 'action') { - return; - } - // The agent emits actions with its own view of the active turnId - // targeting the top-level session. The state manager is the source - // of truth — rewrite `session` and `turnId` so the action lands in - // the right reducer (subagent session for routed signals, queued - // turn ID when the agent hasn't yet seen `sendMessage`, etc.). - // Actions without a `turnId` field (`SessionTitleChanged`, - // `SessionInputRequested`) only get their `session` rewritten. - let action = signal.action; - if (isSessionAction(action) && action.session !== sessionKey) { - action = { ...action, session: sessionKey }; - } - if (hasKey(action, { turnId: true }) && action.turnId !== turnId) { - action = { ...action, turnId }; - } - - // When a parent tool call has an associated subagent session, - // preserve the subagent content metadata in the completion result. - // The SDK's tool_complete provides its own content which would - // overwrite the ToolResultSubagentContent that was set via - // SessionToolCallContentChanged while running. - if (action.type === ActionType.SessionToolCallComplete) { - const subagentKey = `${sessionKey}:${action.toolCallId}`; - const subagentUri = this._subagentSessions.get(subagentKey); - if (subagentUri) { - const parentState = this._stateManager.getSessionState(sessionKey); - const runningContent = this._getRunningToolCallContent(parentState, turnId, action.toolCallId); - const subagentEntry = runningContent.find(c => hasKey(c, { type: true }) && c.type === ToolResultContentType.Subagent); - if (subagentEntry) { - const mergedContent = [...(action.result.content ?? []), subagentEntry]; - const merged: SessionToolCallCompleteAction = { ...action, result: { ...action.result, content: mergedContent } }; - action = merged; - } - } - } - - this._stateManager.dispatchServerAction(action); - - if (action.type === ActionType.SessionToolCallComplete) { - this.completeSubagentSession(sessionKey, action.toolCallId); - if (getToolFileEdits(action.result).length > 0) { - this._scheduleDebouncedDiffComputation(sessionKey, turnId); - } - } - - if (action.type === ActionType.SessionTurnComplete) { - this._runTurnCompleteSideEffects(sessionKey, turnId); - } - } - - /** - * Post-turn side effects: flush any pending debounced diff computation, - * compute final diffs immediately, drain the next queued message, and - * notify the host so it can refresh git state. - */ - private _runTurnCompleteSideEffects(sessionKey: ProtocolURI, turnId: string | undefined): void { - this._cancelDebouncedDiffComputation(sessionKey); - this._computeSessionDiffs(sessionKey, turnId); - this._tryConsumeNextQueuedMessage(sessionKey); - this._options.onTurnComplete(sessionKey); - } - - private _describeSignal(signal: AgentSignal): string { - return signal.kind === 'action' ? `action(${signal.action.type})` : signal.kind; - } - - /** - * Replays any signals that were buffered while waiting for - * `subagent_started` to create the subagent session. Called immediately - * after `_handleSubagentStarted`. - */ - private _drainPendingSubagentSignals(parentSession: ProtocolURI, parentToolCallId: string): void { - const subagentKey = `${parentSession}:${parentToolCallId}`; - const buffer = this._pendingSubagentSignals.get(subagentKey); - if (!buffer) { - return; - } - this._pendingSubagentSignals.delete(subagentKey); - this._logService.trace(`[AgentSideEffects] Draining ${buffer.length} buffered signal(s) for subagent ${subagentKey}`); - for (const { signal, agent } of buffer) { - this._handleAgentSignal(agent, signal); - } - } - - // ---- Subagent session management ---------------------------------------- - - /** - * Creates a subagent session in response to a `subagent_started` event. - * The subagent session is created silently (no `sessionAdded` notification) - * and immediately transitioned to ready with an active turn. - */ - private _handleSubagentStarted( - parentSession: ProtocolURI, - toolCallId: string, - agentName: string, - agentDisplayName: string, - agentDescription?: string, - ): void { - const subagentSessionUri = buildSubagentSessionUri(parentSession, toolCallId); - const subagentKey = `${parentSession}:${toolCallId}`; - - // Already tracking this subagent - if (this._subagentSessions.has(subagentKey)) { - return; - } - - this._logService.info(`[AgentSideEffects] Creating subagent session: ${subagentSessionUri} (parent=${parentSession}, toolCallId=${toolCallId})`); - const parentState = this._stateManager.getSessionState(parentSession); - - // Create the subagent session silently (restoreSession skips notification) - this._stateManager.restoreSession( - { - resource: subagentSessionUri, - provider: 'subagent', - title: agentDisplayName, - status: SessionStatus.Idle, - createdAt: Date.now(), - modifiedAt: Date.now(), - ...(parentState?.summary.project ? { project: parentState.summary.project } : {}), - }, - [], - ); - - // Start a turn on the subagent session - const turnId = generateUuid(); - this._stateManager.dispatchServerAction({ - type: ActionType.SessionTurnStarted, - session: subagentSessionUri, - turnId, - userMessage: { text: '' }, - }); - - this._subagentSessions.set(subagentKey, subagentSessionUri); - - // Dispatch content on the parent tool call so clients discover the subagent. - // Merge with any existing content to avoid dropping prior content blocks. - const parentTurnId = this._stateManager.getActiveTurnId(parentSession); - if (parentTurnId) { - const parentState = this._stateManager.getSessionState(parentSession); - const existingContent = this._getRunningToolCallContent(parentState, parentTurnId, toolCallId); - const mergedContent = [ - ...existingContent, - { - type: ToolResultContentType.Subagent as const, - resource: subagentSessionUri, - title: agentDisplayName, - agentName, - description: agentDescription, - }, - ]; - this._stateManager.dispatchServerAction({ - type: ActionType.SessionToolCallContentChanged, - session: parentSession, - turnId: parentTurnId, - toolCallId, - content: mergedContent, - }); - } - } - - /** - * Gets the current content array from a running tool call, if any. - */ - private _getRunningToolCallContent( - state: SessionState | undefined, - turnId: string, - toolCallId: string, - ): ToolResultContent[] { - if (!state?.activeTurn || state.activeTurn.id !== turnId) { - return []; - } - for (const rp of state.activeTurn.responseParts) { - if (rp.kind === ResponsePartKind.ToolCall && rp.toolCall.toolCallId === toolCallId && rp.toolCall.status === ToolCallStatus.Running) { - return rp.toolCall.content ? [...rp.toolCall.content] : []; - } - } - return []; - } - - /** - * Cancels all active subagent sessions for a given parent session. - */ - cancelSubagentSessions(parentSession: ProtocolURI): void { - for (const [key, subagentUri] of this._subagentSessions) { - if (key.startsWith(`${parentSession}:`)) { - const turnId = this._stateManager.getActiveTurnId(subagentUri); - if (turnId) { - this._stateManager.dispatchServerAction({ - type: ActionType.SessionTurnCancelled, - session: subagentUri, - turnId, - }); - } - this._subagentSessions.delete(key); - } - } - // Drop any buffered events targeted at subagents that never started. - for (const key of [...this._pendingSubagentSignals.keys()]) { - if (key.startsWith(`${parentSession}:`)) { - this._pendingSubagentSignals.delete(key); - } - } - } - - /** - * Completes all active subagent sessions for a given parent session. - * Called when a parent tool call completes. - */ - completeSubagentSession(parentSession: ProtocolURI, toolCallId: string): void { - const key = `${parentSession}:${toolCallId}`; - - // Drop any events that were buffered waiting for a `subagent_started` - // that never arrived (e.g. the parent tool failed before the subagent - // was created). Without this, the buffer entry would leak until the - // parent session is disposed. - this._pendingSubagentSignals.delete(key); - - const subagentUri = this._subagentSessions.get(key); - if (!subagentUri) { - return; - } - - const turnId = this._stateManager.getActiveTurnId(subagentUri); - if (turnId) { - this._stateManager.dispatchServerAction({ - type: ActionType.SessionTurnComplete, - session: subagentUri, - turnId, - }); - } - this._subagentSessions.delete(key); - } - - /** - * Removes all subagent sessions for a given parent session from - * the state manager. Called when the parent session is disposed. - */ - removeSubagentSessions(parentSession: ProtocolURI): void { - const toRemove: string[] = []; - for (const [key, subagentUri] of this._subagentSessions) { - if (key.startsWith(`${parentSession}:`)) { - this._stateManager.removeSession(subagentUri); - toRemove.push(key); - } - } - for (const key of toRemove) { - this._subagentSessions.delete(key); - } - - // Also clean up any subagent sessions that are in the state manager - // but not tracked (e.g. restored sessions) - const prefix = `${parentSession}/subagent/`; - for (const uri of this._stateManager.getSessionUrisWithPrefix(prefix)) { - this._stateManager.removeSession(uri); - } - - // Drop any buffered events targeted at subagents that never started. - for (const key of [...this._pendingSubagentSignals.keys()]) { - if (key.startsWith(`${parentSession}:`)) { - this._pendingSubagentSignals.delete(key); - } - } - } - - /** - * Finds the subagent session that owns a given tool call by checking - * whether the tool call was previously registered under a subagent - * session key in `_toolCallAgents`. Scoped to subagent sessions owned - * by the given parent to avoid cross-session collisions. - */ - private _findSubagentSessionForToolCall(parentSession: ProtocolURI, toolCallId: string): ProtocolURI | undefined { - const prefix = `${parentSession}:`; - for (const [key, subagentUri] of this._subagentSessions) { - if (key.startsWith(prefix) && this._toolCallAgents.has(`${subagentUri}:${toolCallId}`)) { - return subagentUri; - } - } - return undefined; - } - - // ---- Side-effect handlers -------------------------------------------------- - - /** - * Handles a `pending_confirmation` signal end-to-end: checks for - * auto-approval via the permission manager, and if not auto-approved, - * dispatches the `SessionToolCallReady` action with confirmation options - * for the client. - */ - private _handleToolReady(e: IAgentToolPendingConfirmationSignal, sessionKey: ProtocolURI, turnId: string, agent: IAgent): void { - const approvalEvent = { - toolCallId: e.state.toolCallId, - session: e.session, - permissionKind: e.permissionKind, - permissionPath: e.permissionPath, - toolInput: e.state.toolInput, - }; - const autoApproval = this._permissionManager.getAutoApproval(approvalEvent, sessionKey); - let effective = e; - if (autoApproval !== undefined) { - this._toolCallAgents.delete(`${sessionKey}:${e.state.toolCallId}`); - agent.respondToPermissionRequest(e.state.toolCallId, true); - // Strip confirmationTitle so createToolReadyAction emits the - // auto-approved (no-options) action. - effective = { ...e, state: { ...e.state, confirmationTitle: undefined } }; - } - this._stateManager.dispatchServerAction( - this._permissionManager.createToolReadyAction(effective, sessionKey, turnId) - ); - } - - handleAction(action: StateAction): void { - switch (action.type) { - case ActionType.SessionTurnStarted: { - // Per-turn streaming part tracking is owned by the agent - // (e.g. CopilotAgentSession) and reset on its `send()` call. - - // On the very first turn, immediately set the session title to the - // user's message so the UI shows a meaningful title right away - // while waiting for the AI-generated title. Only apply when the - // title is still the default placeholder to avoid clobbering a - // title set by the user or provider before the first turn. - const state = this._stateManager.getSessionState(action.session); - const fallbackTitle = action.userMessage.text.trim().replace(/\s+/g, ' ').slice(0, 200); - if (state && state.turns.length === 0 && !state.summary.title && fallbackTitle.length > 0) { - this._stateManager.dispatchServerAction({ - type: ActionType.SessionTitleChanged, - session: action.session, - title: fallbackTitle, - }); - } - - const agent = this._options.getAgent(action.session); - if (!agent) { - this._stateManager.dispatchServerAction({ - type: ActionType.SessionError, - session: action.session, - turnId: action.turnId, - error: { errorType: 'noAgent', message: 'No agent found for session' }, - }); - return; - } - const attachments = action.userMessage.attachments?.map((a): IAgentAttachment => ({ - type: a.type, - uri: URI.parse(a.uri), - displayName: a.displayName, - })); - agent.sendMessage(URI.parse(action.session), action.userMessage.text, attachments, action.turnId).catch(err => { - const errCode = (err as { code?: number })?.code; - this._logService.error(`[AgentSideEffects] sendMessage failed for session=${action.session}: code=${errCode}, message=${err instanceof Error ? err.message : String(err)}, type=${err?.constructor?.name}`, err); - this._stateManager.dispatchServerAction({ - type: ActionType.SessionError, - session: action.session, - turnId: action.turnId, - error: { errorType: 'sendFailed', message: String(err) }, - }); - }); - break; - } - case ActionType.SessionToolCallConfirmed: { - const toolCallKey = `${action.session}:${action.toolCallId}`; - const agentId = this._toolCallAgents.get(toolCallKey); - if (agentId) { - this._toolCallAgents.delete(toolCallKey); - const agent = this._options.agents.get().find(a => a.id === agentId); - agent?.respondToPermissionRequest(action.toolCallId, action.approved); - } else { - this._logService.warn(`[AgentSideEffects] No agent for tool call confirmation: ${action.toolCallId}`); - } - - // When the user chose "Allow in this Session", add the tool - // to the session's permissions so future calls are auto-approved. - if (action.approved) { - this._permissionManager.handleToolCallConfirmed(action.session, action.toolCallId, action.selectedOptionId); - } - break; - } - case ActionType.SessionInputCompleted: { - const agent = this._options.getAgent(action.session); - agent?.respondToUserInputRequest(action.requestId, action.response, action.answers); - break; - } - case ActionType.SessionTurnCancelled: { - // Cancel all subagent sessions for this parent - this.cancelSubagentSessions(action.session); - const agent = this._options.getAgent(action.session); - agent?.abortSession(URI.parse(action.session)).catch(err => { - this._logService.error('[AgentSideEffects] abortSession failed', err); - }); - break; - } - case ActionType.SessionModelChanged: { - const agent = this._options.getAgent(action.session); - agent?.changeModel?.(URI.parse(action.session), action.model).catch(err => { - this._logService.error('[AgentSideEffects] changeModel failed', err); - }); - break; - } - case ActionType.SessionTitleChanged: { - this._persistSessionFlag(action.session, 'customTitle', action.title); - break; - } - case ActionType.SessionPendingMessageSet: - case ActionType.SessionPendingMessageRemoved: - case ActionType.SessionQueuedMessagesReordered: { - this._syncPendingMessages(action.session); - break; - } - case ActionType.SessionTruncated: { - const agent = this._options.getAgent(action.session); - agent?.truncateSession?.(URI.parse(action.session), action.turnId).catch(err => { - this._logService.error('[AgentSideEffects] truncateSession failed', err); - }); - // Turns were removed — recompute diffs from scratch (no changedTurnId) - this._computeSessionDiffs(action.session); - break; - } - case ActionType.SessionActiveClientChanged: { - const agent = this._options.getAgent(action.session); - if (!agent) { - break; - } - // Always forward client tools, even if empty, to clear previous client's tools - const clientId = action.activeClient?.clientId ?? ''; - agent.setClientTools(URI.parse(action.session), clientId, action.activeClient?.tools ?? []); - - const refs = action.activeClient?.customizations ?? []; - agent.setClientCustomizations( - clientId, - refs, - () => { - this._publishSessionCustomizationsSoon(agent, action.session); - }, - ).then(() => { - this._publishSessionCustomizationsSoon(agent, action.session); - }).catch(err => { - this._logService.error('[AgentSideEffects] setClientCustomizations failed', err); - }); - break; - } - case ActionType.RootConfigChanged: { - // Host customizations are self-managed by each agent's - // PluginController via IAgentConfigurationService.onDidRootConfigChange. - // Republish agent infos for non-customization schema changes - // (e.g. permissions) and session customizations as a catchall. - this._publishAgentInfos(this._options.agents.get()); - this._publishAllSessionCustomizations(); - break; - } - case ActionType.SessionActiveClientToolsChanged: { - const agent = this._options.getAgent(action.session); - if (agent) { - const sessionState = this._stateManager.getSessionState(action.session); - const toolClientId = sessionState?.activeClient?.clientId; - if (toolClientId) { - agent.setClientTools(URI.parse(action.session), toolClientId, action.tools); - } - } - break; - } - case ActionType.SessionCustomizationToggled: { - const agent = this._options.getAgent(action.session); - agent?.setCustomizationEnabled?.(action.uri, action.enabled); - break; - } - case ActionType.SessionIsReadChanged: { - this._persistSessionFlag(action.session, 'isRead', action.isRead ? 'true' : ''); - break; - } - case ActionType.SessionIsArchivedChanged: { - this._persistSessionFlag(action.session, 'isArchived', action.isArchived ? 'true' : ''); - const agent = this._options.getAgent(action.session); - agent?.onArchivedChanged?.(URI.parse(action.session), action.isArchived).catch(err => { - this._logService.warn(`[AgentSideEffects] onArchivedChanged failed for ${action.session}`, err); - }); - break; - } - case ActionType.SessionConfigChanged: { - // Persist merged values so a future `restoreSession` can re-hydrate - // the user's previous selections (e.g. autoApprove). - const sessionState = this._stateManager.getSessionState(action.session); - const values = sessionState?.config?.values; - if (values) { - this._persistSessionFlag(action.session, 'configValues', JSON.stringify(values)); - } - break; - } - case ActionType.SessionToolCallComplete: { - const agent = this._options.getAgent(action.session); - agent?.onClientToolCallComplete(URI.parse(action.session), action.toolCallId, action.result); - break; - } - } - } - - /** - * Persists a session metadata key/value pair to the session database. - * Used for fields the host needs to remember across restarts (custom - * title, isRead/isArchived flags, merged config values). - */ - private _persistSessionFlag(session: ProtocolURI, key: string, value: string): void { - const ref = this._options.sessionDataService.openDatabase(URI.parse(session)); - ref.object.setMetadata(key, value).catch(err => { - this._logService.warn(`[AgentSideEffects] Failed to persist ${key}`, err); - }).finally(() => { - ref.dispose(); - }); - } - - /** - * Pushes the current pending message state from the session to the agent. - * The server controls queued message consumption; only steering messages - * are forwarded to the agent for mid-turn injection. - */ - private _syncPendingMessages(session: ProtocolURI): void { - const state = this._stateManager.getSessionState(session); - if (!state) { - return; - } - const agent = this._options.getAgent(session); - agent?.setPendingMessages?.( - URI.parse(session), - state.steeringMessage, - [], - ); - - // Steering message removal is now dispatched by the agent - // via the 'steering_consumed' progress event once the message - // has actually been sent to the model. - - // If the session is idle, try to consume the next queued message - this._tryConsumeNextQueuedMessage(session); - } - - /** - * Consumes the next queued message by dispatching a server-initiated - * `SessionTurnStarted` action with `queuedMessageId` set. The reducer - * atomically creates the active turn and removes the message from the - * queue. Only consumes one message at a time; subsequent messages are - * consumed when the next `idle` event fires. - */ - private _tryConsumeNextQueuedMessage(session: ProtocolURI): void { - // Bail if there's already an active turn - if (this._stateManager.getActiveTurnId(session)) { - return; - } - const state = this._stateManager.getSessionState(session); - if (!state?.queuedMessages?.length) { - return; - } - - const msg = state.queuedMessages[0]; - const turnId = generateUuid(); - - // Per-turn streaming part tracking is owned by the agent (reset - // inside its `send()` call), so no host-side reset is needed. - - // Dispatch server-initiated turn start; the reducer removes the queued message atomically - this._stateManager.dispatchServerAction({ - type: ActionType.SessionTurnStarted, - session, - turnId, - userMessage: msg.userMessage, - queuedMessageId: msg.id, - }); - - // Send the message to the agent backend - const agent = this._options.getAgent(session); - if (!agent) { - this._stateManager.dispatchServerAction({ - type: ActionType.SessionError, - session, - turnId, - error: { errorType: 'noAgent', message: 'No agent found for session' }, - }); - return; - } - const attachments = msg.userMessage.attachments?.map((a): IAgentAttachment => ({ - type: a.type, - uri: URI.parse(a.uri), - displayName: a.displayName, - })); - agent.sendMessage(URI.parse(session), msg.userMessage.text, attachments, turnId).catch(err => { - this._logService.error('[AgentSideEffects] sendMessage failed (queued)', err); - this._stateManager.dispatchServerAction({ - type: ActionType.SessionError, - session, - turnId, - error: { errorType: 'sendFailed', message: String(err) }, - }); - }); - } - - // ---- Session diff computation ---------------------------------------------- - - /** - * Schedules a debounced diff computation for a session. If a timer is - * already pending for this session, it is replaced (restarting the delay). - * The computation fires after {@link _DIFF_DEBOUNCE_MS} unless cancelled - * or flushed by the turn-complete handler. - */ - private _scheduleDebouncedDiffComputation(session: ProtocolURI, turnId: string): void { - // DisposableMap.set() auto-disposes any previous timer for this session - this._debouncedDiffTimers.set(session, disposableTimeout(() => { - this._debouncedDiffTimers.deleteAndDispose(session); - this._computeSessionDiffs(session, turnId); - }, AgentSideEffects._DIFF_DEBOUNCE_MS)); - } - - /** - * Cancels any pending debounced diff computation for a session. - * Called at turn end before the final (non-debounced) computation. - */ - private _cancelDebouncedDiffComputation(session: ProtocolURI): void { - this._debouncedDiffTimers.deleteAndDispose(session); - } - - /** - * Asynchronously (re)computes aggregated diff statistics for a session - * and dispatches {@link ActionType.SessionDiffsChanged} to update the - * session summary. Fire-and-forget: errors are logged but do not fail - * the turn. - */ - private _computeSessionDiffs(session: ProtocolURI, changedTurnId?: string): void { - // Chain onto any pending computation for this session to ensure - // sequential access to previousDiffs (avoids stale-read races). - this._diffComputationSequencer.queue(session, () => this._doComputeSessionDiffs(session, changedTurnId)); - } - - private async _doComputeSessionDiffs(session: ProtocolURI, changedTurnId?: string): Promise { - let ref: ReturnType; - try { - ref = this._options.sessionDataService.openDatabase(URI.parse(session)); - } catch (err) { - this._logService.warn(`[AgentSideEffects] Failed to open session database for diff computation: ${session}`, err); - return; - } - try { - // Prefer a git-driven diff so terminal-driven file changes show up - // alongside SDK-tracked tool edits. The git path is the source of - // truth whenever the working directory is a real work tree; we - // only fall back to the edit-tracker aggregator when it isn't - // (e.g. agents running in non-git scratch directories or under - // test harnesses without git). - let diffs = await this._tryComputeGitDiffs(session, ref.object); - if (!diffs) { - // Build incremental options when a specific turn triggered the recomputation - let incremental: IIncrementalDiffOptions | undefined; - if (changedTurnId) { - const previousDiffs = this._stateManager.getSessionState(session)?.summary.diffs; - if (previousDiffs) { - incremental = { changedTurnId, previousDiffs }; - } - } - diffs = await computeSessionDiffs(session, ref.object, this._diffComputeService, incremental); - } - - this._stateManager.dispatchServerAction({ - type: ActionType.SessionDiffsChanged, - session, - diffs: [...diffs], - }); - // Persist diffs to the session database so they survive restarts - ref.object.setMetadata('diffs', JSON.stringify(diffs)).catch(err => { - this._logService.warn('[AgentSideEffects] Failed to persist session diffs', err); - }); - } catch (err) { - this._logService.warn('[AgentSideEffects] Failed to compute session diffs', err); - } finally { - ref.dispose(); - } - } - - /** - * Computes session diffs by shelling out to git. Returns the diff list - * when the session has a working directory and that directory is a git - * work tree; returns `undefined` otherwise so the caller can fall back - * to the edit-tracker aggregator. The base branch (anchor for the - * `merge-base` baseline) is read from the provider-agnostic - * {@link META_DIFF_BASE_BRANCH} metadata key — agents that create - * worktrees write it at session-creation time. - */ - private async _tryComputeGitDiffs(session: ProtocolURI, db: ISessionDatabase): Promise { - const workingDirectory = this._stateManager.getSessionState(session)?.summary.workingDirectory; - if (!workingDirectory) { - return undefined; - } - let workingDirectoryUri: URI; - try { - workingDirectoryUri = URI.parse(workingDirectory); - } catch { - return undefined; - } - const baseBranch = (await db.getMetadata(META_DIFF_BASE_BRANCH)) ?? undefined; - try { - return await this._gitService.computeSessionFileDiffs(workingDirectoryUri, { sessionUri: session, baseBranch }); - } catch (err) { - this._logService.warn('[AgentSideEffects] git-driven diff computation failed; falling back to edit-tracker', err); - return undefined; - } - } - - override dispose(): void { - this._toolCallAgents.clear(); - super.dispose(); - } -} diff --git a/src/vs/platform/agentHost/node/claude/CONTEXT.md b/src/vs/platform/agentHost/node/claude/CONTEXT.md deleted file mode 100644 index a4e3e5b3..00000000 --- a/src/vs/platform/agentHost/node/claude/CONTEXT.md +++ /dev/null @@ -1,472 +0,0 @@ -# Claude Agent (Agent Host) - -The Claude-side of the agent host: a `ClaudeAgent` implementation of `IAgent` -that drives Anthropic-format tool-using sessions via the Claude Agent SDK, -with all `messages` traffic proxied through a local server that translates -Anthropic requests into GitHub Copilot CAPI calls. - -## Language - -**Agent Host**: -The utility process that owns all `IAgent` providers (Copilot, Claude). Hosts -this code. - -**Claude Agent**: -The `IAgent` provider for the Claude SDK. Owns the lifecycle of the Claude -proxy and one `Query` per session. -_Avoid_: "Claude provider" (overloaded with `AgentProvider` ID). - -**Claude Proxy** / `ClaudeProxyService`: -A local HTTP server that speaks the **Anthropic Messages API** wire format on -the inbound side and `ICopilotApiService` on the outbound side. Registered -as a singleton in the agent host DI container; **one proxy per agent host -process**, shared across any Claude-family agents. -_Avoid_: "Anthropic proxy", "language model server". - -**CAPI**: -GitHub Copilot's chat completions API, accessed through `ICopilotApiService`. -The terminal hop after the proxy. - -## Relationships - -- The **Agent Host** owns one **Claude Proxy** for the lifetime of the process. -- The **Claude Agent** uses the **Claude Proxy** to talk to **CAPI**; the - proxy is injected via DI, not constructed by the agent. -- The **Claude Proxy** translates inbound **Anthropic Messages API** requests - into outbound `ICopilotApiService` calls, then to **CAPI**. -- The **Claude Agent SDK** subprocess sees the **Claude Proxy** as its - Anthropic API endpoint via `ANTHROPIC_BASE_URL` + `ANTHROPIC_AUTH_TOKEN`. - -## Flagged ambiguities - -_None yet._ - -## Phase 2 — `IClaudeProxyService` design log - -Decisions that have been grilled and locked in. Each entry is the closed -form of a design question. - -### Q1 — Naming -`IClaudeProxyService` / `ClaudeProxyService`. Files under `node/claude/`. - -### Q2 — Lifecycle / DI -Registered as a DI singleton in `agentHostMain.ts` alongside -`ICopilotApiService`. One instance per agent host process. - -### Q3 — API shape - -```ts -interface IClaudeProxyHandle extends IDisposable { - readonly baseUrl: string; - readonly nonce: string; -} - -interface IClaudeProxyService { - readonly _serviceBrand: undefined; - start(githubToken: string): Promise; - dispose(): void; -} -``` - -- **Refcounted**: each `start()` increments, each `handle.dispose()` - decrements. At refcount 0 the listener closes, the token slot clears, - and the nonce is destroyed. The next `start()` rebinds with a new - port and a fresh nonce. -- **Shared token slot, last-writer-wins**. Single-tenant per roadmap; - multi-tenant is a Phase 4+ concern. -- **Subprocess ownership**: callers that hand the handle's `baseUrl` / - `nonce` to a Claude SDK subprocess MUST kill that subprocess before - disposing the handle. The subprocess cannot outlive the handle. - -### Q4 — Auth -Enforce `Authorization: Bearer .` on authenticated -routes. Parse and log `sessionId` at trace level only; treat it as -opaque in Phase 2. `x-api-key` is ignored. - -Bearer enforcement runs FIRST on authenticated routes, so a bad token -yields 401 — never 501 or 404. - -### Q5 — Routes - -| Route | Status | Notes | -| --- | --- | --- | -| `POST /v1/messages` | full impl | streaming + non-streaming | -| `GET /v1/models` | net new | passthrough filtered to `vendor: 'Anthropic'` and `supported_endpoints` containing `/v1/messages`; reshaped to `Anthropic.ModelInfo`: `{ id, type: 'model', display_name, created_at: '1970-01-01T00:00:00Z', capabilities: null, max_input_tokens: null, max_tokens: null }`. (RFC3339 string instead of `0` because the SDK type forces it — settled by council C1.) | -| `POST /v1/messages/count_tokens` | net new | returns 501 with Anthropic error envelope: `{ type: 'error', error: { type: 'api_error', message: 'count_tokens not supported by CAPI' } }` | -| `GET /` | health | plain-text `'ok'` | -| anything else | 404 | Anthropic error envelope | - -No `OPTIONS` handler — same-process consumer, CORS does not apply. - -### Q6 — Model ID translation -The SDK does literal prefix matching (e.g. `id.startsWith('claude-opus-4-6')`). -CAPI uses dotted versions (`claude-opus-4.6`), the SDK uses hyphenated -Anthropic-canonical IDs (`claude-opus-4-6-20250929`). Translation is -**bidirectional**. - -- Port `extensions/copilot/src/extension/chatSessions/claude/{common,node}/claudeModelId.ts` - into `node/claude/claudeModelId.ts` with a comment marking it as a - mirror that must be kept in sync. Lift the same test fixtures. -- Two pure helpers exposed: `tryParseClaudeModelId(id)` returning a - `ParsedClaudeModelId` with `toSdkModelId()` / `toEndpointModelId()`. - No service, no class — the parser caches internally. -- Three rewrite points in the proxy: - 1. inbound `requestBody.model` (SDK → CAPI) - 2. outbound `model` fields on streaming events and non-streaming - responses (CAPI → SDK), e.g. `message_start.message.model` - 3. `GET /v1/models` response IDs (CAPI → SDK) -- **Inbound parse failure**: 404 with Anthropic `not_found_error` — - before any CAPI call. -- **Outbound parse failure**: log a warning, pass the raw value through. - Worse than translating, strictly better than dropping the response. -- Model **availability fallback** ("user picked an unavailable model, - pick the newest Sonnet") is a Phase 4 `ClaudeAgent` concern. The - proxy stays dumb. - -### Q7 — Anthropic-beta + header passthrough - -- Lift `filterSupportedBetas()` and the three-entry `SUPPORTED_ANTHROPIC_BETAS` - allowlist (`interleaved-thinking`, `context-management`, `advanced-tool-use`) - into `node/claude/anthropicBetas.ts` with a "keep in sync" comment. - Allowlist match is prefix + `-` (date-suffix discipline). Lift the - same 7 test fixtures. -- Applied at `POST /v1/messages` after auth, before model translation. - If the filtered result is a non-empty string, set it on the outbound - `ICopilotApiServiceRequestOptions.headers['anthropic-beta']`. If - `undefined`, omit the header entirely — never forward `''`. -- Inbound header passthrough is restricted to `anthropic-version` - (verbatim) and `anthropic-beta` (filtered). All other client headers - are dropped, including `x-request-id` / `request-id` — CAPI generates - its own. -- The proxy ignores `request.metadata` and any SDK-side `betas` field; - only the `anthropic-beta` header drives behavior. - -### Q8 — Streaming: framing, backpressure, mid-stream errors - -The reference (`extensions/copilot/.../claudeLanguageModelServer.ts`) is a -**byte-passthrough** — it pipes raw upstream SSE chunks straight to the -client. Our proxy can't do that: `ICopilotApiService.messages()` returns -`AsyncGenerator` (already-parsed event -objects), so we MUST construct SSE frames ourselves. - -**Framing.** Hand-rolled, per event: - -``` -event: \ndata: \n\n -``` - -Response headers, set once before the first event: -- `Content-Type: text/event-stream` -- `Cache-Control: no-cache` -- `Connection: keep-alive` - -After `writeHead(200, …)` call `res.flushHeaders()` so the SDK gets -status + content-type before the first event, and -`req.socket.setNoDelay(true)` so small frames aren't held by Nagle. - -**Event mutation.** 1:1 passthrough with one surgical rewrite — the -`model` field on `message_start.message.model` (Q6 outbound CAPI→SDK). -No `[DONE]` line (Anthropic ends with `message_stop`, not OpenAI's -sentinel). No synthesized `ping` events. No event reordering. - -**Backpressure.** The reference does NOT handle this and we treat that -as a bug, not a guideline. When `res.write()` returns `false`, -`await once(res, 'drain')` before pulling the next event from the -generator. This naturally backpressures the upstream. - -**Mid-stream errors.** The reference's `catch` tries to `writeHead(500)` -after streaming has started, which throws — its mid-stream error -handling is silently broken. We emit an Anthropic-shaped SSE error -frame and end: - -``` -event: error -data: { "type": "error", "error": { "type": "api_error", "message": "..." } } -``` - -Then `res.end()`. Do not emit `message_stop` after `error` — `error` is -terminal in the Anthropic SDK. - -**Non-streaming branch.** When `request.stream !== true`: -- `Content-Type: application/json` -- Single body: `JSON.stringify(message)` where `message` is the - `Anthropic.Message` returned by the non-streaming - `ICopilotApiService.messages()` overload, with the `model` field - rewritten to SDK format. -- The reference forces `stream: true` upstream and SSE-frames everything - — we explicitly do NOT do this. Honor `request.stream`. - -### Q9 — Abort / disconnect propagation - -One `AbortController` per inbound request, plumbed through to -`ICopilotApiServiceRequestOptions.signal`. - -```ts -const ac = new AbortController(); -res.on('close', () => ac.abort()); - -try { - const stream = copilotApi.messages(body, { signal: ac.signal, headers }); - for await (const event of stream) { /* emit */ } -} catch (err) { - if (ac.signal.aborted) { return; } // client gone — silent - // else: emit Anthropic SSE error frame (Q8) -} -``` - -- **Single trigger**: `res.on('close')`. The reference also uses this; - `req.on('close')` and `req.on('aborted')` are redundant. -- **No polling**. Pass `signal` to `ICopilotApiService.messages()`; the - generator rejects with `AbortError` on the next iteration. The for-await - unwinds naturally. -- **No error frame on client-disconnect**. Writing to a closed socket - either silently drops or throws `ERR_STREAM_WRITE_AFTER_END`. On - caught error, check `signal.aborted` and `return` without writing. -- **Non-streaming branch**: same pattern. The awaited - `ICopilotApiService.messages()` rejects with `AbortError`; we `return` - without writing a body. -- **`dispose()` aborts in-flight**. The service holds a - `Set` (added on request entry, removed in `finally`). - On refcount→0 dispose: abort all controllers, then `server.close()`, - then clear the token slot and destroy the nonce. - -### Q10 — Error envelopes - -Two distinct flows. Don't conflate. - -**Proxy-authored errors.** No upstream response exists, so we construct -the Anthropic envelope ourselves via one helper: - -```ts -function writeJsonError(res, status, type, message) { - res.writeHead(status, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ type: 'error', error: { type, message } })); -} -``` - -Status + `error.type` per case: - -| Case | Status | `error.type` | -| --- | --- | --- | -| Missing/malformed `Authorization` | 401 | `authentication_error` | -| Bearer nonce mismatch | 401 | `authentication_error` | -| Bearer with no `.sessionId` | 401 | `authentication_error` | -| Unknown route | 404 | `not_found_error` | -| Bad JSON body | 400 | `invalid_request_error` | -| Missing required field (`model`, `messages`) | 400 | `invalid_request_error` | -| Model parse failure (Q6 inbound) | 404 | `not_found_error` | -| `POST /v1/messages/count_tokens` | 501 | `api_error` | - -**CAPI errors — passthrough, not mapping.** CAPI already speaks -Anthropic wire format. The `@anthropic-ai/sdk` parses non-2xx into -`Anthropic.APIError` with `err.status` + `err.error` (the parsed -envelope, verbatim). We just re-serialize: - -```ts -catch (err) { - if (err instanceof Anthropic.APIError && err.error) { - res.writeHead(err.status, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify(err.error)); - return; - } - // network / non-APIError — synthesize - writeJsonError(res, 502, 'api_error', err.message ?? 'Upstream error'); -} -``` - -No `instanceof` ladder per `APIError` subclass. No mapping table. No -`err.message` sanitization — passthrough. - -**Mid-stream variant.** Same idea, SSE frame instead of JSON body: - -```ts -res.write(`event: error\ndata: ${JSON.stringify(err.error ?? fallback)}\n\n`); -res.end(); -``` - -Status is ignored once headers are sent (Q8). On `signal.aborted`, -write nothing — client is gone (Q9). - -### Q11 — File layout (as shipped) - -``` -node/claude/ -├── claudeProxyService.ts # interface + impl + lifecycle + server + dispatch + route handlers -├── claudeModelId.ts # parser (Q6) — mirror of extension copy -├── anthropicBetas.ts # filterSupportedBetas + allowlist (Q7) -├── anthropicErrors.ts # buildErrorEnvelope, writeJsonError, writeUpstreamJsonError, formatSseErrorFrame (Q10) -└── claudeProxyAuth.ts # parseProxyBearer (Q4) - -test/node/ -├── claudeModelId.test.ts -├── anthropicBetas.test.ts -├── claudeProxyAuth.test.ts -└── claudeProxyService.test.ts -``` - -- **Pure helpers** (`claudeModelId`, `anthropicBetas`, `anthropicErrors`, - `claudeProxyAuth`) are platform-free pure functions, testable in - isolation. -- **Route handlers live inside `ClaudeProxyService`.** The original plan - split them into separate `claudeProxyMessages.ts` / - `claudeProxyModels.ts` classes; we collapsed them into private methods - (`_handleModels`, `_handleMessages`, `_sendNonStreamingMessage`, - `_streamMessages`) on `ClaudeProxyService` because they share the - per-request bookkeeping (`IInFlight`, the `runtime` token slot, the - abort plumbing). Splitting them would require passing the same five - arguments around; keeping them inline kept the file under 800 lines - and avoided redundant injection. -- **`claudeProxyService.ts`** owns the `IClaudeProxyService` interface, - the impl, `IClaudeProxyHandle`, refcounted lifecycle, the - `http.createServer()`, the top-level dispatch switch on - `url.pathname`, the `Set` of in-flight requests, and all - route handlers. -- **Interface lives next to impl.** No `common/` split for Phase 2 — - both consumers (`agentHostMain.ts`, future `ClaudeAgent`) are in - `node`. Promote later if a second platform consumer appears. - -### Q12 — Acceptance criteria - -Phase 2 is "done" when all of the following pass: - -**Hygiene (must run frequently during development, not as a final step):** -- [ ] `compile-check-ts-native` clean -- [ ] `eslint` clean -- [ ] `valid-layers-check` clean -- [ ] Hygiene check (gulp `hygiene`) clean — copyright headers, tabs, - string quoting, formatting - -**Lifecycle (Q2/Q3):** -- [ ] `start(token)` returns a handle with `baseUrl` (e.g. - `http://127.0.0.1:54321`) and a 256-bit hex `nonce` -- [ ] Two concurrent `start()` calls share one server, share the latest - token, return handles with the same `baseUrl` and `nonce` -- [ ] Disposing one handle while the other is alive: server stays up, - in-flight requests on the other handle continue -- [ ] Disposing the last handle: `server.close()` runs, in-flight - controllers abort, port is freed -- [ ] `start()` after refcount-0 dispose binds a new port and a fresh - nonce - -**Bind safety:** server binds only to `127.0.0.1`, not `0.0.0.0`. - -**Auth (Q4):** missing / wrong nonce / no `.sessionId` / `x-api-key` -alone all → 401; `Bearer .` proceeds. - -**Routes (Q5):** -- [ ] `GET /` → 200 `'ok'`, no auth required -- [ ] `GET /v1/models` (authed) → 200 with Anthropic-shaped model - objects, IDs in SDK format (Q6 outbound) -- [ ] `POST /v1/messages/count_tokens` (authed) → 501 `api_error` -- [ ] `GET /something-else` (authed) → 404 `not_found_error` - -**Model translation (Q6):** SDK ID inbound translates to CAPI; CAPI ID -inbound also works; unparseable → 404 with no CAPI call; -`message_start.message.model` and non-streaming `message.model` -rewritten to SDK format on the way out; `/v1/models` IDs in SDK format. - -**Beta filtering (Q7):** date-suffixed allowlist members forwarded; -non-allowlist dropped; empty result → header omitted. - -**Header passthrough (Q7):** `anthropic-version` forwarded verbatim; -all others (including `x-request-id`) dropped. - -**Streaming (Q8):** `stream:true` → SSE with hand-rolled -`event:/data:` framing in CAPI's emitted order; `stream:false` → JSON -body; tool-use and thinking deltas reach the client; no `[DONE]`; -`socket.setNoDelay(true)`. - -**Abort (Q9):** mid-stream client disconnect aborts upstream; -mid-stream `dispose()` aborts all; non-streaming disconnect writes no -body. - -**Errors (Q10):** proxy-authored errors emit the Q10 envelope; -`Anthropic.APIError` re-emitted verbatim; non-`APIError` → 502 -`api_error`; mid-stream errors become SSE `event: error` frames. - -### Q13 — Testing strategy - -Three surfaces. - -**Surface 1 — pure-helper unit tests** (zero deps, fast, -deterministic): - -``` -src/vs/platform/agentHost/test/node/ -├── claudeModelId.test.ts # port extension fixtures, bidirectional -├── anthropicBetas.test.ts # port the 7 reference fixtures -├── claudeProxyAuth.test.ts # auth matrix -└── claudeProxyService.test.ts # see Surface 2 -``` - -**Surface 2 — service-level tests** with a single mock -(`FakeCopilotApiService`); everything else (real `http.Server`, real -`AbortController`, real parser, real filter) is real. Per the codebase -guideline: minimal mocking, mock only true I/O boundaries. - -```ts -class FakeCopilotApiService implements ICopilotApiService { - nextMessagesResult: Anthropic.Message - | AsyncGenerator - | Anthropic.APIError; - lastCall: { body: Anthropic.MessageCreateParams; options: ICopilotApiServiceRequestOptions }; - messages(body, options): ... - models(): ... - countTokens(): ... -} -``` - -Cases: lifecycle / refcount / port / dispose; auth fixtures end-to-end -through real HTTP; route status matrix; model translation roundtrip; -beta filter forwarded correctly to mock; streaming order + framing; -non-streaming JSON body shape; mid-stream abort propagates -`signal.aborted` to mock; mid-stream `APIError` becomes SSE error -frame; pre-stream `APIError` becomes JSON error; disconnect aborts -upstream. - -**Surface 3 — real-CAPI smoke (Phase 2, not deferred).** - -Procedure (manual, run at the end of Phase 2 implementation): - -1. Temporarily modify the existing `CopilotAgent` (or wherever the - agent host first authenticates) to call - `claudeProxyService.start(token)` once a real GitHub token is - minted, and log the resulting `baseUrl` and `nonce` at info level. -2. Launch the dev build (`./scripts/code.sh --agents` or - `Run Dev Agents`) and authenticate. -3. Use the **code-oss-logs** skill to read `agenthost.log` from the - most recent run; grep for the proxy line; extract `baseUrl` + - `nonce`. -4. From a separate terminal: - ```bash - curl -H "Authorization: Bearer ${NONCE}.smoke-test" \ - "${BASE_URL}/v1/models" - curl -H "Authorization: Bearer ${NONCE}.smoke-test" \ - -H "Content-Type: application/json" \ - -H "anthropic-version: 2023-06-01" \ - --data '{"model":"claude-opus-4-6-20250929","messages":[{"role":"user","content":"say hi"}],"max_tokens":64}' \ - "${BASE_URL}/v1/messages" - ``` -5. Verify: 200; SDK-format model IDs in `/v1/models`; non-streaming - `Anthropic.Message` shape with `model` rewritten to SDK format. -6. Repeat with `"stream": true` and `--no-buffer`; verify SSE frames. -7. Revert the temporary `CopilotAgent` change. - -This validates real CAPI without waiting for Phase 4's `ClaudeAgent`. - -**What we explicitly do NOT test in Phase 2:** real Claude Agent SDK -subprocess (Phase 4), multi-tenant token isolation (Phase 4+), proxy -/ PAC support (deferred — see "Deferred for later phases"). - -## Deferred for later phases - -Captured here so they aren't lost. None of these block Phase 2. - -- **HTTP proxy support** (`HTTP_PROXY` / `HTTPS_PROXY` env vars, VS Code's - `http.proxy` setting, PAC files, proxy auth). The Phase 2 proxy talks - to CAPI through `ICopilotApiService`, so any outbound proxying is - inherited from whatever HTTP client that service uses. If the agent - host needs to honor user proxy configuration we'll need an explicit - agent / dispatcher injection point on `ICopilotApiService` and a way - to source the config from the renderer. Track separately when we - pick this up. diff --git a/src/vs/platform/agentHost/node/claude/anthropicBetas.ts b/src/vs/platform/agentHost/node/claude/anthropicBetas.ts deleted file mode 100644 index be6ed951..00000000 --- a/src/vs/platform/agentHost/node/claude/anthropicBetas.ts +++ /dev/null @@ -1,46 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -/** - * Mirror of {@link - * file://./../../../../../extensions/copilot/src/extension/chatSessions/claude/node/claudeLanguageModelServer.ts - * `extensions/copilot/.../claudeLanguageModelServer.ts`}'s `filterSupportedBetas` - * + `SUPPORTED_ANTHROPIC_BETAS` allowlist. - * - * **Keep in sync with the extension copy.** When CAPI gains support for - * a new Anthropic beta, add it here. The filter is applied at the - * inbound `POST /v1/messages` boundary on the `anthropic-beta` header - * before forwarding to {@link ICopilotApiService.messages}. - */ - -/** - * Beta identifiers (without date suffix) that CAPI is known to honor. - * The match is prefix + `-`, so an entry like `'context-management'` - * accepts `'context-management-2025-06-27'` but rejects - * `'context-management'` (no date) — date-suffix discipline. - */ -const SUPPORTED_ANTHROPIC_BETAS: readonly string[] = [ - 'interleaved-thinking', - 'context-management', - 'advanced-tool-use', -]; - -/** - * Filters a comma-separated `anthropic-beta` header value to only include - * betas that match {@link SUPPORTED_ANTHROPIC_BETAS}. Entries are matched by - * prefix so that e.g. `'context-management'` allows `'context-management-2025-06-27'`. - * - * Returns the filtered comma-separated string, or `undefined` if no betas matched. - * Callers must omit the outbound header entirely when this returns `undefined` - * — never forward an empty string. - */ -export function filterSupportedBetas(headerValue: string): string | undefined { - const filtered = headerValue - .split(',') - .map(b => b.trim()) - .filter(b => b && SUPPORTED_ANTHROPIC_BETAS.some(supported => b.startsWith(supported + '-'))); - - return filtered.length > 0 ? filtered.join(',') : undefined; -} diff --git a/src/vs/platform/agentHost/node/claude/anthropicErrors.ts b/src/vs/platform/agentHost/node/claude/anthropicErrors.ts deleted file mode 100644 index 41e4b698..00000000 --- a/src/vs/platform/agentHost/node/claude/anthropicErrors.ts +++ /dev/null @@ -1,71 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import type Anthropic from '@anthropic-ai/sdk'; -import type * as http from 'http'; - -/** - * Anthropic-error helpers shared by the proxy. Two shapes: - * - * - **Proxy-authored errors**: synthesized by the proxy when no upstream - * response exists (auth failure, bad route, malformed body, model - * parse failure, `count_tokens`). Sent as a JSON body or an SSE error - * frame. - * - **CAPI errors** (passthrough): a `CopilotApiError` already carries - * the upstream `Anthropic.ErrorResponse`. The proxy re-emits it - * verbatim — see `claudeProxyService.ts` for that branch. - */ - -/** - * Build a synthetic Anthropic error envelope. `request_id` is `null` - * because the proxy authored this error itself (no upstream request was - * made, or the upstream request didn't supply one). - */ -export function buildErrorEnvelope(type: Anthropic.ErrorType, message: string): Anthropic.ErrorResponse { - return { - type: 'error', - error: { type, message }, - request_id: null, - }; -} - -/** - * Send a proxy-authored JSON error response. Caller must NOT have - * written headers or body yet. - */ -export function writeJsonError( - res: http.ServerResponse, - status: number, - type: Anthropic.ErrorType, - message: string, -): void { - res.writeHead(status, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify(buildErrorEnvelope(type, message))); -} - -/** - * Send an upstream {@link Anthropic.ErrorResponse} verbatim with the - * supplied HTTP status. Used by the CAPI passthrough branch in - * `claudeProxyService` so any extra fields on the upstream envelope - * (e.g. `request_id`) propagate to the SDK unchanged. - */ -export function writeUpstreamJsonError( - res: http.ServerResponse, - status: number, - envelope: Anthropic.ErrorResponse, -): void { - res.writeHead(status, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify(envelope)); -} - -/** - * Encode a single SSE frame for an Anthropic streaming error. Used after - * `writeHead(200)` has already been called (so we can no longer change - * the HTTP status). Caller should `res.end()` after writing this frame - * — the Anthropic SDK treats `event: error` as terminal. - */ -export function formatSseErrorFrame(envelope: Anthropic.ErrorResponse): string { - return `event: error\ndata: ${JSON.stringify(envelope)}\n\n`; -} diff --git a/src/vs/platform/agentHost/node/claude/claudeModelId.ts b/src/vs/platform/agentHost/node/claude/claudeModelId.ts deleted file mode 100644 index 59617b25..00000000 --- a/src/vs/platform/agentHost/node/claude/claudeModelId.ts +++ /dev/null @@ -1,185 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -/** - * Mirror of `extensions/copilot/src/extension/chatSessions/claude/{common,node}/claudeModelId.ts`. - * - * The Claude Agent SDK speaks Anthropic-canonical hyphenated model IDs - * (e.g. `claude-opus-4-6-20251101`). CAPI speaks dotted endpoint IDs - * (`claude-opus-4.6`). The Phase 2 proxy needs bidirectional translation - * at three points: inbound `requestBody.model` (SDK→CAPI), outbound - * `model` fields on streaming events / non-streaming responses (CAPI→SDK), - * and `GET /v1/models` response IDs (CAPI→SDK). - * - * **Keep in sync with the extension copy.** When the model-ID grammar - * changes (new family name, new modifier suffix), update both files. - */ - -export interface ParsedClaudeModelId { - readonly name: string; - readonly version: string; - readonly modifiers: string; - toSdkModelId(): string; - toEndpointModelId(): string; -} - -/** - * Known model suffixes that are meaningful variants and should be preserved - * in SDK/endpoint model IDs. Mapped per model family. Date-based suffixes - * (e.g. 20251101) are intentionally excluded — they are build identifiers - * that should not appear in the normalized output. - */ -const VALID_SUFFIXES: ReadonlyMap> = new Map([ - ['opus', new Set(['1m'])], -]); - -const cache = new Map(); - -/** - * Parses a Claude model ID string (SDK or endpoint format) into its components. - * Throws if the model ID is unparseable or not a Claude ID. - * - * Use {@link tryParseClaudeModelId} when the input may not be a valid Claude model ID - * (e.g. model IDs from disk or external sources). - */ -export function parseClaudeModelId(modelId: string): ParsedClaudeModelId { - const result = tryParseClaudeModelId(modelId); - if (!result) { - throw new Error(`Unable to parse Claude model ID: '${modelId}'`); - } - return result; -} - -/** - * Attempts to parse a Claude model ID string (SDK or endpoint format) into its components. - * - * Accepts either format: - * - SDK: `claude-opus-4-5-20251101`, `claude-3-5-sonnet-20241022`, `claude-sonnet-4-20250514` - * - Endpoint: `claude-opus-4.5`, `claude-sonnet-4`, `claude-haiku-3.5` - * - * Returns `undefined` for unparseable or non-Claude IDs. - */ -export function tryParseClaudeModelId(modelId: string): ParsedClaudeModelId | undefined { - const cacheKey = modelId.toLowerCase(); - if (cache.has(cacheKey)) { - return cache.get(cacheKey); - } - - const result = doParse(cacheKey); - cache.set(cacheKey, result); - return result; -} - -const DATE_SUFFIX_RE = /^(?.*)-(?\d{8})$/; - -function doParse(lower: string): ParsedClaudeModelId | undefined { - let dateSuffix = ''; - let base = lower; - - const dateMatch = DATE_SUFFIX_RE.exec(lower); - if (dateMatch?.groups) { - base = dateMatch.groups.base; - dateSuffix = dateMatch.groups.date; - } - - // Pattern 1: claude-{name}-{major}-{minor}[-{mod}] (e.g. claude-opus-4-5, claude-opus-4-6-1m) - const p1 = base.match(/^claude-(?\w+)-(?\d+)-(?\d+)(?:-(?.+))?$/); - if (p1?.groups) { - return makeResult(p1.groups.name, p1.groups.major, p1.groups.minor, joinModifiers(p1.groups.mod, dateSuffix)); - } - - // Pattern 2: claude-{major}-{minor}-{name}[-{mod}] (e.g. claude-3-5-sonnet) - const p2 = base.match(/^claude-(?\d+)-(?\d+)-(?\w+)(?:-(?.+))?$/); - if (p2?.groups) { - return makeResult(p2.groups.name, p2.groups.major, p2.groups.minor, joinModifiers(p2.groups.mod, dateSuffix)); - } - - // Pattern 3: claude-{name}-{major}.{minor}[-{mod}] (e.g. claude-opus-4.5, claude-opus-4.6-1m) - const p3 = base.match(/^claude-(?\w+)-(?\d+)\.(?\d+)(?:-(?.+))?$/); - if (p3?.groups) { - return makeResult(p3.groups.name, p3.groups.major, p3.groups.minor, joinModifiers(p3.groups.mod, dateSuffix)); - } - - // Pattern 4: claude-{name}-{major}[-{mod}] (e.g. claude-sonnet-4, claude-sonnet-4-1m) - const p4 = base.match(/^claude-(?\w+)-(?\d+)(?:-(?.+))?$/); - if (p4?.groups) { - return makeResult(p4.groups.name, p4.groups.major, undefined, joinModifiers(p4.groups.mod, dateSuffix)); - } - - // Pattern 5: claude-{major}-{name}[-{mod}] (e.g. claude-3-opus) - const p5 = base.match(/^claude-(?\d+)-(?\w+)(?:-(?.+))?$/); - if (p5?.groups) { - return makeResult(p5.groups.name, p5.groups.major, undefined, joinModifiers(p5.groups.mod, dateSuffix)); - } - - // Pattern 6: bare model name with no version (e.g. nectarine) - const p6 = base.match(/^(?\w+)$/); - if (p6?.groups) { - return makeBareResult(p6.groups.name); - } - - return undefined; -} - -function joinModifiers(mod: string | undefined, dateSuffix: string): string { - if (mod && dateSuffix) { - return `${mod}-${dateSuffix}`; - } - return mod || dateSuffix; -} - -function formatModelId(name: string, major: string, minor: string | undefined, versionSep: string, validSuffix: string): string { - const base = minor !== undefined - ? `claude-${name}-${major}${versionSep}${minor}` - : `claude-${name}-${major}`; - return validSuffix ? `${base}-${validSuffix}` : base; -} - -function makeBareResult(name: string): ParsedClaudeModelId { - return { - name, - version: '', - modifiers: '', - toSdkModelId: () => name, - toEndpointModelId: () => name, - }; -} - -function makeResult(name: string, major: string, minor: string | undefined, modifiers: string): ParsedClaudeModelId { - const version = minor !== undefined ? `${major}.${minor}` : major; - const validSuffix = extractValidSuffix(name, modifiers); - return { - name, - version, - modifiers, - toSdkModelId: () => formatModelId(name, major, minor, '-', validSuffix), - toEndpointModelId: () => formatModelId(name, major, minor, '.', validSuffix), - }; -} - -/** - * Extracts the valid suffix portion from modifiers for a given model family. - * For example, given modifiers '1m-20251101' and family 'opus', returns '1m'. - * Returns an empty string if no valid suffix is found. - */ -function extractValidSuffix(name: string, modifiers: string): string { - if (!modifiers) { - return ''; - } - const allowedSuffixes = VALID_SUFFIXES.get(name); - if (!allowedSuffixes) { - return ''; - } - // Check the full modifier string first (e.g. '1m') - if (allowedSuffixes.has(modifiers)) { - return modifiers; - } - // Check the first segment of compound modifiers (e.g. '1m' from '1m-20251101') - const firstSegment = modifiers.split('-')[0]; - if (allowedSuffixes.has(firstSegment)) { - return firstSegment; - } - return ''; -} diff --git a/src/vs/platform/agentHost/node/claude/claudeProxyAuth.ts b/src/vs/platform/agentHost/node/claude/claudeProxyAuth.ts deleted file mode 100644 index 625d7693..00000000 --- a/src/vs/platform/agentHost/node/claude/claudeProxyAuth.ts +++ /dev/null @@ -1,58 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import type * as http from 'http'; - -/** - * Result of {@link parseProxyBearer}. `valid` is `true` only when the - * `Authorization` header contains `Bearer .` - * with a non-empty `sessionId`. - * - * Phase 2 deliberately does NOT accept the legacy `Bearer ` (no - * dot) format that the Copilot extension's `extractSessionId` accepts — - * the agent host always issues full `nonce.sessionId` tokens. - */ -export interface ProxyBearerAuth { - readonly valid: boolean; - readonly sessionId: string | undefined; -} - -const INVALID: ProxyBearerAuth = Object.freeze({ valid: false, sessionId: undefined }); - -/** - * Parses + validates the inbound Bearer token on Claude proxy requests. - * - * Accepts only `Authorization: Bearer .` where the - * leading nonce equals `expectedNonce` and `sessionId` is non-empty. - * The `x-api-key` header is ignored to prevent a user's - * `ANTHROPIC_API_KEY` env var from interfering with proxy auth. - * - * Rejects (returns `{ valid: false, sessionId: undefined }`): - * - missing or non-`Bearer` `Authorization` - * - `Bearer ` (no dot) - * - `Bearer .` (empty sessionId) - * - `Bearer .` - */ -export function parseProxyBearer(headers: http.IncomingHttpHeaders, expectedNonce: string): ProxyBearerAuth { - const authHeader = headers['authorization']; - if (typeof authHeader !== 'string' || !authHeader.startsWith('Bearer ')) { - return INVALID; - } - - const token = authHeader.slice('Bearer '.length); - const dotIndex = token.indexOf('.'); - if (dotIndex === -1) { - // Phase 2 explicitly rejects the legacy nonce-only format. - return INVALID; - } - - const nonce = token.slice(0, dotIndex); - const sessionId = token.slice(dotIndex + 1); - if (nonce !== expectedNonce || sessionId.length === 0) { - return INVALID; - } - - return { valid: true, sessionId }; -} diff --git a/src/vs/platform/agentHost/node/claude/claudeProxyService.ts b/src/vs/platform/agentHost/node/claude/claudeProxyService.ts deleted file mode 100644 index 3b4f73d9..00000000 --- a/src/vs/platform/agentHost/node/claude/claudeProxyService.ts +++ /dev/null @@ -1,761 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import type Anthropic from '@anthropic-ai/sdk'; -import type { CCAModel } from '@vscode/copilot-api'; -import type * as http from 'http'; -import { once } from 'events'; -import { AddressInfo } from 'net'; -import { IDisposable } from '../../../../base/common/lifecycle.js'; -import { createDecorator } from '../../../instantiation/common/instantiation.js'; -import { ILogService } from '../../../log/common/log.js'; -import { - COPILOT_API_ERROR_STATUS_STREAMING, - CopilotApiError, - ICopilotApiService, - type ICopilotApiServiceRequestOptions, -} from '../shared/copilotApiService.js'; -import { filterSupportedBetas } from './anthropicBetas.js'; -import { - buildErrorEnvelope, - formatSseErrorFrame, - writeJsonError, - writeUpstreamJsonError, -} from './anthropicErrors.js'; -import { tryParseClaudeModelId } from './claudeModelId.js'; -import { parseProxyBearer } from './claudeProxyAuth.js'; - -// #region Public types - -/** - * Handle returned by {@link IClaudeProxyService.start}. Refcounts the - * underlying server: when every handle is disposed, the listener closes, - * the token slot clears, and the nonce is destroyed. The next `start()` - * call rebinds with a new port and a fresh nonce. - * - * **Subprocess ownership invariant.** Callers that hand `baseUrl` / - * `nonce` to a Claude SDK subprocess MUST kill that subprocess before - * calling `dispose()`. The subprocess cannot outlive the handle — - * after `dispose()` the proxy may rebind on a different port and the - * subprocess would silently lose its endpoint. - */ -export interface IClaudeProxyHandle extends IDisposable { - /** e.g. `http://127.0.0.1:54321` — no trailing slash. */ - readonly baseUrl: string; - /** 256-bit hex string. Combine with a session id as `Bearer .`. */ - readonly nonce: string; -} - -export interface IClaudeProxyService { - readonly _serviceBrand: undefined; - - /** - * Start the proxy (if not already running) and return a refcounted - * handle. The supplied `githubToken` becomes the active token for - * outbound CAPI requests; if multiple callers hold handles - * concurrently, the most recent token wins (single-tenant assumption, - * see roadmap section 6). - */ - start(githubToken: string): Promise; - - /** - * Force-close the proxy regardless of refcount and abort any - * in-flight requests. Idempotent. Subsequent `start()` calls rebind. - */ - dispose(): void; -} - -export const IClaudeProxyService = createDecorator('claudeProxyService'); - -// #endregion - -// #region Internal state - -/** - * Per-request bookkeeping. `clientGone` distinguishes a client-driven - * disconnect (socket already closed — write nothing) from a service- - * driven `dispose()` (socket still open — `res.destroy()` to unblock - * the client) when the abort signal fires. - */ -interface IInFlight { - readonly ac: AbortController; - readonly res: http.ServerResponse; - clientGone: boolean; -} - -/** - * Process-wide proxy state. Created lazily on first `start()` and torn - * down when refcount → 0 (or `dispose()` is called explicitly). - */ -interface IProxyRuntime { - readonly server: http.Server; - readonly baseUrl: string; - readonly nonce: string; - readonly inFlight: Set; - githubToken: string; - refcount: number; -} - -// #endregion - -// #region Implementation - -const KNOWN_CLAUDE_VENDORS = new Set(['anthropic']); -const ANTHROPIC_MESSAGES_ENDPOINT = '/v1/messages'; -const PROXY_USER_FACING_NAME = 'ClaudeProxyService'; - -/** - * Build the 256-bit hex nonce embedded in the `Bearer .` - * token. Web Crypto is available in Node 18+ (used elsewhere in this - * folder via `generateUuid`). - */ -function generateNonce(): string { - const bytes = new Uint8Array(32); - crypto.getRandomValues(bytes); - let out = ''; - for (let i = 0; i < bytes.length; i++) { - out += bytes[i].toString(16).padStart(2, '0'); - } - return out; -} - -/** - * Local HTTP proxy that speaks the Anthropic Messages API on the inbound - * side and {@link ICopilotApiService} on the outbound side. The Claude - * Agent SDK connects via `ANTHROPIC_BASE_URL` + `ANTHROPIC_AUTH_TOKEN` - * and sees this as a real Anthropic endpoint. - * - * Lifecycle is refcounted via {@link IClaudeProxyHandle}; see - * {@link IClaudeProxyService.start} and the subprocess-ownership - * invariant on `IClaudeProxyHandle`. - */ -export class ClaudeProxyService implements IClaudeProxyService { - - declare readonly _serviceBrand: undefined; - - private _runtime: IProxyRuntime | undefined; - private _starting: Promise | undefined; - private _disposed = false; - - constructor( - @ILogService private readonly _logService: ILogService, - @ICopilotApiService private readonly _copilotApiService: ICopilotApiService, - ) { } - - async start(githubToken: string): Promise { - if (this._disposed) { - throw new Error('ClaudeProxyService has been disposed'); - } - - const runtime = await this._ensureRuntime(githubToken); - // Re-check after the await: dispose() may have run while - // _ensureRuntime was awaiting the bind, in which case the - // runtime we received is already torn down (see - // _ensureRuntime) — but a fresh start() in between is also - // possible, so verify the active runtime hasn't moved. - if (this._disposed || this._runtime !== runtime) { - throw new Error('ClaudeProxyService has been disposed'); - } - // Late-binding token update covers the case where multiple - // concurrent callers awaited the same _ensureRuntime — last - // caller's token wins, matching the single-tenant contract. - runtime.githubToken = githubToken; - runtime.refcount++; - - let disposed = false; - const handle: IClaudeProxyHandle = { - baseUrl: runtime.baseUrl, - nonce: runtime.nonce, - dispose: () => { - if (disposed) { - return; - } - disposed = true; - this._releaseHandle(runtime); - }, - }; - return handle; - } - - dispose(): void { - if (this._disposed) { - return; - } - this._disposed = true; - this._teardownRuntime(); - } - - /** - * Returns the shared runtime, binding a new server if there isn't - * one yet. Concurrent callers share the same in-flight bind via - * {@link _starting}; this prevents two listeners from being - * created when {@link start} is invoked twice before the first - * bind resolves. - * - * If {@link dispose} runs while the bind is in flight, the - * just-bound server is torn down here and the awaiting caller - * sees a rejected promise. - */ - private _ensureRuntime(githubToken: string): Promise { - if (this._runtime) { - return Promise.resolve(this._runtime); - } - if (!this._starting) { - this._starting = (async () => { - try { - const rt = await this._startServer(githubToken); - if (this._disposed) { - // dispose() ran while we were binding — the - // teardown noop'd because _runtime was still - // undefined, so close what we just created. - rt.server.closeAllConnections(); - rt.server.close(); - throw new Error('ClaudeProxyService has been disposed'); - } - this._runtime = rt; - return rt; - } finally { - this._starting = undefined; - } - })(); - } - return this._starting; - } - - private _releaseHandle(runtime: IProxyRuntime): void { - // If `dispose()` (or a later `start()`) already replaced the - // runtime, the handle's refcount no longer applies. - if (this._runtime !== runtime) { - return; - } - runtime.refcount--; - if (runtime.refcount === 0) { - this._teardownRuntime(); - } - } - - private _teardownRuntime(): void { - const runtime = this._runtime; - if (!runtime) { - return; - } - this._runtime = undefined; - // Abort in-flight requests so the catch handlers run and - // destroy still-open responses; closeAllConnections() then - // frees the listening socket immediately. - for (const entry of runtime.inFlight) { - entry.ac.abort(); - } - runtime.server.closeAllConnections(); - runtime.server.close(err => { - if (err) { - this._logService.warn(`[${PROXY_USER_FACING_NAME}] server.close error: ${err.message}`); - } - }); - } - - private async _startServer(githubToken: string): Promise { - const nonce = generateNonce(); - const inFlight = new Set(); - const httpModule = await import('http'); - const server = httpModule.createServer(); - - await new Promise((resolve, reject) => { - const onError = (err: Error) => { reject(err); }; - server.once('error', onError); - server.listen(0, '127.0.0.1', () => { - server.removeListener('error', onError); - resolve(); - }); - }); - - const address = server.address(); - if (!address || typeof address === 'string') { - server.close(); - throw new Error(`${PROXY_USER_FACING_NAME} failed to bind: unexpected address ${String(address)}`); - } - const baseUrl = `http://127.0.0.1:${(address as AddressInfo).port}`; - this._logService.info(`[${PROXY_USER_FACING_NAME}] listening on ${baseUrl}`); - - const runtime: IProxyRuntime = { - server, - baseUrl, - nonce, - inFlight, - githubToken, - refcount: 0, - }; - - // Attach the request handler only after `runtime` is fully - // built. Node's single-threaded event loop guarantees no - // `request` event can be parsed and dispatched between - // `listen` resolving and this synchronous registration, so - // the handler can safely close over `runtime` as a `const`. - server.on('request', (req, res) => { - this._handleRequest(req, res, runtime).catch(err => { - // Last-resort safety net. All known throw paths are - // already handled inside `_handleRequest`. - this._logService.error(`[${PROXY_USER_FACING_NAME}] unhandled request error: ${stringifyError(err)}`); - if (!res.headersSent) { - try { - writeJsonError(res, 500, 'api_error', 'Internal proxy error'); - } catch { - // nothing else we can do - } - } else if (!res.writableEnded) { - try { - res.end(); - } catch { /* ignore */ } - } - }); - }); - - return runtime; - } - - // #region Dispatch - - private async _handleRequest( - req: http.IncomingMessage, - res: http.ServerResponse, - runtime: IProxyRuntime, - ): Promise { - const method = req.method ?? 'GET'; - const pathname = new URL(req.url ?? '/', 'http://127.0.0.1').pathname; - this._logService.trace(`[${PROXY_USER_FACING_NAME}] ${method} ${pathname}`); - - // Health check is the only unauthenticated route. - if (method === 'GET' && pathname === '/') { - res.writeHead(200, { 'Content-Type': 'text/plain' }); - res.end('ok'); - return; - } - - const auth = parseProxyBearer(req.headers, runtime.nonce); - if (!auth.valid) { - writeJsonError(res, 401, 'authentication_error', 'Invalid authentication'); - return; - } - - if (method === 'GET' && pathname === '/v1/models') { - await this._handleModels(res, runtime); - return; - } - - if (method === 'POST' && pathname === '/v1/messages') { - await this._handleMessages(req, res, runtime); - return; - } - - if (method === 'POST' && pathname === '/v1/messages/count_tokens') { - writeJsonError(res, 501, 'api_error', 'count_tokens not supported by CAPI'); - return; - } - - writeJsonError(res, 404, 'not_found_error', `No route for ${method} ${pathname}`); - } - - // #endregion - - // #region GET /v1/models - - private async _handleModels(res: http.ServerResponse, runtime: IProxyRuntime): Promise { - let models: CCAModel[]; - try { - models = await this._copilotApiService.models(runtime.githubToken); - } catch (err) { - this._writeUpstreamErrorResponse(res, err); - return; - } - - const data: Anthropic.ModelInfo[] = []; - for (const m of models) { - if (!isAnthropicMessagesModel(m)) { - continue; - } - const parsed = tryParseClaudeModelId(m.id); - const sdkId = parsed ? parsed.toSdkModelId() : m.id; - data.push({ - id: sdkId, - type: 'model', - display_name: m.name || sdkId, - created_at: '1970-01-01T00:00:00Z', - capabilities: null, - max_input_tokens: null, - max_tokens: null, - }); - } - - const body = { - data, - has_more: false, - first_id: data.length > 0 ? data[0].id : null, - last_id: data.length > 0 ? data[data.length - 1].id : null, - }; - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify(body)); - } - - // #endregion - - // #region POST /v1/messages - - private async _handleMessages( - req: http.IncomingMessage, - res: http.ServerResponse, - runtime: IProxyRuntime, - ): Promise { - let bodyString: string; - try { - bodyString = await readRequestBody(req); - } catch (err) { - writeJsonError(res, 400, 'invalid_request_error', `Failed to read request body: ${stringifyError(err)}`); - return; - } - - let parsed: unknown; - try { - parsed = JSON.parse(bodyString); - } catch { - writeJsonError(res, 400, 'invalid_request_error', 'Request body is not valid JSON'); - return; - } - if (!parsed || typeof parsed !== 'object') { - writeJsonError(res, 400, 'invalid_request_error', 'Request body must be a JSON object'); - return; - } - - const body = parsed as Record; - const sdkModelId = body.model; - if (typeof sdkModelId !== 'string' || sdkModelId.length === 0) { - writeJsonError(res, 400, 'invalid_request_error', 'Missing required field: model'); - return; - } - if (!Array.isArray(body.messages)) { - writeJsonError(res, 400, 'invalid_request_error', 'Missing required field: messages'); - return; - } - - const parsedModel = tryParseClaudeModelId(sdkModelId); - if (!parsedModel) { - writeJsonError(res, 404, 'not_found_error', `Unknown model: ${sdkModelId}`); - return; - } - const endpointModelId = parsedModel.toEndpointModelId(); - body.model = endpointModelId; - - const stream = body.stream === true; - const headers = buildOutboundHeaders(req.headers); - - const entry: IInFlight = { - ac: new AbortController(), - res, - clientGone: false, - }; - runtime.inFlight.add(entry); - const onClose = () => { - entry.clientGone = true; - entry.ac.abort(); - }; - res.on('close', onClose); - - try { - if (stream) { - await this._streamMessages( - body as unknown as Anthropic.MessageCreateParamsStreaming, - headers, - res, - entry, - runtime, - sdkModelId, - ); - } else { - await this._sendNonStreamingMessage( - body as unknown as Anthropic.MessageCreateParamsNonStreaming, - headers, - res, - entry, - runtime, - sdkModelId, - ); - } - } finally { - res.removeListener('close', onClose); - runtime.inFlight.delete(entry); - } - } - - private async _sendNonStreamingMessage( - body: Anthropic.MessageCreateParamsNonStreaming, - headers: Record, - res: http.ServerResponse, - entry: IInFlight, - runtime: IProxyRuntime, - originalSdkModelId: string, - ): Promise { - const options: ICopilotApiServiceRequestOptions = { headers, signal: entry.ac.signal }; - let message: Anthropic.Message; - try { - message = await this._copilotApiService.messages(runtime.githubToken, body, options); - } catch (err) { - if (entry.ac.signal.aborted) { - if (!entry.clientGone && !res.writableEnded) { - res.destroy(); - } - return; - } - this._writeUpstreamErrorResponse(res, err); - return; - } - - // Rewrite outbound `model` to SDK format. Failure to re-parse - // shouldn't normally happen because we just translated it on - // the way in, but log + passthrough rather than dropping. - const outboundModel = rewriteModelToSdk(message.model, this._logService) ?? originalSdkModelId; - const responseBody: Anthropic.Message = { ...message, model: outboundModel }; - - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify(responseBody)); - } - - private async _streamMessages( - body: Anthropic.MessageCreateParamsStreaming, - headers: Record, - res: http.ServerResponse, - entry: IInFlight, - runtime: IProxyRuntime, - _originalSdkModelId: string, - ): Promise { - const options: ICopilotApiServiceRequestOptions = { headers, signal: entry.ac.signal }; - let stream: AsyncGenerator; - try { - stream = this._copilotApiService.messages(runtime.githubToken, body, options); - } catch (err) { - // Synchronous throws from the generator factory (rare — - // CAPI errors come from the first iteration). - if (entry.ac.signal.aborted) { - if (!entry.clientGone && !res.writableEnded) { - res.destroy(); - } - return; - } - this._writeUpstreamErrorResponse(res, err); - return; - } - - // Pull the first event before committing to a 200 response so - // we can surface a pre-stream error as a regular JSON error. - let first: IteratorResult; - try { - first = await stream.next(); - } catch (err) { - if (entry.ac.signal.aborted) { - if (!entry.clientGone && !res.writableEnded) { - res.destroy(); - } - return; - } - this._writeUpstreamErrorResponse(res, err); - return; - } - - // Commit to streaming response now. - res.writeHead(200, { - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache', - 'Connection': 'keep-alive', - }); - res.flushHeaders(); - req_setNoDelay(res); - - const writeFrame = async (event: Anthropic.MessageStreamEvent): Promise => { - const transformed = rewriteEventModel(event, this._logService); - const frame = `event: ${transformed.type}\ndata: ${JSON.stringify(transformed)}\n\n`; - const ok = res.write(frame); - if (!ok) { - try { - await once(res, 'drain', { signal: entry.ac.signal }); - } catch { - // signal aborted while waiting on drain — bail out - return false; - } - } - return true; - }; - - try { - if (!first.done) { - const ok = await writeFrame(first.value); - if (!ok) { - return; - } - } - while (true) { - let next: IteratorResult; - try { - next = await stream.next(); - } catch (err) { - if (entry.ac.signal.aborted) { - if (!entry.clientGone && !res.writableEnded) { - res.destroy(); - } - return; - } - // Mid-stream error: emit Anthropic SSE error frame, then end. - const envelope = err instanceof CopilotApiError - ? err.envelope - : buildErrorEnvelope('api_error', stringifyError(err)); - if (!res.writableEnded) { - try { - res.write(formatSseErrorFrame(envelope)); - } catch { /* socket may have died */ } - try { - res.end(); - } catch { /* ignore */ } - } - return; - } - if (next.done) { - break; - } - const ok = await writeFrame(next.value); - if (!ok) { - return; - } - } - if (!res.writableEnded) { - res.end(); - } - } catch (err) { - // Defense in depth — should not be reached. - this._logService.warn(`[${PROXY_USER_FACING_NAME}] stream loop unexpected error: ${stringifyError(err)}`); - if (!res.writableEnded) { - try { res.end(); } catch { /* ignore */ } - } - } - } - - // #endregion - - // #region Error helpers - - private _writeUpstreamErrorResponse(res: http.ServerResponse, err: unknown): void { - if (res.headersSent) { - // Headers are already sent — caller should have routed to - // the SSE error path. This is a defensive log. - this._logService.warn(`[${PROXY_USER_FACING_NAME}] cannot write upstream error after headers sent: ${stringifyError(err)}`); - if (!res.writableEnded) { - try { res.end(); } catch { /* ignore */ } - } - return; - } - if (err instanceof CopilotApiError) { - // Mid-stream sentinel doesn't map to a meaningful HTTP - // status before headers are sent. Coerce to 502 so we - // don't ship a 520 with a JSON body that violates HTTP - // semantics for the consumer. - const status = err.status === COPILOT_API_ERROR_STATUS_STREAMING ? 502 : err.status; - writeUpstreamJsonError(res, status, err.envelope); - return; - } - writeJsonError(res, 502, 'api_error', err instanceof Error ? err.message : String(err)); - } - - // #endregion -} - -// #endregion - -// #region Helpers - -function isAnthropicMessagesModel(m: CCAModel): boolean { - if (!KNOWN_CLAUDE_VENDORS.has(m.vendor.toLowerCase())) { - return false; - } - return Array.isArray(m.supported_endpoints) && m.supported_endpoints.includes(ANTHROPIC_MESSAGES_ENDPOINT); -} - -function rewriteModelToSdk(modelId: string, logService: ILogService): string | undefined { - const parsed = tryParseClaudeModelId(modelId); - if (!parsed) { - logService.warn(`[${PROXY_USER_FACING_NAME}] outbound model ID could not be parsed for SDK rewrite: ${modelId}`); - return undefined; - } - return parsed.toSdkModelId(); -} - -/** - * Pure-function rewrite of `model` fields on `Anthropic.MessageStreamEvent` - * objects from CAPI (endpoint format) to SDK (hyphenated) format. Only - * `message_start.message.model` carries a model ID in the streaming - * taxonomy; other event types pass through unchanged. - */ -function rewriteEventModel( - event: Anthropic.MessageStreamEvent, - logService: ILogService, -): Anthropic.MessageStreamEvent { - if (event.type !== 'message_start') { - return event; - } - const sdkModel = rewriteModelToSdk(event.message.model, logService); - if (sdkModel === undefined || sdkModel === event.message.model) { - return event; - } - return { - ...event, - message: { ...event.message, model: sdkModel }, - }; -} - -/** - * Build the headers we forward to {@link ICopilotApiService.messages} - * from the inbound request. Drops everything except `anthropic-version` - * (verbatim) and `anthropic-beta` (filtered through - * {@link filterSupportedBetas}). - */ -function buildOutboundHeaders(inbound: http.IncomingHttpHeaders): Record { - const out: Record = {}; - const version = inbound['anthropic-version']; - if (typeof version === 'string' && version.length > 0) { - out['anthropic-version'] = version; - } - const beta = inbound['anthropic-beta']; - if (typeof beta === 'string' && beta.length > 0) { - const filtered = filterSupportedBetas(beta); - if (filtered !== undefined) { - out['anthropic-beta'] = filtered; - } - } - return out; -} - -function readRequestBody(req: http.IncomingMessage): Promise { - return new Promise((resolve, reject) => { - const chunks: Buffer[] = []; - req.on('data', chunk => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))); - req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))); - req.on('error', reject); - }); -} - -function req_setNoDelay(res: http.ServerResponse): void { - const socket = res.socket; - if (socket && typeof socket.setNoDelay === 'function') { - try { - socket.setNoDelay(true); - } catch { - // not all socket implementations support it (mocks etc.) - } - } -} - -function stringifyError(err: unknown): string { - if (err instanceof Error) { - return err.message; - } - return String(err); -} - -// #endregion diff --git a/src/vs/platform/agentHost/node/claude/phase-plan.1.5.md b/src/vs/platform/agentHost/node/claude/phase-plan.1.5.md deleted file mode 100644 index 308e6151..00000000 --- a/src/vs/platform/agentHost/node/claude/phase-plan.1.5.md +++ /dev/null @@ -1,610 +0,0 @@ -# Phase 1.5 Implementation Plan — Widen `ICopilotApiService` to Raw Anthropic Events - -Council-synthesized plan (GPT + Opus + Gemini). All architectural decisions are final. -Implement as a single atomic PR on top of the Phase 1 commit. - ---- - -## Overview - -| File | Change type | -|------|------------| -| `/package.json` | Add `@anthropic-ai/sdk@^0.82.0` dependency | -| `src/vs/platform/agentHost/node/shared/copilotApiService.ts` | Drop custom types; rewrite interface + implementation | -| `src/vs/platform/agentHost/test/node/shared/copilotApiService.test.ts` | Migrate 80 existing tests + add ~15 new | - ---- - -## Finalized decisions (do not re-litigate) - -1. `@anthropic-ai/sdk@^0.82.0` goes in root `package.json` `dependencies`. -2. Drop `ICopilotMessagesRequest`, `ICopilotChatMessage`, `ICopilotMessagesResponse` entirely. -3. Request type: use SDK's discriminated `Anthropic.MessageCreateParamsStreaming` / `MessageCreateParamsNonStreaming` (and the `MessageCreateParams` union for the implementation signature) — not `MessageCreateParamsBase`. -4. New `ICopilotApiServiceRequestOptions { headers?, signal? }` replaces the positional `signal` everywhere. -5. `messages()` streaming → `AsyncGenerator`. -6. `messages()` non-streaming → `Promise`. -7. ~~`messagesText()` wraps `messages()`, filters to `text_delta.text` strings.~~ **Cut** — no downstream phase consumes it. This is a greenfield service; callers can filter `messages()` in a few lines if needed. -8. `countTokens()` throws `'countTokens not supported by CAPI'` immediately. -9. `message_stop` **is yielded** as the last event before the generator returns. -10. All 80 tests migrate in the same PR. - ---- - -## Step 0 — `package.json` - -**File:** `/package.json` line ~92 (after `@anthropic-ai/sandbox-runtime`) - -```diff - "@anthropic-ai/sandbox-runtime": "0.0.49", -+ "@anthropic-ai/sdk": "^0.82.0", -``` - -Run `npm install` to update the lockfile. - ---- - -## Step 1 — Drop custom types, add import and options type - -**File:** `src/vs/platform/agentHost/node/shared/copilotApiService.ts` - -### 1-A. Add SDK import (top of file, after existing imports ~line 6) - -```typescript -import Anthropic from '@anthropic-ai/sdk'; -``` - -### 1-B. Delete the three custom types in `// #region Types` (lines 15–51) - -- Delete `ICopilotChatMessage` (lines ~18–21) -- Delete `ICopilotMessagesRequest` (lines ~22–38, including doc comment) -- Delete `ICopilotMessagesResponse` (lines ~40–51, including doc comment) - -### 1-C. Add `ICopilotApiServiceRequestOptions` in their place - -```typescript -/** - * Per-call transport options for all {@link ICopilotApiService} methods. - * - * `headers` are merged into the outgoing CAPI request before security- - * sensitive headers (`Authorization`, `Content-Type`, `X-Request-Id`, - * `OpenAI-Intent`), so callers cannot override those. - * - * `signal` propagates to every fetch call (token mint + the API request). - */ -export interface ICopilotApiServiceRequestOptions { - readonly headers?: Readonly>; - readonly signal?: AbortSignal; -} -``` - -Surviving types (`ICopilotTokenEnvelope`, `ICachedToken`, `ICapiInit`) stay untouched. - ---- - -## Step 2 — Rewrite `ICopilotApiService` interface (lines ~142–180) - -Replace the entire interface body: - -```typescript -export interface ICopilotApiService { - - readonly _serviceBrand: undefined; - - /** - * Stream a chat completion as raw Anthropic stream events. - * - * Yields every `Anthropic.MessageStreamEvent` in the order the server - * emits them, **including `message_stop` as the last event** before the - * generator returns. Phase 2 proxy relies on receiving a complete, - * replayable event stream. - * - * @throws on non-2xx status or SSE `error` event. - */ - messages( - githubToken: string, - request: Anthropic.MessageCreateParamsBase & { stream: true }, - options?: ICopilotApiServiceRequestOptions, - ): AsyncGenerator; - - /** - * Send a chat completion and return the full aggregated response. - * @throws on non-2xx status. - */ - messages( - githubToken: string, - request: Anthropic.MessageCreateParamsBase & { stream?: false }, - options?: ICopilotApiServiceRequestOptions, - ): Promise; - - /** - * Stream a chat completion as plain-text deltas only. - * - * Convenience wrapper over the streaming `messages()` overload that - * filters to `content_block_delta` events whose `delta.type === 'text_delta'` - * and yields only `delta.text`. All other event types are discarded. - * - * Semantically identical to the Phase 1 `messages()` streaming behavior. - */ - messagesText( - githubToken: string, - request: Anthropic.MessageCreateParamsBase & { stream: true }, - options?: ICopilotApiServiceRequestOptions, - ): AsyncGenerator; - - /** - * Count tokens for a hypothetical request. - * - * @throws always — `countTokens` is not supported by CAPI in Phase 1.5. - * Phase 2 proxy maps this to HTTP 501. - */ - countTokens( - githubToken: string, - req: Anthropic.MessageCountTokensParams, - options?: ICopilotApiServiceRequestOptions, - ): Promise; - - /** List models available to the GitHub user. */ - models(githubToken: string, options?: ICopilotApiServiceRequestOptions): Promise; -} -``` - ---- - -## Step 3 — Rewrite `CopilotApiService` public methods (lines ~199–237) - -### 3-A. `messages()` overloads + dispatch - -```typescript -messages( - githubToken: string, - request: Anthropic.MessageCreateParamsBase & { stream: true }, - options?: ICopilotApiServiceRequestOptions, -): AsyncGenerator; -messages( - githubToken: string, - request: Anthropic.MessageCreateParamsBase & { stream?: false }, - options?: ICopilotApiServiceRequestOptions, -): Promise; -messages( - githubToken: string, - request: Anthropic.MessageCreateParamsBase & { stream?: boolean }, - options?: ICopilotApiServiceRequestOptions, -): AsyncGenerator | Promise { - if (request.stream) { - return this._messagesStreaming(githubToken, request, options); - } - return this._messagesNonStreaming(githubToken, request, options); -} -``` - -### 3-B. `messagesText()` - -```typescript -async *messagesText( - githubToken: string, - request: Anthropic.MessageCreateParamsBase & { stream: true }, - options?: ICopilotApiServiceRequestOptions, -): AsyncGenerator { - for await (const event of this.messages(githubToken, request, options)) { - if ( - event.type === 'content_block_delta' && - event.delta.type === 'text_delta' - ) { - yield event.delta.text; - } - } -} -``` - -### 3-C. `countTokens()` - -```typescript -async countTokens( - _githubToken: string, - _req: Anthropic.MessageCountTokensParams, - _options?: ICopilotApiServiceRequestOptions, -): Promise { - throw new Error('countTokens not supported by CAPI'); -} -``` - -### 3-D. `models()` — change `signal?` → `options?` - -```typescript -async models(githubToken: string, options?: ICopilotApiServiceRequestOptions): Promise { -``` - -Inside: replace all `signal` references with `options?.signal`. The request header block stays; merge `options?.headers` before `Authorization`: - -```typescript -headers: { - ...options?.headers, - 'Authorization': `Bearer ${copilotToken}`, -}, -signal: options?.signal, -``` - ---- - -## Step 4 — Rewrite private streaming/non-streaming helpers - -### 4-A. `_messagesStreaming` (line ~295) - -```typescript -private async *_messagesStreaming( - githubToken: string, - request: Anthropic.MessageCreateParamsBase, - options?: ICopilotApiServiceRequestOptions, -): AsyncGenerator { - const response = await this._sendRequest(githubToken, request, true, options); - if (!response.body) { - throw new Error('CAPI response has no body'); - } - yield* this._readSSE(response.body); -} -``` - -### 4-B. `_messagesNonStreaming` (line ~309) - -```typescript -private async _messagesNonStreaming( - githubToken: string, - request: Anthropic.MessageCreateParamsBase, - options?: ICopilotApiServiceRequestOptions, -): Promise { - const response = await this._sendRequest(githubToken, request, false, options); - return response.json() as Promise; -} -``` - -Remove all text-concatenation logic that was in this method. - -### 4-C. `_sendRequest` (line ~330) — new signature + body builder - -```typescript -private async _sendRequest( - githubToken: string, - request: Anthropic.MessageCreateParamsBase, - stream: boolean, - options?: ICopilotApiServiceRequestOptions, -): Promise { -``` - -**Body construction** — spread the SDK-shaped request, inject `stream`, normalize `system`: - -```typescript -const { system, ...rest } = request; -const body = JSON.stringify({ - ...rest, - stream, - // CAPI requires system as a text-block array, not a raw string - ...(system !== undefined - ? { system: typeof system === 'string' ? [{ type: 'text', text: system }] : system } - : {}), -}); -``` - -**Headers** — merge caller headers first so security-sensitive ones always win: - -```typescript -headers: { - ...options?.headers, - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${copilotToken}`, - 'X-Request-Id': requestId, - 'OpenAI-Intent': 'conversation', -}, -signal: options?.signal, -``` - -Replace all internal `signal` references with `options?.signal`. Pass `options?.signal` to `_getCopilotToken`. - -### 4-D. `_readSSE` (line ~447) — change return type - -```typescript -private async *_readSSE(body: ReadableStream): AsyncGenerator { -``` - -Loop logic: if the event returned by `_parseDataLine` has `type === 'message_stop'`, yield it and then `return`. - -### 4-E. `_parseDataLine` (line ~497) — yield full events - -Change return type from `string | undefined | null` to `Anthropic.MessageStreamEvent | undefined`: - -```typescript -private _parseDataLine(line: string): Anthropic.MessageStreamEvent | undefined { - if (!line.startsWith('data: ')) { - return undefined; // skip non-data lines (event:, comments, blank) - } - const data = line.slice('data: '.length).trim(); - let parsed: Record; - try { - parsed = JSON.parse(data); - } catch { - return undefined; // skip malformed JSON - } - if (parsed['type'] === 'error') { - throw new Error( - (parsed['error'] as { message?: string })?.message ?? 'Unknown streaming error from CAPI', - ); - } - return parsed as Anthropic.MessageStreamEvent; -} -``` - -Update `_readSSE` caller: - -```typescript -private async *_readSSE(body: ReadableStream): AsyncGenerator { - const reader = body.getReader(); - try { - // ... existing chunk/line splitting logic unchanged ... - for (const line of lines) { - const event = this._parseDataLine(line); - if (event !== undefined) { - yield event; - if (event.type === 'message_stop') { - return; // generator terminates after message_stop - } - } - } - } finally { - reader.cancel(); - } -} -``` - -The `null`-sentinel pattern is removed. `message_stop` is yielded, then `return` triggers `finally`. - ---- - -## Step 5 — Migrate existing 80 tests - -**File:** `src/vs/platform/agentHost/test/node/shared/copilotApiService.test.ts` - -### 5-A. Imports — replace `ICopilotChatMessage` with Anthropic SDK type - -```typescript -// Remove: -import type { ICopilotChatMessage, ... } from '../../...copilotApiService.js'; -// Add: -import Anthropic from '@anthropic-ai/sdk'; -import type { ICopilotApiServiceRequestOptions, ICopilotApiService } from '../../...copilotApiService.js'; -``` - -### 5-B. `collect()` helper — make generic (line ~34) - -```typescript -async function collect(iter: AsyncIterable): Promise { - const result: T[] = []; - for await (const item of iter) { - result.push(item); - } - return result; -} -``` - -No other change to `collect` — it now works for both `string[]` (via `messagesText`) and `Anthropic.MessageStreamEvent[]` (via `messages`). - -### 5-C. Base request fixture — rename fields to SDK snake_case (line ~100) - -```typescript -const baseRequest = { - model: 'claude-sonnet-4-5', - messages: [{ role: 'user' as const, content: 'Hello' }], - max_tokens: 8192, - stream: false as const, -}; -``` - -Remove `headers` from `baseRequest` — headers are now in `options`. - -### 5-D. `ICopilotChatMessage[]` usage in tests (lines ~99, ~518) - -Replace with `Anthropic.MessageParam[]`: - -```typescript -// was: const messages: ICopilotChatMessage[] = [...] -const messages: Anthropic.MessageParam[] = [...] -``` - -The values `{ role: 'user', content: '...' }` are valid `MessageParam` — no shape change needed. - -### 5-E. Request Format suite (lines ~421–581) - -- **`maxTokens`** → **`max_tokens`** in any inline request objects. -- **Custom headers** move from request body to `options`: - ```typescript - // was: service.messages('gh-tok', { ...baseRequest, headers: { 'X-Foo': 'bar' } }) - // new: service.messages('gh-tok', baseRequest, { headers: { 'X-Foo': 'bar' } }) - ``` -- **`system` test** (line ~423): CAPI still expects the array format; the `_sendRequest` body builder handles the normalization. The assertion `system: [{ type: 'text', text: ... }]` stays correct. -- **`stream` flag test**: stays on the streaming call; `{ ...baseRequest, stream: true as const }` is still valid since `baseRequest.stream` is `false as const` and the spread overrides it. - -### 5-F. Non-Streaming suite (lines ~588–682) - -Add a local helper for extracting text from `Anthropic.Message`: - -```typescript -function getText(msg: Anthropic.Message): string { - return msg.content - .filter((b): b is Anthropic.TextBlock => b.type === 'text') - .map(b => b.text) - .join(''); -} -``` - -Per-test changes: - -| Old assertion | New assertion | -|---|---| -| `result.content === 'The answer is 42.'` | `getText(result) === 'The answer is 42.'` | -| `result.content === 'First part. Second part.'` | `getText(result) === 'First part. Second part.'` | -| "skips non-text blocks" → `result.content === 'the answer'` | `getText(result) === 'the answer'` (non-text blocks are in `result.content` but `getText` filters them) | -| `result.content === ''` | `getText(result) === ''` | -| `result.stopReason === 'max_tokens'` | `result.stop_reason === 'max_tokens'` | -| `result.stopReason === 'unknown'` when missing | Rename test: assert `result.stop_reason == null` | - -Error tests (429, 500) — no change. - -### 5-G. Streaming suite (lines ~688–956) — all text-asserting tests move to `messagesText()` - -Mechanical find-replace within this suite: - -```typescript -// was: -collect(service.messages('gh-tok', { ...baseRequest, stream: true as const })) -// new: -collect(service.messagesText('gh-tok', { ...baseRequest, stream: true as const })) -``` - -This applies to all tests asserting `string[]` values. The assertions themselves are unchanged. - -**Keep on `messages()`** (not `messagesText`): -- Error-throwing tests (SSE `error` event, non-200, missing body) — testing transport errors, not text extraction -- Cancellation stream tests (lines ~1057–1133) — testing `reader.cancel()`, not yielded values - -### 5-H. Cancellation suite — `signal` positional → `options.signal` (lines ~1009–1055) - -```typescript -// was: service.messages('gh-tok', baseRequest, controller.signal) -// new: service.messages('gh-tok', baseRequest, { signal: controller.signal }) - -// was: service.models('gh-tok', controller.signal) -// new: service.models('gh-tok', { signal: controller.signal }) -``` - ---- - -## Step 6 — Add new tests (~15) - -Add these three suites after the existing "Streaming Responses" suite. - -### Suite: `Raw Event Stream (messages())` - -```typescript -suite('Raw Event Stream (messages())', () => { - - test('yields all six protocol event types in order', async () => { - // stream: message_start, content_block_start, content_block_delta, - // content_block_stop, message_delta, message_stop - // assert: types array matches exactly - }); - - test('message_stop is the last yielded event', async () => { - // stream: content_block_delta, message_stop - // assert: last event is message_stop - }); - - test('stops after message_stop even if extra SSE data follows', async () => { - // stream: text_delta 'a', message_stop, text_delta 'SHOULD_NOT_APPEAR' - // assert: only 'a' delta present in collected events - }); - - test('yields thinking_delta events (not filtered by messages())', async () => { - // stream: content_block_delta with thinking_delta, message_stop - // assert: event is yielded with delta.type === 'thinking_delta' - }); - - test('yields input_json_delta events', async () => { - // similar — verifies tool call argument streaming passes through - }); - - test('yields message_delta with stop_reason payload', async () => { - // assert: message_delta event has delta.stop_reason === 'max_tokens' - }); - - test('messagesText() from same stream yields only text strings', async () => { - // same stream as above — call messagesText(), assert string[] result - }); -}); -``` - -### Suite: `messagesText()` - -```typescript -suite('messagesText()', () => { - - test('yields only text_delta.text strings', async () => { - // stream with text_delta events mixed with thinking_delta - // assert: only text strings in result - }); - - test('filters out thinking_delta events', async () => {}); - - test('filters out message_stop and lifecycle events', async () => {}); - - test('empty text_delta strings are included', async () => {}); - - test('forwards options.signal', async () => { - // assert: signal reaches fetch - }); -}); -``` - -### Suite: `countTokens` - -```typescript -suite('countTokens', () => { - - test('throws "countTokens not supported by CAPI"', async () => { - await assert.rejects( - () => service.countTokens('gh-tok', { model: 'claude-sonnet-4-5', messages: [{ role: 'user', content: 'hi' }] }), - (err: Error) => err.message.includes('countTokens not supported by CAPI'), - ); - }); - - test('does not mint a token before throwing', async () => { - // verify token mint fetch is never called - }); -}); -``` - ---- - -## Step 7 — Verify - -```bash -# TypeScript — must be zero errors -npm run compile-check-ts-native - -# Unit tests — all 95+ must pass -scripts/test.sh --grep copilotApiService -``` - ---- - -## Key risks - -| Risk | Where | Mitigation | -|------|-------|-----------| -| `_sendRequest` still cherry-picks fields | `copilotApiService.ts:337–343` | Must spread `...rest` from SDK request; delete old remapping | -| `system` field double-wrapping | `_sendRequest` body builder | Keep explicit `typeof system === 'string'` normalization; CAPI requires array format | -| Positional `signal` not fully migrated | All call sites in service + tests | Grep for `, signal` and `, controller.signal` before declaring done | -| Non-streaming tests asserting `result.content` (string) | Lines ~590–657 | All must switch to `getText(result)` helper | -| `message_stop` sentinel pattern removal | `_parseDataLine` / `_readSSE` | Remove `null` return; yield event, then `return`; `finally` still calls `reader.cancel()` | ---- - -## Learnings (from council review post-implementation) - -- **Buffer-flush path must mirror the main loop's `message_stop` early-return guard.** `_readSSE` has two paths that yield events: the main `while` loop (lines ~500–509) and a buffer-flush after the loop (lines ~511–516). The main loop correctly `return`s after yielding `message_stop`. The buffer-flush initially did not — it would yield `message_stop` but fall through naturally. Functionally identical today, but asymmetric: if someone later adds logic after the flush yield, they'd get events after `message_stop`. Always mirror the early-return guard in both paths. Fix: add `if (event.type === 'message_stop') return;` after the buffer-flush `yield event`. - -- **New raw-event suites must include a `tool_use` block round-trip test.** The roadmap explicitly requires testing that `tool_use` events pass through `messages()`. The initial implementation's raw-event suite only covered lifecycle, `thinking_delta`, `input_json_delta`, and `message_delta`. `tool_use` was omitted. Whenever adding new streaming suites, check the roadmap's "Tests" section for explicitly listed cases and add them all. - -- **Security-header override invariants need a separate test per method.** The `messages()` method had a test (line ~571) verifying callers cannot override `Authorization`, `Content-Type`, etc. The `models()` method did not — even though its implementation is also correct. The lack of a test means a future refactor could silently regress the security invariant. Add an equivalent header-override test for every public method that makes authenticated requests. - -## Learnings (from simplify review post-implementation) - -- **`MessageCreateParamsBase` is not re-exported on the `Anthropic` namespace.** The plan specified `Anthropic.MessageCreateParamsBase` as the request type, but it's an SDK internal. Use the discriminated forms: `Anthropic.MessageCreateParamsStreaming` / `Anthropic.MessageCreateParamsNonStreaming` for the overloads, and the `Anthropic.MessageCreateParams` union for the implementation signature. - -- **VS Code DI constructor ordering: non-service params must come first.** `GetLeadingNonServiceArgs` strips `BrandedService`-decorated params from the **end** of the tuple. Putting non-service params (like `fetchFn`) after service params causes `createInstance` to select the wrong overload (`Expected 4 arguments, but got 2`). This contradicts the CLAUDE.md guidance that "non-service parameters come after service parameters." - -- **Signal must not be shared across deduped async operations.** The original `_getCopilotToken` forwarded the caller's `AbortSignal` into the shared token mint promise. Because the mint is deduped across concurrent callers via `_pendingTokenMints`, aborting one caller's signal would cancel the mint for all callers sharing it. Fix: omit the signal from the mint call entirely; each caller forwards its signal only to its own API request. - -- **Avoid `as unknown as` for SSE event parsing — use a runtime type guard.** Instead of `parsed as unknown as Anthropic.MessageStreamEvent`, validate the `type` field against a `Set` of known SSE event types (`message_start`, `message_delta`, `message_stop`, `content_block_start`, `content_block_delta`, `content_block_stop`). This cleanly separates `error` event handling (which should throw) from valid events, and avoids TypeScript error TS2367 when comparing against values not in the target type. - -- **`messagesText()` was YAGNI.** No downstream phase in the roadmap consumes a text-only streaming API. This is a greenfield service with no backcompat obligations. Cut it rather than carry dead surface area. If a future caller needs text-only streaming, filtering `messages()` output is trivial. - -- **`@vscode/copilot-api` ambient typings must avoid `any`.** The package uses extensionless relative imports incompatible with `moduleResolution: "nodenext"`, requiring ambient declarations in `src/typings/copilot-api.d.ts`. The original declarations used `json?: any` and `Promise` — replaced with `unknown` per project coding guidelines. - -- **Use `Iterable.asyncToArray` from `base/common/iterator` instead of a local `collect` helper.** The codebase already has this utility; no need to duplicate it in tests. diff --git a/src/vs/platform/agentHost/node/claude/phase-plan.2.md b/src/vs/platform/agentHost/node/claude/phase-plan.2.md deleted file mode 100644 index 6bd1f69a..00000000 --- a/src/vs/platform/agentHost/node/claude/phase-plan.2.md +++ /dev/null @@ -1,808 +0,0 @@ -# Phase 2 Plan — `IClaudeProxyService` - -**Status:** ✅ done. Shipped `ClaudeProxyService` + helpers + tests + -DI registration in `agentHostMain.ts`. Council-reviewed and hardened -(F1/F2 race fixes applied; F3 test coverage added). Smoke-validated -end-to-end against real CAPI for all three surfaces (`GET /v1/models`, -streaming `POST /v1/messages`, non-streaming `POST /v1/messages`). No -callers wire it up yet — Phase 4 (`ClaudeAgent`) does that. Two -documented deviations from the original plan are inlined below as -"Shipped:" notes. - -A self-contained design + implementation plan for Phase 2 of the Claude -agent roadmap. - -> Sibling docs: [`roadmap.md`](./roadmap.md) (the multi-phase plan -> Phase 2 lives in), [`CONTEXT.md`](./CONTEXT.md) (the glossary that -> defines `Agent Host`, `Claude Agent`, `Claude Proxy`, `CAPI`). - ---- - -## 1. Context - -### What ships before this phase - -**Phase 1 (done):** `ICopilotApiService` at -`src/vs/platform/agentHost/node/shared/copilotApiService.ts`. A -gateway to GitHub Copilot's chat completions API ("**CAPI**") that -exposes: - -```ts -interface ICopilotApiService { - readonly _serviceBrand: undefined; - messages(githubToken: string, body: Anthropic.MessageCreateParamsNonStreaming, options?: ICopilotApiServiceRequestOptions): Promise; - messages(githubToken: string, body: Anthropic.MessageCreateParamsStreaming, options?: ICopilotApiServiceRequestOptions): AsyncGenerator; - countTokens(githubToken: string): Promise; // throws — CAPI does not support this - models(githubToken: string): Promise; -} - -interface ICopilotApiServiceRequestOptions { - headers?: Record; - signal?: AbortSignal; -} -``` - -`messages()` is a TypeScript overload — streaming returns an -`AsyncGenerator` of pre-parsed `Anthropic.MessageStreamEvent` objects; -non-streaming returns a `Promise`. CAPI is wire- -compatible with the Anthropic Messages API. - -`ICopilotApiService` is hand-rolled HTTP (not the `@anthropic-ai/sdk` -client) and currently throws plain `Error` on both non-2xx and SSE -error events. **Q10's passthrough design depends on widening this -contract — see §1.5.** - -**Phase 1.5 (done):** widened `ICopilotApiService` to use raw -`Anthropic.MessageStreamEvent` types and accept -`ICopilotApiServiceRequestOptions { headers?, signal? }`. - -### What this phase delivers - -A `ClaudeProxyService` — a local HTTP server that **speaks the -Anthropic Messages API on the inbound side and `ICopilotApiService` -on the outbound side**. The Claude Agent SDK -(`@anthropic-ai/claude-agent-sdk@0.2.112`) runs as a subprocess and -sees this proxy as its Anthropic API endpoint via -`ANTHROPIC_BASE_URL` + `ANTHROPIC_AUTH_TOKEN`. - -Phase 2 does NOT include the `ClaudeAgent` itself (Phase 4) — only -the proxy and its DI registration in the agent host. - -### Why a proxy at all - -- The SDK is hard-wired to call `https://api.anthropic.com`-shaped - endpoints. We can't redirect at the SDK level. -- We don't ship Anthropic API keys; we ship GitHub Copilot tokens. - The proxy authenticates via Bearer-token-with-nonce, mints CAPI - tokens internally, and forwards. -- All routing/translation logic stays out of the SDK subprocess. - -### Reference implementation - -A working version exists in the Copilot **extension** at -`extensions/copilot/src/extension/chatSessions/claude/`. Our proxy -**borrows** from it (model-ID parser, beta filter, disconnect -detection) but is structurally different in two ways: - -1. The reference is a **byte-passthrough** — it pipes upstream SSE - chunks verbatim. Our `ICopilotApiService.messages()` returns - pre-parsed event objects, so we MUST construct SSE frames ourselves. -2. The reference forces `stream: true` upstream and has known bugs - (no backpressure; mid-stream error handler tries to `writeHead` - after streaming has started). We support both branches and fix - these. - ---- - -## 1.5. Pre-Phase 2 dependency: widen Phase 1 error contract - -**Status:** ✅ done (verified live against CAPI). - -Phase 1 originally threw plain `Error` instances on both failure -paths, which discarded the upstream `status` + Anthropic error -envelope. Q10's passthrough design needs both to re-emit verbatim, -so Phase 1.5 widened the error contract. - -**Shipped change.** A typed error class carrying SDK-typed envelopes: - -```ts -import type Anthropic from '@anthropic-ai/sdk'; - -export class CopilotApiError extends Error { - constructor( - readonly status: number, - readonly envelope: Anthropic.ErrorResponse, - message?: string, - ) { - super(message ?? envelope.error.message); - this.name = 'CopilotApiError'; - } -} -``` - -Both throw sites construct this with the parsed Anthropic envelope: - -- **Non-2xx HTTP responses** — conforming bodies are passed through - verbatim (preserving any `request_id` and SDK-known extras); - non-conforming bodies (plain text, malformed JSON, missing - fields) are synthesized into a minimal `{ type: 'error', error: - { type: 'api_error', message }, request_id: null }` envelope. -- **Streaming `event: error` SSE frames** — `status` is set to the - sentinel `COPILOT_API_ERROR_STATUS_STREAMING = 520` (the upstream - HTTP response was 200; the real status is no longer meaningful - once the stream has started). Consumers should branch on - `envelope.error.type` rather than `status` for these. - -Network/transport failures (connection reset, DNS failure, etc.) -and token-mint failures stay as plain `Error` so consumers can -distinguish API errors from transport/auth-tier errors. - -Phase 2's Q10 does an `instanceof CopilotApiError` check and -re-serializes `err.envelope` with `err.status`; non-`CopilotApiError` -falls through to the synthetic 502 path. - -**Verified live (smoke probe against real CAPI):** `models()` -success, streaming `messages()` success with full -`message_start → content_block_* → message_delta → message_stop` -sequence and SDK-typed `delta.stop_reason` access, and the -documented token-mint-vs-CAPI-error tier separation. - ---- - -## 2. Design decisions - -Each decision was grilled, with alternatives considered, before being -locked in. The numbering matches the design log in `CONTEXT.md`. - -### Q1 — Naming - -`IClaudeProxyService` / `ClaudeProxyService`. Files under -`src/vs/platform/agentHost/node/claude/`. - -### Q2 — Lifecycle / DI - -Registered as a DI singleton in `agentHostMain.ts` alongside -`ICopilotApiService`. One instance per agent host process. - -### Q3 — API shape - -```ts -interface IClaudeProxyHandle extends IDisposable { - readonly baseUrl: string; - readonly nonce: string; -} - -interface IClaudeProxyService { - readonly _serviceBrand: undefined; - start(githubToken: string): Promise; - dispose(): void; -} -``` - -- **Refcounted.** Each `start()` increments, each `handle.dispose()` - decrements. At refcount 0 the listener closes, the token slot - clears, and the nonce is destroyed. The next `start()` rebinds with - a new port and a fresh nonce. -- **Shared token slot, last-writer-wins.** Single-tenant per roadmap. - Multi-tenant is Phase 4+. -- **Subprocess ownership invariant.** Callers that hand the handle's - `baseUrl` / `nonce` to a Claude SDK subprocess MUST kill that - subprocess before disposing the handle. The subprocess cannot - outlive the handle. - -### Q4 — Auth - -Enforce `Authorization: Bearer .` on authenticated -routes. Parse and log `sessionId` at trace level only; treat it as -opaque in Phase 2. `x-api-key` is ignored. - -Bearer enforcement runs FIRST on authenticated routes — bad token -yields 401, never 501 or 404. - -**Do NOT port `extractSessionId` from the reference verbatim.** The -reference -([claudeLanguageModelServer.ts L348-361](../../../../extensions/copilot/src/extension/chatSessions/claude/node/claudeLanguageModelServer.ts#L348-L361)) -accepts nonce-only bearer tokens (no `.sessionId`) as a legacy path. -Phase 2 deliberately rejects that — `Bearer ` (no dot) → 401. -Write a fresh parser in `claudeProxyAuth.ts`; the reference's test -fixtures cannot be lifted unchanged. - -### Q5 — Routes - -| Route | Status | Notes | -| --- | --- | --- | -| `POST /v1/messages` | full impl | streaming + non-streaming | -| `GET /v1/models` | net new | passthrough filtered to `vendor: 'Anthropic'` and `supported_endpoints` containing `/v1/messages`; reshaped to Anthropic's `Page` envelope (see below) | -| `POST /v1/messages/count_tokens` | net new | 501 with Anthropic error envelope: `{ type: 'error', error: { type: 'api_error', message: 'count_tokens not supported by CAPI' } }`. Settled as always-501 because `ICopilotApiService.countTokens()` itself throws (CAPI has no endpoint). The roadmap's earlier "pass-through (or 501)" wording is superseded. | -| `GET /` | health | plain-text `'ok'` | -| anything else | 404 | Anthropic error envelope | - -No `OPTIONS` handler — same-process consumer, CORS does not apply. - -**`/v1/models` response shape.** The Anthropic SDK's -`client.models.list()` calls `getAPIList('/v1/models', Page, ...)` -which expects a `PageResponse` envelope per -[`pagination.d.ts` L39-44](../../../../node_modules/@anthropic-ai/sdk/core/pagination.d.ts#L39-L44): - -```json -{ - "data": [ - { "id": "claude-opus-4-6-20250929", "type": "model", "display_name": "Claude Opus 4.6", "created_at": 0 }, - ... - ], - "has_more": false, - "first_id": null, - "last_id": null -} -``` - -Returning a bare array breaks SDK pagination. - -### Q6 — Model ID translation - -The SDK does literal prefix matching (e.g. -`id.startsWith('claude-opus-4-6')`). CAPI uses dotted versions -(`claude-opus-4.6`); the SDK uses hyphenated Anthropic-canonical IDs -(`claude-opus-4-6-20250929`). Translation is **bidirectional**. - -- Port `extensions/copilot/src/extension/chatSessions/claude/{common,node}/claudeModelId.ts` - into `node/claude/claudeModelId.ts` with a comment marking it as a - mirror that must be kept in sync. Lift the same test fixtures. -- Two pure helpers: `tryParseClaudeModelId(id)` returning a - `ParsedClaudeModelId` with `toSdkModelId()` / - `toEndpointModelId()`. No service, no class — the parser caches - internally. -- **Three rewrite points** in the proxy: - 1. inbound `requestBody.model` (SDK → CAPI) - 2. outbound `model` fields on streaming events and non-streaming - responses (CAPI → SDK), e.g. `message_start.message.model` - 3. `GET /v1/models` response IDs (CAPI → SDK) -- **Inbound parse failure**: 404 `not_found_error` — before any CAPI - call. -- **Outbound parse failure**: log a warning, pass the raw value - through. Strictly worse than translating, but strictly better than - dropping the response. -- Model **availability fallback** ("user picked an unavailable model, - pick the newest Sonnet") is a Phase 4 `ClaudeAgent` concern. The - proxy stays dumb. - -### Q7 — Anthropic-beta + header passthrough - -- Lift `filterSupportedBetas()` and the three-entry - `SUPPORTED_ANTHROPIC_BETAS` allowlist (`interleaved-thinking`, - `context-management`, `advanced-tool-use`) into - `node/claude/anthropicBetas.ts` with a "keep in sync" comment. - Allowlist match is prefix + `-` (date-suffix discipline). -- Applied at `POST /v1/messages` after auth, before model translation. - If the filtered result is a non-empty string, set it on the outbound - `ICopilotApiServiceRequestOptions.headers['anthropic-beta']`. If - `undefined`, omit the header entirely — never forward `''`. -- **Inbound header passthrough** is restricted to `anthropic-version` - (verbatim) and `anthropic-beta` (filtered). All other client headers - are dropped, including `x-request-id` / `request-id` — CAPI - generates its own. -- The proxy ignores `request.metadata` and any SDK-side `betas` - field; only the `anthropic-beta` header drives behavior. - -### Q8 — Streaming: framing, backpressure, mid-stream errors - -The reference is a byte-passthrough. We can't do that: -`ICopilotApiService.messages()` returns -`AsyncGenerator` (already-parsed event -objects), so we MUST construct SSE frames ourselves. - -**Framing.** Hand-rolled, per event: - -``` -event: \ndata: \n\n -``` - -Response headers, set once before the first event: - -- `Content-Type: text/event-stream` -- `Cache-Control: no-cache` -- `Connection: keep-alive` - -After `writeHead(200, …)` call `res.flushHeaders()` so the SDK gets -status + content-type before the first event, and -`req.socket.setNoDelay(true)` so small frames aren't held by Nagle. - -**Event mutation.** 1:1 passthrough with one surgical rewrite — the -`model` field on `message_start.message.model` (Q6 outbound CAPI→SDK). -No `[DONE]` line (Anthropic ends with `message_stop`, not OpenAI's -sentinel). No synthesized `ping` events. No event reordering. - -**Backpressure.** The reference does NOT handle this and we treat -that as a bug, not a guideline. When `res.write()` returns `false`, -`await once(res, 'drain', { signal: ac.signal })` before pulling the -next event from the generator. The signal is required — if the -client closes mid-buffer, `drain` never fires; passing `ac.signal` -lets abort reject the wait and unwind the loop. This also naturally -backpressures the upstream. - -**Mid-stream errors.** The reference's `catch` tries to -`writeHead(500)` after streaming has started, which throws — its -mid-stream error handling is silently broken. We emit an Anthropic- -shaped SSE error frame and end: - -``` -event: error -data: { "type": "error", "error": { "type": "api_error", "message": "..." } } -``` - -Then `res.end()`. Do not emit `message_stop` after `error` — `error` -is terminal in the Anthropic SDK. - -**Non-streaming branch.** When `request.stream !== true`: - -- `Content-Type: application/json` -- Single body: `JSON.stringify(message)` where `message` is the - `Anthropic.Message` returned by the non-streaming - `ICopilotApiService.messages()` overload, with the `model` field - rewritten to SDK format. -- The reference forces `stream: true` upstream and SSE-frames - everything — we explicitly do NOT do this. Honor `request.stream`. - -### Q9 — Abort / disconnect propagation - -One `AbortController` per inbound request, plumbed through to -`ICopilotApiServiceRequestOptions.signal`. Two distinct abort -sources — must be distinguished, because `server.close()` does NOT -destroy existing sockets and a `return` without writing leaves the -client hanging until TCP timeout. - -```ts -type InFlight = { ac: AbortController; res: ServerResponse; clientGone: boolean }; -const inFlight = new Set(); - -const entry: InFlight = { ac: new AbortController(), res, clientGone: false }; -inFlight.add(entry); -res.on('close', () => { entry.clientGone = true; entry.ac.abort(); }); - -try { - const stream = copilotApi.messages(githubToken, body, { signal: entry.ac.signal, headers }); - for await (const event of stream) { /* emit */ } -} catch (err) { - if (entry.ac.signal.aborted) { - if (!entry.clientGone) { res.destroy(); } // dispose-driven; client still listening - return; - } - // else: emit Anthropic SSE error frame (Q8) -} finally { - inFlight.delete(entry); -} -``` - -- **Single client-disconnect trigger**: `res.on('close')` sets - `clientGone = true` and aborts. The reference also uses this; - `req.on('close')` and `req.on('aborted')` are redundant. -- **No polling**. Pass `signal` to `ICopilotApiService.messages()`; - the generator rejects with `AbortError` on the next iteration. The - for-await unwinds naturally. -- **Client-disconnect path**: socket already closed — `return` - without writing. -- **Dispose-driven path**: socket still open — `res.destroy()` - before returning so the client doesn't hang. -- **Non-streaming branch**: same pattern. The awaited - `ICopilotApiService.messages()` rejects with `AbortError`; we - `res.destroy()` (if dispose-driven) or `return` (if - client-disconnect) without writing a body. -- **`dispose()` aborts in-flight**. On refcount→0 dispose: for each - `entry` in `inFlight`, call `entry.ac.abort()`. The catch handler - then `res.destroy()`s any still-open responses. After all - in-flight resolves, `server.close()`, clear the token slot, destroy - the nonce. - -### Q10 — Error envelopes - -Two distinct flows. - -**Proxy-authored errors.** No upstream response exists, so we -construct the Anthropic envelope ourselves via one helper: - -```ts -function writeJsonError(res, status, type, message) { - res.writeHead(status, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ type: 'error', error: { type, message } })); -} -``` - -Status + `error.type` per case: - -| Case | Status | `error.type` | -| --- | --- | --- | -| Missing/malformed `Authorization` | 401 | `authentication_error` | -| Bearer nonce mismatch | 401 | `authentication_error` | -| Bearer with no `.sessionId` | 401 | `authentication_error` | -| Unknown route | 404 | `not_found_error` | -| Bad JSON body | 400 | `invalid_request_error` | -| Missing required field (`model`, `messages`) | 400 | `invalid_request_error` | -| Model parse failure (Q6 inbound) | 404 | `not_found_error` | -| `POST /v1/messages/count_tokens` | 501 | `api_error` | - -**CAPI errors — passthrough, not mapping.** CAPI already speaks -Anthropic wire format. After §1.5's Phase 1 widening, -`ICopilotApiService` throws `CopilotApiError` carrying `err.status` -+ `err.envelope` (the parsed envelope, verbatim). We just -re-serialize: - -```ts -catch (err) { - if (err instanceof CopilotApiError) { - res.writeHead(err.status, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify(err.envelope)); - return; - } - // network / non-API error — synthesize - writeJsonError(res, 502, 'api_error', err.message ?? 'Upstream error'); -} -``` - -No subclass ladder. No mapping table. No `err.message` sanitization -— passthrough. - -**Mid-stream variant.** Same idea, SSE frame instead of JSON body: - -```ts -const envelope = err instanceof CopilotApiError - ? err.envelope - : { type: 'error', error: { type: 'api_error', message: err.message ?? 'Upstream error' } }; -res.write(`event: error\ndata: ${JSON.stringify(envelope)}\n\n`); -res.end(); -``` - -Status is ignored once headers are sent (Q8). On `signal.aborted`, -write nothing — client is gone (Q9). - -### Q11 — File layout - -**Shipped:** route handlers were collapsed into private methods on -`ClaudeProxyService` rather than separate `claudeProxyMessages.ts` / -`claudeProxyModels.ts` classes. They share per-request bookkeeping -(`IInFlight`, the `runtime` token slot, the abort plumbing); splitting -them would have required threading the same five arguments through -every call. Final layout: - -``` -src/vs/platform/agentHost/node/claude/ -├── claudeProxyService.ts # interface + impl + lifecycle + server + dispatch + route handlers -├── claudeModelId.ts # parser (Q6) — mirror of extension copy -├── anthropicBetas.ts # filterSupportedBetas + allowlist (Q7) -├── anthropicErrors.ts # buildErrorEnvelope, writeJsonError, writeUpstreamJsonError, formatSseErrorFrame (Q10) -└── claudeProxyAuth.ts # parseProxyBearer (Q4) - -src/vs/platform/agentHost/test/node/ (flat, matches existing convention) -├── claudeModelId.test.ts -├── anthropicBetas.test.ts -├── claudeProxyAuth.test.ts -└── claudeProxyService.test.ts -``` - -- **Pure helpers** (`claudeModelId`, `anthropicBetas`, - `anthropicErrors`, `claudeProxyAuth`) are platform-free pure - functions, testable in isolation. -- **Route handlers live inside `ClaudeProxyService`** as private - methods (`_handleModels`, `_handleMessages`, - `_sendNonStreamingMessage`, `_streamMessages`). -- **`claudeProxyService.ts`** owns the `IClaudeProxyService` interface, - the impl, `IClaudeProxyHandle`, refcounted lifecycle, the - `http.createServer()`, the top-level dispatch switch on - `url.pathname`, the `Set` of in-flight requests, and all - route handlers. -- **Interface lives next to impl.** No `common/` split for Phase 2 - — both consumers (`agentHostMain.ts`, future `ClaudeAgent`) are - in `node`. - ---- - -## 3. Acceptance criteria - -Phase 2 is "done" when all of the following pass. - -### Hygiene (run frequently during development) - -- [ ] `compile-check-ts-native` clean -- [ ] `eslint` clean -- [ ] `valid-layers-check` clean -- [ ] Hygiene check (gulp `hygiene`) clean — copyright headers, - tabs, string quoting, formatting - -### Lifecycle (Q2/Q3) - -- [ ] `start(token)` returns a handle with `baseUrl` (e.g. - `http://127.0.0.1:54321`) and a 256-bit hex `nonce` -- [ ] Two concurrent `start()` calls share one server, share the - latest token, return handles with the same `baseUrl` and `nonce` -- [ ] Disposing one handle while the other is alive: server stays - up, in-flight requests on the other handle continue -- [ ] Disposing the last handle: `server.close()` runs, in-flight - controllers abort, port is freed -- [ ] `start()` after refcount-0 dispose binds a new port and a - fresh nonce - -### Bind safety - -- [ ] Server binds only to `127.0.0.1`, not `0.0.0.0` - -### Auth (Q4) - -- [ ] Missing `Authorization` → 401 `authentication_error` -- [ ] `Bearer wrong-nonce.x` → 401 -- [ ] `Bearer ` (no dot) → 401 -- [ ] `Bearer .` (empty sessionId) → 401 -- [ ] `Bearer .abc-123` → request proceeds; sessionId logged -- [ ] `x-api-key: ` alone → 401 -- [ ] **Auth-first precedence on authenticated routes:** - - [ ] `GET /v1/models` with `Bearer wrong-nonce.x` → 401 (not 200) - - [ ] `POST /v1/messages/count_tokens` with `Bearer wrong-nonce.x` - → 401 (not 501) - - [ ] `POST /v1/messages` with `Bearer wrong-nonce.x` → 401 (not - 200, not a CAPI call made) - -### Routes (Q5) - -- [ ] `GET /` → 200, body `'ok'`, no auth required -- [ ] `GET /v1/models` (authed) → 200, response shape is - `{ data: [...], has_more: false, first_id: null, last_id: null }` - with each item `{ id, type: 'model', display_name, created_at: '1970-01-01T00:00:00Z', capabilities: null, max_input_tokens: null, max_tokens: null }` - and `id` in SDK format. (RFC3339 string instead of `0` because the - SDK `Anthropic.ModelInfo` type forces it — settled by council C1.) -- [ ] `POST /v1/messages/count_tokens` (authed) → 501 `api_error` -- [ ] `GET /something-else` (authed) → 404 `not_found_error` - -### Model translation (Q6) - -- [ ] SDK ID inbound translates to CAPI; CAPI ID inbound also works -- [ ] Unparseable model ID → 404 with no CAPI call -- [ ] `message_start.message.model` rewritten to SDK format on the - way out (streaming) -- [ ] Non-streaming `message.model` rewritten to SDK format on the - way out -- [ ] `/v1/models` IDs in SDK format - -### Beta filtering (Q7) - -- [ ] `anthropic-beta: interleaved-thinking-2025-05-14` → forwarded -- [ ] `anthropic-beta: foo,bar,baz` → header omitted upstream -- [ ] `anthropic-beta: interleaved-thinking-2025-05-14,foo` → - forwarded as `interleaved-thinking-2025-05-14` -- [ ] `anthropic-beta: interleaved-thinking` (no date) → omitted - -### Header passthrough (Q7) - -- [ ] `anthropic-version: 2023-06-01` → forwarded verbatim -- [ ] `x-request-id: foo` → dropped -- [ ] Random `x-custom-header` → dropped - -### Streaming (Q8) - -- [ ] `request.stream === true` → SSE with hand-rolled - `event:/data:` framing in CAPI's emitted order -- [ ] `request.stream !== true` → JSON body -- [ ] Tool-use streams reach client (`input_json_delta` events - present) -- [ ] Thinking streams reach client (`thinking_delta` events - present) -- [ ] No `[DONE]` line emitted -- [ ] `socket.setNoDelay(true)` is called - -### Abort (Q9) - -- [ ] Mid-stream client disconnect → upstream - `ICopilotApiService.messages()` receives `signal.aborted`; - generator unwinds; no further writes -- [ ] `await once(res, 'drain', { signal: ac.signal })` rejects - promptly when client disconnects mid-buffer (no hang) -- [ ] Mid-stream `dispose()` → all in-flight `res.destroy()`'d; - client doesn't hang waiting on a half-open socket; server closes -- [ ] Non-streaming client disconnect → no JSON body written -- [ ] Non-streaming `dispose()` → `res.destroy()` called; client - doesn't hang - -### Errors (Q10) - -- [ ] Proxy-authored errors emit the Q10 envelope with correct - status from the table -- [ ] CAPI `CopilotApiError` (per §1.5) → re-emitted with original - `err.status` + `err.envelope` verbatim -- [ ] Mid-stream CAPI error → SSE `event: error` frame with - `err.envelope`, then `res.end()`, no `message_stop` -- [ ] Network error (non-`CopilotApiError`) → 502 `api_error` - ---- - -## 4. Testing strategy - -Three surfaces. - -### Surface 1 — pure-helper unit tests - -Zero deps, fast, deterministic. Located in -`src/vs/platform/agentHost/test/node/`: - -- `claudeModelId.test.ts` — port the extension's fixture matrix; - bidirectional round-trip -- `anthropicBetas.test.ts` — port the 7 reference fixtures from - `extensions/copilot/.../test/extractSessionId.spec.ts` -- `claudeProxyAuth.test.ts` — auth matrix from Q4 - -### Surface 2 — service-level tests - -`claudeProxyService.test.ts` boots the real service against a fake -`ICopilotApiService`; uses real `http.Server` to hit -`127.0.0.1:`. - -`FakeCopilotApiService` is the **only** mock — everything else (real -HTTP, real `AbortController`, real parser, real filter) is real. Per -the codebase guideline: minimal mocking, mock only true I/O -boundaries. - -```ts -class FakeCopilotApiService implements ICopilotApiService { - nextMessagesResult: Anthropic.Message - | AsyncGenerator - | CopilotApiError; // §1.5 - lastCall: { - githubToken: string; - body: Anthropic.MessageCreateParams; - options: ICopilotApiServiceRequestOptions; - }; - messages(githubToken, body, options): ... - models(githubToken): ... - countTokens(githubToken): ... -} -``` - -Cases: lifecycle / refcount / port / dispose; auth fixtures -end-to-end through real HTTP; auth-first precedence on each -authenticated route; route status matrix; `/v1/models` Page envelope -shape; model translation roundtrip; beta filter forwarded correctly -to mock; streaming order + framing; non-streaming JSON body shape; -mid-stream abort propagates `signal.aborted` to mock; mid-stream -`CopilotApiError` becomes SSE error frame with `err.envelope`; -pre-stream `CopilotApiError` becomes JSON error with `err.status`; -client-disconnect aborts upstream and writes nothing; -dispose-driven abort calls `res.destroy()` on every in-flight -response. - -### Surface 3 — real-CAPI smoke (Phase 2, manual) - -Validates real CAPI without waiting for Phase 4's `ClaudeAgent`. - -Procedure: - -1. Temporarily modify the existing `CopilotAgent` (or wherever the - agent host first authenticates) to call - `claudeProxyService.start(token)` once a real GitHub token is - minted, and log the resulting `baseUrl` and `nonce` at info - level. -2. Launch the dev build (`./scripts/code.sh --agents` or - `Run Dev Agents`) and authenticate. -3. Use the **code-oss-logs** skill to read `agenthost.log` from the - most recent run; grep for the proxy line; extract `baseUrl` + - `nonce`. -4. From a separate terminal: - - ```bash - curl -H "Authorization: Bearer ${NONCE}.smoke-test" \ - "${BASE_URL}/v1/models" - curl -H "Authorization: Bearer ${NONCE}.smoke-test" \ - -H "Content-Type: application/json" \ - -H "anthropic-version: 2023-06-01" \ - --data '{"model":"claude-opus-4-6-20250929","messages":[{"role":"user","content":"say hi"}],"max_tokens":64}' \ - "${BASE_URL}/v1/messages" - ``` - -5. Verify: 200; SDK-format model IDs in `/v1/models`; non-streaming - `Anthropic.Message` shape with `model` rewritten to SDK format. -6. Repeat with `"stream": true` and `--no-buffer`; verify SSE frames. -7. Revert the temporary `CopilotAgent` change. - -### What we explicitly do NOT test in Phase 2 - -- Real Claude Agent SDK subprocess (Phase 4) -- Multi-tenant token isolation (Phase 4+) -- Proxy / PAC support (deferred — see §6) - ---- - -## 5. Implementation order - -Build in this order so each step has a green compile, lint, and -tests before the next begins. - -0. **Pre-Phase 2 dependency (§1.5)** - - Widen `ICopilotApiService` to throw `CopilotApiError` (or - equivalent typed error carrying `status` + Anthropic envelope) - on both non-2xx and SSE error paths. - - Update Phase 1 tests to cover the new error contract. - - Phase 2 cannot start cleanly without this — Q10 depends on it. -1. **Pure helpers + their tests** (no service yet) - - `claudeModelId.ts` + test - - `anthropicBetas.ts` + test - - `anthropicErrors.ts` (types + helpers, no `res.write` yet) - - `claudeProxyAuth.ts` + test (fresh implementation per Q4 — do - NOT port `extractSessionId`) - - Run hygiene + eslint after each -2. **Service shell** - - `IClaudeProxyService`, `IClaudeProxyHandle` interface - - lifecycle (start / refcount / dispose) with empty server - - test the lifecycle without any routes -3. **Routes** - - `GET /` (health) — easy, builds confidence in dispatch - - `GET /v1/models` — first real CAPI integration; verify Page - envelope - - `POST /v1/messages/count_tokens` — easy 501 - - `POST /v1/messages` — both branches; biggest piece - - tests at each step -4. **Real CAPI smoke** with the temporary `CopilotAgent` hook -5. **Revert hook, ensure all hygiene/lint/test green** - ---- - -## 6. Deferred for later phases - -Captured here so they aren't lost. None of these block Phase 2. - -- **HTTP proxy support** (`HTTP_PROXY` / `HTTPS_PROXY` env vars, - VS Code's `http.proxy` setting, PAC files, proxy auth). The Phase 2 - proxy talks to CAPI through `ICopilotApiService`, so any outbound - proxying is inherited from whatever HTTP client that service uses. - If the agent host needs to honor user proxy configuration we'll - need an explicit agent / dispatcher injection point on - `ICopilotApiService` and a way to source the config from the - renderer. Track separately when we pick this up. -- **Model availability fallback.** "User picked a model that's not - available on CAPI today, pick the newest Sonnet instead." Lives in - the `ClaudeAgent`, not the proxy. Phase 4. -- **Multi-tenant token isolation.** Multiple GitHub identities - concurrently using the same agent host process. Phase 4+. - ---- - -## 7. Open questions for reviewers - -Areas where additional eyes would be most useful: - -1. **Refcounted lifecycle vs. one-shot per agent.** We chose - refcount because the Claude Agent SDK can be invoked - concurrently for multiple sessions and we don't want each session - to bind a new port. Is there a scenario this misses? -2. **Shared token slot, last-writer-wins.** Single-tenant assumption - — good for Phase 2, but the second writer silently shadows the - first. Should we instead reject concurrent `start(token)` calls - if the new token differs from the current one? -3. **Outbound parse failure (Q6) — log + passthrough.** We chose - passthrough so we don't drop responses, but the SDK's prefix - matcher may then fail downstream. Is "warn + passthrough" or - "warn + 502" the better failure mode for a model the parser can't - recognize? -4. **Real-CAPI smoke as a manual procedure.** It validates the - full path against real CAPI without Phase 4 infrastructure, but - it requires a code-modify-then-revert. Worth a dedicated `--smoke` - CLI flag that triggers the same hook automatically? - -## 8. Resolved concerns (council review log) - -A prior cross-reviewed council pass surfaced and resolved the -following items, captured here so reviewers don't re-litigate: - -- **Phase 1 throws plain `Error`, not `Anthropic.APIError`.** - Resolved by §1.5 (widen Phase 1 to `CopilotApiError`). -- **`ICopilotApiService` methods take `githubToken` as first - parameter.** Resolved — interface sketch (§1), Q9 pseudocode, and - `FakeCopilotApiService` (§4) all updated. -- **Auth-first precedence not in acceptance criteria.** Resolved - — §3 now has explicit `Bearer wrong-nonce` cases for each - authenticated route. -- **`/v1/models` needed `Page` envelope, not bare array.** Resolved - — Q5 specifies the full `{ data, has_more, first_id, last_id }` - shape. -- **`dispose()` would orphan client sockets.** Resolved — Q9 - distinguishes client-disconnect (socket already closed) from - dispose-driven abort (`res.destroy()` required). -- **`once(res, 'drain')` could hang on client disconnect.** - Resolved — Q8 now passes `{ signal: ac.signal }`. -- **`extractSessionId` from reference can't be ported verbatim.** - Resolved — Q4 explicitly notes a fresh implementation is required; - reference accepts nonce-only, Phase 2 rejects it. diff --git a/src/vs/platform/agentHost/node/claude/roadmap.md b/src/vs/platform/agentHost/node/claude/roadmap.md deleted file mode 100644 index df833c15..00000000 --- a/src/vs/platform/agentHost/node/claude/roadmap.md +++ /dev/null @@ -1,722 +0,0 @@ -# Claude Agent in the Agent Host — Roadmap - -## North star - -A `ClaudeAgent` implementing `IAgent`, registered alongside the existing -`CopilotAgent`, that uses the **Claude Agent SDK** to drive Anthropic-format -tool-using sessions — but with all `messages` API traffic **proxied through a -local server** that translates Anthropic requests into calls to GitHub Copilot's -CAPI via `ICopilotApiService`. Users authenticate once with their GitHub -Copilot credentials and get Claude models without ever talking to Anthropic -directly. - -## Architecture (target) - -``` -┌────────────────────────┐ -│ Claude Agent SDK │ -│ (subprocess per agent)│ -│ ANTHROPIC_BASE_URL ─►│──HTTP──┐ -│ ANTHROPIC_AUTH_TOKEN │ │ -│ = nonce.sessionId │ │ -└────────────────────────┘ │ - ▼ - ┌──────────────────────────────────┐ - │ IClaudeProxyService (127.0.0.1) │ - │ /v1/messages │ - │ /v1/models │ - │ /v1/messages/count_tokens │ - │ • bearer = nonce.sessionId │ - │ • model ID resolution │ - │ • anthropic-beta filter │ - └──────────────────────────────────┘ - │ - ▼ - ┌──────────────────────────────────┐ - │ ICopilotApiService │ ← Phase 1 ✅ - │ token mint • CAPI • SSE │ - │ raw MessageStreamEvent yield │ ← Phase 1.5 - └──────────────────────────────────┘ - │ - ▼ - GitHub CAPI -``` - -`ClaudeAgent` (Phase 4+) sits on top of the SDK and implements the same -`IAgent` surface as `CopilotAgent`, so the workbench / sessions UI doesn't -need to know whether it's talking to Copilot or Claude. - -### Reference implementations (verified, with caveats) - -Two extension-side files are the canonical reference for the patterns we need -to reproduce in the agent host. **Verify behavior against the file before -porting** — the council found several reference miscitations in the v1 -roadmap. - -- `extensions/copilot/src/extension/chatSessions/claude/node/claudeLanguageModelServer.ts` - — proxy pattern: `127.0.0.1` ephemeral port, `nonce.sessionId` bearer auth, - `anthropic-beta` filtering (uses `b.startsWith(supported + '-')` — see - `filterSupportedBetas` at line 381). Uses `@anthropic-ai/sdk` types. - **Caveat:** only routes `POST /v1/messages` (lines 106–121). `/v1/models` - and `/v1/messages/count_tokens` are net-new work in Phase 2. -- `extensions/copilot/src/extension/chatSessions/claude/node/claudeCodeAgent.ts` - — `ClaudeCodeSession` lifecycle: per-session `Query` kept alive across turns, - request queue with deferred-prompt async iterable, yield-restart on - settings/tools change, MCP gateway with idle timer, restart-on-toggle for - customizations. **Caveats:** - - Uses `_abortController.abort()` (line 719), not `Query.interrupt()` — - verify in Phase 3 spike which mechanism actually cancels the SDK turn. - - Uses `mcpServers` config for HTTP-based MCP (lines 391–416) — does - **not** use `createSdkMcpServer` + `tool()`. The in-process tool path - lives in `extensions/copilot/src/extension/chatSessions/claude/common/mcpServers/ideMcpServer.ts`. - - Uses `systemPrompt: { type: 'preset', preset: 'claude_code' }` (line - 478). There is **no** `tools: { type: 'preset' }` — that was a roadmap - v1 error. - - Pins `settingSources: ['user', 'project', 'local']` (line 482). Does - **not** include `managed`. -- `extensions/copilot/src/extension/chatSessions/claude/node/claudeCodeSdkService.ts` - — wraps SDK functions including `forkSession(sessionId, options)` (top-level - function, lines 57, 121–124), `listSubagents`, `getSubagentMessages` (lines - 60–74). -- `src/vs/platform/agentHost/node/copilot/copilotAgent.ts` — `IAgent` reference - for capability surface and customization hooks. - ---- - -## Phases - -Each phase ships behind the previous one; each is small enough to land as one -PR and ends at a verifiable boundary (tests, a CLI smoke, or an end-to-end -manual run). - -### Phase 1 — `ICopilotApiService` ✅ **DONE** - -Foundational gateway to the Copilot CAPI: token mint + cache + invalidation, -`messages` (streaming + non-streaming), `models`, abort propagation, SSE body -cancellation. Lives at `node/shared/copilotApiService.ts`. Fully unit-tested -(70 tests). Council-reviewed and hardened (C1/C2/S1/S2 fixes applied). - -### Phase 1.5 — Widen `ICopilotApiService` to raw Anthropic events ✅ **DONE** - -Widened `messages()` from text-only deltas to the full -`Anthropic.MessageStreamEvent` stream. Replaced bespoke request/response types -with `@anthropic-ai/sdk` types. Added `countTokens()` (throws — CAPI has no -endpoint). 71 unit tests. - -**`messagesText()` was planned but cut** — no downstream phase consumes it. -This is a greenfield service with no backcompat obligations; if a future caller -wants text-only streaming, it can filter `messages()` output in a few lines. - -**Final interface shape:** - -```typescript -interface ICopilotApiServiceRequestOptions { - headers?: Readonly>; - signal?: AbortSignal; -} - -interface ICopilotApiService { - messages(token: string, req: Anthropic.MessageCreateParamsStreaming, options?: ICopilotApiServiceRequestOptions): AsyncGenerator; - messages(token: string, req: Anthropic.MessageCreateParamsNonStreaming, options?: ICopilotApiServiceRequestOptions): Promise; - countTokens(token: string, req: Anthropic.MessageCountTokensParams, options?: ICopilotApiServiceRequestOptions): Promise; - models(token: string, options?: ICopilotApiServiceRequestOptions): Promise; -} -``` - -**Learnings from implementation:** - -1. **VS Code DI constructor ordering:** `GetLeadingNonServiceArgs` strips - `BrandedService`-decorated params from the **end** of the tuple, so - non-service params (like `fetchFn`) must come **first** in the constructor. - Putting them after service params causes `createInstance` to select the - wrong overload. -2. **Signal-sharing bug in deduped async operations:** the original - `_getCopilotToken` forwarded the caller's `AbortSignal` into the shared - token mint promise. Because the mint is deduped across concurrent callers, - aborting caller A's signal would cancel the mint for all callers sharing - it. Fix: omit the signal from the mint call; each caller forwards its - signal only to its own API request. -3. **Avoid `as unknown as` for SSE event parsing.** Instead of casting raw - JSON to `Anthropic.MessageStreamEvent`, use a `Set` of known event type - strings as a runtime type guard. This also cleanly separates `error` event - handling (which should throw) from valid event types. -4. **`@vscode/copilot-api` typings:** the package's `.d.ts` files use - extensionless relative imports incompatible with `moduleResolution: - "nodenext"`. Ambient declarations in `src/typings/copilot-api.d.ts` are - required until the package is fixed. Keep `any` out of those declarations - (`unknown` instead). - -**Open question (carried to Phase 2):** - -- Where does **model ID translation** live? Tentatively the proxy (Phase 2), - so `model` in `MessageCreateParams` stays an opaque string here. - -### Phase 2 — `IClaudeProxyService` (local proxy) ✅ **DONE** - -A local HTTP server that speaks Anthropic's `/v1/messages`, `/v1/models`, and -`/v1/messages/count_tokens` wire format on the inbound side, and -`ICopilotApiService` on the outbound side. **No `IAgent` integration in this -phase** — the deliverable is a service that any consumer can spin up, point a -Claude SDK / `curl` at, and get streamed responses out. - -Reference: `claudeLanguageModelServer.ts` is the existing implementation of -the `/v1/messages` route (only). `/v1/models` and `/v1/messages/count_tokens` -are net-new. - -Scope: - -- `IClaudeProxyService` interface in `node/claude/claudeProxyService.ts`. -- `start()` returns a `{ port, baseUrl, nonce, dispose }` handle. -- Bind to `127.0.0.1` only, ephemeral port (`listen(0, '127.0.0.1', ...)`). -- **Auth:** per-instance random `nonce`. Bearer format - `Authorization: Bearer .`. The proxy verifies the nonce - prefix; the `sessionId` segment lets the proxy attribute requests to a - session. **Require the `.sessionId` segment** (no nonce-only legacy path — - the reference allows it; we don't need to). `x-api-key` header is - **ignored** (prevents a user's personal `ANTHROPIC_API_KEY` from - interfering). -- **`anthropic-beta` filter:** match the reference exactly: - `b.startsWith(supported + '-')` against the allowlist - (`SUPPORTED_ANTHROPIC_BETAS` constant). Bare values like `interleaved-thinking` - are **rejected** by this rule; only date-suffixed variants like - `interleaved-thinking-2025-05-14` pass. -- **Model ID resolution:** rewrite `requestBody.model` against - `ICopilotApiService.models()` (CCAModel list), mirroring - `claudeLanguageModelServer.ts` → `claudeCodeModels.resolveEndpoint()`. -- Routes: - - `POST /v1/messages` — streaming and non-streaming, request shape passed - through to `ICopilotApiService.messages()` after model resolution. Stream - re-emits `MessageStreamEvent`s as SSE. - - `GET /v1/models` — pass-through to `ICopilotApiService.models()`, mapped - to Anthropic's `/v1/models` response shape. **Net new** vs. the - reference. - - `POST /v1/messages/count_tokens` — pass-through to - `ICopilotApiService.countTokens()` (or 501 if CAPI doesn't support it). - **Net new** vs. the reference. -- **GitHub token:** held in-memory on the proxy, set via a setter the agent - calls after `IAgent.authenticate()`. **Single-tenant token slot** — token - changes affect all sessions sharing the proxy. Document this constraint; - per-session tokens are a follow-up. -- Abort propagation: client disconnect → cancel the upstream - `ICopilotApiService` call (already supports abort). -- **Lifetime:** **one proxy per `ClaudeAgent`**, shared across all sessions. - -Tests: - -- `/v1/messages` non-streaming → mocked `ICopilotApiService` → correct - `Anthropic.Message` JSON. -- `/v1/messages` streaming → SSE `MessageStreamEvent`s in order, including - `tool_use` and `thinking_delta`; `message_stop` closes the stream; - mid-stream abort cancels upstream. -- `/v1/models` → forwards to `ICopilotApiService.models()`, returns Anthropic - shape. -- `/v1/messages/count_tokens` → pass-through (or 501). -- Bearer enforcement: missing nonce, wrong nonce, missing `.sessionId` - segment, `x-api-key` alone → 401 without ever touching `ICopilotApiService`. -- `anthropic-beta`: `interleaved-thinking` (bare) → stripped; - `interleaved-thinking-2025-05-14` → forwarded. -- Model resolution: Anthropic-canonical ID → CAPI model ID rewrite. -- Bind address is `127.0.0.1` (no external interface). -- `dispose()` closes the listener and rejects in-flight requests. - -Exit criteria: `curl` against the proxy with `Bearer .test` returns the -same payload shape Anthropic would, and `ICopilotApiService` sees the right -calls. - -### Phase 3 — Claude Agent SDK integration spike - -A throw-away spike that proves the SDK can be pointed at `IClaudeProxyService` -and complete a tool-using turn end-to-end. **Not** an `IAgent` implementation -yet. - -**SDK version:** pin `@anthropic-ai/claude-agent-sdk` at **`0.2.112`** — the -same version currently in `extensions/copilot/package.json`. Do **not** -upgrade yet; versions > 0.2.112 introduce native binary dependencies that -require additional build infrastructure (see Phase 15). - -Goals: - -- Confirm the SDK actually routes every messages call through - `ANTHROPIC_BASE_URL` (no leaks to api.anthropic.com). -- Confirm the SSE event sequence the proxy emits is byte-compatible with what - the SDK expects. -- Confirm model-ID resolution works end-to-end. -- **Validate which abort mechanism actually works:** `Query.interrupt()` vs. - AbortController on the underlying fetch. The reference uses the - AbortController approach; verify whether `Query.interrupt()` even exists - on the SDK version we pin. -- **Validate `enableFileCheckpointing` and `Query.rewindFiles()` exist** in - the SDK version we pin (they're not in the reference; may be newer-only). - If absent, Phase 8 needs a different mechanism. -- Smoke `Query.setModel()`, `Query.setPermissionMode()`, `Query.streamInput()`. -- Identify any SDK behaviors not anticipated. - -Deliverable: a `node/claude/scripts/spike.ts` plus a short findings note -appended to this roadmap. **Code thrown away**, learnings captured. Spike -findings update Phase 6/8/9 plans. - -Exit criteria: an SDK-driven session completes one turn through the proxy, -including at least one tool call round-trip, with no traffic to anthropic.com. - -### Phase 4 — `ClaudeAgent` skeleton implementing `IAgent` - -A registered `IAgent` whose lifecycle methods are wired but minimal. Mirror -the pattern in `node/copilot/copilotAgent.ts`. - -Scope (just enough surface for the agent to be discoverable): - -- **`id: AgentProvider`** — new string constant (e.g. `'claude'` or - `'claude-code'`). `AgentProvider` is a `string` type alias - (`agentService.ts:113`), not an enum. The provider id IS the URI scheme. -- **Session URI scheme: `:/`** — i.e. `claude:/`, - produced by `AgentSession.uri(provider, sessionId)` - (`agentService.ts:301–305`). **No double-slash, no authority component.** - The URI IS the identity — `AgentSession.id(uri)` extracts the SDK - `sessionId`. No mapping table. -- `getDescriptor(): IAgentDescriptor` — only `provider`, `displayName`, - `description`. **No capability flags exist on `IAgentDescriptor`** - (`agentService.ts:115–120`); capabilities are expressed by *implementing - optional methods* (`truncateSession?`, `setPendingMessages?`, - `getCustomizations?`, etc.). This is how the workbench discovers what an - agent supports. -- `getProtectedResources()` — same github.com resource the Copilot agent - declares. -- `authenticate(resource, token)` — store the GitHub token, push it into the - proxy's GitHub-token slot. -- **Spin up the `IClaudeProxyService` once at agent construction time** (one - proxy per agent, shared across sessions). -- **Strip `ANTHROPIC_API_KEY`** from any spawned SDK subprocess env. -- `models` observable — derived from `ICopilotApiService.models()`, filtered - to Claude-family models. -- Stub ALL remaining required `IAgent` methods with `throw new Error('TODO: Phase N')`: - `createSession`, `sendMessage` (including `attachments` and `turnId` params — - see Phase 6), `disposeSession`, `abortSession`, `getSessionMessages`, - `listSessions`, `resolveSessionConfig`, `sessionConfigCompletions`, - `changeModel`, `respondToUserInputRequest`, `respondToPermissionRequest`, - `setClientTools`, `setClientCustomizations`, `onClientToolCallComplete`, - `setCustomizationEnabled`, `shutdown`, **`dispose()`** (provider-level teardown - — not to be confused with `disposeSession`). -- Wire the **`onDidSessionProgress`** event emitter (`Emitter`) - in the constructor and expose it as the required event property. The emitter - will be fired in Phase 6; it must exist here so `IAgent` compiles. -- The `ClaudeAgent` class must compile against `IAgent` with all required - methods present. Optional methods (`truncateSession?`, `getCustomizations?`, - etc.) can be omitted at this stage. - -Tests: agent registers, descriptor surfaces, `models` observable populates, -`authenticate` persists the token and pushes it to the proxy, URI helpers -round-trip correctly. - -Exit criteria: a workbench client sees the Claude provider listed, can pick -a Claude model, but can't yet send a message. - -### Phase 5 — Session lifecycle: create / dispose / list / shutdown - -Implement the lifecycle methods that don't require live LLM traffic. - -- `createSession(config)` — allocate a fresh UUID `sessionId`, construct the - URI via `AgentSession.uri(this.id, sessionId)`, construct a - `ClaudeAgentSession` (new file `node/claude/claudeAgentSession.ts`). - Persist minimal session metadata via `ISessionDataService`. Do **not** - start the SDK yet — that happens lazily on first `sendMessage`. -- **Honor `IAgentCreateSessionConfig.fork`** - (`agentService.ts:161–173`): when `config.fork` is set, route through the - SDK's `forkSession(sourceSessionId, options)` (top-level SDK function from - `claudeCodeSdkService.ts:57, 121–124`) to mint a new session ID, build the - URI from the new ID, and persist the `turnIdMapping`. -- `disposeSession(session)` — tear down the session's `Query` (if alive), - MCP gateway, in-flight aborts. -- `listSessions()` — `IAgent.listSessions()` returns - `Promise` (`agentService.ts:394`). Call SDK - `listSessions()` with `dir` undefined (across all projects), map each - `SDKSessionInfo` → `IAgentSessionMetadata`. -- `getSessionMessages(session)` — empty stub for now; full implementation - in Phase 13. -- `resolveSessionConfig` / `sessionConfigCompletions` — schema for - Claude-specific session knobs (model, working directory). -- **`shutdown()`** — gracefully close every active `Query`, dispose the - proxy, drain in-flight requests. - -**Read-through cache for the transcript** lands here as a seam: - -- `IClaudeSessionTranscriptStore` interface — `getTranscript(sessionId)` - returns parsed `SessionMessage[]`, keyed on `(sessionId, lastMessageUuid, - fileLastModified)`. -- Default impl wraps `getSessionMessages` from the SDK. -- Future hybrid impl (using `sessionStore` once it exits alpha) can be - swapped in without touching `ClaudeAgentSession`. - -Tests: create a session, list it (including externally-created), get its -(empty) messages, dispose it, verify it's gone from `listSessions`. Fork -via `createSession({ fork })` produces a new URI with the right -`turnIdMapping`. `shutdown()` is idempotent and cancels in-flight work. - -Exit criteria: sessions can be created (including via fork) and persisted; -restarts find them; externally-created Claude Code sessions appear; agent -host can shut down cleanly. - -### Phase 6 — `sendMessage` + streaming progress events (single-turn, no tools) - -Wire the proxy + SDK from Phase 3 into a real session. **Port the lifecycle -machinery from `claudeCodeAgent.ts`:** - -- Per-session `Query` kept alive across turns (streaming-input mode: - `prompt: AsyncIterable`). -- Lazy start: first `sendMessage` calls `_startSession`, which invokes - `query()` with either `sessionId` (new) or `resume: sessionId` (restored). -- Request queue (`_queuedRequests`) — protocol's single-threaded session - contract enforced by serialization. -- Pending-prompt deferred — the async iterable awaits `_pendingPrompt`; - `sendMessage` resolves it to feed the next prompt. -- `_isResumed` flag flips on first SDK message, so subsequent restarts use - `resume`. -- **`sendMessage` attachments and `turnId`:** `IAgent.sendMessage` takes - `attachments?: IAgentAttachment[]` and `turnId?: string` - (`agentService.ts:351`). Initial implementation converts attachments to - `SDKUserMessage` content arrays (file content as text blocks, selections - as text), and passes `turnId` through to the request queue for transcript - association. Mirror how `copilotAgent.ts:730` handles this. -- Map SDK events → `AgentSignal`s on `onDidSessionProgress`, mirroring - `mapSessionEvents.ts` in the Copilot folder. - -SDK options pinned in this phase (matching the reference): - -- **`settingSources: ['user', 'project', 'local']`** — match - `claudeCodeAgent.ts:482`. Excludes `managed` (intentional in the - reference). -- **`allowDangerouslySkipPermissions: true`** — **required** (`claudeCodeAgent.ts:434`). - The agent host owns all permission decisions via `canUseTool`; without this - flag the SDK adds a second internal permission gate that overrides - `permissionMode`. Omitting it silently breaks all tool calls. -- **`executable: process.execPath as 'node'`** — **required** (`claudeCodeAgent.ts:437`). - Tells the SDK which Node binary to use when forking its subprocess. - Without it the SDK must locate Node on its own, which is undefined inside - the utility process. -- **`disallowedTools: ['WebSearch']`** — **required** (`claudeCodeAgent.ts:438–440`). - CAPI does not yet support the WebSearch tool. Without this the SDK may - invoke it and the upstream CAPI call will fail mid-session. -- **`abortController: this._abortController`** — wire the abort controller - at query creation time (`claudeCodeAgent.ts:435`). Phase 9 calls - `_abortController.abort()` on this reference; the field must exist at - construction time or Phase 9 has no handle to cancel in-flight work. -- **`includeHookEvents: true`** — **required** (`claudeCodeAgent.ts:449`). - Without it the SDK subprocess does not emit hook events in the output stream. - Phase 11 customization-hooks wiring depends on receiving these events. -- `spawnClaudeCodeProcess`: **not overridden** (Agent Host is the isolation - boundary). -- `enableFileCheckpointing: true` if the spike confirms it exists; otherwise - defer to Phase 8 to find an alternative. -- **`systemPrompt: { type: 'preset', preset: 'claude_code' }`** — match - `claudeCodeAgent.ts:478`. **Not** `tools: { type: 'preset' }` (that was a - v1 roadmap error). -- `tools`: omit (default Claude Code toolset). Restrict only when client - asks via Phase 10 client-tools or Phase 11 customizations. -- `cwd`: from session config. -- **`env`** (matching reference `claudeCodeAgent.ts:454–460`): - - `ANTHROPIC_BASE_URL=` - - `ANTHROPIC_AUTH_TOKEN=.` - - `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1` — **prevents telemetry - leakage to Anthropic**. - - `USE_BUILTIN_RIPGREP=0` + bundled ripgrep prepended to `PATH` — tool - execution depends on this. - - Forward OTel env (`OTEL_*`) for observability. - - **Strip `ANTHROPIC_API_KEY`** from the inherited env. -- `model`: resolved from session config / current model selection. -- `permissionMode`: `acceptEdits` default; client can override. - -**Stderr handling** — log SDK stderr at trace level by default; surface to -client only on subprocess crash. - -Defer: tool calls (Phase 7), file edit tracking (Phase 8), abort/steering -(Phase 9), client tools (Phase 10), customizations (Phase 11), subagents -(Phase 12), restoration/truncate (Phase 13). - -Tests: integration test with a stubbed `ICopilotApiService` returning a -canned Anthropic stream → verify the resulting `AgentSignal` sequence. - -Exit criteria: a workbench client sends "hi" and sees a streamed assistant -response in the UI. - -### Phase 7 — Tool calls + permission + user input - -Wire the SDK's tool-use loop through to the agent host's tool infrastructure. -**Transcript-only in this phase** — file edit tracking is Phase 8. - -- Map Claude's tool-use events → `AgentSignal` tool-call request parts. -- Map tool-result events → tool-result response parts. -- **`respondToPermissionRequest`** — gate tool execution like the Copilot - agent does. Wire through SDK's permission callback / `canUseTool`. -- **`respondToUserInputRequest`** (`agentService.ts:384–385`) — handle the - SDK's user-input / `ask_user` flow. Forward client-provided answers back - to the SDK. -- Mirror per-tool annotations (`Read`, `Write`, `Edit`, `Bash`, etc.) so - the client can render them. - -Tests: a session that asks for `Read`, gets prompted, approves, sees the -tool output streamed back. A session that triggers an `ask_user` request, -client responds, SDK continues. - -Exit criteria: a real "read this file" prompt completes end-to-end. - -### Phase 8 — File edit tracking - -Build the Claude analog of `fileEditTracker.ts` from `node/copilot/`. - -- Track every file read/write/edit emitted by the SDK as a tool-use event. -- Expose tracked files via `resourceRead` / `resourceWrite` URIs so the - client can render diffs, accept/reject per-file, and undo. -- Per-file undo: client-driven, **not** part of `truncateSession`. -- **Undo mechanism:** - - **If Phase 3 spike confirms `enableFileCheckpointing` + `Query.rewindFiles` - exist:** use them, but the surface stays per-file (the SDK rewinds all - files; we apply that selectively). - - **If they don't exist:** record file-edit history in the agent itself - and restore via direct file writes. Document this as a fallback in the - Phase 3 findings. -- **Known gap:** Bash-tool edits (`sed -i`, `cat > file`) aren't tracked by - the SDK. Document; consider a file-watcher diff fallback in a follow-up. - -Tests: a session that edits two files, exposes both via `resourceWrite`, -client-side accept of one and reject of the other behaves correctly. - -Exit criteria: file diffs render in the workbench; per-file accept/reject -works. - -### Phase 9 — Abort + steering + model change + shutdown polish - -- **`abortSession`** — cancel the underlying SDK turn. The reference uses - `_abortController.abort()`; if Phase 3 spike confirms `Query.interrupt()` - also works, prefer it (cleaner). Propagates through SDK → proxy → - `ICopilotApiService`. -- **Steering / `setPendingMessages`** — use `Query.streamInput()` to push - additional `SDKUserMessage`s mid-turn. -- `changeModel` — `Query.setModel()` on the live `Query`. Resolve the new - model ID through the proxy's resolver first. -- `setPermissionMode` (internal SDK concern, not a protocol method) — - `Query.setPermissionMode()` on the live `Query`. Permission mode changes - are driven by SDK events (`EnterPlanMode`/`ExitPlanMode` tools), not - by direct `IAgent` method calls. Wire accordingly - (see `claudeCodeAgent.ts:174–181` for the reference). -- **Yield-restart** mechanism (port from `claudeCodeAgent.ts`): when - settings files change or tool set changes mid-turn, drain the current - generator and restart via `resume: sessionId`. -- **Subprocess crash recovery** — if the SDK subprocess dies mid-turn, - surface to the client as a turn error and mark the session ready for a - fresh `_startSession` on the next `sendMessage`. - -Tests: abort mid-stream releases the proxy's HTTP connection, steering lands -in the next turn, model swap takes effect, killed subprocess triggers -recovery. - -Exit criteria: parity with Copilot agent on stop / steer / switch model. - -### Phase 10 — Client-provided tools (in-process MCP) - -- `setClientTools(session, clientId, tools)` — convert the protocol's - `ToolDefinition[]` into SDK MCP tool definitions via SDK's `tool(name, - description, zodSchema, handler)`, wrap in `createSdkMcpServer`, pass via - `options.mcpServers` on the next `query()` call. -- Reference for the MCP-tool path: `extensions/copilot/src/extension/chatSessions/claude/common/mcpServers/ideMcpServer.ts` - (uses `tool()` / `createSdkMcpServer`). **Not** `claudeCodeAgent.ts`. -- The handler is a deferred promise that the host resolves when - `onClientToolCallComplete` delivers the result. -- **Per-query MCP server recreation** — recreate from current tool list on - each `_startSession` / yield-restart. Tools changing between turns - triggers a yield-restart (mirror `_toolsMatch` from `claudeCodeAgent.ts`). -- **MCP gateway lifecycle** — port the `_gateway` + `_gatewayIdleTimeout` - pattern: gateway disposed after N seconds of idle to release resources. - -Tests: a client registers a custom tool, the agent invokes it via a Claude -prompt, result returns to the client and is fed back into the SDK. - -Exit criteria: client tools callable from a Claude session. - -**Open questions for this phase:** -- ZodSchema generation from the protocol's JSON Schema - `ToolDefinition.inputSchema` — use a converter library or hand-roll? - Check what `ideMcpServer.ts` does. -- Idle timeout for the MCP gateway — sensible default? - -### Phase 11 — Customizations / plugins (full surface) - -**Inbound (host → SDK):** - -- `setClientCustomizations(clientId, customizations, progress?)` — call - `agentPluginManager.syncCustomizations` to download `CustomizationRef[]` - to local dirs, get back `ISyncedCustomization[]` with local paths. - Forward incremental results via the `progress` callback - (`agentService.ts:439`) for progressive loading UI. -- Pass the local paths as `options.plugins: [{ type: 'local', path }, ...]` - on the next `query()` call. -- **Restart-on-toggle** flag (`_pendingRestart` from `claudeCodeAgent.ts`): - customization toggles mark the session for restart before the next - `sendMessage`. -- `setCustomizationEnabled(uri, enabled)` — flips the flag. - -**Outbound (SDK → host) — required for Copilot parity -(`agentService.ts:399–417`):** - -- `onDidCustomizationsChange` event. -- `getCustomizations()` — return host-known customizations (synced + active). -- `getSessionCustomizations(session)` — per-session active list. -- See `copilotAgent.ts:190–205, 232–240` for the wiring pattern. - -Tests: client provides a customization → agent syncs it → next `query()` -includes the local path → SDK init message confirms the plugin loaded; -customization toggle triggers restart; published events fire correctly. - -Exit criteria: customization round-trip works; workbench renders Claude -customizations like Copilot's. - -### Phase 12 — Subagents - -Subagents are inner sessions spawned by the SDK (e.g. when the model -delegates to a sub-task). The protocol has first-class support; we need to -mirror it for Claude. - -Scope: - -- **Detect subagent starts** in the SDK event stream and emit - `IAgentSubagentStartedSignal` (`agentService.ts:273–287`). The signal - carries `{ session, toolCallId, agentName, ... }`; the subagent URI is - **derived by the host** from `buildSubagentSessionUri(parentSession, - toolCallId)` — the signal itself does NOT carry a URI. -- **Subagent URI scheme** — helpers are in `state/sessionState.ts`: - `buildSubagentSessionUri` (line 200) and `parseSubagentSessionUri` (line - 210). See `copilotAgent.ts:34` for the import and `copilotAgent.ts:761` - for usage. -- `mapSessionEvents.ts` uses `buildSubagentSessionUri` (imported at line - 10) for constructing child URIs during event mapping. -- **`getSessionMessages(subagentUri)`** — return the subagent's transcript - via SDK's `getSubagentMessages` (`claudeCodeSdkService.ts:65–74`). -- **`listSessions()` filter** — decide whether subagents appear in the - top-level session list (Copilot's behavior is the reference). -- Restore subagent associations on agent host restart. - -Tests: trigger a subagent, verify the signal fires with a valid URI, -verify `getSessionMessages` returns the subagent transcript. - -Exit criteria: subagent sessions are first-class for clients. - -### Phase 13 — Session restoration (no in-place truncate) - -- **`getSessionMessages(session)`** reconstructs the full turn history from - the SDK's transcript via `IClaudeSessionTranscriptStore` (Phase 5 seam). - Maps `SessionMessage[]` (Anthropic events) → agent host `Turn[]`. The - mapper is the same logic used by the live event stream — factor it out in - Phase 6 and reuse here. Includes subagent transcripts (Phase 12). -- **Do NOT implement `IAgent.truncateSession`**. The SDK's `forkSession` - always produces a *new* session ID, which conflicts with the protocol's - expectation that `truncateSession` mutates the existing session URI in - place. `truncateSession?` is optional in `IAgent` - (`agentService.ts:430`), so we omit it and document: - - Clients wanting truncate-like behavior use - `createSession({ fork: { session, turnIndex, turnId, turnIdMapping } })` - (Phase 5), which legitimately mints a new session URI. - - The workbench should follow the new URI, just like for any other fork. - - Adding in-place truncate later would require a URI→sessionId mapping - layer; we'd revisit when there's user demand. -- Session forking via `createSession({ fork })` is already covered in - Phase 5; this phase verifies the round-trip with persisted state. - -Tests: persist a session, restart the agent host, reload the session, -verify turns are intact and a new turn appends correctly. Fork via -`createSession({ fork })` produces a new URI with the prefix turns intact. - -Exit criteria: agent-host restart is invisible; fork works; truncate is -documented as fork-by-another-name. - -### Phase 14 — Hardening + telemetry - -- Telemetry events for proxy request/response counts, model usage, token - refresh frequency, error rates, SDK subprocess crashes (follow telemetry - instructions in `copilot-instructions.md`). -- Map SDK `result` messages (cost, usage, duration) → telemetry + turn - metadata. -- Stress test: long-running sessions, large outputs, frequent token - rotations. -- Leak check on the proxy under abort storms. -- Subprocess lifecycle audit: zombie detection, graceful shutdown timing. -- Dogfood within the team for one cycle, file follow-up issues. - -Exit criteria: ready to enable for external preview. - -### Phase 15 — SDK upgrade (> 0.2.112) - -The initial implementation pins `@anthropic-ai/claude-agent-sdk` at -**`0.2.112`** — the same version the Copilot extension currently ships -(`extensions/copilot/package.json`). Versions above 0.2.112 introduce a -**native binary dependency** (prebuilt platform-specific addons), which -requires additional build infrastructure and cross-platform packaging work -beyond the scope of the initial rollout. - -This phase upgrades to a version > 0.2.112 once that infrastructure is -in place. - -**Checklist:** -- Identify the minimum version that provides the desired new SDK capabilities - (check changelog / GitHub releases for `@anthropic-ai/claude-agent-sdk`). -- Audit the native dependency: determine the addon's platform matrix, verify - the agent-host build pipeline can package and code-sign it for all - supported targets (win32-x64, darwin-x64, darwin-arm64, linux-x64). -- Validate the upgraded SDK against the full Phase 3 spike test matrix - (`Query.*` API surface, `enableFileCheckpointing`, `Query.rewindFiles`, - `Query.interrupt`). -- Update `agentHost/package.json` (or the shared platform `package.json`) - to the new version and update any API callsites that changed between - 0.2.112 and the target version. -- Run the full Phase 6–13 integration test suite against the new SDK version. -- Coordinate with the Copilot extension team to keep both consumers in sync - (or document the divergence intentionally). - -Exit criteria: agent host runs on the upgraded SDK with no regressions; -native dependency is packaged in all production builds. - ---- - -## Open questions (to resolve as we go) - -- **CAPI `count_tokens` support** — must answer in Phase 1.5 to define the - signature. -- **Model ID translation location** — tentatively the proxy. Confirm in - Phase 1.5. -- **`Query.interrupt()` vs `_abortController.abort()`** — which actually - cancels a turn? Phase 3 spike answers. -- **`enableFileCheckpointing` / `Query.rewindFiles()` SDK availability** — - Phase 3 spike answers; Phase 8 plan branches. -- **ZodSchema generation strategy for client tools** — Phase 10. -- **MCP gateway idle timeout default** — Phase 10. -- **Transcript cache invalidation key** — - `(sessionId, lastMessageUuid, fileLastModified)` proposed; alternatives - in Phase 13. -- **Sessionstore (alpha):** SDK's `sessionStore` option mirrors transcripts - externally. Currently alpha — parking on SDK as sole source of truth. - Once it exits alpha, the hybrid model becomes available without changing - callers. -- **GHE support:** tracked in - [microsoft/vscode#313396](https://github.com/microsoft/vscode/issues/313396). - All phases assume github.com auth. -- **Single-tenant proxy token** — one GitHub token per agent affects all - sessions. Document for now; per-session tokens are a follow-up if needed. - -## Non-goals (explicit) - -- **Anthropic-direct authentication** (`x-api-key` against - api.anthropic.com). This is a Copilot-routed Claude, not a BYOK Claude. -- **Re-implementing the Anthropic SDK ourselves.** We host the official - Claude Agent SDK and proxy beneath it; we re-use `@anthropic-ai/sdk` - types. -- **In-place `truncateSession`.** SDK's `forkSession` always mints a new - session ID. Clients use `createSession({ fork })` for truncate-like - effect; we revisit if there's demand. -- **File rewind as part of `truncateSession`.** Per-file undo is exposed - via `resourceRead` / `resourceWrite` URIs (Phase 8). -- **Custom subprocess sandboxing** via `spawnClaudeCodeProcess`. The Agent - Host itself is the isolation boundary. -- **Including `managed` in `settingSources`.** Match the reference's - `['user', 'project', 'local']`. Revisit if managed-policy users complain. -- **Tracking Bash-tool file edits** in Phase 8. Documented gap; - follow-up if needed. -- **Per-session GitHub token** in the proxy. Single-tenant for v1. -- **Cross-agent feature parity at every step.** Phases 4–14 catch up to - `CopilotAgent` incrementally; gaps are acceptable mid-flight. diff --git a/src/vs/platform/agentHost/node/commandAutoApprover.ts b/src/vs/platform/agentHost/node/commandAutoApprover.ts deleted file mode 100644 index 143c5442..00000000 --- a/src/vs/platform/agentHost/node/commandAutoApprover.ts +++ /dev/null @@ -1,441 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import type { Language, Parser, Query, QueryCapture } from '@vscode/tree-sitter-wasm'; -import * as fs from 'fs'; -import { Disposable, toDisposable } from '../../../base/common/lifecycle.js'; -import { FileAccess } from '../../../base/common/network.js'; -import { escapeRegExpCharacters, regExpLeadsToEndlessLoop } from '../../../base/common/strings.js'; -import { URI } from '../../../base/common/uri.js'; -import { ILogService } from '../../log/common/log.js'; - -/** Pattern that detects compound commands (&&, ||, ;, |, backtick, $()) */ -const compoundCommandPattern = /&&|\|\||[;|]|`|\$\(/; - -/** - * Result of a command auto-approval check. - * - `approved`: all sub-commands match allow rules and none are denied - * - `denied`: at least one sub-command matches a deny rule - * - `noMatch`: no rule matched — requires user confirmation - */ -export type CommandApprovalResult = 'approved' | 'denied' | 'noMatch'; - -interface IAutoApproveRule { - readonly regex: RegExp; -} - -const neverMatchRegex = /(?!.*)/; -const transientEnvVarRegex = /^[A-Z_][A-Z0-9_]*=/i; - -/** - * Auto-approves or denies shell commands based on default rules. - * - * Uses tree-sitter to parse compound commands (`foo && bar`) into - * sub-commands that are individually checked against allow/deny lists. - * The default rules mirror the VS Code `chat.tools.terminal.autoApprove` - * setting defaults. - * - * Tree-sitter is initialized eagerly; call {@link initialize} and await the - * result before using {@link shouldAutoApprove} to guarantee synchronous - * parsing. If tree-sitter failed to load, compound commands fall back to - * `noMatch` (user confirmation required). - */ -export class CommandAutoApprover extends Disposable { - - private _allowRules: IAutoApproveRule[] | undefined; - private _denyRules: IAutoApproveRule[] | undefined; - private _parser: Parser | undefined; - private _bashLanguage: Language | undefined; - private _queryClass: typeof Query | undefined; - private readonly _initPromise: Promise; - - constructor( - private readonly _logService: ILogService, - ) { - super(); - this._initPromise = this._initTreeSitter(); - } - - /** - * Returns a promise that resolves once tree-sitter WASM has been loaded. - * Await this before processing any events to guarantee that - * {@link shouldAutoApprove} can parse commands synchronously. - */ - initialize(): Promise { - return this._initPromise; - } - - /** - * Synchronously check whether the given command line should be auto-approved. - * Uses tree-sitter (if loaded) to parse compound commands into sub-commands. - */ - shouldAutoApprove(commandLine: string): CommandApprovalResult { - const trimmed = commandLine.trimStart(); - if (trimmed.length === 0) { - return 'approved'; - } - - this._ensureRules(); - - // Try to extract sub-commands via tree-sitter - const subCommands = this._extractSubCommands(trimmed); - if (subCommands && subCommands.length > 0) { - return this._matchSubCommands(subCommands); - } - - // Fallback: if this looks like a compound command but tree-sitter - // failed to parse it, require user confirmation rather than risking - // auto-approving a dangerous sub-command. - if (compoundCommandPattern.test(trimmed)) { - this._logService.trace('[CommandAutoApprover] Compound command without tree-sitter, requiring confirmation'); - return 'noMatch'; - } - - // Simple single command — match against rules - return this._matchCommandLine(trimmed); - } - - private _matchSubCommands(subCommands: string[]): CommandApprovalResult { - let allApproved = true; - for (const subCommand of subCommands) { - // Deny transient env var assignments - if (transientEnvVarRegex.test(subCommand)) { - return 'denied'; - } - - const result = this._matchSingleCommand(subCommand); - if (result === 'denied') { - return 'denied'; - } - if (result !== 'approved') { - allApproved = false; - } - } - return allApproved ? 'approved' : 'noMatch'; - } - - private _matchCommandLine(commandLine: string): CommandApprovalResult { - if (transientEnvVarRegex.test(commandLine)) { - return 'denied'; - } - return this._matchSingleCommand(commandLine); - } - - private _matchSingleCommand(command: string): CommandApprovalResult { - // Check deny rules first - for (const rule of this._denyRules!) { - if (rule.regex.test(command)) { - return 'denied'; - } - } - - // Then check allow rules - for (const rule of this._allowRules!) { - if (rule.regex.test(command)) { - return 'approved'; - } - } - - return 'noMatch'; - } - - // ---- Tree-sitter -------------------------------------------------------- - - private _extractSubCommands(commandLine: string): string[] | undefined { - if (!this._parser || !this._bashLanguage || !this._queryClass) { - return undefined; - } - - try { - this._parser.setLanguage(this._bashLanguage); - const tree = this._parser.parse(commandLine); - if (!tree) { - return undefined; - } - - try { - const query = new this._queryClass(this._bashLanguage, '(command) @command'); - const captures: QueryCapture[] = query.captures(tree.rootNode); - const subCommands = captures.map(c => c.node.text); - query.delete(); - return subCommands.length > 0 ? subCommands : undefined; - } finally { - tree.delete(); - } - } catch (err) { - this._logService.warn('[CommandAutoApprover] Tree-sitter parsing failed', err); - return undefined; - } - } - - private async _initTreeSitter(): Promise { - try { - const { default: TreeSitter } = (await import('@vscode/tree-sitter-wasm')); - - if (this._store.isDisposed) { - return; - } - - // Resolve WASM files from node_modules - const moduleRoot = URI.joinPath(FileAccess.asFileUri(''), '..', 'node_modules', '@vscode', 'tree-sitter-wasm', 'wasm'); - const wasmPath = URI.joinPath(moduleRoot, 'tree-sitter.wasm').fsPath; - - await TreeSitter.Parser.init({ - locateFile() { - return wasmPath; - } - }); - - if (this._store.isDisposed) { - return; - } - - const parser = new TreeSitter.Parser(); - this._register(toDisposable(() => { - try { - parser.delete(); - } catch { - // WASM memory may already be freed - } - })); - - // Load bash grammar - const bashWasmPath = URI.joinPath(moduleRoot, 'tree-sitter-bash.wasm').fsPath; - const bashWasm = await fs.promises.readFile(bashWasmPath); - - if (this._store.isDisposed) { - return; - } - - const bashLanguage = await TreeSitter.Language.load(new Uint8Array(bashWasm.buffer, bashWasm.byteOffset, bashWasm.byteLength)); - - if (this._store.isDisposed) { - return; - } - - this._parser = parser; - this._bashLanguage = bashLanguage; - this._queryClass = TreeSitter.Query; - this._logService.info('[CommandAutoApprover] Tree-sitter initialized successfully'); - } catch (err) { - this._logService.warn('[CommandAutoApprover] Failed to initialize tree-sitter', err); - } - } - - // ---- Rules -------------------------------------------------------------- - - private _ensureRules(): void { - if (this._allowRules && this._denyRules) { - return; - } - - const allowRules: IAutoApproveRule[] = []; - const denyRules: IAutoApproveRule[] = []; - - for (const [key, value] of Object.entries(DEFAULT_TERMINAL_AUTO_APPROVE_RULES)) { - const regex = convertAutoApproveEntryToRegex(key); - if (value === true) { - allowRules.push({ regex }); - } else if (value === false) { - denyRules.push({ regex }); - } - } - - this._allowRules = allowRules; - this._denyRules = denyRules; - } -} - -// ---- Regex conversion ------------------------------------------------------- - -function convertAutoApproveEntryToRegex(value: string): RegExp { - // If wrapped in `/`, treat as regex - const regexMatch = value.match(/^\/(?.+)\/(?[dgimsuvy]*)$/); - const regexPattern = regexMatch?.groups?.pattern; - if (regexPattern) { - let flags = regexMatch.groups?.flags; - if (flags) { - flags = flags.replaceAll('g', ''); - } - - if (regexPattern === '.*') { - return new RegExp(regexPattern); - } - - try { - const regex = new RegExp(regexPattern, flags || undefined); - if (regExpLeadsToEndlessLoop(regex)) { - return neverMatchRegex; - } - return regex; - } catch { - return neverMatchRegex; - } - } - - if (value === '') { - return neverMatchRegex; - } - - let sanitizedValue: string; - - // Match both path separators if it looks like a path - if (value.includes('/') || value.includes('\\')) { - let pattern = value.replace(/[/\\]/g, '%%PATH_SEP%%'); - pattern = escapeRegExpCharacters(pattern); - pattern = pattern.replace(/%%PATH_SEP%%*/g, '[/\\\\]'); - sanitizedValue = `^(?:\\.[/\\\\])?${pattern}`; - } else { - sanitizedValue = escapeRegExpCharacters(value); - } - - return new RegExp(`^${sanitizedValue}\\b`); -} - -// ---- Default rules ---------------------------------------------------------- -// -// These mirror the VS Code `chat.tools.terminal.autoApprove` setting defaults. -// Kept in sync manually — the actual setting will be wired up later. - -const DEFAULT_TERMINAL_AUTO_APPROVE_RULES: Readonly> = { - // Safe readonly commands - cd: true, - echo: true, - ls: true, - dir: true, - pwd: true, - cat: true, - head: true, - tail: true, - findstr: true, - wc: true, - tr: true, - cut: true, - cmp: true, - which: true, - basename: true, - dirname: true, - realpath: true, - readlink: true, - stat: true, - file: true, - od: true, - du: true, - df: true, - sleep: true, - nl: true, - - grep: true, - - // Safe git sub-commands - '/^git(\\s+(-C\\s+\\S+|--no-pager))*\\s+status\\b/': true, - '/^git(\\s+(-C\\s+\\S+|--no-pager))*\\s+log\\b/': true, - '/^git(\\s+(-C\\s+\\S+|--no-pager))*\\s+log\\b.*\\s--output(=|\\s|$)/': false, - '/^git(\\s+(-C\\s+\\S+|--no-pager))*\\s+show\\b/': true, - '/^git(\\s+(-C\\s+\\S+|--no-pager))*\\s+diff\\b/': true, - '/^git(\\s+(-C\\s+\\S+|--no-pager))*\\s+ls-files\\b/': true, - '/^git(\\s+(-C\\s+\\S+|--no-pager))*\\s+grep\\b/': true, - '/^git(\\s+(-C\\s+\\S+|--no-pager))*\\s+branch\\b/': true, - '/^git(\\s+(-C\\s+\\S+|--no-pager))*\\s+branch\\b.*\\s-(d|D|m|M|-delete|-force)\\b/': false, - - // Docker readonly sub-commands - '/^docker\\s+(ps|images|info|version|inspect|logs|top|stats|port|diff|search|events)\\b/': true, - '/^docker\\s+(container|image|network|volume|context|system)\\s+(ls|ps|inspect|history|show|df|info)\\b/': true, - '/^docker\\s+compose\\s+(ps|ls|top|logs|images|config|version|port|events)\\b/': true, - - // PowerShell - 'Get-ChildItem': true, - 'Get-Content': true, - 'Get-Date': true, - 'Get-Random': true, - 'Get-Location': true, - 'Set-Location': true, - 'Write-Host': true, - 'Write-Output': true, - 'Out-String': true, - 'Split-Path': true, - 'Join-Path': true, - 'Start-Sleep': true, - 'Where-Object': true, - '/^Select-[a-z0-9]/i': true, - '/^Measure-[a-z0-9]/i': true, - '/^Compare-[a-z0-9]/i': true, - '/^Format-[a-z0-9]/i': true, - '/^Sort-[a-z0-9]/i': true, - - // Package manager read-only commands - '/^npm\\s+(ls|list|outdated|view|info|show|explain|why|root|prefix|bin|search|doctor|fund|repo|bugs|docs|home|help(-search)?)\\b/': true, - '/^npm\\s+config\\s+(list|get)\\b/': true, - '/^npm\\s+pkg\\s+get\\b/': true, - '/^npm\\s+audit$/': true, - '/^npm\\s+cache\\s+verify\\b/': true, - '/^yarn\\s+(list|outdated|info|why|bin|help|versions)\\b/': true, - '/^yarn\\s+licenses\\b/': true, - '/^yarn\\s+audit\\b(?!.*\\bfix\\b)/': true, - '/^yarn\\s+config\\s+(list|get)\\b/': true, - '/^yarn\\s+cache\\s+dir\\b/': true, - '/^pnpm\\s+(ls|list|outdated|why|root|bin|doctor)\\b/': true, - '/^pnpm\\s+licenses\\b/': true, - '/^pnpm\\s+audit\\b(?!.*\\bfix\\b)/': true, - '/^pnpm\\s+config\\s+(list|get)\\b/': true, - - // Safe lockfile-only installs - 'npm ci': true, - '/^yarn\\s+install\\s+--frozen-lockfile\\b/': true, - '/^pnpm\\s+install\\s+--frozen-lockfile\\b/': true, - - // Safe commands with dangerous arg blocking - column: true, - '/^column\\b.*\\s-c\\s+[0-9]{4,}/': false, - date: true, - '/^date\\b.*\\s(-s|--set)\\b/': false, - find: true, - '/^find\\b.*\\s-(delete|exec|execdir|fprint|fprintf|fls|ok|okdir)\\b/': false, - rg: true, - '/^rg\\b.*\\s(--pre|--hostname-bin)\\b/': false, - sed: true, - '/^sed\\b.*\\s(-[a-zA-Z]*(e|f)[a-zA-Z]*|--expression|--file)\\b/': false, - '/^sed\\b.*s\\/.*\\/.*\\/[ew]/': false, - '/^sed\\b.*;W/': false, - sort: true, - '/^sort\\b.*\\s-(o|S)\\b/': false, - tree: true, - '/^tree\\b.*\\s-o\\b/': false, - '/^xxd$/': true, - '/^xxd\\b(\\s+-\\S+)*\\s+[^-\\s]\\S*$/': true, - - // Dangerous commands - rm: false, - rmdir: false, - del: false, - 'Remove-Item': false, - ri: false, - rd: false, - erase: false, - dd: false, - kill: false, - ps: false, - top: false, - 'Stop-Process': false, - spps: false, - taskkill: false, - 'taskkill.exe': false, - curl: false, - wget: false, - 'Invoke-RestMethod': false, - 'Invoke-WebRequest': false, - irm: false, - iwr: false, - chmod: false, - chown: false, - 'Set-ItemProperty': false, - sp: false, - 'Set-Acl': false, - jq: false, - xargs: false, - eval: false, - 'Invoke-Expression': false, - iex: false, -}; diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts deleted file mode 100644 index 20e57ffa..00000000 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ /dev/null @@ -1,1747 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { CopilotClient, ResumeSessionConfig, type CopilotClientOptions, type SessionConfig } from '@github/copilot-sdk'; -import { rgPath } from '@vscode/ripgrep'; -import * as fs from 'fs/promises'; -import { Limiter, SequencerByKey } from '../../../../base/common/async.js'; -import { Emitter, Event } from '../../../../base/common/event.js'; -import { appendEscapedMarkdownInlineCode } from '../../../../base/common/htmlContent.js'; -import { Disposable, DisposableMap, toDisposable } from '../../../../base/common/lifecycle.js'; -import { ResourceMap } from '../../../../base/common/map.js'; -import { FileAccess } from '../../../../base/common/network.js'; -import { equals } from '../../../../base/common/objects.js'; -import { observableValue } from '../../../../base/common/observable.js'; -import { basename, delimiter, dirname } from '../../../../base/common/path.js'; -import { URI } from '../../../../base/common/uri.js'; -import { generateUuid } from '../../../../base/common/uuid.js'; -import { localize } from '../../../../nls.js'; -import { IParsedPlugin, parsePlugin } from '../../../agentPlugins/common/pluginParsers.js'; -import { IFileService } from '../../../files/common/files.js'; -import { IInstantiationService } from '../../../instantiation/common/instantiation.js'; -import { ILogService } from '../../../log/common/log.js'; -import { AgentHostConfigKey, agentHostCustomizationConfigSchema } from '../../common/agentHostCustomizationConfig.js'; -import { AutoApproveLevel, ISchemaProperty, SessionMode, createSchema, platformSessionSchema, schemaProperty } from '../../common/agentHostSchema.js'; -import { IAgentPluginManager, ISyncedCustomization } from '../../common/agentPluginManager.js'; -import { AgentSession, AgentSignal, IAgent, IAgentAttachment, IAgentCreateSessionConfig, IAgentCreateSessionResult, IAgentDescriptor, IAgentModelInfo, IAgentResolveSessionConfigParams, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, IAgentSessionProjectInfo } from '../../common/agentService.js'; -import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; -import { ISessionDataService, SESSION_DB_FILENAME } from '../../common/sessionDataService.js'; -import type { ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../../common/state/protocol/commands.js'; -import { ProtectedResourceMetadata, type ConfigSchema, type ModelSelection, type SessionCustomization, type ToolDefinition } from '../../common/state/protocol/state.js'; -import { AHP_AUTH_REQUIRED, ProtocolError } from '../../common/state/sessionProtocol.js'; -import { CustomizationRef, CustomizationStatus, ResponsePartKind, SessionInputResponseKind, parseSubagentSessionUri, type PendingMessage, type PolicyState, type ResponsePart, type SessionInputAnswer, type ToolCallResult, type Turn } from '../../common/state/sessionState.js'; -import { IAgentConfigurationService } from '../agentConfigurationService.js'; -import { IAgentHostGitService, META_DIFF_BASE_BRANCH } from '../agentHostGitService.js'; -import { IAgentHostTerminalManager } from '../agentHostTerminalManager.js'; -import { CopilotAgentSession, SessionWrapperFactory, type CopilotSdkMode, type IActiveClientSnapshot } from './copilotAgentSession.js'; -import { ICopilotSessionContext, projectFromCopilotContext } from './copilotGitProject.js'; -import { parsedPluginsEqual, toSdkCustomAgents, toSdkHooks, toSdkMcpServers, toSdkSkillDirectories } from './copilotPluginConverters.js'; -import { CopilotSessionWrapper } from './copilotSessionWrapper.js'; -import { ShellManager, createShellTools } from './copilotShellTools.js'; - -interface ICreatedWorktree { - readonly repositoryRoot: URI; - readonly worktree: URI; -} - -const ThinkingLevelConfigKey = 'thinkingLevel'; -const ReasoningEfforts = ['low', 'medium', 'high', 'xhigh'] as const; -type ReasoningEffort = NonNullable; - -interface ISerializedModelSelection { - id?: unknown; - config?: unknown; -} - -/** - * Narrow surface of {@link CopilotClient} used by this provider. The SDK class - * has private members, so tests use this structural type to inject a fake. - */ -export interface ICopilotClient { - start(): Promise; - stop: CopilotClient['stop']; - listSessions: CopilotClient['listSessions']; - listModels: () => Promise; - createSession: CopilotClient['createSession']; - resumeSession: CopilotClient['resumeSession']; - getSessionMetadata: CopilotClient['getSessionMetadata']; - readonly rpc: { readonly sessions: { readonly fork: CopilotClient['rpc']['sessions']['fork'] } }; -} - -/** - * Subset of the JSON-RPC `MessageConnection` we reach into via the SDK's - * private `connection` field to wire plan mode. See {@link CopilotAgent._enablePlanModeOnClient}. - */ -interface IExitPlanModeConnection { - sendRequest(method: string, params: unknown): Promise; - onRequest(method: string, handler: (params: IExitPlanModeRequestParams) => Promise): { dispose(): void }; -} - -/** - * Payload of the CLI's `exitPlanMode.request` RPC. The CLI dispatches one - * per `exit_plan_mode` tool invocation when the session was created with - * `requestExitPlanMode: true`. - */ -export interface IExitPlanModeRequestParams { - readonly sessionId: string; - readonly summary: string; - readonly planContent: string; - readonly actions: readonly string[]; - readonly recommendedAction: string; -} - -/** - * Response for the CLI's `exitPlanMode.request` RPC. The CLI feeds this - * directly into `session.respondToExitPlanMode`, which resolves the - * pending tool call and (when approved) updates the SDK's `currentMode`. - */ -export interface IExitPlanModeResponse { - readonly approved: boolean; - readonly selectedAction?: string; - readonly autoApproveEdits?: boolean; - readonly feedback?: string; -} - -/** - * Corrected shape of {@link CopilotClient.listModels} entries. - * - * The SDK's `ModelInfo` type declares `capabilities`, `capabilities.limits`, - * and `capabilities.limits.max_context_window_tokens` as required, but at - * runtime synthetic entries (e.g. the `auto` router) ship with an empty - * `capabilities: {}` object. We mirror the SDK fields we consume but mark the - * unreliable parts as optional so callers must defensively handle them. - */ -export interface ICopilotModelInfo { - readonly id: string; - readonly name: string; - readonly capabilities?: { - readonly supports?: { readonly vision?: boolean }; - readonly limits?: { readonly max_context_window_tokens?: number }; - }; - readonly policy?: { readonly state?: string }; - readonly supportedReasoningEfforts?: readonly string[]; - readonly defaultReasoningEffort?: string; -} - -function isReasoningEffort(value: string | undefined): value is ReasoningEffort { - return ReasoningEfforts.some(reasoningEffort => reasoningEffort === value); -} - -export function getCopilotWorktreesRoot(repositoryRoot: URI): URI { - return URI.joinPath(repositoryRoot, '..', `${basename(repositoryRoot.fsPath)}.worktrees`); -} - -export function getCopilotWorktreeName(branchName: string): string { - return branchName.replace(/\//g, '-'); -} - -export function getCopilotWorktreeBranchName(sessionId: string, branchNameHint: string | undefined): string { - return `agents/${branchNameHint ? `${branchNameHint}-${sessionId.substring(0, 8)}` : sessionId}`; -} - -/** - * Builds the localized "Created isolated worktree for branch X" markdown - * shown at the top of the first response in worktree-isolated sessions. - * The branch name is wrapped as inline code so the localized template - * doesn't have to embed markdown punctuation. The trailing blank line - * keeps the announcement visually separated when it gets merged into the - * same markdown part as the model's reply. - */ -function buildWorktreeAnnouncementText(branchName: string): string { - return localize( - 'copilotAgent.worktreeCreated', - "Created isolated worktree for branch {0}", - appendEscapedMarkdownInlineCode(branchName) - ) + '\n\n'; -} - -/** - * Returns a copy of `turns` where `announcement` has been prepended to the - * first top-level assistant turn's first markdown response part. Used on - * session restore so the worktree announcement remains visible after the - * session is reopened. If no assistant content exists yet, a fresh - * markdown part is inserted at the top of the first turn. - */ -function prependAnnouncementToFirstTurn( - turns: readonly Turn[], - announcement: string, -): readonly Turn[] { - if (turns.length === 0) { - return turns; - } - const result = turns.slice(); - const first = result[0]; - const partIdx = first.responseParts.findIndex(rp => rp.kind === ResponsePartKind.Markdown); - if (partIdx >= 0) { - const part = first.responseParts[partIdx]; - const updated: ResponsePart = part.kind === ResponsePartKind.Markdown - ? { ...part, content: announcement + part.content } - : part; - const responseParts = first.responseParts.slice(); - responseParts[partIdx] = updated; - result[0] = { ...first, responseParts }; - } else { - const responseParts: ResponsePart[] = [ - { kind: ResponsePartKind.Markdown, id: generateUuid(), content: announcement }, - ...first.responseParts, - ]; - result[0] = { ...first, responseParts }; - } - return result; -} - -/** - * Agent provider backed by the Copilot SDK {@link CopilotClient}. - */ -export class CopilotAgent extends Disposable implements IAgent { - readonly id = 'copilotcli' as const; - private static readonly _BRANCH_COMPLETION_LIMIT = 25; - - private readonly _onDidSessionProgress = this._register(new Emitter()); - readonly onDidSessionProgress = this._onDidSessionProgress.event; - private readonly _models = observableValue(this, []); - readonly models = this._models; - - private _client: ICopilotClient | undefined; - private _clientStarting: Promise | undefined; - private _githubToken: string | undefined; - private readonly _sessions = this._register(new DisposableMap()); - private readonly _createdWorktrees = new Map(); - /** - * Per-session announcement (markdown string) that should be emitted as - * a synthetic streaming `delta` event the first time {@link sendMessage} - * is called for the session. Currently used to surface the "Created - * isolated worktree for branch X" message live during the first turn. - * The same announcement is also injected on restore via - * {@link getSessionMessages} by prepending to the first assistant - * message's content so it stays visible after the session is reopened. - */ - private readonly _pendingFirstTurnAnnouncements = new Map(); - private readonly _sessionSequencer = new SequencerByKey(); - private _shutdownPromise: Promise | undefined; - private readonly _plugins: PluginController; - readonly onDidCustomizationsChange: Event; - /** Per-session active client state for tools + plugin snapshot tracking. */ - private readonly _activeClients = new ResourceMap(); - - constructor( - @ILogService private readonly _logService: ILogService, - @IInstantiationService private readonly _instantiationService: IInstantiationService, - @IFileService private readonly _fileService: IFileService, - @ISessionDataService private readonly _sessionDataService: ISessionDataService, - @IAgentHostGitService private readonly _gitService: IAgentHostGitService, - @IAgentHostTerminalManager private readonly _terminalManager: IAgentHostTerminalManager, - @IAgentConfigurationService private readonly _configurationService: IAgentConfigurationService, - ) { - super(); - this._plugins = this._register(this._instantiationService.createInstance(PluginController)); - this.onDidCustomizationsChange = this._plugins.onDidChange; - } - - protected _createCopilotClient(options: CopilotClientOptions): ICopilotClient { - return new CopilotClient(options); - } - - // ---- auth --------------------------------------------------------------- - - getDescriptor(): IAgentDescriptor { - return { - provider: 'copilotcli', - displayName: 'Copilot CLI', - description: 'Copilot SDK agent running in a dedicated process', - }; - } - - getProtectedResources(): ProtectedResourceMetadata[] { - return [{ - resource: 'https://api.github.com', - resource_name: 'GitHub Copilot', - authorization_servers: ['https://github.com/login/oauth'], - scopes_supported: ['read:user', 'user:email'], - required: true, - }]; - } - - getCustomizations(): readonly CustomizationRef[] { - return this._plugins.getConfiguredHostCustomizations(); - } - - async getSessionCustomizations(session: URI): Promise { - const entry = this._sessions.get(AgentSession.id(session)); - const metadata = entry ? undefined : await this._readSessionMetadata(session); - return this._plugins.getSessionCustomizations(entry?.customizationDirectory ?? metadata?.customizationDirectory ?? metadata?.workingDirectory); - } - - async authenticate(resource: string, token: string): Promise { - if (resource !== 'https://api.github.com') { - return false; - } - const tokenChanged = this._githubToken !== token; - this._githubToken = token; - this._logService.info(`[Copilot] Auth token ${tokenChanged ? 'updated' : 'unchanged'}`); - if (tokenChanged && this._client && this._sessions.size === 0) { - this._logService.info('[Copilot] Restarting CopilotClient with new token'); - await this._stopClient(); - } - if (tokenChanged) { - void this._refreshModels(); - } - return true; - } - - private async _refreshModels(): Promise { - const tokenAtRefreshStart = this._githubToken; - if (!tokenAtRefreshStart) { - this._models.set([], undefined); - return; - } - try { - const models = await this._listModels(); - if (this._githubToken === tokenAtRefreshStart) { - this._models.set(models, undefined); - } - } catch (err) { - this._logService.error(err, '[Copilot] Failed to refresh models'); - if (this._githubToken === tokenAtRefreshStart) { - this._models.set([], undefined); - } - } - } - - private async _stopClient(): Promise { - const client = this._client; - this._client = undefined; - this._clientStarting = undefined; - await client?.stop(); - } - - /** - * Enables plan mode by injecting `requestExitPlanMode: true` into the - * payload of every `session.create` / `session.resume` JSON-RPC request, - * and registers a connection-level handler for the resulting - * `exitPlanMode.request` RPC the CLI sends back. - * - * The SDK (`@github/copilot-sdk@^0.3.0`) does not expose `onExitPlanMode` - * in its public {@link SessionConfig} surface, so both the wire flag and - * the response handler are wired through the SDK's private - * `MessageConnection`. Once the SDK adds first-class support, this shim - * should be removed. - */ - protected _enablePlanModeOnClient(client: ICopilotClient): void { - // `connection` is declared private on `CopilotClient` at the type - // level but is a plain field at runtime — see the SDK's compiled - // `dist/client.js`. - const connection = (client as unknown as { connection?: IExitPlanModeConnection }).connection; - if (!connection) { - this._logService.warn('[Copilot] Could not enable plan mode: client.connection is null'); - return; - } - if (typeof connection.sendRequest !== 'function') { - this._logService.warn(`[Copilot] Could not enable plan mode: client.connection.sendRequest is ${typeof connection.sendRequest}`); - return; - } - if (typeof connection.onRequest !== 'function') { - this._logService.warn(`[Copilot] Could not enable plan mode: client.connection.onRequest is ${typeof connection.onRequest}`); - return; - } - const originalSendRequest = connection.sendRequest.bind(connection); - connection.sendRequest = (method: string, params: unknown) => { - if ((method === 'session.create' || method === 'session.resume') && params && typeof params === 'object') { - return originalSendRequest(method, { ...params as Record, requestExitPlanMode: true }); - } - return originalSendRequest(method, params); - }; - - // Handle the inbound `exitPlanMode.request` RPC the CLI dispatches - // when the model invokes `exit_plan_mode`. Routing by `sessionId` - // hands the request off to the matching {@link CopilotAgentSession}, - // which surfaces it as a {@link SessionInputRequest} and resolves - // this promise with the user's choice. - const handlerDisposable = connection.onRequest('exitPlanMode.request', async (params: IExitPlanModeRequestParams): Promise => { - const session = this._sessions.get(params.sessionId); - if (!session) { - this._logService.warn(`[Copilot] exitPlanMode.request for unknown session ${params.sessionId}`); - return { approved: false }; - } - return session.handleExitPlanModeRequest(params); - }); - this._register(toDisposable(() => handlerDisposable.dispose())); - } - - // ---- client lifecycle --------------------------------------------------- - - private async _ensureClient(): Promise { - const tokenAtStartup = this._githubToken; - if (!tokenAtStartup) { - throw new ProtocolError(AHP_AUTH_REQUIRED, 'Authentication is required to use Copilot', this.getProtectedResources()); - } - if (this._client) { - return this._client; - } - if (this._clientStarting) { - return this._clientStarting; - } - const clientStarting = (async () => { - this._logService.info('[Copilot] Starting CopilotClient... (with token)'); - - // Build a clean env for the CLI subprocess, stripping Electron/VS Code vars - // that can interfere with the Node.js process the SDK spawns. - const env: Record = Object.assign({}, process.env, { ELECTRON_RUN_AS_NODE: '1' }); - delete env['NODE_OPTIONS']; - delete env['VSCODE_INSPECTOR_OPTIONS']; - delete env['VSCODE_ESM_ENTRYPOINT']; - delete env['VSCODE_HANDLES_UNCAUGHT_ERRORS']; - for (const key of Object.keys(env)) { - if (key === 'ELECTRON_RUN_AS_NODE') { - continue; - } - if (key.startsWith('VSCODE_') || key.startsWith('ELECTRON_')) { - delete env[key]; - } - } - env['COPILOT_CLI_RUN_AS_NODE'] = '1'; - env['USE_BUILTIN_RIPGREP'] = 'false'; - - // Resolve the CLI entry point from node_modules. We can't use require.resolve() - // because @github/copilot's exports map blocks direct subpath access. - // FileAccess.asFileUri('') points to the `out/` directory; node_modules is one level up. - const cliPath = URI.joinPath(FileAccess.asFileUri(''), '..', 'node_modules', '@github', 'copilot', 'index.js').fsPath; - - // Add VS Code's built-in ripgrep to PATH so the CLI subprocess can find it. - // If @vscode/ripgrep is in an .asar file, the binary is unpacked. - const rgDiskPath = rgPath.replace(/\bnode_modules\.asar\b/, 'node_modules.asar.unpacked'); - const rgDir = dirname(rgDiskPath); - // On Windows the env key is typically "Path" (not "PATH"). Since we copied - // process.env into a plain (case-sensitive) object, we must find the actual key. - const pathKey = Object.keys(env).find(k => k.toUpperCase() === 'PATH') ?? 'PATH'; - const currentPath = env[pathKey]; - env[pathKey] = currentPath ? `${currentPath}${delimiter}${rgDir}` : rgDir; - this._logService.info(`[Copilot] Resolved CLI path: ${cliPath}`); - - const client = this._createCopilotClient({ - gitHubToken: tokenAtStartup, - useLoggedInUser: false, - useStdio: true, - autoStart: true, - env, - cliPath, - }); - await client.start(); - if (this._githubToken !== tokenAtStartup) { - await client.stop(); - throw new Error('Copilot authentication changed while the client was starting'); - } - this._logService.info('[Copilot] CopilotClient started successfully'); - this._enablePlanModeOnClient(client); - this._client = client; - this._clientStarting = undefined; - return client; - })(); - this._clientStarting = clientStarting; - void clientStarting.catch(() => { - this._clientStarting = undefined; - }); - return clientStarting; - } - - // ---- session management ------------------------------------------------- - - private _createThinkingLevelConfigSchema(supportedReasoningEfforts: readonly string[] | undefined, defaultReasoningEffort: string | undefined): ConfigSchema | undefined { - if (!supportedReasoningEfforts?.length) { - return undefined; - } - - const enumLabels = supportedReasoningEfforts.map(value => { - switch (value) { - case 'low': return localize('copilot.modelThinkingLevel.low', "Low"); - case 'medium': return localize('copilot.modelThinkingLevel.medium', "Medium"); - case 'high': return localize('copilot.modelThinkingLevel.high', "High"); - case 'xhigh': return localize('copilot.modelThinkingLevel.xhigh', "Extra High"); - default: return value; - } - }); - - return { - type: 'object', - properties: { - [ThinkingLevelConfigKey]: { - type: 'string', - title: localize('copilot.modelThinkingLevel.title', "Thinking Level"), - description: localize('copilot.modelThinkingLevel.description', "Controls how much reasoning effort the model uses."), - default: defaultReasoningEffort, - enum: [...supportedReasoningEfforts], - enumLabels, - }, - }, - }; - } - - private _getReasoningEffort(model: ModelSelection | undefined): SessionConfig['reasoningEffort'] { - const thinkingLevel = model?.config?.[ThinkingLevelConfigKey]; - return isReasoningEffort(thinkingLevel) ? thinkingLevel : undefined; - } - - private _serializeModelSelection(model: ModelSelection): string { - return JSON.stringify(model); - } - - private _parseModelSelection(raw: string | undefined): ModelSelection | undefined { - if (!raw) { - return undefined; - } - - try { - const value: ISerializedModelSelection | string | number | boolean | null = JSON.parse(raw); - if (value && typeof value === 'object' && typeof value.id === 'string') { - const modelSelection: ModelSelection = { id: value.id }; - if (value.config && typeof value.config === 'object') { - const config: Record = {}; - for (const [key, configValue] of Object.entries(value.config)) { - if (typeof configValue === 'string') { - config[key] = configValue; - } - } - if (Object.keys(config).length > 0) { - modelSelection.config = config; - } - } - return modelSelection; - } - } catch { - // Older session metadata stored the raw model id as a plain string. - } - - return { id: raw }; - } - - async listSessions(): Promise { - this._logService.info('[Copilot] Listing sessions...'); - const client = await this._ensureClient(); - const sessions = await client.listSessions(); - const projectLimiter = new Limiter(4); - const projectByContext = new Map>(); - const mapped = await Promise.all(sessions.map(async s => { - const session = AgentSession.uri(this.id, s.sessionId); - const metadata = await this._readStoredSessionMetadata(session); - if (!metadata) { - return undefined; - } - let { project, resolved } = metadata; - if (!resolved) { - project = await this._resolveSessionProject(s.context, projectLimiter, projectByContext); - void this._storeSessionProjectResolution(session, project); - } - const workingDirectory = metadata.workingDirectory ?? (typeof s.context?.cwd === 'string' ? URI.file(s.context.cwd) : undefined); - const result: IAgentSessionMetadata = { - session, - startTime: s.startTime.getTime(), - modifiedTime: s.modifiedTime.getTime(), - project, - summary: s.summary, - model: metadata.model, - workingDirectory, - customizationDirectory: metadata.customizationDirectory, - }; - return result; - })); - const result = mapped.filter((s): s is IAgentSessionMetadata => s !== undefined); - this._logService.info(`[Copilot] Found ${result.length} sessions`); - return result; - } - - private async _listModels(): Promise { - this._logService.info('[Copilot] Listing models...'); - const client = await this._ensureClient(); - const models = await client.listModels(); - const result = models.map((m): IAgentModelInfo => ({ - provider: this.id, - id: m.id, - name: m.name, - // Synthetic SDK entries like `auto` ship with `capabilities: {}` and - // no fixed context window — surface them with maxContextWindow undefined. - maxContextWindow: m.capabilities?.limits?.max_context_window_tokens, - supportsVision: !!m.capabilities?.supports?.vision, - configSchema: this._createThinkingLevelConfigSchema(m.supportedReasoningEfforts, m.defaultReasoningEffort), - policyState: m.policy?.state as PolicyState | undefined, - })); - this._logService.info(`[Copilot] Found ${result.length} models`); - return result; - } - - async createSession(config?: IAgentCreateSessionConfig): Promise { - if (!config?.workingDirectory) { - throw new Error('workingDirectory is required to create a Copilot session'); - } - - this._logService.info(`[Copilot] Creating session... ${config?.model ? `model=${config.model.id}` : ''}`); - const client = await this._ensureClient(); - - // When forking, use the SDK's sessions.fork RPC. - if (config?.fork) { - const sourceSessionId = AgentSession.id(config.fork.session); - - // Serialize against the source session to prevent concurrent - // modifications while we read its state. - return this._sessionSequencer.queue(sourceSessionId, async () => { - this._logService.info(`[Copilot] Forking session ${sourceSessionId} at turnId=${config.fork!.turnId}`); - - // Ensure the source session is loaded so we can read its event IDs - const sourceEntry = this._sessions.get(sourceSessionId) ?? await this._resumeSession(sourceSessionId); - - // Look up the SDK event ID for the turn *after* the fork point. - // toEventId is exclusive — events before it are included. - // If there's no next turn, omit toEventId to include all events. - const toEventId = await sourceEntry.getNextTurnEventId(config.fork!.turnId); - - const forkResult = await client.rpc.sessions.fork({ - sessionId: sourceSessionId, - ...(toEventId ? { toEventId } : {}), - }); - const newSessionId = forkResult.sessionId; - - // Copy the source session's database using VACUUM INTO so the - // forked session inherits turn event IDs and file-edit snapshots. - // VACUUM INTO is safe even while the source DB is open. - const targetDbDir = this._sessionDataService.getSessionDataDirById(newSessionId); - const targetDbPath = URI.joinPath(targetDbDir, SESSION_DB_FILENAME); - try { - const sourceDbRef = await this._sessionDataService.tryOpenDatabase(config.fork!.session); - if (sourceDbRef) { - try { - await fs.mkdir(targetDbDir.fsPath, { recursive: true }); - await sourceDbRef.object.vacuumInto(targetDbPath.fsPath); - } finally { - sourceDbRef.dispose(); - } - } - } catch (err) { - this._logService.warn(`[Copilot] Failed to copy session database for fork: ${err instanceof Error ? err.message : String(err)}`); - } - - // Resume the forked session so the SDK loads the forked history - const agentSession = await this._resumeSession(newSessionId); - - // Remap turn IDs to match the new protocol turn IDs - if (config.fork!.turnIdMapping) { - await agentSession.remapTurnIds(config.fork!.turnIdMapping); - } - - const session = agentSession.sessionUri; - this._logService.info(`[Copilot] Forked session created: ${session.toString()}`); - const project = await projectFromCopilotContext({ cwd: config.workingDirectory?.fsPath }, this._gitService); - await this._storeSessionMetadata(session, config.model, config.workingDirectory, config.workingDirectory, project, true); - return { session, workingDirectory: config.workingDirectory, ...(project ? { project } : {}) }; - }); - } - - const sessionId = config?.session ? AgentSession.id(config.session) : generateUuid(); - const sessionUri = AgentSession.uri(this.id, sessionId); - const customizationDirectory = config?.workingDirectory; - let seededActiveClient = false; - if (config?.activeClient) { - const ac = this._getOrCreateActiveClient(sessionUri); - seededActiveClient = true; - ac.updateTools(config.activeClient.clientId, config.activeClient.tools); - if (config.activeClient.customizations !== undefined) { - await this._plugins.sync(config.activeClient.clientId, config.activeClient.customizations); - } - } - const activeClient = this._activeClients.get(sessionUri); - const snapshot = activeClient ? await activeClient.snapshot(customizationDirectory) : undefined; - const workingDirectory = await this._resolveSessionWorkingDirectory(config, sessionId); - const shellManager = this._instantiationService.createInstance(ShellManager, sessionUri, workingDirectory); - const sessionConfig = this._buildSessionConfig(snapshot, shellManager); - - const factory: SessionWrapperFactory = async callbacks => { - const raw = await client.createSession({ - model: config?.model?.id, - reasoningEffort: this._getReasoningEffort(config?.model), - sessionId, - streaming: true, - workingDirectory: workingDirectory?.fsPath, - ...await sessionConfig(callbacks), - }); - return new CopilotSessionWrapper(raw); - }; - - let agentSession: CopilotAgentSession; - try { - agentSession = this._createAgentSession(factory, sessionId, shellManager, workingDirectory, customizationDirectory, snapshot); - await agentSession.initializeSession(); - } catch (error) { - if (seededActiveClient) { - this._activeClients.delete(sessionUri); - } - await this._removeCreatedWorktree(sessionId); - throw error; - } - const session = agentSession.sessionUri; - this._logService.info(`[Copilot] Session created: ${session.toString()}`); - const project = await projectFromCopilotContext({ cwd: workingDirectory?.fsPath }, this._gitService); - // Persist model, working directory, and project so we can recreate the - // session if the SDK loses it and avoid rediscovering git metadata. - await this._storeSessionMetadata(agentSession.sessionUri, config?.model, workingDirectory, customizationDirectory, project, true); - return { session, workingDirectory, ...(project ? { project } : {}) }; - } - - async resolveSessionConfig(params: IAgentResolveSessionConfigParams): Promise { - const gitInfo = params.workingDirectory ? await this._getGitInfo(params.workingDirectory) : undefined; - - const isolationProperty = schemaProperty<'folder' | 'worktree'>({ - type: 'string', - title: localize('agentHost.sessionConfig.isolation', "Isolation"), - description: localize('agentHost.sessionConfig.isolationDescription', "Where the agent should make changes"), - enum: gitInfo ? ['folder', 'worktree'] : ['folder'], - enumLabels: gitInfo ? [localize('agentHost.sessionConfig.isolation.folder', "Folder"), localize('agentHost.sessionConfig.isolation.worktree', "Worktree")] : [localize('agentHost.sessionConfig.isolation.folder', "Folder")], - enumDescriptions: gitInfo ? [localize('agentHost.sessionConfig.isolation.folderDescription', "Work directly in the folder"), localize('agentHost.sessionConfig.isolation.worktreeDescription', "Create a Git worktree for isolation")] : [localize('agentHost.sessionConfig.isolation.folderDescription', "Work directly in the folder")], - default: gitInfo ? 'worktree' : 'folder', - readOnly: !gitInfo, - }); - - // Resolve isolation first — downstream schema shapes (branch's - // read-only mode + enum restriction) depend on the effective value. - const isolationDefault: 'folder' | 'worktree' = gitInfo ? 'worktree' : 'folder'; - const isolationValue = isolationProperty.validate(params.config?.[SessionConfigKey.Isolation]) - ? params.config[SessionConfigKey.Isolation] as 'folder' | 'worktree' - : isolationDefault; - - let branchProperty: ISchemaProperty | undefined; - let branchDefault: string | undefined; - if (gitInfo) { - const branchReadOnly = isolationValue === 'folder'; - branchDefault = isolationValue === 'worktree' ? gitInfo.defaultBranch : gitInfo.currentBranch; - branchProperty = schemaProperty({ - type: 'string', - title: localize('agentHost.sessionConfig.branch', "Branch"), - description: localize('agentHost.sessionConfig.branchDescription', "Base branch to work from"), - enum: [branchDefault], - enumLabels: [branchDefault], - default: branchDefault, - enumDynamic: !branchReadOnly, - readOnly: branchReadOnly, - }); - } - - const sessionSchema = createSchema({ - [SessionConfigKey.Isolation]: isolationProperty, - ...platformSessionSchema.definition, - ...(branchProperty ? { [SessionConfigKey.Branch]: branchProperty } : {}), - }); - - const values = sessionSchema.validateOrDefault(params.config, { - [SessionConfigKey.Isolation]: isolationValue, - [SessionConfigKey.AutoApprove]: 'default' satisfies AutoApproveLevel, - [SessionConfigKey.Mode]: 'interactive' satisfies SessionMode, - // Permissions intentionally omitted — leave unset so auto-approval - // falls through to the host-level `permissions` default, and only - // materializes on the session once the user hits "Allow in this - // Session". - ...(branchDefault !== undefined ? { [SessionConfigKey.Branch]: branchDefault } : {}), - }); - - return { - schema: sessionSchema.toProtocol(), - values, - }; - } - - async sessionConfigCompletions(params: IAgentSessionConfigCompletionsParams): Promise { - if (params.property !== 'branch' || !params.workingDirectory) { - return { items: [] }; - } - - const branches = await this._getBranches(params.workingDirectory, params.query); - return { items: branches.map(branch => ({ value: branch, label: branch })) }; - } - - async setClientCustomizations(clientId: string, customizations: CustomizationRef[], progress?: (results: ISyncedCustomization[]) => void): Promise { - return this._plugins.sync(clientId, customizations, progress); - } - - setClientTools(session: URI, clientId: string, tools: ToolDefinition[]): void { - const sessionId = AgentSession.id(session); - const activeClient = this._getOrCreateActiveClient(session); - const hasCachedEntry = this._sessions.has(sessionId); - this._logService.info(`[Copilot:${sessionId}] setClientTools: clientId=${clientId}, tools=[${tools.map(t => t.name).join(', ') || '(none)'}], hasCachedSdkSession=${hasCachedEntry}`); - activeClient.updateTools(clientId, tools); - } - - onClientToolCallComplete(session: URI, toolCallId: string, result: ToolCallResult): void { - const sessionId = AgentSession.id(parseSubagentSessionUri(session.toString())?.parentSession || session); - const entry = this._sessions.get(sessionId); - entry?.handleClientToolCallComplete(toolCallId, result); - } - - setCustomizationEnabled(uri: string, enabled: boolean): void { - this._plugins.setEnabled(uri, enabled); - } - - async sendMessage(session: URI, prompt: string, attachments?: IAgentAttachment[], turnId?: string): Promise { - const sessionId = AgentSession.id(session); - await this._sessionSequencer.queue(sessionId, async () => { - - // If the active client's config changed (tools or plugins), - // dispose this session so it gets resumed with the updated config. - let entry = this._sessions.get(sessionId); - const activeClient = this._activeClients.get(session); - const hadCachedEntry = !!entry; - this._logService.info(`[Copilot:${sessionId}] sendMessage: cachedEntry=${hadCachedEntry}, hasActiveClient=${!!activeClient}, activeClientId=${activeClient ? '(set)' : '(none)'}`); - if (entry && activeClient && await activeClient.isOutdated(entry.appliedSnapshot, entry.customizationDirectory)) { - this._logService.info(`[Copilot:${sessionId}] Session config changed (isOutdated=true), refreshing session. snapshotClientId=${entry.appliedSnapshot.clientId}`); - this._sessions.deleteAndDispose(sessionId); - entry = undefined; - } - - if (!entry) { - this._logService.info(`[Copilot:${sessionId}] No cached entry${hadCachedEntry ? ' (was evicted by isOutdated)' : ''}, calling _resumeSession`); - } - entry ??= await this._resumeSession(sessionId); - - // Reset per-turn streaming state on the session so that the - // next text/reasoning chunk (and any host-emitted announcement) - // allocates a fresh response part. - if (turnId) { - entry.resetTurnState(turnId); - } - - // Emit any pending first-turn announcement (e.g. worktree - // created) as a synthetic markdown response part before - // delegating to the SDK. The SDK's subsequent deltas append to - // the same markdown part because the session has already - // allocated `_currentMarkdownPartId`. - const announcement = this._pendingFirstTurnAnnouncements.get(sessionId); - if (announcement !== undefined) { - this._pendingFirstTurnAnnouncements.delete(sessionId); - entry.emitInitialMarkdown(announcement); - } - - try { - const sdkMode = this._resolveSdkMode(session); - await entry.send(prompt, attachments, turnId, sdkMode); - } catch (err) { - const errCode = (err as { code?: number })?.code; - const errMsg = err instanceof Error ? err.message : String(err); - this._logService.error(`[Copilot:${sessionId}] entry.send() failed: code=${errCode}, message=${errMsg}, hadCachedEntry=${hadCachedEntry}, errorType=${err?.constructor?.name}`); - throw err; - } - }); - } - - /** - * Translates the AHP-side `(mode, autoApprove)` pair to the Copilot - * SDK's three-mode space (`interactive` / `plan` / `autopilot`): - * - * - `mode='plan'` → SDK `plan` (auto-approval is irrelevant; the - * agent host's existing session-state auto-approval logic handles - * `plan.md` writes). - * - `mode='interactive'` + `autoApprove='autopilot'` → SDK `autopilot` - * (the SDK auto-approves all tool calls). - * - `mode='interactive'` + any other autoApprove → SDK `interactive` - * (the agent host's own auto-approval logic continues to gate tool - * calls based on `autoApprove`). - * - * Returns `undefined` when no mode is configured for the session, so - * the SDK's current mode is left untouched. - */ - private _resolveSdkMode(session: URI): CopilotSdkMode | undefined { - const sessionKey = session.toString(); - const mode = this._configurationService.getEffectiveValue(sessionKey, platformSessionSchema, SessionConfigKey.Mode); - if (mode === 'plan') { - return 'plan'; - } - if (mode === 'interactive') { - const autoApprove = this._configurationService.getEffectiveValue(sessionKey, platformSessionSchema, SessionConfigKey.AutoApprove); - return autoApprove === 'autopilot' ? 'autopilot' : 'interactive'; - } - return undefined; - } - - setPendingMessages(session: URI, steeringMessage: PendingMessage | undefined, _queuedMessages: readonly PendingMessage[]): void { - const sessionId = AgentSession.id(session); - const entry = this._sessions.get(sessionId); - if (!entry) { - this._logService.warn(`[Copilot:${sessionId}] setPendingMessages: session not found`); - return; - } - - // Steering: send with mode 'immediate' so the SDK injects it mid-turn - if (steeringMessage) { - entry.sendSteering(steeringMessage); - } - - // Queued messages are consumed by the server (AgentSideEffects) - // which dispatches SessionTurnStarted and calls sendMessage directly. - // No SDK-level enqueue is needed. - } - - async getSessionMessages(session: URI): Promise { - // If the URI describes a subagent child session (`/subagent/`), - // load the parent's events once and extract the child's filtered turns. - const subagentInfo = parseSubagentSessionUri(session.toString()); - if (subagentInfo) { - const parentUri = URI.parse(subagentInfo.parentSession); - const parentSessionId = AgentSession.id(parentUri); - const parentEntry = this._sessions.get(parentSessionId) ?? await this._resumeSession(parentSessionId).catch(err => { - this._logService.warn(`[Copilot:${parentSessionId}] Failed to resume parent for subagent restore`, err); - return undefined; - }); - if (!parentEntry) { - return []; - } - return parentEntry.getSubagentMessages(subagentInfo.toolCallId, session.toString()); - } - - const sessionId = AgentSession.id(session); - const entry = this._sessions.get(sessionId) ?? await this._resumeSession(sessionId).catch(err => { - this._logService.warn(`[Copilot:${sessionId}] Failed to resume session for message lookup`, err); - return undefined; - }); - if (!entry) { - return []; - } - const rawTurns = await entry.getMessages(); - - // If a worktree was created for this session at create-time, prepend - // If a worktree was created for this session at create-time, prepend - // the announcement to the first turn so it appears at the top of the - // first response when the session is reopened. The live path - // (sendMessage) handles the very first turn when the session is fresh; - // this path takes over on subsequent loads, where - // _pendingFirstTurnAnnouncements is empty. - const worktreeMeta = await this._readWorktreeMetadata(session).catch(err => { - this._logService.warn(`[Copilot:${sessionId}] Failed to read worktree branch metadata`, err); - return undefined; - }); - if (!worktreeMeta?.branchName) { - return rawTurns; - } - return prependAnnouncementToFirstTurn(rawTurns, buildWorktreeAnnouncementText(worktreeMeta.branchName)); - } - - async disposeSession(session: URI): Promise { - const sessionId = AgentSession.id(session); - await this._sessionSequencer.queue(sessionId, async () => { - await this._destroyAndDisposeSession(sessionId); - }); - } - - async onArchivedChanged(session: URI, isArchived: boolean): Promise { - const sessionId = AgentSession.id(session); - await this._sessionSequencer.queue(sessionId, async () => { - if (isArchived) { - await this._cleanupWorktreeOnArchive(session, sessionId); - } else { - await this._recreateWorktreeOnUnarchive(session, sessionId); - } - }); - } - - private async _cleanupWorktreeOnArchive(session: URI, sessionId: string): Promise { - const meta = await this._readWorktreeMetadata(session).catch(() => undefined); - if (!meta?.worktreePath || !meta.repositoryRoot) { - return; - } - const { branchName, worktreePath, repositoryRoot } = meta; - - // Skip if the worktree directory is already gone — nothing to clean. - try { - await fs.access(worktreePath.fsPath); - } catch { - this._createdWorktrees.delete(sessionId); - return; - } - - // Skip if the branch is missing — without it we can't safely recreate - // the worktree on unarchive, so leave the working tree intact. - const branchPresent = await this._gitService.branchExists(repositoryRoot, branchName).catch(() => false); - if (!branchPresent) { - this._logService.info(`[Copilot:${sessionId}] Skipping worktree cleanup: branch '${branchName}' is missing`); - return; - } - - // Skip if there are uncommitted changes — don't silently destroy work. - const dirty = await this._gitService.hasUncommittedChanges(worktreePath).catch(() => true); - if (dirty) { - this._logService.info(`[Copilot:${sessionId}] Skipping worktree cleanup: '${worktreePath.fsPath}' has uncommitted changes`); - return; - } - - try { - await this._gitService.removeWorktree(repositoryRoot, worktreePath); - this._logService.info(`[Copilot:${sessionId}] Removed worktree '${worktreePath.fsPath}' on archive`); - } catch (error) { - this._logService.warn(`[Copilot:${sessionId}] Failed to remove worktree '${worktreePath.fsPath}' on archive: ${error instanceof Error ? error.message : String(error)}`); - } finally { - this._createdWorktrees.delete(sessionId); - } - } - - private async _recreateWorktreeOnUnarchive(session: URI, sessionId: string): Promise { - const meta = await this._readWorktreeMetadata(session).catch(() => undefined); - if (!meta?.worktreePath || !meta.repositoryRoot) { - return; - } - const { branchName, worktreePath, repositoryRoot } = meta; - - // Skip if the worktree directory already exists — nothing to do. - try { - await fs.access(worktreePath.fsPath); - return; - } catch { - // expected when the worktree was cleaned up on archive - } - - // Skip if the branch is missing — we have no commit to attach the - // recreated worktree to. - const branchPresent = await this._gitService.branchExists(repositoryRoot, branchName).catch(() => false); - if (!branchPresent) { - this._logService.info(`[Copilot:${sessionId}] Skipping worktree recreation: branch '${branchName}' is missing`); - return; - } - - try { - await fs.mkdir(URI.joinPath(worktreePath, '..').fsPath, { recursive: true }); - await this._gitService.addExistingWorktree(repositoryRoot, worktreePath, branchName); - this._createdWorktrees.set(sessionId, { repositoryRoot, worktree: worktreePath }); - this._logService.info(`[Copilot:${sessionId}] Recreated worktree '${worktreePath.fsPath}' on unarchive`); - } catch (error) { - this._logService.warn(`[Copilot:${sessionId}] Failed to recreate worktree '${worktreePath.fsPath}' on unarchive: ${error instanceof Error ? error.message : String(error)}`); - } - } - - async abortSession(session: URI): Promise { - const sessionId = AgentSession.id(session); - await this._sessionSequencer.queue(sessionId, async () => { - const entry = this._sessions.get(sessionId); - if (entry) { - await entry.abort(); - } - }); - } - - async truncateSession(session: URI, turnId?: string): Promise { - const sessionId = AgentSession.id(session); - await this._sessionSequencer.queue(sessionId, async () => { - this._logService.info(`[Copilot:${sessionId}] Truncating session${turnId !== undefined ? ` at turnId=${turnId}` : ' (all turns)'}`); - - // Ensure the session is loaded so we can use the SDK RPC - const entry = this._sessions.get(sessionId) ?? await this._resumeSession(sessionId); - - // Look up the SDK event ID for the truncation boundary. - // The protocol semantics: turnId is the last turn to KEEP. - // The SDK semantics: eventId and all events after it are removed. - // So we need the event ID of the *next* turn after turnId. - // For "remove all", we need the first turn's event ID. - let eventId: string | undefined; - if (turnId) { - eventId = await entry.getNextTurnEventId(turnId); - } else { - eventId = await entry.getFirstTurnEventId(); - } - - if (eventId) { - await entry.truncateAtEventId(eventId, turnId); - } else { - this._logService.info(`[Copilot:${sessionId}] No event ID found for truncation, nothing to truncate`); - } - - this._logService.info(`[Copilot:${sessionId}] Session truncated`); - }); - } - - async changeModel(session: URI, model: ModelSelection): Promise { - const sessionId = AgentSession.id(session); - const entry = this._sessions.get(sessionId); - if (entry) { - await entry.setModel(model.id, this._getReasoningEffort(model)); - } - await this._storeSessionMetadata(session, model, undefined, undefined, undefined); - } - - async shutdown(): Promise { - this._shutdownPromise ??= (async () => { - this._logService.info('[Copilot] Shutting down...'); - const sessionIds = new Set([...this._sessions.keys(), ...this._createdWorktrees.keys()]); - for (const sessionId of sessionIds) { - await this._sessionSequencer.queue(sessionId, () => this._destroyAndDisposeSession(sessionId)); - } - await this._client?.stop(); - this._client = undefined; - })(); - return this._shutdownPromise; - } - - respondToPermissionRequest(requestId: string, approved: boolean): void { - for (const [, session] of this._sessions) { - if (session.respondToPermissionRequest(requestId, approved)) { - return; - } - } - } - - respondToUserInputRequest(requestId: string, response: SessionInputResponseKind, answers?: Record): void { - for (const [, session] of this._sessions) { - if (session.respondToUserInputRequest(requestId, response, answers)) { - return; - } - } - } - - /** - * Returns true if this provider owns the given session ID. - */ - hasSession(session: URI): boolean { - return this._sessions.has(AgentSession.id(session)); - } - - // ---- helpers ------------------------------------------------------------ - - private _getOrCreateActiveClient(session: URI): ActiveClient { - let client = this._activeClients.get(session); - if (!client) { - client = new ActiveClient(directory => this._plugins.getAppliedPlugins(directory)); - this._activeClients.set(session, client); - } - return client; - } - - /** - * Creates a {@link CopilotAgentSession}, registers it in the sessions map, - * and returns it. The caller must call {@link CopilotAgentSession.initializeSession} - * to wire up the SDK session. - */ - private _createAgentSession(wrapperFactory: SessionWrapperFactory, sessionId: string, shellManager: ShellManager, workingDirectory: URI | undefined, customizationDirectory: URI | undefined, snapshot?: IActiveClientSnapshot): CopilotAgentSession { - const sessionUri = AgentSession.uri(this.id, sessionId); - - const agentSession = this._instantiationService.createInstance( - CopilotAgentSession, - { - sessionUri, - rawSessionId: sessionId, - onDidSessionProgress: this._onDidSessionProgress, - wrapperFactory, - shellManager, - workingDirectory, - customizationDirectory, - clientSnapshot: snapshot, - }, - ); - - this._sessions.set(sessionId, agentSession); - return agentSession; - } - - private async _destroyAndDisposeSession(sessionId: string): Promise { - const entry = this._sessions.get(sessionId); - if (entry) { - try { - await entry.destroySession(); - } catch (error) { - this._logService.warn(`[Copilot:${sessionId}] Failed to destroy session before cleanup: ${error instanceof Error ? error.message : String(error)}`); - } - } - this._sessions.deleteAndDispose(sessionId); - await this._removeCreatedWorktree(sessionId); - } - - /** - * Builds the common session configuration (plugins + shell tools) shared - * by both {@link createSession} and {@link _resumeSession}. - * - * Returns an async function that resolves the final config given the - * session's permission/hook callbacks, so it can be called lazily - * inside the {@link SessionWrapperFactory}. - */ - private _buildSessionConfig(snapshot: IActiveClientSnapshot | undefined, shellManager: ShellManager): (args: Parameters[0]) => Promise { - const shellTools = createShellTools(shellManager, this._terminalManager, this._logService); - const plugins = snapshot?.plugins ?? []; - - return async (callbacks: Parameters[0]) => { - const customAgents = await toSdkCustomAgents(plugins.flatMap(p => p.agents), this._fileService); - return { - onPermissionRequest: callbacks.onPermissionRequest, - onUserInputRequest: callbacks.onUserInputRequest, - hooks: toSdkHooks(plugins.flatMap(p => p.hooks), callbacks.hooks), - mcpServers: toSdkMcpServers(plugins.flatMap(p => p.mcpServers)), - customAgents, - skillDirectories: toSdkSkillDirectories(plugins.flatMap(p => p.skills)), - tools: [...shellTools, ...callbacks.clientTools], - // Enable infinite sessions so the SDK provisions a workspace - // directory (containing `plan.md`, `checkpoints/`, `files/`). - // The workspace is required for plan mode to work — without - // it, `rpc.plan.read()` returns `path: null` and the SDK - // never emits `exit_plan_mode.requested`. - infiniteSessions: { enabled: true }, - }; - }; - } - - protected async _resumeSession(sessionId: string): Promise { - this._logService.info(`[Copilot:${sessionId}] _resumeSession called — session not in memory, resuming...`); - const client = await this._ensureClient(); - - const sessionUri = AgentSession.uri(this.id, sessionId); - const storedMetadata = await this._readSessionMetadata(sessionUri); - const customizationDirectory = storedMetadata.customizationDirectory ?? storedMetadata.workingDirectory; - const activeClient = this._activeClients.get(sessionUri); - const snapshot = activeClient ? await activeClient.snapshot(customizationDirectory) : undefined; - const sessionMetadata = await client.getSessionMetadata(sessionId).catch(err => { - this._logService.warn(`[Copilot:${sessionId}] getSessionMetadata failed`, err); - return undefined; - }); - const workingDirectory = storedMetadata.workingDirectory ?? (typeof sessionMetadata?.context?.cwd === 'string' ? URI.file(sessionMetadata.context.cwd) : undefined); - if (!workingDirectory) { - throw new Error(`workingDirectory is required to resume Copilot session '${sessionId}'`); - } - - const shellManager = this._instantiationService.createInstance(ShellManager, sessionUri, workingDirectory); - const sessionConfig = this._buildSessionConfig(snapshot, shellManager); - - const factory: SessionWrapperFactory = async callbacks => { - const config = await sessionConfig(callbacks); - try { - this._logService.info(`[Copilot:${sessionId}] Calling SDK resumeSession...`); - const raw = await client.resumeSession(sessionId, { - ...config, - workingDirectory: workingDirectory?.fsPath, - }); - this._logService.info(`[Copilot:${sessionId}] SDK resumeSession succeeded`); - return new CopilotSessionWrapper(raw); - } catch (err) { - const errCode = (err as { code?: number })?.code; - const errMsg = err instanceof Error ? err.message : String(err); - this._logService.warn(`[Copilot:${sessionId}] SDK resumeSession failed: code=${errCode}, message=${errMsg}`); - // The SDK fails to resume sessions that have no messages. - // Fall back to creating a new session with the same ID, - // seeding model & working directory from stored metadata. - if (!err || errCode !== -32603) { - throw err; - } - - this._logService.warn(`[Copilot:${sessionId}] Resume failed (code=-32603), falling back to createSession with same ID`); - const raw = await client.createSession({ - ...config, - sessionId, - streaming: true, - model: storedMetadata.model?.id, - reasoningEffort: this._getReasoningEffort(storedMetadata.model), - workingDirectory: workingDirectory?.fsPath, - }); - this._logService.info(`[Copilot:${sessionId}] Fallback createSession succeeded`); - - return new CopilotSessionWrapper(raw); - } - }; - - const agentSession = this._createAgentSession(factory, sessionId, shellManager, workingDirectory, customizationDirectory, snapshot); - await agentSession.initializeSession(); - - return agentSession; - } - - private async _getGitInfo(workingDirectory: URI): Promise<{ currentBranch: string; defaultBranch: string } | undefined> { - if (!await this._gitService.isInsideWorkTree(workingDirectory)) { - return undefined; - } - - const currentBranch = await this._gitService.getCurrentBranch(workingDirectory) ?? 'HEAD'; - const defaultBranch = await this._gitService.getDefaultBranch(workingDirectory) ?? currentBranch; - return { currentBranch, defaultBranch }; - } - - private async _getBranches(workingDirectory: URI, query?: string): Promise { - return this._gitService.getBranches(workingDirectory, { query, limit: CopilotAgent._BRANCH_COMPLETION_LIMIT }); - } - - protected async _resolveSessionWorkingDirectory(config: IAgentCreateSessionConfig | undefined, sessionId: string): Promise { - if (config?.config?.isolation !== 'worktree' || !config.workingDirectory || typeof config.config.branch !== 'string') { - return config?.workingDirectory; - } - - const repositoryRoot = await this._gitService.getRepositoryRoot(config.workingDirectory); - if (!repositoryRoot) { - return config.workingDirectory; - } - - const worktreesRoot = getCopilotWorktreesRoot(repositoryRoot); - const branchNameHintRaw = config.config[SessionConfigKey.BranchNameHint]; - const branchNameHint = typeof branchNameHintRaw === 'string' ? branchNameHintRaw : undefined; - const branchName = getCopilotWorktreeBranchName(sessionId, branchNameHint); - const worktree = URI.joinPath(worktreesRoot, getCopilotWorktreeName(branchName)); - await fs.mkdir(worktreesRoot.fsPath, { recursive: true }); - const baseBranch = typeof config.config[SessionConfigKey.Branch] === 'string' ? config.config[SessionConfigKey.Branch] as string : undefined; - // `addWorktree`'s signature requires a startPoint, but historically the - // runtime accepted undefined when `branch` was not set in config. Preserve - // that behavior by passing through whatever value (or undefined) was set. - await this._gitService.addWorktree(repositoryRoot, worktree, branchName, baseBranch as string); - this._createdWorktrees.set(sessionId, { repositoryRoot, worktree }); - // Queue the worktree announcement so the first turn (live) and any - // subsequent restore (history) both surface the message in the chat. - this._pendingFirstTurnAnnouncements.set(sessionId, buildWorktreeAnnouncementText(branchName)); - const sessionUri = AgentSession.uri(this.id, sessionId); - try { - await this._writeWorktreeMetadata(sessionUri, { branchName, baseBranch, worktreePath: worktree, repositoryRoot }); - } catch (error) { - this._logService.warn(`[Copilot:${sessionId}] Failed to persist worktree branch metadata: ${error instanceof Error ? error.message : String(error)}`); - } - return worktree; - } - - private async _removeCreatedWorktree(sessionId: string): Promise { - const worktree = this._createdWorktrees.get(sessionId); - if (!worktree) { - return; - } - try { - await this._gitService.removeWorktree(worktree.repositoryRoot, worktree.worktree); - } catch (error) { - this._logService.warn(`[Copilot:${sessionId}] Failed to remove worktree '${worktree.worktree.fsPath}': ${error instanceof Error ? error.message : String(error)}`); - } finally { - this._createdWorktrees.delete(sessionId); - } - } - - // ---- session metadata persistence -------------------------------------- - - private static readonly _META_MODEL = 'copilot.model'; - private static readonly _META_CWD = 'copilot.workingDirectory'; - private static readonly _META_CUSTOMIZATION_DIRECTORY = 'copilot.customizationDirectory'; - private static readonly _META_PROJECT_RESOLVED = 'copilot.project.resolved'; - private static readonly _META_PROJECT_URI = 'copilot.project.uri'; - private static readonly _META_PROJECT_DISPLAY_NAME = 'copilot.project.displayName'; - private static readonly _META_WORKTREE_BRANCH = 'copilot.worktree.branchName'; - private static readonly _META_WORKTREE_PATH = 'copilot.worktree.path'; - private static readonly _META_WORKTREE_REPOSITORY_ROOT = 'copilot.worktree.repositoryRoot'; - - private async _writeWorktreeMetadata(session: URI, metadata: { branchName: string; baseBranch: string | undefined; worktreePath: URI; repositoryRoot: URI }): Promise { - const dbRef = this._sessionDataService.openDatabase(session); - try { - const work: Promise[] = [ - dbRef.object.setMetadata(CopilotAgent._META_WORKTREE_BRANCH, metadata.branchName), - dbRef.object.setMetadata(CopilotAgent._META_WORKTREE_PATH, metadata.worktreePath.toString()), - dbRef.object.setMetadata(CopilotAgent._META_WORKTREE_REPOSITORY_ROOT, metadata.repositoryRoot.toString()), - ]; - if (metadata.baseBranch) { - work.push(dbRef.object.setMetadata(META_DIFF_BASE_BRANCH, metadata.baseBranch)); - } - await Promise.all(work); - } finally { - dbRef.dispose(); - } - } - - private async _readWorktreeMetadata(session: URI): Promise<{ branchName: string; worktreePath?: URI; repositoryRoot?: URI } | undefined> { - const ref = await this._sessionDataService.tryOpenDatabase(session); - if (!ref) { - return undefined; - } - try { - const [branchName, worktreePathRaw, repositoryRootRaw] = await Promise.all([ - ref.object.getMetadata(CopilotAgent._META_WORKTREE_BRANCH), - ref.object.getMetadata(CopilotAgent._META_WORKTREE_PATH), - ref.object.getMetadata(CopilotAgent._META_WORKTREE_REPOSITORY_ROOT), - ]); - if (!branchName) { - return undefined; - } - const worktreePath = worktreePathRaw ? URI.parse(worktreePathRaw) : undefined; - const repositoryRoot = repositoryRootRaw ? URI.parse(repositoryRootRaw) : undefined; - return { branchName, worktreePath, repositoryRoot }; - } finally { - ref.dispose(); - } - } - - private async _storeSessionMetadata(session: URI, model: ModelSelection | undefined, workingDirectory: URI | undefined, customizationDirectory: URI | undefined, project: IAgentSessionProjectInfo | undefined, projectResolved = project !== undefined): Promise { - const dbRef = this._sessionDataService.openDatabase(session); - const db = dbRef.object; - try { - const work: Promise[] = []; - if (model) { - work.push(db.setMetadata(CopilotAgent._META_MODEL, this._serializeModelSelection(model))); - } - if (workingDirectory) { - work.push(db.setMetadata(CopilotAgent._META_CWD, workingDirectory.toString())); - } - if (customizationDirectory) { - work.push(db.setMetadata(CopilotAgent._META_CUSTOMIZATION_DIRECTORY, customizationDirectory.toString())); - } - if (projectResolved) { - work.push(db.setMetadata(CopilotAgent._META_PROJECT_RESOLVED, 'true')); - } - if (project) { - work.push(db.setMetadata(CopilotAgent._META_PROJECT_URI, project.uri.toString())); - work.push(db.setMetadata(CopilotAgent._META_PROJECT_DISPLAY_NAME, project.displayName)); - } - await Promise.all(work); - } finally { - dbRef.dispose(); - } - } - - private async _readSessionMetadata(session: URI): Promise<{ model?: ModelSelection; workingDirectory?: URI; customizationDirectory?: URI }> { - const ref = await this._sessionDataService.tryOpenDatabase(session); - if (!ref) { - return {}; - } - try { - const [model, cwd, customizationDirectory] = await Promise.all([ - ref.object.getMetadata(CopilotAgent._META_MODEL), - ref.object.getMetadata(CopilotAgent._META_CWD), - ref.object.getMetadata(CopilotAgent._META_CUSTOMIZATION_DIRECTORY), - ]); - return { - model: this._parseModelSelection(model), - workingDirectory: cwd ? URI.parse(cwd) : undefined, - customizationDirectory: customizationDirectory ? URI.parse(customizationDirectory) : undefined, - }; - } finally { - ref.dispose(); - } - } - - private async _readStoredSessionMetadata(session: URI): Promise<{ model?: ModelSelection; workingDirectory?: URI; customizationDirectory?: URI; project?: IAgentSessionProjectInfo; resolved: boolean } | undefined> { - const ref = await this._sessionDataService.tryOpenDatabase(session); - if (!ref) { - return undefined; - } - try { - const [model, cwd, customizationDirectory, resolved, uri, displayName] = await Promise.all([ - ref.object.getMetadata(CopilotAgent._META_MODEL), - ref.object.getMetadata(CopilotAgent._META_CWD), - ref.object.getMetadata(CopilotAgent._META_CUSTOMIZATION_DIRECTORY), - ref.object.getMetadata(CopilotAgent._META_PROJECT_RESOLVED), - ref.object.getMetadata(CopilotAgent._META_PROJECT_URI), - ref.object.getMetadata(CopilotAgent._META_PROJECT_DISPLAY_NAME), - ]); - const workingDirectory = cwd ? URI.parse(cwd) : undefined; - const project = uri && displayName ? { uri: URI.parse(uri), displayName } : undefined; - return { - model: this._parseModelSelection(model), - workingDirectory, - customizationDirectory: customizationDirectory ? URI.parse(customizationDirectory) : undefined, - project, - resolved: resolved === 'true' || project !== undefined, - }; - } finally { - ref.dispose(); - } - } - - private async _storeSessionProjectResolution(session: URI, project: IAgentSessionProjectInfo | undefined): Promise { - await this._storeSessionMetadata(session, undefined, undefined, undefined, project, true); - } - - private _resolveSessionProject(context: ICopilotSessionContext | undefined, limiter: Limiter, projectByContext: Map>): Promise { - const key = this._projectContextKey(context); - if (!key) { - return Promise.resolve(undefined); - } - - let project = projectByContext.get(key); - if (!project) { - project = limiter.queue(() => projectFromCopilotContext(context, this._gitService)); - projectByContext.set(key, project); - } - return project; - } - - private _projectContextKey(context: ICopilotSessionContext | undefined): string | undefined { - if (context?.cwd) { - return `cwd:${context.cwd}`; - } - if (context?.gitRoot) { - return `gitRoot:${context.gitRoot}`; - } - if (context?.repository) { - return `repository:${context.repository}`; - } - return undefined; - } - - override dispose(): void { - this.shutdown().catch(err => { - this._logService.warn('[Copilot] Shutdown failed during dispose', err); - }).finally(() => super.dispose()); - } -} - -interface IResolvedCustomization { - readonly customization: SessionCustomization; - readonly pluginDir?: URI; - readonly plugin?: IParsedPlugin; -} - -class PluginController extends Disposable { - private readonly _onDidChange = this._register(new Emitter()); - readonly onDidChange = this._onDidChange.event; - - private readonly _enablement = new Map(); - private _clientCustomizations: readonly IResolvedCustomization[] = []; - private _hostCustomizations: readonly IResolvedCustomization[] = []; - private _clientSync: Promise = Promise.resolve([]); - private _hostSync: Promise = Promise.resolve([]); - private _clientRevision = 0; - private _hostRevision = 0; - private _lastAppliedRefs: readonly CustomizationRef[] = []; - - constructor( - @IAgentPluginManager private readonly _pluginManager: IAgentPluginManager, - @ILogService private readonly _logService: ILogService, - @IFileService private readonly _fileService: IFileService, - @IAgentConfigurationService private readonly _configurationService: IAgentConfigurationService, - ) { - super(); - - // Seed from current root config and subscribe to future changes. - this._applyHostCustomizations(); - this._register(this._configurationService.onDidRootConfigChange(() => { - this._applyHostCustomizations(); - })); - } - - public getConfiguredHostCustomizations(): readonly CustomizationRef[] { - return this._hostCustomizations.map(item => item.customization.customization); - } - - public getSessionCustomizations(directory: URI | undefined): readonly SessionCustomization[] { - return [ - ...this._hostCustomizations.map(item => this._applyEnablement(item.customization)), - ...this._clientCustomizations.map(item => this._applyEnablement(item.customization)), - ]; - } - - /** - * Returns the current parsed plugins, awaiting any pending sync. - */ - public async getAppliedPlugins(directory: URI | undefined): Promise { - const [host, client] = await Promise.all([ - this._hostSync.catch(err => { - this._logService.warn('[Copilot:PluginController] Host customization update failed', err); - return this._hostCustomizations; - }), - this._clientSync.catch(err => { - this._logService.warn('[Copilot:PluginController] Customization sync failed', err); - return this._clientCustomizations; - }), - ]); - - return [ - ...host.filter(item => - !!item.plugin - && this._isEnabled(item.customization) - ).map(item => item.plugin!), - ...client.filter(item => - !!item.plugin - && this._isEnabled(item.customization) - ).map(item => item.plugin!), - ]; - } - - public setEnabled(pluginProtocolUri: string, enabled: boolean) { - this._enablement.set(pluginProtocolUri, enabled); - } - - /** - * Reads the current host customizations from the root config and - * resolves them. Skips the update when the configured refs have not - * changed since the last application. - */ - private _applyHostCustomizations(): void { - const customizations = this._configurationService.getRootValue(agentHostCustomizationConfigSchema, AgentHostConfigKey.Customizations) ?? []; - if (equals(customizations, this._lastAppliedRefs)) { - return; - } - this._lastAppliedRefs = customizations; - - const revision = ++this._hostRevision; - this._hostCustomizations = customizations.map(customization => ({ - customization: { - customization, - enabled: true, - status: CustomizationStatus.Loading, - }, - })); - this._onDidChange.fire(); - this._hostSync = Promise.all(customizations.map(customization => this._resolveConfiguredCustomization(customization))).then(resolved => { - if (revision === this._hostRevision) { - this._hostCustomizations = resolved; - } - return resolved; - }).finally(() => { - if (revision === this._hostRevision) { - this._onDidChange.fire(); - } - }); - } - - public sync(clientId: string, customizations: CustomizationRef[], progress?: (results: ISyncedCustomization[]) => void) { - const revision = ++this._clientRevision; - this._clientCustomizations = customizations.map(customization => ({ - customization: { - customization, - clientId, - enabled: true, - status: CustomizationStatus.Loading, - }, - })); - progress?.(this._clientCustomizations.map(item => ({ customization: this._applyEnablement(item.customization) }))); - - const prev = this._clientSync; - const promise = this._clientSync = prev.catch(err => { - this._logService.warn('[Copilot:PluginController] Previous customization sync failed', err); - }).then(async () => { - const result = await this._pluginManager.syncCustomizations(clientId, customizations, status => { - if (revision !== this._clientRevision) { - return; - } - - this._clientCustomizations = status.map(customization => ({ - customization: { - ...customization, - clientId, - }, - })); - progress?.(this._clientCustomizations.map(item => ({ customization: this._applyEnablement(item.customization) }))); - }); - - const resolved = await Promise.all(result.map(item => this._resolveSyncedCustomization(item, clientId))); - if (revision === this._clientRevision) { - this._clientCustomizations = resolved; - } - return resolved; - }); - - return promise.then(results => results.map(item => ({ - customization: this._applyEnablement(item.customization), - ...(item.pluginDir ? { pluginDir: item.pluginDir } : {}), - }))); - } - - private _isEnabled(customization: SessionCustomization): boolean { - return this._enablement.get(customization.customization.uri) ?? customization.enabled; - } - - private _applyEnablement(customization: SessionCustomization): SessionCustomization { - const enabled = this._isEnabled(customization); - return customization.enabled === enabled ? customization : { ...customization, enabled }; - } - - private async _resolveConfiguredCustomization(customization: CustomizationRef): Promise { - const pluginDir = URI.parse(customization.uri); - const parsed = await this._tryParsePlugin(pluginDir); - if (!parsed) { - return { - customization: { - customization, - enabled: true, - status: CustomizationStatus.Error, - statusMessage: localize('copilotAgent.pluginParseError', "Error parsing plugin."), - }, - }; - } - - return { - customization: { - customization, - enabled: true, - status: CustomizationStatus.Loaded, - }, - pluginDir, - plugin: parsed, - }; - } - - private async _resolveSyncedCustomization(item: ISyncedCustomization, clientId: string): Promise { - if (!item.pluginDir) { - return { - customization: { - ...item.customization, - clientId, - }, - }; - } - - const parsed = await this._tryParsePlugin(item.pluginDir); - if (!parsed) { - return { - customization: { - ...item.customization, - clientId, - status: CustomizationStatus.Error, - statusMessage: localize('copilotAgent.pluginParseError', "Error parsing plugin."), - }, - }; - } - - return { - customization: { - ...item.customization, - clientId, - }, - pluginDir: item.pluginDir, - plugin: parsed, - }; - } - - private async _tryParsePlugin(pluginDir: URI): Promise { - try { - return await parsePlugin(pluginDir, this._fileService, undefined, this._getUserHome()); - } catch (error) { - this._logService.warn(`[Copilot:PluginController] Error parsing plugin '${pluginDir.toString()}': ${error instanceof Error ? error.message : String(error)}`); - return undefined; - } - } - - private _getUserHome(): string { - return process.env['HOME'] ?? process.env['USERPROFILE'] ?? ''; - } -} - -/** - * Tracks per-session active client contributions (tools and plugins). - * The {@link snapshot} captures the state at session creation time, and - * {@link isOutdated} detects when the session needs to be refreshed. - */ -class ActiveClient { - private _tools: readonly ToolDefinition[] = []; - private _clientId = ''; - - constructor( - /** Resolves the current set of applied plugins. May block while a sync is in progress. */ - private readonly _resolvePlugins: (directory: URI | undefined) => Promise, - ) { } - - updateTools(clientId: string, tools: readonly ToolDefinition[]): void { - this._clientId = clientId; - this._tools = tools; - } - - async snapshot(directory: URI | undefined): Promise { - return { clientId: this._clientId, tools: this._tools, plugins: await this._resolvePlugins(directory) }; - } - - async isOutdated(snap: IActiveClientSnapshot, directory: URI | undefined): Promise { - const plugins = await this._resolvePlugins(directory); - if (!parsedPluginsEqual(snap.plugins, plugins)) { - return true; - } - if (snap.tools.length !== this._tools.length) { - return true; - } - // Compare tool definitions by name, description, and schema — - // not just names — so schema/description changes trigger a refresh. - const snapByName = new Map(snap.tools.map(t => [t.name, t])); - for (const tool of this._tools) { - const prev = snapByName.get(tool.name); - if (!prev - || prev.description !== tool.description - || !equals(prev.inputSchema, tool.inputSchema)) { - return true; - } - } - return false; - } -} diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts deleted file mode 100644 index f88d1572..00000000 --- a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts +++ /dev/null @@ -1,1668 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import type { PermissionRequestResult, SessionConfig, Tool, ToolResultObject } from '@github/copilot-sdk'; -import { DeferredPromise } from '../../../../base/common/async.js'; -import { VSBuffer } from '../../../../base/common/buffer.js'; -import { Emitter } from '../../../../base/common/event.js'; -import { Disposable, IReference, toDisposable } from '../../../../base/common/lifecycle.js'; -import { join } from '../../../../base/common/path.js'; -import { extUriBiasedIgnorePathCase, normalizePath } from '../../../../base/common/resources.js'; -import { URI } from '../../../../base/common/uri.js'; -import { generateUuid } from '../../../../base/common/uuid.js'; -import { localize } from '../../../../nls.js'; -import type { IParsedPlugin } from '../../../agentPlugins/common/pluginParsers.js'; -import { INativeEnvironmentService } from '../../../environment/common/environment.js'; -import { IFileService } from '../../../files/common/files.js'; -import { IInstantiationService } from '../../../instantiation/common/instantiation.js'; -import { ILogService } from '../../../log/common/log.js'; -import { platformSessionSchema } from '../../common/agentHostSchema.js'; -import { AgentSignal, IAgentAttachment } from '../../common/agentService.js'; -import { stripRedundantCdPrefix } from '../../common/commandLineHelpers.js'; -import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; -import { ISessionDatabase, ISessionDataService } from '../../common/sessionDataService.js'; -import type { FileEdit, ToolDefinition } from '../../common/state/protocol/state.js'; -import { ActionType, type SessionAction } from '../../common/state/sessionActions.js'; -import { ResponsePartKind, SessionInputAnswerState, SessionInputAnswerValueKind, SessionInputQuestionKind, SessionInputResponseKind, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, type PendingMessage, type URI as ProtocolURI, type SessionInputAnswer, type SessionInputRequest, type ToolCallResult, type ToolResultContent, type Turn } from '../../common/state/sessionState.js'; -import { IAgentConfigurationService } from '../agentConfigurationService.js'; -import type { IExitPlanModeRequestParams, IExitPlanModeResponse } from './copilotAgent.js'; -import { CopilotSessionWrapper } from './copilotSessionWrapper.js'; -import type { ShellManager } from './copilotShellTools.js'; -import { getEditFilePath, getInvocationMessage, getPastTenseMessage, getPermissionDisplay, getShellLanguage, getSubagentMetadata, getToolDisplayName, getToolInputString, getToolKind, isEditTool, isHiddenTool, isShellTool, synthesizeSkillToolCall, tryStringify, type ITypedPermissionRequest } from './copilotToolDisplay.js'; -import { FileEditTracker } from './fileEditTracker.js'; -import { mapSessionEvents } from './mapSessionEvents.js'; -import { buildPendingEditContentUri } from './pendingEditContentStore.js'; - -/** - * The full set of agent modes the Copilot SDK accepts. Wider than the - * {@link SessionMode} the AHP exposes — the SDK has a first-class - * `'autopilot'` mode while AHP models that as - * `mode='interactive', autoApprove='autopilot'`. The Copilot agent - * translates between the two views in {@link CopilotAgentSession.send} - * and the `session.mode_changed` listener. - */ -export type CopilotSdkMode = 'interactive' | 'plan' | 'autopilot'; - -const COPILOT_HOME_DIRECTORY = '.copilot'; -const SESSION_STATE_DIRECTORY = join(COPILOT_HOME_DIRECTORY, 'session-state'); - -/** - * Display labels and descriptions for the SDK's `exit_plan_mode` action ids. - * Keys not present here fall back to the raw action id. - */ -function getPlanActionDescription(actionId: string): { label: string; description: string } | undefined { - switch (actionId) { - case 'autopilot': - return { - label: localize('agentHost.planReview.autopilot.label', "Implement with Autopilot"), - description: localize('agentHost.planReview.autopilot.description', "Auto-approve all tool calls and continue until done."), - }; - case 'autopilot_fleet': - return { - label: localize('agentHost.planReview.autopilotFleet.label', "Implement with Autopilot Fleet"), - description: localize('agentHost.planReview.autopilotFleet.description', "Auto-approve all tool calls, including fleet management actions, and continue until done."), - }; - case 'interactive': - return { - label: localize('agentHost.planReview.interactive.label', "Implement Plan"), - description: localize('agentHost.planReview.interactive.description', "Implement the plan, asking for input and approval for each action."), - }; - case 'exit_only': - return { - label: localize('agentHost.planReview.exitOnly.label', "Approve Plan Only"), - description: localize('agentHost.planReview.exitOnly.description', "Approve the plan without executing it. I will implement it myself."), - }; - default: - return undefined; - } -} - -type UserInputHandler = NonNullable; -type UserInputRequest = Parameters[0]; -type UserInputResponse = Awaited>; -type SessionHooks = NonNullable; -type PreToolUseHookInput = Parameters>[0]; -type PostToolUseHookInput = Parameters>[0]; - -function getCopilotCLISessionStateDir(userHome: string): string { - const xdgHome = process.env['XDG_STATE_HOME']; - return xdgHome ? join(xdgHome, SESSION_STATE_DIRECTORY) : join(userHome, SESSION_STATE_DIRECTORY); -} - -/** - * Immutable snapshot of the active client's contributions at session creation - * time. Used to detect when the session needs to be refreshed. - */ -export interface IActiveClientSnapshot { - readonly clientId: string; - readonly tools: readonly ToolDefinition[]; - readonly plugins: readonly IParsedPlugin[]; -} - -/** - * Factory function that produces a {@link CopilotSessionWrapper}. - * Called by {@link CopilotAgentSession.initializeSession} with the - * session's permission handler and edit-tracking hooks so the factory - * can wire them into the SDK session it creates. - * - * In production, the factory calls `CopilotClient.createSession()` or - * `resumeSession()`. In tests, it returns a mock wrapper directly. - */ -export type SessionWrapperFactory = (callbacks: { - readonly onPermissionRequest: (request: ITypedPermissionRequest) => Promise; - readonly onUserInputRequest: (request: UserInputRequest, invocation: { sessionId: string }) => Promise; - readonly hooks: { - readonly onPreToolUse: (input: PreToolUseHookInput) => Promise; - readonly onPostToolUse: (input: PostToolUseHookInput) => Promise; - }; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - readonly clientTools: Tool[]; -}) => Promise; - -/** - * Options for constructing a {@link CopilotAgentSession}. - */ -export interface ICopilotAgentSessionOptions { - readonly sessionUri: URI; - readonly rawSessionId: string; - readonly onDidSessionProgress: Emitter; - readonly wrapperFactory: SessionWrapperFactory; - readonly shellManager: ShellManager | undefined; - /** Working directory associated with the session, used to strip redundant `cd` prefixes from shell commands. */ - readonly workingDirectory?: URI; - /** Directory used to resolve workspace-scoped customizations for this session. */ - readonly customizationDirectory?: URI; - /** Snapshot of the active client's tools and plugins at session creation time. */ - readonly clientSnapshot?: IActiveClientSnapshot; -} - -/** - * Encapsulates a single Copilot SDK session and all its associated bookkeeping. - * - * Created by {@link CopilotAgent}, one instance per active session. Disposing - * this class tears down all per-session resources (SDK wrapper, edit tracker, - * database reference, pending permissions). - */ -export class CopilotAgentSession extends Disposable { - readonly sessionId: string; - readonly sessionUri: URI; - - /** Tracks active tool invocations so we can produce past-tense messages on completion. */ - private readonly _activeToolCalls = new Map | undefined; content: ToolResultContent[]; parentToolCallId: string | undefined }>(); - /** Pending permission requests awaiting a renderer-side decision. */ - private readonly _pendingPermissions = new Map>(); - /** Pending user input requests awaiting a renderer-side answer. */ - private readonly _pendingUserInputs = new Map }>; questionId: string }>(); - /** - * Pending plan-review requests originating from the CLI's - * `exitPlanMode.request` RPC. Tracked separately from - * {@link _pendingUserInputs} so the completion handler can resolve the - * RPC with a structured {@link IExitPlanModeResponse} (which the CLI - * forwards to `session.respondToExitPlanMode`) rather than feeding it - * back through the SDK's `ask_user` callback. - */ - private readonly _pendingPlanReviews = new Map; - }>(); - /** File edit tracker for this session. */ - private readonly _editTracker: FileEditTracker; - /** Session database reference. */ - private readonly _databaseRef: IReference; - /** Protocol turn ID set by {@link send}, used for file edit tracking. */ - private _turnId = ''; - /** SDK session wrapper, set by {@link initializeSession}. */ - private _wrapper!: CopilotSessionWrapper; - /** Last agent mode pushed to the SDK via {@link applyMode}, to elide redundant `rpc.mode.set` calls. */ - private _lastAppliedMode: CopilotSdkMode | undefined; - - /** Snapshot captured at session creation for refresh detection. */ - private readonly _appliedSnapshot: IActiveClientSnapshot; - /** Tool names that are client-provided, derived from snapshot. */ - private readonly _clientToolNames: ReadonlySet; - /** Deferred promises for pending client tool calls, keyed by toolCallId. */ - private readonly _pendingClientToolCalls = new Map>(); - /** `pending-edit-content:` URIs written during permission requests, keyed - * by toolCallId. Cleaned up when the permission resolves or the session - * is disposed. */ - private readonly _pendingEditContentUris = new Map(); - - private readonly _onDidSessionProgress: Emitter; - private readonly _wrapperFactory: SessionWrapperFactory; - private readonly _shellManager: ShellManager | undefined; - private readonly _workingDirectory: URI | undefined; - private readonly _customizationDirectory: URI | undefined; - - /** - * Current markdown response part ID for the active turn. Streaming text - * deltas append to this part; the first delta of a turn allocates a new - * part ID. Reset on each new turn (in {@link send}) and invalidated when - * a tool call begins so subsequent text creates a fresh part. - */ - private _currentMarkdownPartId: string | undefined; - /** Current reasoning response part ID for the active turn. Reset on each new turn. */ - private _currentReasoningPartId: string | undefined; - /** Tracks whether a non-empty activity has been published, so we only emit a clear when needed. */ - private _hasReportedActivity = false; - - constructor( - options: ICopilotAgentSessionOptions, - @IInstantiationService private readonly _instantiationService: IInstantiationService, - @ILogService private readonly _logService: ILogService, - @ISessionDataService sessionDataService: ISessionDataService, - @IFileService private readonly _fileService: IFileService, - @INativeEnvironmentService private readonly _environmentService: INativeEnvironmentService, - @IAgentConfigurationService private readonly _configurationService: IAgentConfigurationService, - ) { - super(); - this.sessionId = options.rawSessionId; - this.sessionUri = options.sessionUri; - this._onDidSessionProgress = options.onDidSessionProgress; - this._wrapperFactory = options.wrapperFactory; - this._shellManager = options.shellManager; - this._workingDirectory = options.workingDirectory; - this._customizationDirectory = options.customizationDirectory; - - this._appliedSnapshot = options.clientSnapshot ?? { clientId: '', tools: [], plugins: [] }; - this._clientToolNames = new Set(this._appliedSnapshot.tools.map(t => t.name)); - - this._databaseRef = sessionDataService.openDatabase(options.sessionUri); - this._register(toDisposable(() => this._databaseRef.dispose())); - - this._editTracker = this._instantiationService.createInstance(FileEditTracker, options.sessionUri.toString(), this._databaseRef.object); - - this._register(toDisposable(() => this._denyPendingPermissions())); - this._register(toDisposable(() => this._shellManager?.dispose())); - this._register(toDisposable(() => this._cancelPendingUserInputs())); - this._register(toDisposable(() => this._cancelPendingPlanReviews())); - - // When a shell tool associates a terminal with a tool call, fire a - // tool_content_changed event so the UI can connect to the terminal - // while the command is still running. - if (this._shellManager) { - this._register(this._shellManager.onDidAssociateTerminal(({ toolCallId, terminalUri, displayName }) => { - const tracked = this._activeToolCalls.get(toolCallId); - if (!tracked) { - return; - } - - tracked.content.push({ - type: ToolResultContentType.Terminal, - resource: terminalUri, - title: displayName, - }); - - this._emitAction({ - type: ActionType.SessionToolCallContentChanged, - session: this._protocolSession(), - turnId: this._turnId, - toolCallId, - content: tracked.content, - }); - })); - } - this._register(toDisposable(() => this._cancelPendingClientToolCalls())); - } - - // ---- AgentSignal helpers ------------------------------------------------ - - private _protocolSession(): ProtocolURI { - return this.sessionUri.toString(); - } - - /** Wraps a {@link SessionAction} in an {@link AgentSignal} envelope and emits it. */ - private _emitAction(action: SessionAction, parentToolCallId?: string): void { - this._onDidSessionProgress.fire({ - kind: 'action', - session: this.sessionUri, - action, - parentToolCallId, - }); - } - - /** - * Resets per-turn streaming state so the next text/reasoning chunk - * allocates a fresh response part. Called by the agent when a new turn - * starts (typically right before {@link send}). - */ - resetTurnState(turnId: string): void { - this._turnId = turnId; - this._currentMarkdownPartId = undefined; - this._currentReasoningPartId = undefined; - } - - /** - * Emits a synthetic markdown content block for the active turn and - * makes it the current markdown response part so that subsequent SDK - * deltas append to it. Used by the agent to surface one-shot host - * messages (e.g. the worktree-created announcement) at the top of the - * first response. - */ - emitInitialMarkdown(content: string): void { - this._emitMarkdownDelta(content); - } - - /** - * Emits a streaming text delta. The first delta of a turn allocates a - * markdown response part; subsequent deltas append to it. - */ - private _emitMarkdownDelta(content: string, parentToolCallId?: string): void { - const session = this._protocolSession(); - let partId = this._currentMarkdownPartId; - if (!partId) { - partId = generateUuid(); - this._currentMarkdownPartId = partId; - this._emitAction({ - type: ActionType.SessionResponsePart, - session, - turnId: this._turnId, - part: { kind: ResponsePartKind.Markdown, id: partId, content }, - }, parentToolCallId); - return; - } - this._emitAction({ - type: ActionType.SessionDelta, - session, - turnId: this._turnId, - partId, - content, - }, parentToolCallId); - } - - /** Emits a reasoning delta, similar to {@link _emitMarkdownDelta} but for reasoning parts. */ - private _emitReasoningDelta(content: string): void { - const session = this._protocolSession(); - let partId = this._currentReasoningPartId; - if (!partId) { - partId = generateUuid(); - this._currentReasoningPartId = partId; - this._emitAction({ - type: ActionType.SessionResponsePart, - session, - turnId: this._turnId, - part: { kind: ResponsePartKind.Reasoning, id: partId, content }, - }); - return; - } - this._emitAction({ - type: ActionType.SessionReasoning, - session, - turnId: this._turnId, - partId, - content, - }); - } - - /** - * The snapshot of client contributions captured when this session was - * created. Used by the agent to detect when the session is 1stale. - */ - get appliedSnapshot(): IActiveClientSnapshot { - return this._appliedSnapshot; - } - - get customizationDirectory(): URI | undefined { - return this._customizationDirectory; - } - - /** - * Creates SDK {@link Tool} objects for the client-provided tools in the - * applied snapshot. The handler creates a {@link DeferredPromise} and waits - * for the client to dispatch `session/toolCallComplete`. - */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - createClientSdkTools(): Tool[] { - const tools = this._appliedSnapshot.tools; - if (tools.length === 0) { - return []; - } - return tools.map(def => ({ - name: def.name, - description: def.description ?? '', - parameters: def.inputSchema ?? { type: 'object' as const, properties: {} }, - handler: async (_args: Record, { toolCallId }) => { - try { - let deferred = this._pendingClientToolCalls.get(toolCallId); - if (!deferred) { - deferred = new DeferredPromise(); - this._pendingClientToolCalls.set(toolCallId, deferred); - } - const result = await deferred.p; - this._pendingClientToolCalls.delete(toolCallId); - return result; - } catch (error) { - this._logService.error(error, `[Copilot:${this.sessionId}] Failed in client tool handler: tool=${def.name}, toolCallId=${toolCallId}`); - throw error; - } - }, - })); - } - - /** - * Resolves a pending client tool call. Returns `true` if the - * toolCallId was found and handled. - */ - handleClientToolCallComplete(toolCallId: string, result: ToolCallResult) { - let deferred = this._pendingClientToolCalls.get(toolCallId); - if (!deferred) { - deferred = new DeferredPromise(); - this._pendingClientToolCalls.set(toolCallId, deferred); - } - - const textContent = result.content - ?.filter(c => c.type === ToolResultContentType.Text) - .map(c => c.text) - .join('\n') ?? ''; - - const binaryResults = result.content - ?.filter(c => c.type === ToolResultContentType.EmbeddedResource) - .map(c => { - return { data: c.data, mimeType: c.contentType, type: c.contentType }; - }); - - if (result.success) { - deferred.complete({ - textResultForLlm: textContent, - resultType: 'success', - binaryResultsForLlm: binaryResults?.length ? binaryResults : undefined, - }); - } else { - deferred.complete({ - textResultForLlm: textContent || result.error?.message || 'Tool call failed', - resultType: 'failure', - error: result.error?.message, - binaryResultsForLlm: binaryResults?.length ? binaryResults : undefined, - }); - } - } - - /** - * Creates (or resumes) the SDK session via the injected factory and - * wires up all event listeners. Must be called exactly once after - * construction before using the session. - */ - async initializeSession(): Promise { - this._wrapper = this._register(await this._wrapperFactory({ - onPermissionRequest: request => this.handlePermissionRequest(request), - onUserInputRequest: (request, invocation) => this.handleUserInputRequest(request, invocation), - clientTools: this.createClientSdkTools(), - hooks: { - onPreToolUse: input => this._handlePreToolUse(input), - onPostToolUse: input => this._handlePostToolUse(input), - }, - })); - this._subscribeToEvents(); - this._subscribeForLogging(); - } - - // ---- session operations ------------------------------------------------- - - async send(prompt: string, attachments?: IAgentAttachment[], turnId?: string, mode?: CopilotSdkMode): Promise { - if (turnId) { - this._turnId = turnId; - } - this._logService.info(`[Copilot:${this.sessionId}] sendMessage called: "${prompt.substring(0, 100)}${prompt.length > 100 ? '...' : ''}" (${attachments?.length ?? 0} attachments)`); - - const sdkAttachments = attachments?.map(a => { - const path = a.uri.scheme === 'file' ? a.uri.fsPath : a.uri.toString(); - if (a.type === 'selection') { - return { type: 'selection' as const, filePath: path, displayName: a.displayName ?? path, text: a.text, selection: a.selection }; - } - return { type: a.type, path, displayName: a.displayName }; - }); - if (sdkAttachments?.length) { - this._logService.trace(`[Copilot:${this.sessionId}] Attachments: ${JSON.stringify(sdkAttachments.map(a => ({ type: a.type, path: a.type === 'selection' ? a.filePath : a.path })))}`); - } - - await this.applyMode(mode); - await this._wrapper.session.send({ prompt, attachments: sdkAttachments }); - this._logService.info(`[Copilot:${this.sessionId}] session.send() returned`); - } - - /** - * Pushes `mode` to the SDK via `rpc.mode.set` if it differs from the - * last applied value. Failures are logged and swallowed so that mode - * propagation does not block the turn. - */ - async applyMode(mode: CopilotSdkMode | undefined): Promise { - if (!mode || mode === this._lastAppliedMode) { - return; - } - try { - await this._wrapper.session.rpc.mode.set({ mode }); - this._lastAppliedMode = mode; - this._logService.info(`[Copilot:${this.sessionId}] rpc.mode.set succeeded: mode=${mode}`); - } catch (err) { - this._logService.error(err, `[Copilot:${this.sessionId}] rpc.mode.set failed: mode=${mode}`); - } - } - - async sendSteering(steeringMessage: PendingMessage): Promise { - this._logService.info(`[Copilot:${this.sessionId}] Sending steering message: "${steeringMessage.userMessage.text.substring(0, 100)}"`); - try { - await this._wrapper.session.send({ - prompt: steeringMessage.userMessage.text, - mode: 'immediate', - }); - this._onDidSessionProgress.fire({ - kind: 'steering_consumed', - session: this.sessionUri, - id: steeringMessage.id, - }); - } catch (err) { - this._logService.error(`[Copilot:${this.sessionId}] Steering message failed`, err); - } - } - - async getMessages(): Promise { - const events = await this._wrapper.session.getMessages(); - let db: ISessionDatabase | undefined; - try { - db = this._databaseRef.object; - } catch { - // Database may not exist yet — that's fine - } - const result = await mapSessionEvents(this.sessionUri, db, events, this._workingDirectory); - return result.turns; - } - - async getSubagentMessages(parentToolCallId: string, childSessionUri: string): Promise { - const events = await this._wrapper.session.getMessages(); - let db: ISessionDatabase | undefined; - try { - db = this._databaseRef.object; - } catch { - // Database may not exist yet — that's fine - } - const result = await mapSessionEvents(this.sessionUri, db, events, this._workingDirectory); - return result.subagentTurnsByToolCallId.get(parentToolCallId) ?? []; - } - - async abort(): Promise { - this._logService.info(`[Copilot:${this.sessionId}] Aborting session...`); - this._denyPendingPermissions(); - await this._wrapper.session.abort(); - } - - /** - * Explicitly destroys the underlying SDK session and waits for cleanup - * to complete. Call this before {@link dispose} when you need to ensure - * the session's on-disk data is no longer locked (e.g. before - * truncation or fork operations that modify the session files). - */ - async destroySession(): Promise { - await this._wrapper.session.destroy(); - } - - async setModel(model: string, reasoningEffort?: SessionConfig['reasoningEffort']): Promise { - this._logService.info(`[Copilot:${this.sessionId}] Changing model to: ${model}`); - await this._wrapper.session.setModel(model, { reasoningEffort }); - } - - // ---- permission handling ------------------------------------------------ - - /** - * Handles a permission request from the SDK by firing a `tool_ready` event - * (which transitions the tool to PendingConfirmation) and waiting for the - * side-effects layer to respond via {@link respondToPermissionRequest}. - */ - async handlePermissionRequest( - request: ITypedPermissionRequest, - ): Promise { - this._logService.info(`[Copilot:${this.sessionId}] Permission request: kind=${request.kind}`); - - try { - const toolCallId = request.toolCallId; - if (!toolCallId) { - // TODO: handle permission requests without a toolCallId by creating a synthetic tool call - this._logService.warn(`[Copilot:${this.sessionId}] Permission request without toolCallId, auto-denying: kind=${request.kind}`); - return { kind: 'reject' }; - } - - const sessionResourcePath = this._getInternalSessionResourcePath(request); - if (sessionResourcePath) { - this._logService.info(`[Copilot:${this.sessionId}] Auto-approving internal session resource ${sessionResourcePath}`); - return { kind: 'approve-once' }; - } - - this._logService.info(`[Copilot:${this.sessionId}] Requesting confirmation for tool call: ${toolCallId}`); - - const deferred = new DeferredPromise(); - this._pendingPermissions.set(toolCallId, deferred); - - // Derive display information from the permission request kind - const { confirmationTitle, invocationMessage, toolInput, permissionKind, permissionPath } = getPermissionDisplay(request, this._workingDirectory); - - // For write permission requests, build an FileEdit preview so the - // client can show a diff before the user approves or denies. This - // awaits async filesystem operations; the SDK already calls - // `handlePermissionRequest` from an arbitrary async context, so the - // extra await here is fine. - const edits = await this._buildEditsForPermission(request, toolCallId); - - // If the session was aborted/disposed while we were building the - // preview, the deferred has already been resolved and the - // `pending-edit-content:` entry has been cleaned up. Bail without - // firing tool_ready. - if (!this._pendingPermissions.has(toolCallId)) { - return { kind: 'reject' }; - } - - // Fire a pending_confirmation signal to transition the tool to PendingConfirmation - const toolName = request.toolName ?? request.kind; - // Forward the tool's parentToolCallId (if any) so the host can - // route the resulting SessionToolCallReady to the correct - // subagent session — without it the action would land on the - // parent session, which has no matching SessionToolCallStart. - const parentToolCallId = this._activeToolCalls.get(toolCallId)?.parentToolCallId; - this._onDidSessionProgress.fire({ - kind: 'pending_confirmation', - session: this.sessionUri, - state: { - status: ToolCallStatus.PendingConfirmation, - toolCallId, - toolName, - displayName: getToolDisplayName(toolName), - invocationMessage, - toolInput, - confirmationTitle, - edits, - }, - permissionKind, - permissionPath, - parentToolCallId, - }); - - const approved = await deferred.p; - this._logService.info(`[Copilot:${this.sessionId}] Permission response: toolCallId=${toolCallId}, approved=${approved}`); - return { kind: approved ? 'approve-once' : 'reject' }; - } catch (error) { - this._logService.error(error, `[Copilot:${this.sessionId}] Failed to handle permission request: kind=${request.kind}, toolCallId=${request.toolCallId ?? 'missing'}`); - throw error; - } - } - - private _getInternalSessionResourcePath(request: ITypedPermissionRequest): string | undefined { - let permissionPath: string | undefined; - if (request.kind === 'read') { - permissionPath = typeof request.path === 'string' ? request.path : undefined; - } else if (request.kind === 'write') { - permissionPath = typeof request.fileName === 'string' ? request.fileName : undefined; - } - - if (!permissionPath) { - return undefined; - } - - const sessionStateDir = normalizePath(URI.file(getCopilotCLISessionStateDir(this._environmentService.userHome.fsPath))); - const sessionDir = normalizePath(URI.joinPath(sessionStateDir, this.sessionId)); - if (!extUriBiasedIgnorePathCase.isEqualOrParent(sessionDir, sessionStateDir)) { - return undefined; - } - - const permissionUri = normalizePath(URI.file(permissionPath)); - return extUriBiasedIgnorePathCase.isEqualOrParent(permissionUri, sessionDir) ? permissionPath : undefined; - } - - /** - * Builds an {@link FileEdit} preview for a write permission request. - * - * The `before` side references the existing file on disk directly (if it - * exists); the `after` side is written to the `pending-edit-content:` - * in-memory filesystem so the client can fetch it via `resourceRead`. - * - * Returns `undefined` for permission kinds that don't describe file - * edits or when the request is missing the fields needed to build a - * preview. If the permission request is no longer pending by the time - * the in-memory write completes (e.g. the session was aborted), the - * just-written entry is deleted so it cannot leak. - */ - private async _buildEditsForPermission(request: ITypedPermissionRequest, toolCallId: string): Promise<{ items: FileEdit[] } | undefined> { - if (request.kind !== 'write') { - return undefined; - } - const filePath = typeof request.fileName === 'string' ? request.fileName : undefined; - const newFileContents = typeof request.newFileContents === 'string' ? request.newFileContents : undefined; - if (!filePath || newFileContents === undefined) { - return undefined; - } - - const fileUri = URI.file(filePath); - const fileUriStr = fileUri.toString(); - - let beforeExists = false; - try { - beforeExists = await this._fileService.exists(fileUri); - } catch (err) { - this._logService.warn(`[Copilot:${this.sessionId}] Failed to check file for edit preview: ${filePath}`, err); - } - - const afterUri = buildPendingEditContentUri(this.sessionUri.toString(), toolCallId, filePath); - try { - await this._fileService.writeFile(afterUri, VSBuffer.fromString(newFileContents)); - } catch (err) { - this._logService.warn(`[Copilot:${this.sessionId}] Failed to write pending edit content for ${filePath}`, err); - return undefined; - } - - // If the request was already resolved (aborted/disposed) while we - // were awaiting the write, drop the in-memory entry immediately; - // `_deletePendingEditContent` has already run and won't run again. - if (!this._pendingPermissions.has(toolCallId)) { - this._fileService.del(afterUri).catch(err => { - this._logService.warn(`[Copilot:${this.sessionId}] Failed to delete orphaned pending edit content: ${afterUri.toString()}`, err); - }); - return undefined; - } - this._pendingEditContentUris.set(toolCallId, afterUri); - - const diffCounts = typeof request.diff === 'string' ? countUnifiedDiffLines(request.diff) : undefined; - - const edit: FileEdit = { - ...(beforeExists ? { before: { uri: fileUriStr, content: { uri: fileUriStr } } } : {}), - after: { uri: fileUriStr, content: { uri: afterUri.toString() } }, - ...(diffCounts ? { diff: diffCounts } : {}), - }; - return { items: [edit] }; - } - - respondToPermissionRequest(requestId: string, approved: boolean): boolean { - const deferred = this._pendingPermissions.get(requestId); - if (deferred) { - this._pendingPermissions.delete(requestId); - this._deletePendingEditContent(requestId); - deferred.complete(approved); - return true; - } - return false; - } - - // ---- user input handling ------------------------------------------------ - - /** - * Handles a user input request from the SDK (ask_user tool) by firing a - * `user_input_request` progress event and waiting for the renderer to - * respond via {@link respondToUserInputRequest}. - */ - async handleUserInputRequest( - request: UserInputRequest, - _invocation: { sessionId: string }, - ): Promise { - const isAutopilot = this._configurationService.getEffectiveValue(this.sessionUri.toString(), platformSessionSchema, SessionConfigKey.AutoApprove) === 'autopilot'; - if (isAutopilot) { - return { - answer: 'The user is not available to answer your question. Choose a pragmatic option best aligned with the context of the request.', - wasFreeform: true, - }; - } - - const questionPreview = request.question.substring(0, 100); - try { - const requestId = generateUuid(); - const questionId = generateUuid(); - this._logService.info(`[Copilot:${this.sessionId}] User input request: requestId=${requestId}, question="${questionPreview}"`); - - const deferred = new DeferredPromise<{ response: SessionInputResponseKind; answers?: Record }>(); - this._pendingUserInputs.set(requestId, { deferred, questionId }); - - // Build the protocol SessionInputRequest from the SDK's simple format - const inputRequest: SessionInputRequest = { - id: requestId, - questions: [request.choices && request.choices.length > 0 - ? { - kind: SessionInputQuestionKind.SingleSelect, - id: questionId, - message: request.question, - required: true, - options: request.choices.map(c => ({ id: c, label: c })), - allowFreeformInput: request.allowFreeform ?? true, - } - : { - kind: SessionInputQuestionKind.Text, - id: questionId, - message: request.question, - required: true, - }, - ], - }; - - this._emitAction({ - type: ActionType.SessionInputRequested, - session: this._protocolSession(), - request: inputRequest, - }); - - const result = await deferred.p; - this._logService.info(`[Copilot:${this.sessionId}] User input response: requestId=${requestId}, response=${result.response}`); - - if (result.response !== SessionInputResponseKind.Accept || !result.answers) { - return { answer: '', wasFreeform: true }; - } - - // Extract the answer for our single question - const answer = result.answers[questionId]; - if (!answer || answer.state === SessionInputAnswerState.Skipped) { - return { answer: '', wasFreeform: true }; - } - - const { value: val } = answer; - if (val.kind === SessionInputAnswerValueKind.Text) { - return { answer: val.value, wasFreeform: true }; - } else if (val.kind === SessionInputAnswerValueKind.Selected) { - const wasFreeform = !request.choices?.includes(val.value); - return { answer: val.value, wasFreeform }; - } - - return { answer: '', wasFreeform: true }; - } catch (error) { - this._logService.error(error, `[Copilot:${this.sessionId}] Failed to handle user input request: question="${questionPreview}"`); - throw error; - } - } - - respondToUserInputRequest(requestId: string, response: SessionInputResponseKind, answers?: Record): boolean { - const pendingPlanReview = this._pendingPlanReviews.get(requestId); - if (pendingPlanReview) { - this._pendingPlanReviews.delete(requestId); - pendingPlanReview.deferred.complete(this._resolveExitPlanMode(pendingPlanReview, response, answers)); - return true; - } - - const pending = this._pendingUserInputs.get(requestId); - if (pending) { - this._pendingUserInputs.delete(requestId); - pending.deferred.complete({ response, answers }); - return true; - } - return false; - } - - /** - * Maps an `exit_plan_mode` input response back to an - * {@link IExitPlanModeResponse} that the CLI can feed into - * `session.respondToExitPlanMode`. Mapping rules: - * - * - Decline / Cancel / no answer → `{ approved: false }` (model gets a - * rejection result and stays in plan mode). - * - Accept + freeform feedback → `{ approved: false, feedback, selectedAction? }` - * (the SDK treats this as a revision request and re-emits - * `exit_plan_mode.requested` after revising the plan). - * - Accept + selected option → `{ approved: true, selectedAction, autoApproveEdits }` - * where `autoApproveEdits` is set for the autopilot variants. - * - * `selectedAction` is validated against the SDK's offered `actions`; an - * unknown value is treated as a decline so the SDK isn't fed a value it - * cannot handle. - */ - private _resolveExitPlanMode( - pending: { actions: readonly string[]; recommendedAction: string; questionId: string }, - response: SessionInputResponseKind, - answers?: Record, - ): IExitPlanModeResponse { - if (response !== SessionInputResponseKind.Accept) { - return { approved: false }; - } - const answer = answers?.[pending.questionId]; - if (!answer || answer.state === SessionInputAnswerState.Skipped) { - return { approved: false }; - } - const value = answer.value; - - // Determine the selected action and any freeform feedback. The - // `single-select` question may carry both (when the user picks an - // option AND types feedback), or just freeform text (when the - // user types instead of picking). Normalize to one shape. - let candidateAction: string | undefined; - let feedback: string | undefined; - if (value.kind === SessionInputAnswerValueKind.Selected) { - candidateAction = value.value; - const freeform = value.freeformValues?.find(s => s.trim().length > 0)?.trim(); - feedback = freeform; - } else if (value.kind === SessionInputAnswerValueKind.Text) { - feedback = value.value.trim() || undefined; - } else { - return { approved: false }; - } - - // Clamp `selectedAction` to the SDK's offered set. Anything else - // (including freeform text smuggled into the `value` field) falls - // back to the recommended action so we never feed the SDK a value - // it can't act on. - const selectedAction = candidateAction && pending.actions.includes(candidateAction) - ? candidateAction - : pending.actions.includes(pending.recommendedAction) - ? pending.recommendedAction - : undefined; - - // Freeform feedback => revision request. The SDK semantics are - // `approved: false` with a non-empty `feedback`; it will revise - // the plan and re-emit `exit_plan_mode.requested`. - if (feedback) { - return { - approved: false, - feedback, - ...(selectedAction ? { selectedAction } : {}), - }; - } - - // No selectable action and no feedback — nothing actionable. - if (!selectedAction) { - return { approved: false }; - } - - const isAutopilot = selectedAction === 'autopilot' || selectedAction === 'autopilot_fleet'; - return { - approved: true, - selectedAction, - ...(isAutopilot ? { autoApproveEdits: true } : {}), - }; - } - - private async _handlePreToolUse(input: PreToolUseHookInput): Promise { - try { - if (isEditTool(input.toolName)) { - const filePath = getEditFilePath(input.toolArgs); - if (filePath) { - await this._editTracker.trackEditStart(filePath); - } - } - } catch (error) { - this._logService.error(error, `[Copilot:${this.sessionId}] Failed in onPreToolUse: tool=${input.toolName}`); - throw error; - } - } - - private async _handlePostToolUse(input: PostToolUseHookInput): Promise { - try { - if (isEditTool(input.toolName)) { - const filePath = getEditFilePath(input.toolArgs); - if (filePath) { - await this._editTracker.completeEdit(filePath); - } - } - } catch (error) { - this._logService.error(error, `[Copilot:${this.sessionId}] Failed in onPostToolUse: tool=${input.toolName}`); - throw error; - } - } - - // ---- event wiring ------------------------------------------------------- - - private _subscribeToEvents(): void { - const wrapper = this._wrapper; - const sessionId = this.sessionId; - - // Capture SDK event IDs for each user.message event so we can map - // protocol turn indices to the event IDs needed by the SDK's - // history.truncate and sessions.fork RPCs. - this._register(wrapper.onUserMessage(e => { - if (this._turnId) { - this._databaseRef.object.setTurnEventId(this._turnId, e.id); - } - })); - - this._register(wrapper.onMessageDelta(e => { - this._logService.trace(`[Copilot:${sessionId}] delta: ${e.data.deltaContent}`); - this._emitMarkdownDelta(e.data.deltaContent, e.data.parentToolCallId); - })); - - this._register(wrapper.onMessage(e => { - this._logService.info(`[Copilot:${sessionId}] Full message received: ${e.data.content.length} chars`); - // The SDK fires a `message` event with the full assembled content after - // streaming deltas. If deltas already created a markdown part for this - // turn, the live state is up to date and we skip. Only emit a fresh - // part when no deltas preceded the message (e.g. text after tool calls - // where the SDK delivered the full message at once). - // - // Other fields (toolRequests, reasoningText, encryptedContent) are - // only used for history reconstruction and live tool calls fire - // their own tool_start events, so we can safely drop them here. - if (!e.data.content) { - return; - } - if (this._currentMarkdownPartId) { - return; - } - const partId = generateUuid(); - this._currentMarkdownPartId = partId; - this._emitAction({ - type: ActionType.SessionResponsePart, - session: this._protocolSession(), - turnId: this._turnId, - part: { kind: ResponsePartKind.Markdown, id: partId, content: e.data.content }, - }, e.data.parentToolCallId); - })); - - this._register(wrapper.onToolStart(e => { - if (isHiddenTool(e.data.toolName)) { - this._logService.trace(`[Copilot:${sessionId}] Tool started (hidden): ${e.data.toolName}`); - // The CLI uses the `report_intent` tool to signal what the - // agent is currently doing. Surface this as session activity - // so the UI can show a live "what is the agent doing now?" - // hint while the turn is in progress. - if (e.data.toolName === 'report_intent') { - const intent = (e.data.arguments as { intent?: unknown } | undefined)?.intent; - if (typeof intent === 'string' && intent.length > 0) { - this._hasReportedActivity = true; - this._emitAction({ - type: ActionType.SessionActivityChanged, - session: this._protocolSession(), - activity: intent, - }); - } - } - return; - } - this._logService.info(`[Copilot:${sessionId}] Tool started: ${e.data.toolName}`); - let toolArgs = e.data.arguments !== undefined ? tryStringify(e.data.arguments) : undefined; - let parameters: Record | undefined; - if (toolArgs) { - try { parameters = JSON.parse(toolArgs) as Record; } catch { /* ignore */ } - } - // Strip redundant `cd && …` prefixes from shell tool - // commands so clients see the simplified form. Mirrors the logic in - // mapSessionEvents (which handles the history-replay path). - if (stripRedundantCdPrefix(e.data.toolName, parameters, this._workingDirectory)) { - toolArgs = tryStringify(parameters); - } - const displayName = getToolDisplayName(e.data.toolName); - this._activeToolCalls.set(e.data.toolCallId, { toolName: e.data.toolName, displayName, parameters, content: [], parentToolCallId: e.data.parentToolCallId }); - const toolKind = getToolKind(e.data.toolName); - const subagentMeta = toolKind === 'subagent' ? getSubagentMetadata(parameters) : undefined; - const toolClientId = this._clientToolNames.has(e.data.toolName) ? this._appliedSnapshot.clientId : undefined; - const parentToolCallId = e.data.parentToolCallId; - - // A new tool call invalidates the current markdown and reasoning - // parts so the next text/reasoning delta after the tool call - // starts a fresh part. Without invalidating reasoning here, a - // later round of reasoning (after tool_start/tool_complete) - // would silently append to the pre-tool-call reasoning block. - this._currentMarkdownPartId = undefined; - this._currentReasoningPartId = undefined; - - const meta: Record = { toolKind, language: toolKind === 'terminal' ? getShellLanguage(e.data.toolName) : undefined }; - if (subagentMeta?.description) { - meta.subagentDescription = subagentMeta.description; - } - if (subagentMeta?.agentName) { - meta.subagentAgentName = subagentMeta.agentName; - } - if (toolArgs !== undefined) { - meta.toolArguments = toolArgs; - } - if (e.data.mcpServerName) { - meta.mcpServerName = e.data.mcpServerName; - } - if (e.data.mcpToolName) { - meta.mcpToolName = e.data.mcpToolName; - } - - const protocolSession = this._protocolSession(); - this._emitAction({ - type: ActionType.SessionToolCallStart, - session: protocolSession, - turnId: this._turnId, - toolCallId: e.data.toolCallId, - toolName: e.data.toolName, - displayName, - toolClientId, - _meta: meta, - }, parentToolCallId); - - // For client tools, do NOT auto-ready — the tool handler will fire - // a separate tool_ready signal once the deferred is in place (or - // the permission flow fires it first). - if (toolClientId) { - return; - } - - this._emitAction({ - type: ActionType.SessionToolCallReady, - session: protocolSession, - turnId: this._turnId, - toolCallId: e.data.toolCallId, - invocationMessage: getInvocationMessage(e.data.toolName, displayName, parameters), - toolInput: getToolInputString(e.data.toolName, parameters, toolArgs), - confirmed: ToolCallConfirmationReason.NotNeeded, - }, parentToolCallId); - })); - - this._register(wrapper.onToolComplete(async e => { - const tracked = this._activeToolCalls.get(e.data.toolCallId); - if (!tracked) { - return; - } - this._logService.info(`[Copilot:${sessionId}] Tool completed: ${e.data.toolCallId}`); - this._activeToolCalls.delete(e.data.toolCallId); - const displayName = tracked.displayName; - const toolOutput = e.data.error?.message ?? e.data.result?.content; - - const content: ToolResultContent[] = [...tracked.content]; - if (toolOutput !== undefined) { - content.push({ type: ToolResultContentType.Text, text: toolOutput }); - } - - const filePath = isEditTool(tracked.toolName) ? getEditFilePath(tracked.parameters) : undefined; - if (filePath) { - try { - const fileEdit = await this._editTracker.takeCompletedEdit(this._turnId, e.data.toolCallId, filePath); - if (fileEdit) { - content.push(fileEdit); - } - } catch (err) { - this._logService.warn(`[Copilot:${sessionId}] Failed to take completed edit`, err); - } - } - - // Add terminal content reference for shell tools (skip if already - // added during onDidAssociateTerminal while the tool was running) - if (isShellTool(tracked.toolName) && this._shellManager) { - const terminalUri = this._shellManager.getTerminalUriForToolCall(e.data.toolCallId); - if (terminalUri && !content.some(c => c.type === ToolResultContentType.Terminal && c.resource === terminalUri)) { - content.push({ - type: ToolResultContentType.Terminal, - resource: terminalUri, - title: tracked.displayName, - }); - } - } - - this._emitAction({ - type: ActionType.SessionToolCallComplete, - session: this._protocolSession(), - turnId: this._turnId, - toolCallId: e.data.toolCallId, - result: { - success: e.data.success, - pastTenseMessage: getPastTenseMessage(tracked.toolName, displayName, tracked.parameters, e.data.success), - content: content.length > 0 ? content : undefined, - error: e.data.error, - }, - }, e.data.parentToolCallId); - })); - - this._register(wrapper.onIdle(() => { - this._logService.info(`[Copilot:${sessionId}] Session idle`); - // Clear any in-progress activity description set during the - // turn (e.g. via the `report_intent` tool) — the agent is no - // longer doing anything once the turn completes. - if (this._hasReportedActivity) { - this._hasReportedActivity = false; - this._emitAction({ - type: ActionType.SessionActivityChanged, - session: this._protocolSession(), - activity: undefined, - }); - } - this._emitAction({ - type: ActionType.SessionTurnComplete, - session: this._protocolSession(), - turnId: this._turnId, - }); - })); - - // The SDK emits a `skill` tool call (which we hide) and a richer - // `skill.invoked` event with the resolved SKILL.md path. Synthesize a - // tool-start/complete pair from the latter so the UI can render a - // clickable file link, matching the `view`-tool display style. - this._register(wrapper.onSkillInvoked(e => { - this._logService.info(`[Copilot:${sessionId}] Skill invoked: ${e.data.name} (${e.data.path})`); - const synth = synthesizeSkillToolCall(e.data, e.id); - const protocolSession = this._protocolSession(); - this._emitAction({ - type: ActionType.SessionToolCallStart, - session: protocolSession, - turnId: this._turnId, - toolCallId: synth.toolCallId, - toolName: synth.toolName, - displayName: synth.displayName, - }); - this._emitAction({ - type: ActionType.SessionToolCallReady, - session: protocolSession, - turnId: this._turnId, - toolCallId: synth.toolCallId, - invocationMessage: synth.invocationMessage, - confirmed: ToolCallConfirmationReason.NotNeeded, - }); - this._emitAction({ - type: ActionType.SessionToolCallComplete, - session: protocolSession, - turnId: this._turnId, - toolCallId: synth.toolCallId, - result: { - success: true, - pastTenseMessage: synth.pastTenseMessage, - }, - }); - })); - - this._register(wrapper.onSubagentStarted(e => { - this._logService.info(`[Copilot:${sessionId}] Subagent started: toolCallId=${e.data.toolCallId}, agent=${e.data.agentName}`); - this._onDidSessionProgress.fire({ - kind: 'subagent_started', - session: this.sessionUri, - toolCallId: e.data.toolCallId, - agentName: e.data.agentName, - agentDisplayName: e.data.agentDisplayName, - agentDescription: e.data.agentDescription, - }); - })); - - this._register(wrapper.onSessionError(e => { - this._logService.error(`[Copilot:${sessionId}] Session error: ${e.data.errorType} - ${e.data.message}`); - this._emitAction({ - type: ActionType.SessionError, - session: this._protocolSession(), - turnId: this._turnId, - error: { - errorType: e.data.errorType, - message: e.data.message, - stack: e.data.stack, - }, - }); - })); - - this._register(wrapper.onUsage(e => { - this._logService.trace(`[Copilot:${sessionId}] Usage: model=${e.data.model}, in=${e.data.inputTokens ?? '?'}, out=${e.data.outputTokens ?? '?'}, cacheRead=${e.data.cacheReadTokens ?? '?'}`); - this._emitAction({ - type: ActionType.SessionUsage, - session: this._protocolSession(), - turnId: this._turnId, - usage: { - inputTokens: e.data.inputTokens, - outputTokens: e.data.outputTokens, - model: e.data.model, - cacheReadTokens: e.data.cacheReadTokens, - }, - }); - })); - - this._register(wrapper.onReasoningDelta(e => { - this._logService.trace(`[Copilot:${sessionId}] Reasoning delta: ${e.data.deltaContent.length} chars`); - this._emitReasoningDelta(e.data.deltaContent); - })); - - // Sync the AHP session config when the SDK's `currentMode` changes - // (e.g. after the model approves a plan, or after we set the mode - // before sending). The SDK has three modes (`interactive` / `plan` / - // `autopilot`); AHP only models `interactive` / `plan` and treats - // autopilot as `mode='interactive', autoApprove='autopilot'`, so we - // translate before writing. - this._register(wrapper.onSessionModeChanged(e => { - this._logService.info(`[Copilot:${sessionId}] session.mode_changed: ${e.data.previousMode} -> ${e.data.newMode}`); - const newMode = e.data.newMode; - if (newMode !== 'interactive' && newMode !== 'plan' && newMode !== 'autopilot') { - return; - } - this._lastAppliedMode = newMode; - this._syncAhpConfigFromSdkMode(newMode); - })); - } - - /** - * Translates the SDK's three-mode space (`interactive` / `plan` / - * `autopilot`) to AHP's two-axis model: - * - * - SDK `plan` → AHP `mode='plan'`. - * - SDK `interactive` → AHP `mode='interactive'`. - * - SDK `autopilot` → AHP `mode='interactive', autoApprove='autopilot'`. - * Autopilot is exposed in AHP as the highest auto-approval level on - * the orthogonal `autoApprove` axis, not as a mode value. - * - * Patches that already match the current AHP values are still - * dispatched (the reducer is a no-op in that case) but written values - * propagate to all subscribed clients via `session/configChanged`. - */ - private _syncAhpConfigFromSdkMode(sdkMode: CopilotSdkMode): void { - const sessionUri = this.sessionUri.toString(); - const patch: Record = {}; - switch (sdkMode) { - case 'plan': - patch[SessionConfigKey.Mode] = 'plan'; - break; - case 'autopilot': - patch[SessionConfigKey.Mode] = 'interactive'; - patch[SessionConfigKey.AutoApprove] = 'autopilot'; - break; - case 'interactive': - patch[SessionConfigKey.Mode] = 'interactive'; - break; - } - this._configurationService.updateSessionConfig(sessionUri, patch); - } - - /** - * Handles the CLI's `exitPlanMode.request` RPC by surfacing it as a - * {@link SessionInputRequest} and awaiting the client's response. The - * resolved {@link IExitPlanModeResponse} flows back to the CLI, which - * calls `session.respondToExitPlanMode` internally — that resumes the - * paused `exit_plan_mode` tool call and (on accept) updates the SDK's - * `currentMode` so the model can continue with implementation. - */ - async handleExitPlanModeRequest(data: IExitPlanModeRequestParams): Promise { - const requestId = generateUuid(); - const questionId = generateUuid(); - this._logService.info(`[Copilot:${this.sessionId}] exitPlanMode.request: rpcId=${requestId}, actions=[${data.actions.join(',')}], recommended=${data.recommendedAction}`); - - // When the session's effective auto-approval level is `autopilot`, - // approve the plan automatically without surfacing a question to - // the user. Mirrors the "autopilot fast-path" in the Copilot CLI's - // own plan-mode handler. - const autoApprove = this._configurationService.getEffectiveValue(this.sessionUri.toString(), platformSessionSchema, SessionConfigKey.AutoApprove); - if (autoApprove === 'autopilot') { - const response = autoApproveExitPlanMode(data); - this._logService.info(`[Copilot:${this.sessionId}] exitPlanMode.request auto-accepted (autoApprove=autopilot): selectedAction=${response.selectedAction ?? '(none)'}`); - return response; - } - - // Resolve the plan file path so we can embed a markdown link. - let planPath: string | null = null; - try { - const planRead = await this._wrapper.session.rpc.plan.read(); - planPath = planRead.path ?? null; - } catch (err) { - this._logService.warn(`[Copilot:${this.sessionId}] rpc.plan.read failed for exit_plan_mode: ${err instanceof Error ? err.message : String(err)}`); - } - - // Build the input-request markdown: summary + link to the plan file. - let message = data.summary || localize('agentHost.planReview.fallbackSummary', "A plan is ready for review."); - if (planPath) { - const planUri = URI.file(planPath); - message += `\n\n[${localize('agentHost.planReview.viewPlanLink', "View full plan")}](${planUri.toString()})`; - } - - this._emitMarkdownDelta(message); - - const options = data.actions.map(actionId => { - const desc = getPlanActionDescription(actionId); - return { - id: actionId, - label: desc?.label ?? actionId, - description: desc?.description, - recommended: actionId === data.recommendedAction, - }; - }); - - const inputRequest: SessionInputRequest = { - id: requestId, - questions: [{ - kind: SessionInputQuestionKind.SingleSelect, - id: questionId, - title: localize('agentHost.planReview.title', "Review Plan"), - message: localize('agentHost.planReview.questionMessage', "How would you like to proceed?"), - required: true, - options, - allowFreeformInput: true, - }], - }; - - const deferred = new DeferredPromise(); - this._pendingPlanReviews.set(requestId, { - actions: data.actions, - recommendedAction: data.recommendedAction, - questionId, - deferred, - }); - - this._onDidSessionProgress.fire({ - kind: 'action', - session: this.sessionUri, - action: { - type: ActionType.SessionInputRequested, - session: this.sessionUri.toString(), - request: inputRequest, - } - }); - - try { - return await deferred.p; - } catch (err) { - this._logService.error(err, `[Copilot:${this.sessionId}] exitPlanMode.request handler failed: rpcId=${requestId}`); - return { approved: false }; - } - } - - private _subscribeForLogging(): void { - const wrapper = this._wrapper; - const sessionId = this.sessionId; - - this._register(wrapper.onSessionStart(e => { - this._logService.trace(`[Copilot:${sessionId}] Session started: model=${e.data.selectedModel ?? 'default'}, producer=${e.data.producer}`); - })); - - this._register(wrapper.onSessionResume(e => { - this._logService.trace(`[Copilot:${sessionId}] Session resumed: eventCount=${e.data.eventCount}`); - })); - - this._register(wrapper.onSessionInfo(e => { - this._logService.trace(`[Copilot:${sessionId}] Session info [${e.data.infoType}]: ${e.data.message}`); - })); - - this._register(wrapper.onSessionModelChange(e => { - this._logService.trace(`[Copilot:${sessionId}] Model changed: ${e.data.previousModel ?? '(none)'} -> ${e.data.newModel}`); - })); - - this._register(wrapper.onSessionHandoff(e => { - this._logService.trace(`[Copilot:${sessionId}] Session handoff: sourceType=${e.data.sourceType}, remoteSessionId=${e.data.remoteSessionId ?? '(none)'}`); - })); - - this._register(wrapper.onSessionTruncation(e => { - this._logService.trace(`[Copilot:${sessionId}] Session truncation: removed ${e.data.tokensRemovedDuringTruncation} tokens, ${e.data.messagesRemovedDuringTruncation} messages`); - })); - - this._register(wrapper.onSessionSnapshotRewind(e => { - this._logService.trace(`[Copilot:${sessionId}] Snapshot rewind: upTo=${e.data.upToEventId}, eventsRemoved=${e.data.eventsRemoved}`); - })); - - this._register(wrapper.onSessionShutdown(e => { - this._logService.trace(`[Copilot:${sessionId}] Session shutdown: type=${e.data.shutdownType}, premiumRequests=${e.data.totalPremiumRequests}, apiDuration=${e.data.totalApiDurationMs}ms`); - })); - - this._register(wrapper.onSessionUsageInfo(e => { - this._logService.trace(`[Copilot:${sessionId}] Usage info: ${e.data.currentTokens}/${e.data.tokenLimit} tokens, ${e.data.messagesLength} messages`); - })); - - this._register(wrapper.onSessionCompactionStart(() => { - this._logService.trace(`[Copilot:${sessionId}] Compaction started`); - })); - - this._register(wrapper.onSessionCompactionComplete(e => { - this._logService.trace(`[Copilot:${sessionId}] Compaction complete: success=${e.data.success}, tokensRemoved=${e.data.tokensRemoved ?? '?'}`); - })); - - this._register(wrapper.onUserMessage(e => { - this._logService.trace(`[Copilot:${sessionId}] User message: ${e.data.content.length} chars, ${e.data.attachments?.length ?? 0} attachments`); - })); - - this._register(wrapper.onPendingMessagesModified(() => { - this._logService.trace(`[Copilot:${sessionId}] Pending messages modified`); - })); - - this._register(wrapper.onTurnStart(e => { - this._logService.trace(`[Copilot:${sessionId}] Turn started: ${e.data.turnId}`); - })); - - this._register(wrapper.onIntent(e => { - this._logService.trace(`[Copilot:${sessionId}] Intent: ${e.data.intent}`); - })); - - this._register(wrapper.onReasoning(e => { - this._logService.trace(`[Copilot:${sessionId}] Reasoning: ${e.data.content.length} chars`); - })); - - this._register(wrapper.onTurnEnd(e => { - this._logService.trace(`[Copilot:${sessionId}] Turn ended: ${e.data.turnId}`); - })); - - this._register(wrapper.onAbort(e => { - this._logService.trace(`[Copilot:${sessionId}] Aborted: ${e.data.reason}`); - })); - - this._register(wrapper.onToolUserRequested(e => { - this._logService.trace(`[Copilot:${sessionId}] Tool user-requested: ${e.data.toolName} (${e.data.toolCallId})`); - })); - - this._register(wrapper.onToolPartialResult(e => { - this._logService.trace(`[Copilot:${sessionId}] Tool partial result: ${e.data.toolCallId} (${e.data.partialOutput.length} chars)`); - })); - - this._register(wrapper.onToolProgress(e => { - this._logService.trace(`[Copilot:${sessionId}] Tool progress: ${e.data.toolCallId} - ${e.data.progressMessage}`); - })); - - this._register(wrapper.onSkillInvoked(e => { - this._logService.trace(`[Copilot:${sessionId}] Skill invoked: ${e.data.name} (${e.data.path})`); - })); - - this._register(wrapper.onSubagentStarted(e => { - this._logService.trace(`[Copilot:${sessionId}] Subagent started: ${e.data.agentName} (${e.data.agentDisplayName})`); - })); - - this._register(wrapper.onSubagentCompleted(e => { - this._logService.trace(`[Copilot:${sessionId}] Subagent completed: ${e.data.agentName}`); - })); - - this._register(wrapper.onSubagentFailed(e => { - this._logService.error(`[Copilot:${sessionId}] Subagent failed: ${e.data.agentName} - ${e.data.error}`); - })); - - this._register(wrapper.onSubagentSelected(e => { - this._logService.trace(`[Copilot:${sessionId}] Subagent selected: ${e.data.agentName}`); - })); - - this._register(wrapper.onHookStart(e => { - this._logService.trace(`[Copilot:${sessionId}] Hook started: ${e.data.hookType} (${e.data.hookInvocationId})`); - })); - - this._register(wrapper.onHookEnd(e => { - this._logService.trace(`[Copilot:${sessionId}] Hook ended: ${e.data.hookType} (${e.data.hookInvocationId}), success=${e.data.success}`); - })); - - this._register(wrapper.onSystemMessage(e => { - this._logService.trace(`[Copilot:${sessionId}] System message [${e.data.role}]: ${e.data.content.length} chars`); - })); - } - - // ---- SDK event ID tracking & truncation --------------------------------- - - /** - * Returns the SDK event ID for the turn inserted after the given turn, - * or `undefined` if it's the last turn. - */ - getNextTurnEventId(turnId: string): Promise { - return this._databaseRef.object.getNextTurnEventId(turnId); - } - - /** - * Returns the SDK event ID of the earliest turn. - */ - getFirstTurnEventId(): Promise { - return this._databaseRef.object.getFirstTurnEventId(); - } - - /** - * Truncates the session history via the SDK's RPC and cleans up - * stale turns from the session database. - * - * @param eventId The SDK event ID at which to truncate. This event - * and all events after it are removed. - * @param keepTurnId If provided, turns inserted after this turn are - * deleted from the DB. If omitted, all turns are deleted. - */ - async truncateAtEventId(eventId: string, keepTurnId?: string): Promise { - this._logService.info(`[Copilot:${this.sessionId}] Truncating via SDK RPC at eventId=${eventId}`); - const result = await this._wrapper.session.rpc.history.truncate({ eventId }); - this._logService.info(`[Copilot:${this.sessionId}] SDK truncation removed ${result.eventsRemoved} events`); - - // Clean up stale turns from our DB so getNextTurnEventId doesn't - // return event IDs for turns that no longer exist in the SDK. - if (keepTurnId) { - await this._databaseRef.object.deleteTurnsAfter(keepTurnId); - } else { - await this._databaseRef.object.deleteAllTurns(); - } - } - - /** - * Bulk-remaps turn IDs in this session's database. - * Used after file-copying a source session's database for a fork. - */ - async remapTurnIds(mapping: ReadonlyMap): Promise { - await this._databaseRef.object.remapTurnIds(mapping); - } - - // ---- cleanup ------------------------------------------------------------ - - private _denyPendingPermissions(): void { - for (const [toolCallId, deferred] of this._pendingPermissions) { - this._deletePendingEditContent(toolCallId); - deferred.complete(false); - } - this._pendingPermissions.clear(); - } - - /** - * Removes any `pending-edit-content:` entries associated with a resolved - * (approved, denied, or cancelled) permission request. - */ - private _deletePendingEditContent(toolCallId: string): void { - const uri = this._pendingEditContentUris.get(toolCallId); - if (!uri) { - return; - } - this._pendingEditContentUris.delete(toolCallId); - this._fileService.del(uri).catch(err => { - this._logService.warn(`[Copilot:${this.sessionId}] Failed to delete pending edit content: ${uri.toString()}`, err); - }); - } - - private _cancelPendingUserInputs(): void { - for (const [, pending] of this._pendingUserInputs) { - pending.deferred.complete({ response: SessionInputResponseKind.Cancel }); - } - this._pendingUserInputs.clear(); - } - - private _cancelPendingPlanReviews(): void { - for (const [, pending] of this._pendingPlanReviews) { - pending.deferred.complete({ approved: false }); - } - this._pendingPlanReviews.clear(); - } - - private _cancelPendingClientToolCalls(): void { - for (const [, deferred] of this._pendingClientToolCalls) { - deferred.complete({ textResultForLlm: 'Tool call cancelled: session ended', resultType: 'failure', error: 'Session ended' }); - } - this._pendingClientToolCalls.clear(); - } -} - -/** - * Builds the {@link IExitPlanModeResponse} used when the session is in - * autopilot and we approve the plan without user interaction. - * - * Selection priority mirrors the Copilot CLI's own autopilot handler. - * - * 1. If the SDK's `recommendedAction` is offered, take it. - * 2. Otherwise fall back to `autopilot` → `autopilot_fleet` → `interactive` - * → `exit_only`. - * 3. As a last resort, approve without picking a `selectedAction` (the SDK - * keeps `currentMode='interactive'` in that case). - * - * `autoApproveEdits: true` is set whenever the chosen action is one of the - * autopilot variants, mirroring the CLI behavior. - */ -function autoApproveExitPlanMode(data: IExitPlanModeRequestParams): IExitPlanModeResponse { - const choices = data.actions ?? []; - const isAutopilotAction = (action: string) => action === 'autopilot' || action === 'autopilot_fleet'; - - if (data.recommendedAction && choices.includes(data.recommendedAction)) { - const selectedAction = data.recommendedAction; - return { - approved: true, - selectedAction, - ...(isAutopilotAction(selectedAction) ? { autoApproveEdits: true } : {}), - }; - } - - for (const action of ['autopilot', 'autopilot_fleet', 'interactive', 'exit_only']) { - if (choices.includes(action)) { - return { - approved: true, - selectedAction: action, - ...(isAutopilotAction(action) ? { autoApproveEdits: true } : {}), - }; - } - } - - return { approved: true, autoApproveEdits: true }; -} - -/** - * Counts added/removed lines in a unified diff string. Ignores the `+++` and - * `---` header rows and any non-hunk context. - */ -function countUnifiedDiffLines(diff: string): { added: number; removed: number } | undefined { - let added = 0; - let removed = 0; - for (const line of diff.split('\n')) { - if (line.startsWith('+++') || line.startsWith('---')) { - continue; - } - if (line.startsWith('+')) { - added++; - } else if (line.startsWith('-')) { - removed++; - } - } - if (added === 0 && removed === 0) { - return undefined; - } - return { added, removed }; -} diff --git a/src/vs/platform/agentHost/node/copilot/copilotGitProject.ts b/src/vs/platform/agentHost/node/copilot/copilotGitProject.ts deleted file mode 100644 index e45ca942..00000000 --- a/src/vs/platform/agentHost/node/copilot/copilotGitProject.ts +++ /dev/null @@ -1,58 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { Schemas } from '../../../../base/common/network.js'; -import { basename } from '../../../../base/common/path.js'; -import { URI } from '../../../../base/common/uri.js'; -import type { IAgentSessionProjectInfo } from '../../common/agentService.js'; -import type { IAgentHostGitService } from '../agentHostGitService.js'; - -export interface ICopilotSessionContext { - readonly cwd?: string; - readonly gitRoot?: string; - readonly repository?: string; -} - -export async function resolveGitProject(workingDirectory: URI | undefined, gitService: IAgentHostGitService): Promise { - if (!workingDirectory || workingDirectory.scheme !== Schemas.file) { - return undefined; - } - - if (!await gitService.isInsideWorkTree(workingDirectory)) { - return undefined; - } - - const uri = (await gitService.getWorktreeRoots(workingDirectory))[0] - ?? await gitService.getRepositoryRoot(workingDirectory); - if (!uri) { - return undefined; - } - return { uri, displayName: basename(uri.fsPath) || uri.toString() }; -} - -export function projectFromRepository(repository: string): IAgentSessionProjectInfo | undefined { - const uri = repository.includes('://') ? URI.parse(repository) : URI.parse(`https://github.com/${repository}`); - const rawDisplayName = basename(uri.path) || repository.split('/').filter(Boolean).pop() || repository; - const displayName = rawDisplayName.endsWith('.git') ? rawDisplayName.slice(0, -'.git'.length) : rawDisplayName; - return { uri, displayName }; -} - -export async function projectFromCopilotContext(context: ICopilotSessionContext | undefined, gitService: IAgentHostGitService): Promise { - const workingDirectory = typeof context?.cwd === 'string' - ? URI.file(context.cwd) - : typeof context?.gitRoot === 'string' - ? URI.file(context.gitRoot) - : undefined; - const gitProject = await resolveGitProject(workingDirectory, gitService); - if (gitProject) { - return gitProject; - } - - if (context?.repository) { - return projectFromRepository(context.repository); - } - - return undefined; -} diff --git a/src/vs/platform/agentHost/node/copilot/copilotPluginConverters.ts b/src/vs/platform/agentHost/node/copilot/copilotPluginConverters.ts deleted file mode 100644 index 61e93e5a..00000000 --- a/src/vs/platform/agentHost/node/copilot/copilotPluginConverters.ts +++ /dev/null @@ -1,350 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { spawn } from 'child_process'; -import type { CustomAgentConfig, MCPServerConfig, SessionConfig } from '@github/copilot-sdk'; -import { OperatingSystem, OS } from '../../../../base/common/platform.js'; -import { IFileService } from '../../../files/common/files.js'; -import { McpServerType } from '../../../mcp/common/mcpPlatformTypes.js'; -import type { IMcpServerDefinition, INamedPluginResource, IParsedHookCommand, IParsedHookGroup, IParsedPlugin } from '../../../agentPlugins/common/pluginParsers.js'; -import { dirname } from '../../../../base/common/path.js'; - -type SessionHooks = NonNullable; -type PreToolUseHookInput = Parameters>[0]; -type PostToolUseHookInput = Parameters>[0]; -type UserPromptSubmittedHookInput = Parameters>[0]; -type SessionStartHookInput = Parameters>[0]; -type SessionEndHookInput = Parameters>[0]; -type ErrorOccurredHookInput = Parameters>[0]; - -// --------------------------------------------------------------------------- -// MCP servers -// --------------------------------------------------------------------------- - -/** - * Converts parsed MCP server definitions into the SDK's `mcpServers` config. - */ -export function toSdkMcpServers(defs: readonly IMcpServerDefinition[]): Record { - const result: Record = {}; - for (const def of defs) { - const config = def.configuration; - if (config.type === McpServerType.LOCAL) { - result[def.name] = { - type: 'local', - command: config.command, - args: config.args ? [...config.args] : [], - tools: ['*'], - ...(config.env && { env: toStringEnv(config.env) }), - ...(config.cwd && { cwd: config.cwd }), - }; - } else { - result[def.name] = { - type: 'http', - url: config.url, - tools: ['*'], - ...(config.headers && { headers: { ...config.headers } }), - }; - } - } - return result; -} - -/** - * Ensures all env values are strings (the SDK requires `Record`). - */ -function toStringEnv(env: Record): Record { - const result: Record = {}; - for (const [key, value] of Object.entries(env)) { - if (value !== null) { - result[key] = String(value); - } - } - return result; -} - -// --------------------------------------------------------------------------- -// Custom agents -// --------------------------------------------------------------------------- - -/** - * Converts parsed plugin agents into the SDK's `customAgents` config. - * Reads each agent's `.md` file to use as the prompt. - */ -export async function toSdkCustomAgents(agents: readonly INamedPluginResource[], fileService: IFileService): Promise { - const configs: CustomAgentConfig[] = []; - for (const agent of agents) { - try { - const content = await fileService.readFile(agent.uri); - configs.push({ - name: agent.name, - prompt: content.value.toString(), - }); - } catch { - // Skip agents whose file cannot be read - } - } - return configs; -} - -// --------------------------------------------------------------------------- -// Skill directories -// --------------------------------------------------------------------------- - -/** - * Converts parsed plugin skills into the SDK's `skillDirectories` config. - * The SDK expects directory paths; we extract the parent directory of each SKILL.md. - */ -export function toSdkSkillDirectories(skills: readonly INamedPluginResource[]): string[] { - const seen = new Set(); - const result: string[] = []; - for (const skill of skills) { - // SKILL.md parent directory is the skill directory - const dir = dirname(skill.uri.fsPath); - if (!seen.has(dir)) { - seen.add(dir); - result.push(dir); - } - } - return result; -} - -// --------------------------------------------------------------------------- -// Hooks -// --------------------------------------------------------------------------- - -/** - * Resolves the effective command for the current platform from a parsed hook command. - */ -function resolveEffectiveCommand(hook: IParsedHookCommand, os: OperatingSystem): string | undefined { - if (os === OperatingSystem.Windows && hook.windows) { - return hook.windows; - } else if (os === OperatingSystem.Macintosh && hook.osx) { - return hook.osx; - } else if (os === OperatingSystem.Linux && hook.linux) { - return hook.linux; - } - return hook.command; -} - -/** - * Executes a hook command as a shell process. Returns the stdout on success, - * or throws on non-zero exit code or timeout. - */ -function executeHookCommand(hook: IParsedHookCommand, stdin?: string): Promise { - const command = resolveEffectiveCommand(hook, OS); - if (!command) { - return Promise.resolve(''); - } - - const timeout = (hook.timeout ?? 30) * 1000; - const cwd = hook.cwd?.fsPath; - - return new Promise((resolve, reject) => { - const isWindows = OS === OperatingSystem.Windows; - const shell = isWindows ? 'cmd.exe' : '/bin/sh'; - const shellArgs = isWindows ? ['/c', command] : ['-c', command]; - - const child = spawn(shell, shellArgs, { - cwd, - env: { ...process.env, ...hook.env }, - stdio: ['pipe', 'pipe', 'pipe'], - timeout, - }); - - let stdout = ''; - let stderr = ''; - - child.stdout.on('data', (data: Buffer) => { stdout += data.toString(); }); - child.stderr.on('data', (data: Buffer) => { stderr += data.toString(); }); - - if (stdin) { - child.stdin.write(stdin); - child.stdin.end(); - } else { - child.stdin.end(); - } - - child.on('error', reject); - child.on('close', (code) => { - if (code === 0) { - resolve(stdout); - } else { - reject(new Error(`Hook command exited with code ${code}: ${stderr || stdout}`)); - } - }); - }); -} - -/** - * Runs a list of hook commands sequentially, passing `input` as JSON stdin. - * Returns the parsed output of the first command that emits a valid JSON object, - * or `undefined` if no command produces parseable JSON output. - * Command failures are swallowed — hooks are non-fatal. - */ -async function runHookCommands(commands: readonly IParsedHookCommand[] | undefined, input: unknown): Promise { - if (!commands) { - return undefined; - } - const stdin = JSON.stringify(input); - for (const cmd of commands) { - try { - const output = await executeHookCommand(cmd, stdin); - if (output.trim()) { - try { - const parsed = JSON.parse(output); - if (parsed && typeof parsed === 'object') { - return parsed; - } - } catch { - // Non-JSON output is fine — no modification - } - } - } catch { - // Hook failures are non-fatal - } - } - return undefined; -} - -/** - * Mapping from canonical hook type identifiers to SDK SessionHooks handler keys. - */ -const HOOK_TYPE_TO_SDK_KEY: Record = { - 'PreToolUse': 'onPreToolUse', - 'PostToolUse': 'onPostToolUse', - 'UserPromptSubmit': 'onUserPromptSubmitted', - 'SessionStart': 'onSessionStart', - 'SessionEnd': 'onSessionEnd', - 'ErrorOccurred': 'onErrorOccurred', -}; - -/** - * Converts parsed plugin hooks into SDK {@link SessionHooks} handler functions. - * - * Each handler executes the hook's shell commands sequentially when invoked. - * Hook types that don't map to SDK handler keys are silently ignored. - * - * The optional `editTrackingHooks` parameter provides internal edit-tracking - * callbacks from {@link CopilotAgentSession} that are merged with plugin hooks. - */ -export function toSdkHooks( - hookGroups: readonly IParsedHookGroup[], - editTrackingHooks?: { - readonly onPreToolUse: (input: PreToolUseHookInput) => Promise; - readonly onPostToolUse: (input: PostToolUseHookInput) => Promise; - }, -): SessionHooks { - // Group all commands by SDK handler key - const commandsByKey = new Map(); - for (const group of hookGroups) { - const sdkKey = HOOK_TYPE_TO_SDK_KEY[group.type]; - if (!sdkKey) { - continue; - } - const existing = commandsByKey.get(sdkKey) ?? []; - existing.push(...group.commands); - commandsByKey.set(sdkKey, existing); - } - - const hooks: SessionHooks = {}; - - // Pre-tool-use handler - const preToolCommands = commandsByKey.get('onPreToolUse'); - if (preToolCommands?.length || editTrackingHooks) { - hooks.onPreToolUse = async (input: PreToolUseHookInput) => { - await editTrackingHooks?.onPreToolUse(input); - return runHookCommands(preToolCommands, input); - }; - } - - // Post-tool-use handler - const postToolCommands = commandsByKey.get('onPostToolUse'); - if (postToolCommands?.length || editTrackingHooks) { - hooks.onPostToolUse = async (input: PostToolUseHookInput) => { - await editTrackingHooks?.onPostToolUse(input); - return runHookCommands(postToolCommands, input); - }; - } - - // User-prompt-submitted handler - const promptCommands = commandsByKey.get('onUserPromptSubmitted'); - if (promptCommands?.length) { - hooks.onUserPromptSubmitted = async (input: UserPromptSubmittedHookInput) => { - const stdin = JSON.stringify(input); - for (const cmd of promptCommands) { - try { - await executeHookCommand(cmd, stdin); - } catch { - // Hook failures are non-fatal - } - } - }; - } - - // Session-start handler - const startCommands = commandsByKey.get('onSessionStart'); - if (startCommands?.length) { - hooks.onSessionStart = async (input: SessionStartHookInput) => { - const stdin = JSON.stringify(input); - for (const cmd of startCommands) { - try { - await executeHookCommand(cmd, stdin); - } catch { - // Hook failures are non-fatal - } - } - }; - } - - // Session-end handler - const endCommands = commandsByKey.get('onSessionEnd'); - if (endCommands?.length) { - hooks.onSessionEnd = async (input: SessionEndHookInput) => { - const stdin = JSON.stringify(input); - for (const cmd of endCommands) { - try { - await executeHookCommand(cmd, stdin); - } catch { - // Hook failures are non-fatal - } - } - }; - } - - // Error-occurred handler - const errorCommands = commandsByKey.get('onErrorOccurred'); - if (errorCommands?.length) { - hooks.onErrorOccurred = async (input: ErrorOccurredHookInput) => { - const stdin = JSON.stringify(input); - for (const cmd of errorCommands) { - try { - await executeHookCommand(cmd, stdin); - } catch { - // Hook failures are non-fatal - } - } - }; - } - - return hooks; -} - -/** - * Checks whether two sets of parsed plugins produce equivalent SDK config. - * Used to determine if a session needs to be refreshed. - */ -export function parsedPluginsEqual(a: readonly IParsedPlugin[], b: readonly IParsedPlugin[]): boolean { - // Simple structural comparison via JSON serialization. - // We serialize only the essential fields, replacing URIs with strings. - const serialize = (plugins: readonly IParsedPlugin[]) => { - return JSON.stringify(plugins.map(p => ({ - hooks: p.hooks.map(h => ({ type: h.type, commands: h.commands.map(c => ({ command: c.command, windows: c.windows, linux: c.linux, osx: c.osx, cwd: c.cwd?.toString(), env: c.env, timeout: c.timeout })) })), - mcpServers: p.mcpServers.map(m => ({ name: m.name, configuration: m.configuration })), - skills: p.skills.map(s => ({ uri: s.uri.toString(), name: s.name })), - agents: p.agents.map(a => ({ uri: a.uri.toString(), name: a.name })), - }))); - }; - return serialize(a) === serialize(b); -} diff --git a/src/vs/platform/agentHost/node/copilot/copilotSessionWrapper.ts b/src/vs/platform/agentHost/node/copilot/copilotSessionWrapper.ts deleted file mode 100644 index 9a0fd2a7..00000000 --- a/src/vs/platform/agentHost/node/copilot/copilotSessionWrapper.ts +++ /dev/null @@ -1,222 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { CopilotSession, SessionEventPayload, SessionEventType } from '@github/copilot-sdk'; -import { Emitter, Event } from '../../../../base/common/event.js'; -import { Disposable, toDisposable } from '../../../../base/common/lifecycle.js'; - -/** - * Thin wrapper around {@link CopilotSession} that exposes each SDK event as a - * proper VS Code `Event`. All subscriptions and the underlying SDK session - * are cleaned up on dispose. - */ -export class CopilotSessionWrapper extends Disposable { - - constructor(readonly session: CopilotSession) { - super(); - this._register(toDisposable(() => { - session.destroy().catch(() => { /* best-effort */ }); - })); - } - - get sessionId(): string { return this.session.sessionId; } - - private _onMessageDelta: Event> | undefined; - get onMessageDelta(): Event> { - return this._onMessageDelta ??= this._sdkEvent('assistant.message_delta'); - } - - private _onMessage: Event> | undefined; - get onMessage(): Event> { - return this._onMessage ??= this._sdkEvent('assistant.message'); - } - - private _onToolStart: Event> | undefined; - get onToolStart(): Event> { - return this._onToolStart ??= this._sdkEvent('tool.execution_start'); - } - - private _onToolComplete: Event> | undefined; - get onToolComplete(): Event> { - return this._onToolComplete ??= this._sdkEvent('tool.execution_complete'); - } - - private _onIdle: Event> | undefined; - get onIdle(): Event> { - return this._onIdle ??= this._sdkEvent('session.idle'); - } - - private _onSessionStart: Event> | undefined; - get onSessionStart(): Event> { - return this._onSessionStart ??= this._sdkEvent('session.start'); - } - - private _onSessionResume: Event> | undefined; - get onSessionResume(): Event> { - return this._onSessionResume ??= this._sdkEvent('session.resume'); - } - - private _onSessionError: Event> | undefined; - get onSessionError(): Event> { - return this._onSessionError ??= this._sdkEvent('session.error'); - } - - private _onSessionInfo: Event> | undefined; - get onSessionInfo(): Event> { - return this._onSessionInfo ??= this._sdkEvent('session.info'); - } - - private _onSessionModelChange: Event> | undefined; - get onSessionModelChange(): Event> { - return this._onSessionModelChange ??= this._sdkEvent('session.model_change'); - } - - private _onSessionHandoff: Event> | undefined; - get onSessionHandoff(): Event> { - return this._onSessionHandoff ??= this._sdkEvent('session.handoff'); - } - - private _onSessionTruncation: Event> | undefined; - get onSessionTruncation(): Event> { - return this._onSessionTruncation ??= this._sdkEvent('session.truncation'); - } - - private _onSessionSnapshotRewind: Event> | undefined; - get onSessionSnapshotRewind(): Event> { - return this._onSessionSnapshotRewind ??= this._sdkEvent('session.snapshot_rewind'); - } - - private _onSessionShutdown: Event> | undefined; - get onSessionShutdown(): Event> { - return this._onSessionShutdown ??= this._sdkEvent('session.shutdown'); - } - - private _onSessionUsageInfo: Event> | undefined; - get onSessionUsageInfo(): Event> { - return this._onSessionUsageInfo ??= this._sdkEvent('session.usage_info'); - } - - private _onSessionCompactionStart: Event> | undefined; - get onSessionCompactionStart(): Event> { - return this._onSessionCompactionStart ??= this._sdkEvent('session.compaction_start'); - } - - private _onSessionCompactionComplete: Event> | undefined; - get onSessionCompactionComplete(): Event> { - return this._onSessionCompactionComplete ??= this._sdkEvent('session.compaction_complete'); - } - - private _onUserMessage: Event> | undefined; - get onUserMessage(): Event> { - return this._onUserMessage ??= this._sdkEvent('user.message'); - } - - private _onPendingMessagesModified: Event> | undefined; - get onPendingMessagesModified(): Event> { - return this._onPendingMessagesModified ??= this._sdkEvent('pending_messages.modified'); - } - - private _onTurnStart: Event> | undefined; - get onTurnStart(): Event> { - return this._onTurnStart ??= this._sdkEvent('assistant.turn_start'); - } - - private _onIntent: Event> | undefined; - get onIntent(): Event> { - return this._onIntent ??= this._sdkEvent('assistant.intent'); - } - - private _onReasoning: Event> | undefined; - get onReasoning(): Event> { - return this._onReasoning ??= this._sdkEvent('assistant.reasoning'); - } - - private _onReasoningDelta: Event> | undefined; - get onReasoningDelta(): Event> { - return this._onReasoningDelta ??= this._sdkEvent('assistant.reasoning_delta'); - } - - private _onTurnEnd: Event> | undefined; - get onTurnEnd(): Event> { - return this._onTurnEnd ??= this._sdkEvent('assistant.turn_end'); - } - - private _onUsage: Event> | undefined; - get onUsage(): Event> { - return this._onUsage ??= this._sdkEvent('assistant.usage'); - } - - private _onAbort: Event> | undefined; - get onAbort(): Event> { - return this._onAbort ??= this._sdkEvent('abort'); - } - - private _onToolUserRequested: Event> | undefined; - get onToolUserRequested(): Event> { - return this._onToolUserRequested ??= this._sdkEvent('tool.user_requested'); - } - - private _onToolPartialResult: Event> | undefined; - get onToolPartialResult(): Event> { - return this._onToolPartialResult ??= this._sdkEvent('tool.execution_partial_result'); - } - - private _onToolProgress: Event> | undefined; - get onToolProgress(): Event> { - return this._onToolProgress ??= this._sdkEvent('tool.execution_progress'); - } - - private _onSkillInvoked: Event> | undefined; - get onSkillInvoked(): Event> { - return this._onSkillInvoked ??= this._sdkEvent('skill.invoked'); - } - - private _onSubagentStarted: Event> | undefined; - get onSubagentStarted(): Event> { - return this._onSubagentStarted ??= this._sdkEvent('subagent.started'); - } - - private _onSubagentCompleted: Event> | undefined; - get onSubagentCompleted(): Event> { - return this._onSubagentCompleted ??= this._sdkEvent('subagent.completed'); - } - - private _onSubagentFailed: Event> | undefined; - get onSubagentFailed(): Event> { - return this._onSubagentFailed ??= this._sdkEvent('subagent.failed'); - } - - private _onSubagentSelected: Event> | undefined; - get onSubagentSelected(): Event> { - return this._onSubagentSelected ??= this._sdkEvent('subagent.selected'); - } - - private _onHookStart: Event> | undefined; - get onHookStart(): Event> { - return this._onHookStart ??= this._sdkEvent('hook.start'); - } - - private _onHookEnd: Event> | undefined; - get onHookEnd(): Event> { - return this._onHookEnd ??= this._sdkEvent('hook.end'); - } - - private _onSystemMessage: Event> | undefined; - get onSystemMessage(): Event> { - return this._onSystemMessage ??= this._sdkEvent('system.message'); - } - - private _onSessionModeChanged: Event> | undefined; - get onSessionModeChanged(): Event> { - return this._onSessionModeChanged ??= this._sdkEvent('session.mode_changed'); - } - - private _sdkEvent(eventType: K): Event> { - const emitter = this._register(new Emitter>()); - const unsubscribe = this.session.on(eventType, (data: SessionEventPayload) => emitter.fire(data)); - this._register(toDisposable(unsubscribe)); - return emitter.event; - } -} diff --git a/src/vs/platform/agentHost/node/copilot/copilotShellTools.ts b/src/vs/platform/agentHost/node/copilot/copilotShellTools.ts deleted file mode 100644 index 9872b06b..00000000 --- a/src/vs/platform/agentHost/node/copilot/copilotShellTools.ts +++ /dev/null @@ -1,728 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import type { Tool, ToolResultObject } from '@github/copilot-sdk'; -import { generateUuid } from '../../../../base/common/uuid.js'; -import { URI } from '../../../../base/common/uri.js'; -import { removeAnsiEscapeCodes } from '../../../../base/common/strings.js'; -import * as platform from '../../../../base/common/platform.js'; -import { DisposableStore, toDisposable } from '../../../../base/common/lifecycle.js'; -import { Emitter, Event } from '../../../../base/common/event.js'; -import { ILogService } from '../../../log/common/log.js'; -import { TerminalClaimKind, type TerminalSessionClaim } from '../../common/state/protocol/state.js'; -import { IAgentHostTerminalManager } from '../agentHostTerminalManager.js'; - -/** - * Maximum scrollback content (in bytes) returned to the model in tool results. - */ -const MAX_OUTPUT_BYTES = 80_000; - -/** - * Default command timeout in milliseconds (120 seconds). - */ -const DEFAULT_TIMEOUT_MS = 120_000; - -/** - * The sentinel prefix used to detect command completion in terminal output. - * The full sentinel format is: `<<_EXIT_>>`. - */ -const SENTINEL_PREFIX = '<<(); - private readonly _toolCallShells = new Map(); - - private readonly _onDidAssociateTerminal = new Emitter<{ toolCallId: string; terminalUri: string; displayName: string }>(); - readonly onDidAssociateTerminal: Event<{ toolCallId: string; terminalUri: string; displayName: string }> = this._onDidAssociateTerminal.event; - - constructor( - private readonly _sessionUri: URI, - private readonly _workingDirectory: URI | undefined, - @IAgentHostTerminalManager private readonly _terminalManager: IAgentHostTerminalManager, - @ILogService private readonly _logService: ILogService, - ) { } - - async getOrCreateShell( - shellType: ShellType, - turnId: string, - toolCallId: string, - cwd?: string, - ): Promise { - for (const shell of this._shells.values()) { - if (shell.shellType === shellType && this._terminalManager.hasTerminal(shell.terminalUri)) { - const exitCode = this._terminalManager.getExitCode(shell.terminalUri); - if (exitCode === undefined) { - this._trackToolCall(toolCallId, shell.id); - return shell; - } - this._shells.delete(shell.id); - } - } - - const id = generateUuid(); - const terminalUri = `agenthost-terminal://shell/${id}`; - - const claim: TerminalSessionClaim = { - kind: TerminalClaimKind.Session, - session: this._sessionUri.toString(), - turnId, - toolCallId, - }; - - const shellDisplayName = shellType === 'bash' ? 'Bash' : 'PowerShell'; - - await this._terminalManager.createTerminal({ - terminal: terminalUri, - claim, - name: shellDisplayName, - cwd: cwd ?? this._workingDirectory?.fsPath, - }, { shell: getShellExecutable(shellType), preventShellHistory: true, nonInteractive: true }); - - const shell: IManagedShell = { id, terminalUri, shellType }; - this._shells.set(id, shell); - this._trackToolCall(toolCallId, id); - this._logService.info(`[ShellManager] Created ${shellType} shell ${id} (terminal=${terminalUri})`); - return shell; - } - - private _trackToolCall(toolCallId: string, shellId: string): void { - this._toolCallShells.set(toolCallId, shellId); - const shell = this._shells.get(shellId); - if (shell) { - const displayName = shell.shellType === 'bash' ? 'Bash' : 'PowerShell'; - this._onDidAssociateTerminal.fire({ toolCallId, terminalUri: shell.terminalUri, displayName }); - } - } - - getTerminalUriForToolCall(toolCallId: string): string | undefined { - const shellId = this._toolCallShells.get(toolCallId); - if (!shellId) { - return undefined; - } - return this._shells.get(shellId)?.terminalUri; - } - - getShell(id: string): IManagedShell | undefined { - return this._shells.get(id); - } - - listShells(): IManagedShell[] { - const result: IManagedShell[] = []; - for (const shell of this._shells.values()) { - if (this._terminalManager.hasTerminal(shell.terminalUri)) { - result.push(shell); - } - } - return result; - } - - shutdownShell(id: string): boolean { - const shell = this._shells.get(id); - if (!shell) { - return false; - } - this._terminalManager.disposeTerminal(shell.terminalUri); - this._shells.delete(id); - this._logService.info(`[ShellManager] Shut down shell ${id}`); - return true; - } - - dispose(): void { - for (const shell of this._shells.values()) { - if (this._terminalManager.hasTerminal(shell.terminalUri)) { - this._terminalManager.disposeTerminal(shell.terminalUri); - } - } - this._shells.clear(); - this._toolCallShells.clear(); - } -} - -// --------------------------------------------------------------------------- -// Sentinel helpers -// --------------------------------------------------------------------------- - -function makeSentinelId(): string { - return generateUuid().replace(/-/g, ''); -} - -function buildSentinelCommand(sentinelId: string, shellType: ShellType): string { - if (shellType === 'powershell') { - return `Write-Output "${SENTINEL_PREFIX}${sentinelId}_EXIT_$LASTEXITCODE>>>"`; - } - return `echo "${SENTINEL_PREFIX}${sentinelId}_EXIT_$?>>>"`; -} - -/** - * For POSIX shells (bash/zsh) that honor `HISTCONTROL=ignorespace` / - * `HIST_IGNORE_SPACE`, prepending a single space prevents the command from - * being recorded in shell history. The shell integration scripts opt these - * settings in via the `VSCODE_PREVENT_SHELL_HISTORY` env var (set when the - * terminal is created with `preventShellHistory: true`). PowerShell - * suppresses history through PSReadLine instead, so no prefix is needed. - * - * Exported for tests. - */ -export function prefixForHistorySuppression(shellType: ShellType): string { - return shellType === 'powershell' ? '' : ' '; -} - -function parseSentinel(content: string, sentinelId: string): { found: boolean; exitCode: number; outputBeforeSentinel: string } { - const marker = `${SENTINEL_PREFIX}${sentinelId}_EXIT_`; - const idx = content.indexOf(marker); - if (idx === -1) { - return { found: false, exitCode: -1, outputBeforeSentinel: content }; - } - - const outputBeforeSentinel = content.substring(0, idx); - const afterMarker = content.substring(idx + marker.length); - const endIdx = afterMarker.indexOf('>>>'); - const exitCodeStr = endIdx >= 0 ? afterMarker.substring(0, endIdx) : afterMarker.trim(); - const exitCode = parseInt(exitCodeStr, 10); - return { - found: true, - exitCode: isNaN(exitCode) ? -1 : exitCode, - outputBeforeSentinel, - }; -} - -function prepareOutputForModel(rawOutput: string): string { - let text = removeAnsiEscapeCodes(rawOutput).trim(); - if (text.length > MAX_OUTPUT_BYTES) { - text = text.substring(text.length - MAX_OUTPUT_BYTES); - } - return text; -} - -// --------------------------------------------------------------------------- -// Tool implementations -// --------------------------------------------------------------------------- - -function makeSuccessResult(text: string): ToolResultObject { - return { textResultForLlm: text, resultType: 'success' }; -} - -function makeFailureResult(text: string, error?: string): ToolResultObject { - return { textResultForLlm: text, resultType: 'failure', error }; -} - -async function executeCommandInShell( - shell: IManagedShell, - command: string, - timeoutMs: number, - terminalManager: IAgentHostTerminalManager, - logService: ILogService, -): Promise { - const result = terminalManager.supportsCommandDetection(shell.terminalUri) - ? await executeCommandWithShellIntegration(shell, command, timeoutMs, terminalManager, logService) - : await executeCommandWithSentinel(shell, command, timeoutMs, terminalManager, logService); - return { - ...result, - textResultForLlm: `Shell ID: ${shell.id}\n${result.textResultForLlm}`, - }; -} - -/** - * Execute a command using shell integration (OSC 633) for completion detection. - * No sentinel echo is injected — the shell's own command-finished signal - * provides the exit code and cleanly delineated output. - */ -async function executeCommandWithShellIntegration( - shell: IManagedShell, - command: string, - timeoutMs: number, - terminalManager: IAgentHostTerminalManager, - logService: ILogService, -): Promise { - const disposables = new DisposableStore(); - - terminalManager.writeInput(shell.terminalUri, `${prefixForHistorySuppression(shell.shellType)}${command}\r`); - - return new Promise(resolve => { - let resolved = false; - const finish = (result: ToolResultObject) => { - if (resolved) { - return; - } - resolved = true; - disposables.dispose(); - resolve(result); - }; - - disposables.add(terminalManager.onCommandFinished(shell.terminalUri, event => { - const output = prepareOutputForModel(event.output); - const exitCode = event.exitCode ?? 0; - logService.info(`[ShellTool] Command completed (shell integration) with exit code ${exitCode}`); - if (exitCode === 0) { - finish(makeSuccessResult(`Exit code: ${exitCode}\n${output}`)); - } else { - finish(makeFailureResult(`Exit code: ${exitCode}\n${output}`)); - } - })); - - disposables.add(terminalManager.onExit(shell.terminalUri, (exitCode: number) => { - logService.info(`[ShellTool] Shell exited unexpectedly with code ${exitCode}`); - const fullContent = terminalManager.getContent(shell.terminalUri) ?? ''; - const output = prepareOutputForModel(fullContent); - finish(makeFailureResult(`Shell exited with code ${exitCode}\n${output}`)); - })); - - disposables.add(terminalManager.onClaimChanged(shell.terminalUri, (claim) => { - if (claim.kind === TerminalClaimKind.Session && !claim.toolCallId) { - logService.info(`[ShellTool] Continuing in background (claim narrowed)`); - finish(makeSuccessResult('The user chose to continue this command in the background. The terminal is still running.')); - } - })); - - const timer = setTimeout(() => { - logService.warn(`[ShellTool] Command timed out after ${timeoutMs}ms`); - const fullContent = terminalManager.getContent(shell.terminalUri) ?? ''; - const output = prepareOutputForModel(fullContent); - finish(makeFailureResult( - `Command timed out after ${Math.round(timeoutMs / 1000)}s. Partial output:\n${output}`, - 'timeout', - )); - }, timeoutMs); - disposables.add(toDisposable(() => clearTimeout(timer))); - }); -} - -/** - * Fallback: execute a command using a sentinel echo to detect completion. - * Used when shell integration is not available. - */ -async function executeCommandWithSentinel( - shell: IManagedShell, - command: string, - timeoutMs: number, - terminalManager: IAgentHostTerminalManager, - logService: ILogService, -): Promise { - const sentinelId = makeSentinelId(); - const sentinelCmd = buildSentinelCommand(sentinelId, shell.shellType); - const disposables = new DisposableStore(); - - const contentBefore = terminalManager.getContent(shell.terminalUri) ?? ''; - const offsetBefore = contentBefore.length; - - // PTY input uses \r for line endings — the PTY translates to \r\n - const input = `${prefixForHistorySuppression(shell.shellType)}${command}\r${sentinelCmd}\r`; - terminalManager.writeInput(shell.terminalUri, input); - - return new Promise(resolve => { - let resolved = false; - const finish = (result: ToolResultObject) => { - if (resolved) { - return; - } - resolved = true; - disposables.dispose(); - resolve(result); - }; - - const checkForSentinel = () => { - const fullContent = terminalManager.getContent(shell.terminalUri) ?? ''; - // Clamp offset: the terminal manager trims content when it exceeds - // 100k chars (slices to last 80k). If trimming happened after we - // captured offsetBefore, scan from the start of the current buffer. - const clampedOffset = Math.min(offsetBefore, fullContent.length); - const newContent = fullContent.substring(clampedOffset); - const parsed = parseSentinel(newContent, sentinelId); - if (parsed.found) { - const output = prepareOutputForModel(parsed.outputBeforeSentinel); - logService.info(`[ShellTool] Command completed with exit code ${parsed.exitCode}`); - if (parsed.exitCode === 0) { - finish(makeSuccessResult(`Exit code: ${parsed.exitCode}\n${output}`)); - } else { - finish(makeFailureResult(`Exit code: ${parsed.exitCode}\n${output}`)); - } - } - }; - - disposables.add(terminalManager.onData(shell.terminalUri, () => { - checkForSentinel(); - })); - - disposables.add(terminalManager.onExit(shell.terminalUri, (exitCode: number) => { - logService.info(`[ShellTool] Shell exited unexpectedly with code ${exitCode}`); - const fullContent = terminalManager.getContent(shell.terminalUri) ?? ''; - const newContent = fullContent.substring(offsetBefore); - const output = prepareOutputForModel(newContent); - finish(makeFailureResult(`Shell exited with code ${exitCode}\n${output}`)); - })); - - disposables.add(terminalManager.onClaimChanged(shell.terminalUri, (claim) => { - if (claim.kind === TerminalClaimKind.Session && !claim.toolCallId) { - logService.info(`[ShellTool] Continuing in background (claim narrowed)`); - finish(makeSuccessResult('The user chose to continue this command in the background. The terminal is still running.')); - } - })); - - const timer = setTimeout(() => { - logService.warn(`[ShellTool] Command timed out after ${timeoutMs}ms`); - const fullContent = terminalManager.getContent(shell.terminalUri) ?? ''; - const newContent = fullContent.substring(offsetBefore); - const output = prepareOutputForModel(newContent); - finish(makeFailureResult( - `Command timed out after ${Math.round(timeoutMs / 1000)}s. Partial output:\n${output}`, - 'timeout', - )); - }, timeoutMs); - disposables.add(toDisposable(() => clearTimeout(timer))); - - checkForSentinel(); - }); -} - -// --------------------------------------------------------------------------- -// Public factory -// --------------------------------------------------------------------------- - -interface IShellToolArgs { - command: string; - timeout?: number; -} - -interface IWriteShellArgs { - command: string; -} - -interface IReadShellArgs { - shell_id?: string; -} - -interface IShutdownShellArgs { - shell_id?: string; -} - -/** - * Creates the set of SDK {@link Tool} definitions that override the built-in - * Copilot CLI shell tools with PTY-backed implementations. - * - * Returns tools for the platform-appropriate shell (bash or powershell), - * including companion tools (read, write, shutdown, list). - */ -export function createShellTools( - shellManager: ShellManager, - terminalManager: IAgentHostTerminalManager, - logService: ILogService, - // eslint-disable-next-line @typescript-eslint/no-explicit-any -): Tool[] { - const shellType: ShellType = platform.isWindows ? 'powershell' : 'bash'; - - const primaryTool: Tool = { - name: shellType, - description: shellType === 'bash' ? createBashModelDescription(false) : createPowerShellModelDescription(shellType, 'pwsh.exe', false), - parameters: { - type: 'object', - properties: { - command: { type: 'string', description: 'The command to execute' }, - timeout: { type: 'number', description: 'Timeout in milliseconds (default 120000)' }, - }, - required: ['command'], - }, - overridesBuiltInTool: true, - handler: async (args, invocation) => { - const shell = await shellManager.getOrCreateShell( - shellType, - invocation.toolCallId, - invocation.toolCallId, - ); - const timeoutMs = args.timeout ?? DEFAULT_TIMEOUT_MS; - return executeCommandInShell(shell, args.command, timeoutMs, terminalManager, logService); - }, - }; - - const readTool: Tool = { - name: `read_${shellType}`, - description: `Read the latest output from a running ${shellType} shell.`, - parameters: { - type: 'object', - properties: { - shell_id: { type: 'string', description: 'Shell ID to read from (optional; uses latest shell if omitted)' }, - }, - }, - overridesBuiltInTool: true, - skipPermission: true, - handler: (args) => { - const shells = shellManager.listShells(); - const shell = args.shell_id - ? shellManager.getShell(args.shell_id) - : shells[shells.length - 1]; - if (!shell) { - return makeFailureResult('No active shell found.', 'no_shell'); - } - const content = terminalManager.getContent(shell.terminalUri); - if (!content) { - return makeSuccessResult('(no output)'); - } - return makeSuccessResult(prepareOutputForModel(content)); - }, - }; - - const writeTool: Tool = { - name: `write_${shellType}`, - description: `Send input to a running ${shellType} shell (e.g. answering a prompt, sending Ctrl+C).`, - parameters: { - type: 'object', - properties: { - command: { type: 'string', description: 'Text to write to the shell stdin' }, - }, - required: ['command'], - }, - overridesBuiltInTool: true, - skipPermission: true, - handler: (args) => { - const shells = shellManager.listShells(); - const shell = shells[shells.length - 1]; - if (!shell) { - return makeFailureResult('No active shell found.', 'no_shell'); - } - terminalManager.writeInput(shell.terminalUri, args.command); - return makeSuccessResult('Input sent to shell.'); - }, - }; - - const shutdownTool: Tool = { - name: shellType === 'bash' ? 'bash_shutdown' : `${shellType}_shutdown`, - description: `Stop a ${shellType} shell.`, - parameters: { - type: 'object', - properties: { - shell_id: { type: 'string', description: 'Shell ID to stop (optional; stops latest shell if omitted)' }, - }, - }, - overridesBuiltInTool: true, - skipPermission: true, - handler: (args) => { - if (args.shell_id) { - const success = shellManager.shutdownShell(args.shell_id); - return success - ? makeSuccessResult('Shell stopped.') - : makeFailureResult('Shell not found.', 'not_found'); - } - const shells = shellManager.listShells(); - const shell = shells[shells.length - 1]; - if (!shell) { - return makeFailureResult('No active shell to stop.', 'no_shell'); - } - shellManager.shutdownShell(shell.id); - return makeSuccessResult('Shell stopped.'); - }, - }; - - const listTool: Tool> = { - name: `list_${shellType}`, - description: `List active ${shellType} shell instances.`, - parameters: { type: 'object', properties: {} }, - overridesBuiltInTool: true, - skipPermission: true, - handler: () => { - const shells = shellManager.listShells(); - if (shells.length === 0) { - return makeSuccessResult('No active shells.'); - } - const descriptions = shells.map(s => { - const exitCode = terminalManager.getExitCode(s.terminalUri); - const status = exitCode !== undefined ? `exited (${exitCode})` : 'running'; - return `- ${s.id}: ${s.shellType} [${status}]`; - }); - return makeSuccessResult(descriptions.join('\n')); - }, - }; - - return [primaryTool, readTool, writeTool, shutdownTool, listTool]; -} -interface ITerminalSandboxResolvedNetworkDomains { - allowedDomains: string[]; - deniedDomains: string[]; -} - -function isWindowsPowerShell(envShell: string): boolean { - return envShell.endsWith('System32\\WindowsPowerShell\\v1.0\\powershell.exe'); -} - -function createPowerShellModelDescription(shellType: string, shellPath: string, isSandboxEnabled: boolean, networkDomains?: ITerminalSandboxResolvedNetworkDomains): string { - const isWinPwsh = isWindowsPowerShell(shellPath); - const parts = [ - `This tool allows you to execute ${isWinPwsh ? 'Windows PowerShell 5.1' : 'PowerShell'} commands in a persistent terminal session, preserving environment variables, working directory, and other context across multiple commands.`, - '', - 'Command Execution:', - // IMPORTANT: PowerShell 5 does not support `&&` so always re-write them to `;`. Note that - // the behavior of `&&` differs a little from `;` but in general it's fine - isWinPwsh ? '- Use semicolons ; to chain commands on one line, NEVER use && even when asked explicitly' : '- Prefer ; when chaining commands on one line', - '- Prefer pipelines | for object-based data flow', - '- Never create a sub-shell (eg. powershell -c "command") unless explicitly asked', - '', - 'Directory Management:', - '- Prefer relative paths when navigating directories, only use absolute when the path is far away or the current cwd is not expected', - '- By default (mode=sync), shell and cwd are reused by subsequent sync commands', - '- Use $PWD or Get-Location for current directory', - '- Use Push-Location/Pop-Location for directory stack', - '', - 'Program Execution:', - '- Supports .NET, Python, Node.js, and other executables', - '- Install modules via Install-Module, Install-Package', - '- Use Get-Command to verify cmdlet/function availability', - '', - 'Async Mode:', - '- For long-running tasks (e.g., servers), use mode=async', - '- Returns a terminal ID for checking status and runtime later', - '- Use Start-Job for background PowerShell jobs', - '', - `Use write_${shellType} to send commands or input to a terminal session.`, - ]; - - if (isSandboxEnabled) { - parts.push(...createSandboxLines(networkDomains)); - } - - parts.push( - '', - 'Output Management:', - '- Output is automatically truncated if longer than 60KB to prevent context overflow', - '- Use Select-Object, Where-Object, Format-Table to filter output', - '- Use -First/-Last parameters to limit results', - '- For pager commands, add | Out-String or | Format-List', - '', - 'Best Practices:', - '- Use proper cmdlet names instead of aliases in scripts', - '- Quote paths with spaces: "C:\\Path With Spaces"', - '- Prefer PowerShell cmdlets over external commands when available', - '- Prefer idiomatic PowerShell like Get-ChildItem instead of dir or ls for file listings', - '- Use Test-Path to check file/directory existence', - '- Be specific with Select-Object properties to avoid excessive output', - '- Avoid printing credentials unless absolutely required', - '', - 'Interactive Input Handling:', - '- When a terminal command is waiting for interactive input, do NOT suggest alternatives or ask the user whether to proceed. Instead, use the ask_user tool to collect the needed values from the user, then send them.', - `- Send exactly one answer per prompt using write_${shellType}. Never send multiple answers in a single send.`, - `- After each send, call read_${shellType} to read the next prompt before sending the next answer.`, - '- Continue one prompt at a time until the command finishes.', - ); - - return parts.join('\n'); -} - -function createSandboxLines(networkDomains?: ITerminalSandboxResolvedNetworkDomains): string[] { - const lines = [ - '', - 'Sandboxing:', - '- ATTENTION: Terminal sandboxing is enabled, commands run in a sandbox by default', - '- When executing commands within the sandboxed environment, all operations requiring a temporary directory must utilize the $TMPDIR environment variable. The /tmp directory is not guaranteed to be accessible or writable and must be avoided', - '- Tools and scripts should respect the TMPDIR environment variable, which is automatically set to an appropriate path within the sandbox', - '- When a command fails due to sandbox restrictions, immediately re-run it with requestUnsandboxedExecution=true. Do NOT ask the user for permission — setting this flag automatically shows a confirmation prompt to the user', - '- Only set requestUnsandboxedExecution=true when there is evidence of failures caused by the sandbox, e.g. \'Operation not permitted\' errors, network failures, or file access errors, etc', - '- Do NOT set requestUnsandboxedExecution=true without first executing the command in sandbox mode. Always try the command in the sandbox first, and only set requestUnsandboxedExecution=true when retrying after that sandboxed execution failed due to sandbox restrictions.', - '- When setting requestUnsandboxedExecution=true, also provide requestUnsandboxedExecutionReason explaining why the command needs unsandboxed access', - ]; - if (networkDomains) { - const deniedSet = new Set(networkDomains.deniedDomains); - const effectiveAllowed = networkDomains.allowedDomains.filter(d => !deniedSet.has(d)); - if (effectiveAllowed.length === 0) { - lines.push('- All network access is blocked in the sandbox'); - } else { - lines.push(`- Only the following domains are accessible in the sandbox (all other network access is blocked): ${effectiveAllowed.join(', ')}`); - } - if (networkDomains.deniedDomains.length > 0) { - lines.push(`- The following domains are explicitly blocked in the sandbox: ${networkDomains.deniedDomains.join(', ')}`); - } - } - return lines; -} - -function createGenericDescription(shellType: string, isSandboxEnabled: boolean, networkDomains?: ITerminalSandboxResolvedNetworkDomains): string { - const parts = [` -Command Execution: -- Use && to chain simple commands on one line -- Prefer pipelines | over temporary files for data flow -- Never create a sub-shell (eg. bash -c "command") unless explicitly asked - -Directory Management: -- Prefer relative paths when navigating directories, only use absolute when the path is far away or the current cwd is not expected -- By default (mode=sync), shell and cwd are reused by subsequent sync commands -- Use $PWD for current directory references -- Consider using pushd/popd for directory stack management -- Supports directory shortcuts like ~ and - - -Program Execution: -- Supports Python, Node.js, and other executables -- Install packages via package managers (brew, apt, etc.) -- Use which or command -v to verify command availability - -Async Mode: -- For long-running tasks (e.g., servers), use mode=async -- Returns a terminal ID for checking status and runtime later - -Use write_${shellType} to send commands or input to a terminal session.`]; - - if (isSandboxEnabled) { - parts.push(createSandboxLines(networkDomains).join('\n')); - } - - parts.push(` - -Output Management: -- Output is automatically truncated if longer than 60KB to prevent context overflow -- Use head, tail, grep, awk to filter and limit output size -- For pager commands, disable paging: git --no-pager or add | cat -- Use wc -l to count lines before displaying large outputs - -Best Practices: -- Quote variables: "$var" instead of $var to handle spaces -- Use find with -exec or xargs for file operations -- Be specific with commands to avoid excessive output -- Avoid printing credentials unless absolutely required -- NEVER run sleep or similar wait commands in a terminal. You will be automatically notified on your next turn when async terminal commands or timed-out sync commands complete or need input. Do NOT poll for completion. - -Interactive Input Handling: -- When a terminal command is waiting for interactive input, do NOT suggest alternatives or ask the user whether to proceed. Instead, use the ask_user tool to collect the needed values from the user, then send them. -- Send exactly one answer per prompt using write_${shellType}. Never send multiple answers in a single send. -- After each send, call read_${shellType} to read the next prompt before sending the next answer. -- Continue one prompt at a time until the command finishes.`); - - return parts.join(''); -} - -function createBashModelDescription(isSandboxEnabled: boolean, networkDomains?: ITerminalSandboxResolvedNetworkDomains): string { - return [ - 'This tool allows you to execute shell commands in a persistent bash terminal session, preserving environment variables, working directory, and other context across multiple commands.', - createGenericDescription('bash', isSandboxEnabled, networkDomains), - '- Use [[ ]] for conditional tests instead of [ ]', - '- Prefer $() over backticks for command substitution', - '- Use set -e at start of complex commands to exit on errors' - ].join('\n'); -} diff --git a/src/vs/platform/agentHost/node/copilot/copilotToolDisplay.ts b/src/vs/platform/agentHost/node/copilot/copilotToolDisplay.ts deleted file mode 100644 index 4a5d8ac1..00000000 --- a/src/vs/platform/agentHost/node/copilot/copilotToolDisplay.ts +++ /dev/null @@ -1,743 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import type { PermissionRequest } from '@github/copilot-sdk'; -import { hasKey } from '../../../../base/common/types.js'; -import { URI } from '../../../../base/common/uri.js'; -import { appendEscapedMarkdownInlineCode, escapeMarkdownLinkLabel } from '../../../../base/common/htmlContent.js'; -import { hash } from '../../../../base/common/hash.js'; -import { localize } from '../../../../nls.js'; -import type { IAgentToolPendingConfirmationSignal } from '../../common/agentService.js'; -import { stripRedundantCdPrefix } from '../../common/commandLineHelpers.js'; -import { StringOrMarkdown } from '../../common/state/protocol/state.js'; -import { basename } from '../../../../base/common/resources.js'; - -// ============================================================================= -// Copilot CLI built-in tool interfaces -// -// The Copilot CLI (via @github/copilot) exposes these built-in tools. Tool names -// and parameter shapes are not typed in the SDK -- they come from the CLI server -// as plain strings. These interfaces are derived from observing the CLI's actual -// tool events and the ShellConfig class in @github/copilot. -// -// Shell tool names follow a pattern per ShellConfig: -// shellToolName, readShellToolName, writeShellToolName, -// stopShellToolName, listShellsToolName -// For bash: bash, read_bash, write_bash, bash_shutdown, list_bash -// For powershell: powershell, read_powershell, write_powershell, list_powershell -// ============================================================================= - -/** - * Known Copilot CLI tool names. These are the `toolName` values that appear - * in `tool.execution_start` events from the SDK. - */ -const enum CopilotToolName { - Bash = 'bash', - ReadBash = 'read_bash', - WriteBash = 'write_bash', - BashShutdown = 'bash_shutdown', - ListBash = 'list_bash', - - PowerShell = 'powershell', - ReadPowerShell = 'read_powershell', - WritePowerShell = 'write_powershell', - ListPowerShell = 'list_powershell', - - View = 'view', - Edit = 'edit', - Create = 'create', - Grep = 'grep', - Glob = 'glob', - ApplyPatch = 'apply_patch', - GitApplyPatch = 'git_apply_patch', - WebSearch = 'web_search', - WebFetch = 'web_fetch', - AskUser = 'ask_user', - ReportIntent = 'report_intent', - Skill = 'skill', - ExitPlanMode = 'exit_plan_mode', -} - -/** Parameters for the `bash` / `powershell` shell tools. */ -interface ICopilotShellToolArgs { - command: string; - timeout?: number; -} - -/** Parameters for file tools (`view`, `edit`, `create`). */ -interface ICopilotFileToolArgs { - path: string; -} - -/** - * Parameters for the `view` tool. The Copilot CLI accepts an optional - * `view_range: [startLine, endLine]` (1-based, inclusive). `endLine` may be - * `-1` to mean "to end of file". - */ -interface ICopilotViewToolArgs extends ICopilotFileToolArgs { - view_range?: number[]; -} - -/** - * Normalizes a `view_range` array. Returns `undefined` unless the array has - * exactly two integer elements with `startLine >= 0`. `endLine === -1` is - * preserved as the "to end of file" sentinel; otherwise `endLine` must be - * `>= startLine`. - */ -function formatViewRange(view_range: number[] | undefined): { startLine: number; endLine: number } | undefined { - if (!Array.isArray(view_range) || view_range.length !== 2) { - return undefined; - } - const [startLine, endLine] = view_range; - if (!Number.isInteger(startLine) || !Number.isInteger(endLine)) { - return undefined; - } - if (startLine < 0) { - return undefined; - } - if (endLine !== -1 && endLine < startLine) { - return undefined; - } - return { startLine, endLine }; -} - -/** Parameters for the `grep` tool. */ -interface ICopilotGrepToolArgs { - pattern: string; - path?: string; - include?: string; -} - -/** Parameters for the `glob` tool. */ -interface ICopilotGlobToolArgs { - pattern: string; - path?: string; -} - -/** Set of tool names that perform file edits. */ -const EDIT_TOOL_NAMES: ReadonlySet = new Set([ - CopilotToolName.Edit, - CopilotToolName.Create, - CopilotToolName.ApplyPatch, - CopilotToolName.GitApplyPatch, -]); - -/** - * Returns true if the tool modifies files on disk. - */ -export function isEditTool(toolName: string): boolean { - return EDIT_TOOL_NAMES.has(toolName); -} - -/** - * Extracts the target file path from an edit tool's parameters, if available. - */ -export function getEditFilePath(parameters: unknown): string | undefined { - if (typeof parameters === 'string') { - try { - parameters = JSON.parse(parameters); - } catch { - return undefined; - } - } - - const args = parameters as ICopilotFileToolArgs | undefined; - return args?.path; -} - -/** Set of tool names that execute shell commands (bash or powershell). */ -const SHELL_TOOL_NAMES: ReadonlySet = new Set([ - CopilotToolName.Bash, - CopilotToolName.PowerShell, -]); - -/** Set of tool names that write input to an interactive shell session. */ -const WRITE_SHELL_TOOL_NAMES: ReadonlySet = new Set([ - CopilotToolName.WriteBash, - CopilotToolName.WritePowerShell, -]); - -/** Set of tool names that read output from an interactive shell session. */ -const READ_SHELL_TOOL_NAMES: ReadonlySet = new Set([ - CopilotToolName.ReadBash, - CopilotToolName.ReadPowerShell, -]); - -/** Set of tool names that spawn subagent sessions. */ -const SUBAGENT_TOOL_NAMES: ReadonlySet = new Set([ - 'task', -]); - -/** - * Tools that should not be shown to the user. These are internal tools - * used by the CLI for its own purposes (e.g., reporting intent to the model). - * - * `skill` is hidden because the SDK already emits a richer `skill.invoked` - * lifecycle event with the resolved skill file path; the agent session - * synthesizes a tool-start/complete pair from that event so the UI can - * render a clickable file link instead of just the skill name. See - * {@link synthesizeSkillToolCall}. - */ -const HIDDEN_TOOL_NAMES: ReadonlySet = new Set([ - CopilotToolName.ReportIntent, - CopilotToolName.Skill, -]); - -/** - * Returns true if the tool should be hidden from the UI. - */ -export function isHiddenTool(toolName: string): boolean { - return HIDDEN_TOOL_NAMES.has(toolName); -} - -/** - * Returns true if the tool executes shell commands. - */ -export function isShellTool(toolName: string): boolean { - return SHELL_TOOL_NAMES.has(toolName); -} - -// ============================================================================= -// Display helpers -// -// These functions translate Copilot CLI tool names and arguments into -// human-readable display strings. This logic lives here -- in the agent-host -// process -- so the IPC protocol stays agent-agnostic; the renderer never needs -// to know about specific tool names. -// ============================================================================= - -function truncate(text: string, maxLength: number): string { - return text.length > maxLength ? text.substring(0, maxLength - 3) + '...' : text; -} - -/** - * Formats a file path as a markdown link `[](file-uri)` so it renders - * as a clickable file widget in the chat UI. - */ -function formatPathAsMarkdownLink(path: string): string { - const uri = URI.file(path); - return `[${basename(uri)}](${uri})`; -} - -/** - * Wraps a localized message containing a markdown file link into a - * `StringOrMarkdown` object so the renderer treats it as markdown. - */ -function md(value: string): StringOrMarkdown { - return { markdown: value }; -} - -export function getToolDisplayName(toolName: string): string { - switch (toolName) { - case CopilotToolName.Bash: return localize('toolName.bash', "Bash"); - case CopilotToolName.PowerShell: return localize('toolName.powershell', "PowerShell"); - case CopilotToolName.ReadBash: - case CopilotToolName.ReadPowerShell: return localize('toolName.readShell', "Read Shell Output"); - case CopilotToolName.WriteBash: - case CopilotToolName.WritePowerShell: return localize('toolName.writeShell', "Write Shell Input"); - case CopilotToolName.BashShutdown: return localize('toolName.bashShutdown', "Stop Shell"); - case CopilotToolName.ListBash: - case CopilotToolName.ListPowerShell: return localize('toolName.listShells', "List Shells"); - case CopilotToolName.View: return localize('toolName.view', "View File"); - case CopilotToolName.Edit: return localize('toolName.edit', "Edit File"); - case CopilotToolName.Create: return localize('toolName.create', "Create File"); - case CopilotToolName.Grep: return localize('toolName.grep', "Search"); - case CopilotToolName.Glob: return localize('toolName.glob', "Find Files"); - case CopilotToolName.ApplyPatch: - case CopilotToolName.GitApplyPatch: return localize('toolName.patch', "Patch"); - case CopilotToolName.WebSearch: return localize('toolName.webSearch', "Web Search"); - case CopilotToolName.WebFetch: return localize('toolName.webFetch', "Web Fetch"); - case CopilotToolName.AskUser: return localize('toolName.askUser', "Ask User"); - case CopilotToolName.ExitPlanMode: return localize('toolName.exitPlanMode', "Plan"); - default: return toolName; - } -} - -export function getInvocationMessage(toolName: string, displayName: string, parameters: Record | undefined): StringOrMarkdown { - if (SHELL_TOOL_NAMES.has(toolName)) { - const args = parameters as ICopilotShellToolArgs | undefined; - if (args?.command) { - const firstLine = args.command.split('\n')[0]; - return md(localize('toolInvoke.shellCmd', "Running {0}", appendEscapedMarkdownInlineCode(truncate(firstLine, 80)))); - } - return localize('toolInvoke.shell', "Running {0} command", displayName); - } - - if (WRITE_SHELL_TOOL_NAMES.has(toolName)) { - const args = parameters as ICopilotShellToolArgs | undefined; - if (args?.command) { - const firstLine = args.command.split('\n')[0]; - return md(localize('toolInvoke.writeShellCmd', "Sending {0} to shell", appendEscapedMarkdownInlineCode(truncate(firstLine, 80)))); - } - return localize('toolInvoke.writeShell', "Sending input to shell"); - } - - if (READ_SHELL_TOOL_NAMES.has(toolName)) { - return localize('toolInvoke.readShell', "Reading shell output"); - } - - switch (toolName) { - case CopilotToolName.View: { - const args = parameters as ICopilotViewToolArgs | undefined; - if (args?.path) { - const link = formatPathAsMarkdownLink(args.path); - const range = formatViewRange(args.view_range); - if (range) { - if (range.endLine === -1) { - return md(localize('toolInvoke.viewFileFromLine', "Reading {0}, line {1} to the end", link, range.startLine)); - } - if (range.endLine !== range.startLine) { - return md(localize('toolInvoke.viewFileRange', "Reading {0}, lines {1} to {2}", link, range.startLine, range.endLine)); - } - return md(localize('toolInvoke.viewFileLine', "Reading {0}, line {1}", link, range.startLine)); - } - return md(localize('toolInvoke.viewFile', "Reading {0}", link)); - } - return localize('toolInvoke.view', "Reading file"); - } - case CopilotToolName.Edit: { - const args = parameters as ICopilotFileToolArgs | undefined; - if (args?.path) { - return md(localize('toolInvoke.editFile', "Editing {0}", formatPathAsMarkdownLink(args.path))); - } - return localize('toolInvoke.edit', "Editing file"); - } - case CopilotToolName.Create: { - const args = parameters as ICopilotFileToolArgs | undefined; - if (args?.path) { - return md(localize('toolInvoke.createFile', "Creating {0}", formatPathAsMarkdownLink(args.path))); - } - return localize('toolInvoke.create', "Creating file"); - } - case CopilotToolName.Grep: { - const args = parameters as ICopilotGrepToolArgs | undefined; - if (args?.pattern) { - return md(localize('toolInvoke.grepPattern', "Searching for {0}", appendEscapedMarkdownInlineCode(truncate(args.pattern, 80)))); - } - return localize('toolInvoke.grep', "Searching files"); - } - case CopilotToolName.Glob: { - const args = parameters as ICopilotGlobToolArgs | undefined; - if (args?.pattern) { - return md(localize('toolInvoke.globPattern', "Finding files matching {0}", appendEscapedMarkdownInlineCode(truncate(args.pattern, 80)))); - } - return localize('toolInvoke.glob', "Finding files"); - } - case CopilotToolName.ExitPlanMode: - return localize('toolInvoke.exitPlanMode', "Presenting plan"); - default: - return localize('toolInvoke.generic', "Using \"{0}\"", displayName); - } -} - -export function getPastTenseMessage(toolName: string, displayName: string, parameters: Record | undefined, success: boolean): StringOrMarkdown { - if (!success) { - return localize('toolComplete.failed', "\"{0}\" failed", displayName); - } - - if (SHELL_TOOL_NAMES.has(toolName)) { - const args = parameters as ICopilotShellToolArgs | undefined; - if (args?.command) { - const firstLine = args.command.split('\n')[0]; - return md(localize('toolComplete.shellCmd', "Ran {0}", appendEscapedMarkdownInlineCode(truncate(firstLine, 80)))); - } - return localize('toolComplete.shell', "Ran {0} command", displayName); - } - - if (WRITE_SHELL_TOOL_NAMES.has(toolName)) { - const args = parameters as ICopilotShellToolArgs | undefined; - if (args?.command) { - const firstLine = args.command.split('\n')[0]; - return md(localize('toolComplete.writeShellCmd', "Sent {0} to shell", appendEscapedMarkdownInlineCode(truncate(firstLine, 80)))); - } - return localize('toolComplete.writeShell', "Sent input to shell"); - } - - if (READ_SHELL_TOOL_NAMES.has(toolName)) { - return localize('toolComplete.readShell', "Read shell output"); - } - - switch (toolName) { - case CopilotToolName.View: { - const args = parameters as ICopilotViewToolArgs | undefined; - if (args?.path) { - const link = formatPathAsMarkdownLink(args.path); - const range = formatViewRange(args.view_range); - if (range) { - if (range.endLine === -1) { - return md(localize('toolComplete.viewFileFromLine', "Read {0}, line {1} to the end", link, range.startLine)); - } - if (range.endLine !== range.startLine) { - return md(localize('toolComplete.viewFileRange', "Read {0}, lines {1} to {2}", link, range.startLine, range.endLine)); - } - return md(localize('toolComplete.viewFileLine', "Read {0}, line {1}", link, range.startLine)); - } - return md(localize('toolComplete.viewFile', "Read {0}", link)); - } - return localize('toolComplete.view', "Read file"); - } - case CopilotToolName.Edit: { - const args = parameters as ICopilotFileToolArgs | undefined; - if (args?.path) { - return md(localize('toolComplete.editFile', "Edited {0}", formatPathAsMarkdownLink(args.path))); - } - return localize('toolComplete.edit', "Edited file"); - } - case CopilotToolName.Create: { - const args = parameters as ICopilotFileToolArgs | undefined; - if (args?.path) { - return md(localize('toolComplete.createFile', "Created {0}", formatPathAsMarkdownLink(args.path))); - } - return localize('toolComplete.create', "Created file"); - } - case CopilotToolName.Grep: { - const args = parameters as ICopilotGrepToolArgs | undefined; - if (args?.pattern) { - return md(localize('toolComplete.grepPattern', "Searched for {0}", appendEscapedMarkdownInlineCode(truncate(args.pattern, 80)))); - } - return localize('toolComplete.grep', "Searched files"); - } - case CopilotToolName.Glob: { - const args = parameters as ICopilotGlobToolArgs | undefined; - if (args?.pattern) { - return md(localize('toolComplete.globPattern', "Found files matching {0}", appendEscapedMarkdownInlineCode(truncate(args.pattern, 80)))); - } - return localize('toolComplete.glob', "Found files"); - } - case CopilotToolName.ExitPlanMode: - return localize('toolComplete.exitPlanMode', "Exited plan mode"); - default: - return localize('toolComplete.generic', "Used \"{0}\"", displayName); - } -} - -// ============================================================================= -// Skill event synthesis -// -// The Copilot SDK emits a `skill` tool call (which we hide) and, separately, a -// `skill.invoked` lifecycle event with the resolved skill file path. We turn -// the latter into a synthesized tool-start/complete pair so clients can render -// a clickable file link to the SKILL.md the agent loaded -- matching the -// existing `view`-tool display style. Live and replay paths share this helper -// so they stay in lock-step (see also the mirrored-pair gotcha for tool-call -// display in this file). -// ============================================================================= - -/** Subset of the SDK's `skill.invoked` payload that the synth helper needs. */ -export interface ICopilotSkillInvokedData { - readonly name: string; - readonly path?: string; - readonly description?: string; -} - -/** - * Builds a stable synthetic tool call id for a `skill.invoked` event so - * reconnect/replay produces the same id as the original live emit. The id - * is used unencoded as a path segment (e.g. by `ChatResponseResource.createUri`), - * so it must not contain characters like `/` -- we hash any fallback values - * that could carry filesystem paths or arbitrary text. - */ -export function getSkillSyntheticToolCallId(eventId: string | undefined, data: ICopilotSkillInvokedData): string { - if (eventId) { - return `synth-skill-${eventId}`; - } - const seed = data.path ?? data.name; - return `synth-skill-${hash(seed).toString(16)}`; -} - -/** - * Synthesized data for a `skill.invoked` tool call. Used by both the live - * session handler and the history-replay mapper so the two paths render - * identically. Callers wrap this into protocol actions or {@link Turn} - * data; this helper avoids any agent-protocol coupling. - */ -export interface ISynthesizedSkillToolCall { - readonly toolCallId: string; - readonly toolName: string; - readonly displayName: string; - readonly invocationMessage: StringOrMarkdown; - readonly pastTenseMessage: StringOrMarkdown; -} - -/** - * Synthesizes the data for a `skill.invoked` tool call (a tool-start / - * tool-complete pair). Returns the constituent fields without coupling to - * any specific event or action shape — callers compose them into protocol - * actions or {@link Turn} entries as needed. - */ -export function synthesizeSkillToolCall( - data: ICopilotSkillInvokedData, - eventId: string | undefined, -): ISynthesizedSkillToolCall { - const toolCallId = getSkillSyntheticToolCallId(eventId, data); - const displayName = localize('toolName.skill', "Read Skill"); - // Use the skill name as the link text rather than the basename: every skill - // file is named SKILL.md, so `Reading skill [plan]` reads better than the - // always-identical `Reading skill [SKILL.md]`. The client may further upgrade - // this link to a rich pill based on the `SKILL.md` basename. Skill names and - // paths come from the SDK / agent host and are escaped to prevent markdown - // injection from a malicious skill author. - // Escape only the characters that would break out of markdown link text - // syntax (`\` and `]`); a full markdown escape would leave visible - // backslashes in renderers (like the skill pill) that extract link text - // without re-parsing markdown. - const escapedName = escapeMarkdownLinkLabel(data.name); - const skillLink = data.path ? `[${escapedName}](${URI.file(data.path)})` : undefined; - const invocationMessage: StringOrMarkdown = skillLink - ? md(localize('toolInvoke.skill', "Reading skill {0}", skillLink)) - : localize('toolInvoke.skillName', "Reading skill {0}", data.name); - const pastTenseMessage: StringOrMarkdown = skillLink - ? md(localize('toolComplete.skill', "Read skill {0}", skillLink)) - : localize('toolComplete.skillName', "Read skill {0}", data.name); - return { - toolCallId, - toolName: CopilotToolName.Skill, - displayName, - invocationMessage, - pastTenseMessage, - }; -} - -export function getToolInputString(toolName: string, parameters: Record | undefined, rawArguments: string | undefined): string | undefined { - if (!parameters && !rawArguments) { - return undefined; - } - - if (SHELL_TOOL_NAMES.has(toolName) || WRITE_SHELL_TOOL_NAMES.has(toolName)) { - const args = parameters as ICopilotShellToolArgs | undefined; - // Custom tool overrides may wrap the args: { kind: 'custom-tool', args: { command: '...' } } - const command = args?.command ?? (args as Record | undefined)?.args; - if (typeof command === 'string') { - return command; - } - if (typeof command === 'object' && command !== null && hasKey(command, { command: true })) { - return (command as ICopilotShellToolArgs).command; - } - return rawArguments; - } - - switch (toolName) { - case CopilotToolName.Grep: { - const args = parameters as ICopilotGrepToolArgs | undefined; - return args?.pattern ?? rawArguments; - } - default: - // For other tools, show the formatted JSON arguments - if (parameters) { - try { - return JSON.stringify(parameters, null, 2); - } catch { - return rawArguments; - } - } - return rawArguments; - } -} - -/** - * Returns a rendering hint for the given tool. Currently only 'terminal' is - * supported, which tells the renderer to display the tool as a terminal command - * block. - */ -export function getToolKind(toolName: string): 'terminal' | 'subagent' | undefined { - if (SHELL_TOOL_NAMES.has(toolName)) { - return 'terminal'; - } - if (SUBAGENT_TOOL_NAMES.has(toolName)) { - return 'subagent'; - } - return undefined; -} - -/** - * Extracts subagent metadata (agent name, description) from the parsed - * arguments of a Copilot SDK subagent tool call. The Copilot `task` tool - * uses `agent_type` (snake_case), which this normalizes into the generic - * `subagentAgentName` / `subagentDescription` shape used by the rest of the - * agent host code. - * - * Only call this for tools where {@link getToolKind} returned `'subagent'`. - */ -export function getSubagentMetadata(parameters: Record | undefined): { agentName?: string; description?: string } { - if (!parameters) { - return {}; - } - const agentName = typeof parameters.agent_type === 'string' && parameters.agent_type.length > 0 - ? parameters.agent_type - : undefined; - const description = typeof parameters.description === 'string' && parameters.description.length > 0 - ? parameters.description - : undefined; - return { agentName, description }; -} - -/** - * Returns the shell language identifier for syntax highlighting. - * Used when creating terminal tool-specific data for the renderer. - */ -export function getShellLanguage(toolName: string): string { - switch (toolName) { - case CopilotToolName.PowerShell: - case CopilotToolName.WritePowerShell: - case CopilotToolName.ReadPowerShell: return 'powershell'; - default: return 'shellscript'; - } -} - -// ============================================================================= -// Permission display -// -// Derives display fields from SDK permission requests for the tool -// confirmation UI. Colocated with the tool-start display helpers above so -// that formatting utilities (formatPathAsMarkdownLink, md, etc.) are shared. -// ============================================================================= - -export function tryStringify(value: unknown): string | undefined { - try { - return JSON.stringify(value); - } catch { - return undefined; - } -} - -/** - * Extends the SDK's {@link PermissionRequest} with the known extra properties - * that arrive on the index-signature. The SDK defines these as `[key: string]: unknown` - * so this interface adds proper types for the fields we actually use. - */ -export interface ITypedPermissionRequest extends PermissionRequest { - /** File path — set for `read` permission requests. */ - path?: string; - /** File path — set for `write` permission requests. */ - fileName?: string; - /** Full shell command text — set for `shell` permission requests. */ - fullCommandText?: string; - /** Human-readable intention describing the operation. */ - intention?: string; - /** MCP server name — set for `mcp` permission requests. */ - serverName?: string; - /** Tool name — set for `mcp` and `custom-tool` permission requests. */ - toolName?: string; - /** Tool arguments — set for `custom-tool` permission requests. */ - args?: Record; - /** URL — set for `url` permission requests. */ - url?: string; - /** Unified diff of the proposed change — set for `write` permission requests. */ - diff?: string; - /** New file contents that will be written — set for `write` permission requests. */ - newFileContents?: string; -} - -/** Safely extract a string value from an SDK field that may be `unknown` at runtime. */ -function str(value: unknown): string | undefined { - return typeof value === 'string' ? value : undefined; -} - -/** - * Derives display fields from a permission request for the tool confirmation UI. - */ -export function getPermissionDisplay(request: ITypedPermissionRequest, workingDirectory?: URI): { - confirmationTitle: string; - invocationMessage: StringOrMarkdown; - toolInput?: string; - /** Normalized permission kind for auto-approval routing. */ - permissionKind: IAgentToolPendingConfirmationSignal['permissionKind']; - /** File path extracted from the request. */ - permissionPath?: string; -} { - const path = str(request.path) ?? str(request.fileName); - const fullCommandText = str(request.fullCommandText); - const intention = str(request.intention); - const serverName = str(request.serverName); - const toolName = str(request.toolName); - - switch (request.kind) { - case 'shell': { - // Strip a redundant `cd && …` prefix so the - // confirmation dialog shows the simplified command. - const shellParams: Record | undefined = fullCommandText ? { command: fullCommandText } : undefined; - stripRedundantCdPrefix(CopilotToolName.Bash, shellParams, workingDirectory); - const cleanedCommand = typeof shellParams?.command === 'string' ? shellParams.command : fullCommandText; - return { - confirmationTitle: localize('copilot.permission.shell.title', "Run in terminal?"), - invocationMessage: intention ?? getInvocationMessage(CopilotToolName.Bash, getToolDisplayName(CopilotToolName.Bash), cleanedCommand ? { command: cleanedCommand } : undefined), - toolInput: cleanedCommand, - permissionKind: 'shell', - permissionPath: path, - }; - } - case 'custom-tool': { - // Custom tool overrides (e.g. our shell tool). Extract the actual - // tool args from the SDK's wrapper envelope. - const args = typeof request.args === 'object' && request.args !== null ? request.args as Record : undefined; - const sdkToolName = str(request.toolName); - if (args && sdkToolName && isShellTool(sdkToolName) && typeof args.command === 'string') { - stripRedundantCdPrefix(sdkToolName, args, workingDirectory); - const command = args.command as string; - return { - confirmationTitle: localize('copilot.permission.shell.title', "Run in terminal?"), - invocationMessage: getInvocationMessage(sdkToolName, getToolDisplayName(sdkToolName), { command }), - toolInput: command, - permissionKind: 'shell', - permissionPath: path, - }; - } - return { - confirmationTitle: localize('copilot.permission.default.title', "Allow tool call?"), - invocationMessage: md(localize('copilot.permission.default.message', "Allow the model to call {0}?", appendEscapedMarkdownInlineCode(toolName ?? request.kind))), - toolInput: args ? tryStringify(args) : tryStringify(request), - permissionKind: request.kind, - permissionPath: path, - }; - } - case 'write': - return { - confirmationTitle: localize('copilot.permission.write.title', "Write file?"), - invocationMessage: getInvocationMessage(CopilotToolName.Edit, getToolDisplayName(CopilotToolName.Edit), path ? { path } : undefined), - toolInput: tryStringify(path ? { path } : request) ?? undefined, - permissionKind: 'write', - permissionPath: path, - }; - case 'mcp': { - const title = toolName ?? localize('copilot.permission.mcp.defaultTool', "MCP Tool"); - return { - confirmationTitle: serverName - ? localize('copilot.permission.mcp.title', "Allow tool from {0}?", serverName) - : localize('copilot.permission.default.title', "Allow tool call?"), - invocationMessage: serverName ? `${serverName}: ${title}` : title, - toolInput: tryStringify({ serverName, toolName }) ?? undefined, - permissionKind: 'mcp', - permissionPath: path, - }; - } - case 'read': - return { - confirmationTitle: localize('copilot.permission.read.title', "Read file?"), - invocationMessage: intention ?? getInvocationMessage(CopilotToolName.View, getToolDisplayName(CopilotToolName.View), path ? { path } : undefined), - toolInput: tryStringify(path ? { path, intention } : request) ?? undefined, - permissionKind: 'read', - permissionPath: path, - }; - case 'url': { - const url = str(request.url); - // Parse through URL for punycode escaping, but preserve the raw value if parsing fails. - const normalizedUrl = url ? (URL.canParse(url) ? new URL(url).href : url) : undefined; - return { - confirmationTitle: localize('copilot.permission.url.title', "Fetch URL?"), - invocationMessage: md(localize('copilot.permission.url.message', "Allow fetching web content?")), - toolInput: normalizedUrl ? JSON.stringify({ url: normalizedUrl }) : undefined, - permissionKind: 'url', - }; - } - default: - return { - confirmationTitle: localize('copilot.permission.default.title', "Allow tool call?"), - invocationMessage: md(localize('copilot.permission.default.message', "Allow the model to call {0}?", appendEscapedMarkdownInlineCode(toolName ?? request.kind))), - toolInput: tryStringify(request) ?? undefined, - permissionKind: request.kind, - permissionPath: path, - }; - } -} diff --git a/src/vs/platform/agentHost/node/copilot/fileEditTracker.ts b/src/vs/platform/agentHost/node/copilot/fileEditTracker.ts deleted file mode 100644 index 3fbd7db2..00000000 --- a/src/vs/platform/agentHost/node/copilot/fileEditTracker.ts +++ /dev/null @@ -1,197 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { decodeHex, encodeHex, VSBuffer } from '../../../../base/common/buffer.js'; -import { basename } from '../../../../base/common/path.js'; -import { URI } from '../../../../base/common/uri.js'; -import { IFileService } from '../../../files/common/files.js'; -import { ILogService } from '../../../log/common/log.js'; -import { IDiffComputeService } from '../../common/diffComputeService.js'; -import { ISessionDatabase } from '../../common/sessionDataService.js'; -import { FileEditKind, ToolResultContentType, type ToolResultFileEditContent } from '../../common/state/sessionState.js'; - -const SESSION_DB_SCHEME = 'session-db'; - -/** - * Builds a `session-db:` URI that references a file-edit content blob - * stored in the session database. Parsed by {@link parseSessionDbUri}. - */ -export function buildSessionDbUri(sessionUri: string, toolCallId: string, filePath: string, part: 'before' | 'after'): string { - return URI.from({ - scheme: SESSION_DB_SCHEME, - authority: encodeHex(VSBuffer.fromString(sessionUri)).toString(), - path: `/${encodeURIComponent(toolCallId)}/${encodeHex(VSBuffer.fromString(filePath))}/${part}/${basename(filePath)}`, - }).toString(); -} - -/** Parsed fields from a `session-db:` content URI. */ -export interface ISessionDbUriFields { - sessionUri: string; - toolCallId: string; - filePath: string; - part: 'before' | 'after'; -} - -/** - * Parses a `session-db:` URI produced by {@link buildSessionDbUri}. - * Returns `undefined` if the URI is not a valid `session-db:` URI. - */ -export function parseSessionDbUri(raw: string): ISessionDbUriFields | undefined { - const parsed = URI.parse(raw); - if (parsed.scheme !== SESSION_DB_SCHEME) { - return undefined; - } - const [, toolCallId, filePath, part] = parsed.path.split('/'); - if (!toolCallId || !filePath || (part !== 'before' && part !== 'after')) { - return undefined; - } - try { - return { - sessionUri: decodeHex(parsed.authority).toString(), - toolCallId: decodeURIComponent(toolCallId), - filePath: decodeHex(filePath).toString(), - part - }; - } catch { - return undefined; - } -} - -/** - * Tracks file edits made by tools in a session by snapshotting file content - * before and after each edit tool invocation, persisting snapshots into the - * session database. - */ -export class FileEditTracker { - - /** - * Pending edits keyed by file path. The `onPreToolUse` hook stores - * entries here; `completeEdit` pops them when the tool finishes. - */ - private readonly _pendingEdits = new Map }>(); - - /** - * Completed edits keyed by file path. The `onPostToolUse` hook stores - * entries here; `takeCompletedEdit` retrieves them from the - * `onToolComplete` handler and persists to the database. - */ - private readonly _completedEdits = new Map(); - - constructor( - private readonly _sessionUri: string, - private readonly _db: ISessionDatabase, - @IFileService private readonly _fileService: IFileService, - @ILogService private readonly _logService: ILogService, - @IDiffComputeService private readonly _diffComputeService: IDiffComputeService, - ) { } - - /** - * Call from the `onPreToolUse` hook before an edit tool runs. - * Reads the file's current content into memory as the "before" state. - * The hook blocks the SDK until this returns, ensuring the snapshot - * captures pre-edit content. - * - * @param filePath - Absolute path of the file being edited. - */ - async trackEditStart(filePath: string): Promise { - const snapshotDone = this._readFile(filePath); - const entry = { beforeContent: VSBuffer.fromString(''), snapshotDone: snapshotDone.then(buf => { entry.beforeContent = buf; }) }; - this._pendingEdits.set(filePath, entry); - await entry.snapshotDone; - } - - /** - * Call from the `onPostToolUse` hook after an edit tool finishes. - * Reads the file content again as the "after" state and stores the - * result for later retrieval via {@link takeCompletedEdit}. - * - * @param filePath - Absolute path of the file that was edited. - */ - async completeEdit(filePath: string): Promise { - const pending = this._pendingEdits.get(filePath); - if (!pending) { - return; - } - this._pendingEdits.delete(filePath); - await pending.snapshotDone; - - const afterContent = await this._readFile(filePath); - - this._completedEdits.set(filePath, { - beforeContent: pending.beforeContent, - afterContent, - }); - } - - /** - * Retrieves and removes a completed edit for the given file path, - * persists it to the session database with computed diff counts, - * and returns the result as an {@link ToolResultFileEditContent} - * for inclusion in the tool result. - * - * @param toolCallId - The tool call that produced this edit. - * @param filePath - Absolute path of the edited file. - */ - async takeCompletedEdit(turnId: string, toolCallId: string, filePath: string): Promise { - const edit = this._completedEdits.get(filePath); - if (!edit) { - return undefined; - } - this._completedEdits.delete(filePath); - - const beforeBytes = edit.beforeContent.buffer; - const afterBytes = edit.afterContent.buffer; - const beforeText = edit.beforeContent.toString(); - const afterText = edit.afterContent.toString(); - - let addedLines: number | undefined; - let removedLines: number | undefined; - try { - const counts = await this._diffComputeService.computeDiffCounts(beforeText, afterText); - addedLines = counts.added; - removedLines = counts.removed; - } catch (err) { - this._logService.warn(`[FileEditTracker] Failed to compute diff counts: ${filePath}`, err); - } - - try { - await this._db.storeFileEdit({ - turnId, - toolCallId, - filePath, - kind: FileEditKind.Edit, - beforeContent: beforeBytes, - afterContent: afterBytes, - addedLines, - removedLines, - }); - } catch (err) { - this._logService.warn(`[FileEditTracker] Failed to persist file edit to database: ${filePath}`, err); - } - - return { - type: ToolResultContentType.FileEdit, - before: { - uri: URI.file(filePath).toString(), - content: { uri: buildSessionDbUri(this._sessionUri, toolCallId, filePath, 'before') }, - }, - after: { - uri: URI.file(filePath).toString(), - content: { uri: buildSessionDbUri(this._sessionUri, toolCallId, filePath, 'after') }, - }, - diff: addedLines !== undefined ? { added: addedLines, removed: removedLines } : undefined, - }; - } - - private async _readFile(filePath: string): Promise { - try { - const content = await this._fileService.readFile(URI.file(filePath)); - return content.value; - } catch (err) { - this._logService.trace(`[FileEditTracker] Could not read file for snapshot: ${filePath}`, err); - return VSBuffer.fromString(''); - } - } -} diff --git a/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts b/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts deleted file mode 100644 index 269d7689..00000000 --- a/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts +++ /dev/null @@ -1,507 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { URI } from '../../../../base/common/uri.js'; -import { generateUuid } from '../../../../base/common/uuid.js'; -import { stripRedundantCdPrefix } from '../../common/commandLineHelpers.js'; -import { IFileEditRecord, ISessionDatabase } from '../../common/sessionDataService.js'; -import { ResponsePartKind, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, TurnState, buildSubagentSessionUri, type ResponsePart, type StringOrMarkdown, type ToolCallCompletedState, type ToolResultContent, type Turn } from '../../common/state/sessionState.js'; -import { getInvocationMessage, getPastTenseMessage, getShellLanguage, getSubagentMetadata, getToolDisplayName, getToolInputString, getToolKind, isEditTool, isHiddenTool, synthesizeSkillToolCall } from './copilotToolDisplay.js'; -import { buildSessionDbUri } from './fileEditTracker.js'; - -function tryStringify(value: unknown): string | undefined { - try { - return JSON.stringify(value); - } catch { - return undefined; - } -} - -// ---- Minimal event shapes matching the SDK's SessionEvent union --------- -// Defined here so tests can construct events without importing the SDK. - -export interface ISessionEventToolStart { - type: 'tool.execution_start'; - data: { - toolCallId: string; - toolName: string; - arguments?: unknown; - mcpServerName?: string; - mcpToolName?: string; - parentToolCallId?: string; - }; -} - -export interface ISessionEventToolComplete { - type: 'tool.execution_complete'; - data: { - toolCallId: string; - success: boolean; - result?: { content?: string }; - error?: { message: string; code?: string }; - isUserRequested?: boolean; - toolTelemetry?: unknown; - parentToolCallId?: string; - }; -} - -export interface ISessionEventMessage { - type: 'assistant.message' | 'user.message'; - data?: { - messageId?: string; - interactionId?: string; - content?: string; - toolRequests?: readonly { toolCallId: string; name: string; arguments?: unknown; type?: 'function' | 'custom' }[]; - reasoningOpaque?: string; - reasoningText?: string; - encryptedContent?: string; - parentToolCallId?: string; - /** - * Origin of this message. The SDK sets this to a non-`'user'` value - * (e.g. `'skill-pdf'`) for messages it injects on behalf of a skill or - * other internal mechanism. We filter those out so they don't render - * as user turns. - */ - source?: string; - }; -} - -/** Minimal event shape for `skill.invoked`, used to synthesize a tool-style render. */ -export interface ISessionEventSkillInvoked { - type: 'skill.invoked'; - id?: string; - data: { - name: string; - path?: string; - description?: string; - }; -} - -export interface ISessionEventSubagentStarted { - type: 'subagent.started'; - data: { - toolCallId: string; - agentName: string; - agentDisplayName: string; - agentDescription: string; - }; -} - -/** Minimal event shape for session history mapping. */ -export type ISessionEvent = - | ISessionEventToolStart - | ISessionEventToolComplete - | ISessionEventMessage - | ISessionEventSubagentStarted - | ISessionEventSkillInvoked - | { type: string; data?: unknown }; - -/** - * Returns true if the event is a SDK-injected `user.message` that should not - * be shown to the user (e.g. skill-content injection). - * - * The SDK marks these via a non-`'user'` `source` field. Older sessions - * persisted before `source` existed will not be filtered; that is accepted - * leakage rather than guessed-at content sniffing. - */ -function isSyntheticUserMessage(event: ISessionEvent): boolean { - if (event.type !== 'user.message') { - return false; - } - const source = (event as ISessionEventMessage).data?.source; - return !!source && source.toLowerCase() !== 'user'; -} - -// ============================================================================= -// Single-pass turn builder -// ============================================================================= - -/** Per-tool-call info captured from `tool.execution_start` and reused at `tool.execution_complete`. */ -interface IToolStartInfo { - readonly toolName: string; - readonly displayName: string; - readonly invocationMessage: StringOrMarkdown; - readonly toolInput?: string; - readonly toolKind?: 'terminal' | 'subagent'; - readonly language?: string; - readonly subagentAgentName?: string; - readonly subagentDescription?: string; - readonly parameters: Record | undefined; - readonly parentToolCallId?: string; -} - -/** Subagent metadata seen via `subagent.started`, applied to the parent tool call's content at `tool.execution_complete`. */ -interface ISubagentInfo { - readonly agentName: string; - readonly agentDisplayName: string; - readonly agentDescription?: string; -} - -/** - * Mutable per-turn state used while iterating events. The parent session - * has one builder; each subagent turn (one per `parentToolCallId`) has its - * own builder so inner events route there directly. - */ -interface ITurnBuilder { - readonly id: string; - readonly userMessage: { text: string }; - readonly responseParts: ResponsePart[]; - /** Tool starts seen but not yet completed in this turn, keyed by toolCallId. */ - readonly pendingTools: Map; -} - -function newTurnBuilder(id: string, text: string): ITurnBuilder { - return { id, userMessage: { text }, responseParts: [], pendingTools: new Map() }; -} - -function finalizeTurn(builder: ITurnBuilder, state: TurnState): Turn { - return { - id: builder.id, - userMessage: builder.userMessage, - responseParts: builder.responseParts, - usage: undefined, - state, - }; -} - -/** - * Maps raw SDK session events directly into agent-protocol {@link Turn}s - * for the parent session and any subagent child sessions, restoring stored - * file-edit metadata from the session database when available. - * - * Subagent inner events are routed to per-`parentToolCallId` turn builders - * so they appear under their own session view rather than polluting the - * parent transcript. Each subagent's tool calls are returned via - * {@link mapSessionEventsToTurns.subagentTurnsByToolCallId} so callers can - * expose `getSubagentMessages` cheaply. - * - * If `workingDirectory` is provided, redundant `cd &&` - * (or PowerShell equivalent) prefixes are stripped from shell tool - * commands so clients see the simplified form. - */ -export async function mapSessionEvents( - session: URI, - db: ISessionDatabase | undefined, - events: readonly ISessionEvent[], - workingDirectory?: URI, -): Promise<{ turns: Turn[]; subagentTurnsByToolCallId: ReadonlyMap }> { - // First pass: collect tool-arg info and identify edit tool calls so we - // can batch-load their stored file edits before the second pass needs - // them at `tool.execution_complete` time. - const toolInfoByCallId = new Map(); - const editToolCallIds: string[] = []; - for (const e of events) { - if (e.type !== 'tool.execution_start') { - continue; - } - const d = (e as ISessionEventToolStart).data; - if (isHiddenTool(d.toolName)) { - continue; - } - const rawArgs = d.arguments !== undefined ? tryStringify(d.arguments) : undefined; - let parameters: Record | undefined; - if (rawArgs) { - try { parameters = JSON.parse(rawArgs) as Record; } catch { /* ignore */ } - } - // stripRedundantCdPrefix mutates `parameters` and signals via its - // return value. We re-stringify only when it changed something so - // `getToolInputString` sees the cleaned command line. - const cleaned = stripRedundantCdPrefix(d.toolName, parameters, workingDirectory) ? tryStringify(parameters) : undefined; - const toolArgs = cleaned ?? rawArgs; - const toolKind = getToolKind(d.toolName); - const subagentMeta = toolKind === 'subagent' ? getSubagentMetadata(parameters) : undefined; - const displayName = getToolDisplayName(d.toolName); - toolInfoByCallId.set(d.toolCallId, { - toolName: d.toolName, - displayName, - invocationMessage: getInvocationMessage(d.toolName, displayName, parameters), - toolInput: getToolInputString(d.toolName, parameters, toolArgs), - toolKind, - language: toolKind === 'terminal' ? getShellLanguage(d.toolName) : undefined, - subagentAgentName: subagentMeta?.agentName, - subagentDescription: subagentMeta?.description, - parameters, - parentToolCallId: d.parentToolCallId, - }); - if (isEditTool(d.toolName)) { - editToolCallIds.push(d.toolCallId); - } - } - - // Pre-load stored file-edit metadata for all edit tool calls. - let storedEdits: Map | undefined; - if (db && editToolCallIds.length > 0) { - try { - const records = await db.getFileEdits(editToolCallIds); - if (records.length > 0) { - storedEdits = new Map(); - for (const r of records) { - let list = storedEdits.get(r.toolCallId); - if (!list) { - list = []; - storedEdits.set(r.toolCallId, list); - } - list.push(r); - } - } - } catch { - // Database may not exist yet for new sessions — that's fine. - } - } - - const sessionUriStr = session.toString(); - const turns: Turn[] = []; - - // Subagent state. Each subagent has its own active turn builder; only - // the most recent turn per subagent is built (subagents currently emit - // at most one turn per invocation). - const subagentBuilders = new Map(); - const subagentTurns = new Map(); - const subagentInfoByToolCallId = new Map(); - - let parentBuilder: ITurnBuilder | undefined; - - const flushSubagent = (parentToolCallId: string): void => { - const builder = subagentBuilders.get(parentToolCallId); - if (!builder) { - return; - } - subagentBuilders.delete(parentToolCallId); - if (builder.responseParts.length === 0) { - return; - } - const list = subagentTurns.get(parentToolCallId) ?? []; - list.push(finalizeTurn(builder, TurnState.Complete)); - subagentTurns.set(parentToolCallId, list); - }; - - const ensureSubagentBuilder = (parentToolCallId: string): ITurnBuilder => { - let builder = subagentBuilders.get(parentToolCallId); - if (!builder) { - builder = newTurnBuilder(generateUuid(), ''); - subagentBuilders.set(parentToolCallId, builder); - } - return builder; - }; - - const targetBuilderFor = (parentToolCallId: string | undefined): ITurnBuilder | undefined => { - if (parentToolCallId) { - return ensureSubagentBuilder(parentToolCallId); - } - return parentBuilder; - }; - - for (const e of events) { - switch (e.type) { - case 'user.message': { - if (isSyntheticUserMessage(e)) { - continue; - } - const d = (e as ISessionEventMessage).data; - const messageId = d?.messageId ?? d?.interactionId ?? ''; - const content = d?.content ?? ''; - if (d?.parentToolCallId) { - // User messages with a parent tool call route into the - // subagent's transcript. They never start a new parent - // turn; subagents currently only see assistant messages - // in practice, but route conservatively. - const builder = ensureSubagentBuilder(d.parentToolCallId); - if (content) { - builder.responseParts.push({ - kind: ResponsePartKind.Markdown, - id: generateUuid(), - content, - }); - } - } else { - // A new top-level user message starts a new parent turn. - if (parentBuilder) { - turns.push(finalizeTurn(parentBuilder, TurnState.Cancelled)); - } - parentBuilder = newTurnBuilder(messageId, content); - } - break; - } - case 'assistant.message': { - const d = (e as ISessionEventMessage).data; - const messageId = d?.messageId ?? d?.interactionId ?? ''; - const content = d?.content ?? ''; - const reasoningText = d?.reasoningText; - const hasToolRequests = !!d?.toolRequests && d.toolRequests.length > 0; - const builder = targetBuilderFor(d?.parentToolCallId) - ?? (parentBuilder = newTurnBuilder(messageId, '')); - if (reasoningText) { - builder.responseParts.push({ - kind: ResponsePartKind.Reasoning, - id: generateUuid(), - content: reasoningText, - }); - } - if (content) { - builder.responseParts.push({ - kind: ResponsePartKind.Markdown, - id: generateUuid(), - content, - }); - } - // A parent assistant message without further tool requests - // terminates the current parent turn (no more responses - // expected). Subagent turns are flushed at the parent's - // `tool.execution_complete` instead. - if (!d?.parentToolCallId && !hasToolRequests && builder === parentBuilder) { - turns.push(finalizeTurn(parentBuilder, TurnState.Complete)); - parentBuilder = undefined; - } - break; - } - case 'subagent.started': { - const d = (e as ISessionEventSubagentStarted).data; - subagentInfoByToolCallId.set(d.toolCallId, { - agentName: d.agentName, - agentDisplayName: d.agentDisplayName, - agentDescription: d.agentDescription, - }); - break; - } - case 'tool.execution_start': { - // Already collected in the first pass; no per-event work - // needed here. Hidden tools are filtered above. - break; - } - case 'tool.execution_complete': { - const d = (e as ISessionEventToolComplete).data; - const info = toolInfoByCallId.get(d.toolCallId); - if (!info) { - // Orphan complete (no matching start), or hidden tool. - continue; - } - toolInfoByCallId.delete(d.toolCallId); - const builder = targetBuilderFor(d.parentToolCallId); - if (!builder) { - // No active turn to attach this completion to. - continue; - } - const completedPart = makeCompletedToolCallPart(d, info, sessionUriStr, storedEdits, subagentInfoByToolCallId.get(d.toolCallId)); - builder.responseParts.push(completedPart); - // When a parent tool call that spawned a subagent completes, - // flush the subagent's accumulated turn. - if (!d.parentToolCallId && subagentInfoByToolCallId.has(d.toolCallId)) { - flushSubagent(d.toolCallId); - } - break; - } - case 'skill.invoked': { - const skill = (e as ISessionEventSkillInvoked); - const synth = synthesizeSkillToolCall(skill.data, skill.id); - const builder = parentBuilder ?? (parentBuilder = newTurnBuilder(generateUuid(), '')); - builder.responseParts.push({ - kind: ResponsePartKind.ToolCall, - toolCall: { - status: ToolCallStatus.Completed, - toolCallId: synth.toolCallId, - toolName: synth.toolName, - displayName: synth.displayName, - invocationMessage: synth.invocationMessage, - success: true, - pastTenseMessage: synth.pastTenseMessage, - confirmed: ToolCallConfirmationReason.NotNeeded, - } satisfies ToolCallCompletedState, - }); - break; - } - default: - break; - } - } - - // Drain any unfinished turns. - if (parentBuilder) { - turns.push(finalizeTurn(parentBuilder, TurnState.Cancelled)); - parentBuilder = undefined; - } - for (const parentToolCallId of [...subagentBuilders.keys()]) { - flushSubagent(parentToolCallId); - } - - return { turns, subagentTurnsByToolCallId: subagentTurns }; -} - -/** - * Builds a {@link ToolCallCompletedState}-shaped response part from an - * SDK `tool.execution_complete` event. Restores file-edit content - * references from `storedEdits` and merges subagent metadata when the - * tool call spawned a child session. - */ -function makeCompletedToolCallPart( - d: ISessionEventToolComplete['data'], - info: IToolStartInfo, - sessionUriStr: string, - storedEdits: Map | undefined, - subagent: ISubagentInfo | undefined, -): ResponsePart { - const toolOutput = d.error?.message ?? d.result?.content; - const content: ToolResultContent[] = []; - if (toolOutput !== undefined) { - content.push({ type: ToolResultContentType.Text, text: toolOutput }); - } - - // Restore file edit content references from the database. - const edits = storedEdits?.get(d.toolCallId); - if (edits) { - for (const edit of edits) { - const beforeUri = edit.kind === 'rename' && edit.originalPath - ? URI.file(edit.originalPath).toString() - : URI.file(edit.filePath).toString(); - const afterUri = URI.file(edit.filePath).toString(); - const hasBefore = edit.kind !== 'create'; - const hasAfter = edit.kind !== 'delete'; - content.push({ - type: ToolResultContentType.FileEdit, - before: hasBefore ? { - uri: beforeUri, - content: { uri: buildSessionDbUri(sessionUriStr, edit.toolCallId, edit.filePath, 'before') }, - } : undefined, - after: hasAfter ? { - uri: afterUri, - content: { uri: buildSessionDbUri(sessionUriStr, edit.toolCallId, edit.filePath, 'after') }, - } : undefined, - diff: (edit.addedLines !== undefined || edit.removedLines !== undefined) - ? { added: edit.addedLines, removed: edit.removedLines } - : undefined, - }); - } - } - - if (subagent) { - content.push({ - type: ToolResultContentType.Subagent, - resource: buildSubagentSessionUri(sessionUriStr, d.toolCallId), - title: subagent.agentDisplayName, - agentName: subagent.agentName, - description: subagent.agentDescription, - }); - } - - const tc: ToolCallCompletedState = { - status: ToolCallStatus.Completed, - toolCallId: d.toolCallId, - toolName: info.toolName, - displayName: info.displayName, - invocationMessage: info.invocationMessage, - toolInput: info.toolInput, - success: d.success, - pastTenseMessage: getPastTenseMessage(info.toolName, info.displayName, info.parameters, d.success), - content: content.length > 0 ? content : undefined, - error: d.error, - confirmed: ToolCallConfirmationReason.NotNeeded, - _meta: { - toolKind: info.toolKind, - language: info.language, - subagentDescription: info.subagentDescription, - subagentAgentName: info.subagentAgentName, - }, - }; - return { kind: ResponsePartKind.ToolCall, toolCall: tc }; -} diff --git a/src/vs/platform/agentHost/node/copilot/pendingEditContentStore.ts b/src/vs/platform/agentHost/node/copilot/pendingEditContentStore.ts deleted file mode 100644 index 08888a2d..00000000 --- a/src/vs/platform/agentHost/node/copilot/pendingEditContentStore.ts +++ /dev/null @@ -1,48 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { encodeHex, VSBuffer } from '../../../../base/common/buffer.js'; -import { IDisposable } from '../../../../base/common/lifecycle.js'; -import { URI } from '../../../../base/common/uri.js'; -import { IFileService } from '../../../files/common/files.js'; -import { InMemoryFileSystemProvider } from '../../../files/common/inMemoryFilesystemProvider.js'; - -/** - * URI scheme for transient file content backing tool-call write-permission - * previews. Files under this scheme live in an in-memory provider registered - * on the agent host's file service; content can be read/written through the - * file service just like any other resource. - */ -export const PENDING_EDIT_CONTENT_SCHEME = 'pending-edit-content'; - -/** - * Builds a `pending-edit-content:` URI identifying the proposed "after" - * content for a write permission request. The authority is a hex-encoded - * session URI so multiple concurrent sessions don't collide. - */ -export function buildPendingEditContentUri(sessionUri: string, toolCallId: string, filePath: string): URI { - return URI.from({ - scheme: PENDING_EDIT_CONTENT_SCHEME, - authority: encodeHex(VSBuffer.fromString(sessionUri)).toString(), - path: `/${encodeURIComponent(toolCallId)}/${encodeHex(VSBuffer.fromString(filePath))}`, - }); -} - -/** - * Registers a fresh {@link InMemoryFileSystemProvider} for the - * `pending-edit-content:` scheme on the given file service. Callers use the - * returned disposable to unregister the provider. - */ -export function registerPendingEditContentProvider(fileService: IFileService): IDisposable { - const provider = new InMemoryFileSystemProvider(); - const registration = fileService.registerProvider(PENDING_EDIT_CONTENT_SCHEME, provider); - return { - dispose() { - registration.dispose(); - provider.dispose(); - }, - }; -} - diff --git a/src/vs/platform/agentHost/node/diffComputeService.ts b/src/vs/platform/agentHost/node/diffComputeService.ts deleted file mode 100644 index faa12631..00000000 --- a/src/vs/platform/agentHost/node/diffComputeService.ts +++ /dev/null @@ -1,94 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { Worker } from 'worker_threads'; -import { Disposable } from '../../../base/common/lifecycle.js'; -import { FileAccess } from '../../../base/common/network.js'; -import { ILogService } from '../../log/common/log.js'; -import { DEFAULT_DIFF_TIMEOUT_MS, IDiffComputeService, type IDiffCountResult } from '../common/diffComputeService.js'; - -/** - * Node.js implementation of {@link IDiffComputeService} that runs - * {@link DefaultLinesDiffComputer} in a worker thread to avoid blocking - * the main thread. - */ -export class NodeWorkerDiffComputeService extends Disposable implements IDiffComputeService { - - declare readonly _serviceBrand: undefined; - - private _worker: Worker | undefined; - private _workerFailures = 0; - private _nextId = 1; - private readonly _pending = new Map void; reject: (err: Error) => void }>(); - - constructor( - @ILogService private readonly _logService: ILogService, - ) { - super(); - } - - async computeDiffCounts(original: string, modified: string, timeoutMs: number = DEFAULT_DIFF_TIMEOUT_MS): Promise { - const worker = this._ensureWorker(); - const id = this._nextId++; - return new Promise((resolve, reject) => { - this._pending.set(id, { resolve, reject }); - try { - worker.postMessage({ id, fn: 'computeDiffCounts', args: [original, modified, timeoutMs] }); - } catch (err) { - this._pending.delete(id); - reject(err instanceof Error ? err : new Error(String(err))); - } - }); - } - - private _ensureWorker(): Worker { - if (this._workerFailures >= 3) { - throw new Error('Diff compute worker failed too many times'); - } - if (!this._worker) { - const workerPath = FileAccess.asFileUri('vs/platform/agentHost/node/diffWorkerMain.js').fsPath; - const w = new Worker(workerPath, { name: 'Diff compute worker' }); - w.on('message', (msg: { id: number; res?: IDiffCountResult; err?: { message: string; stack?: string } }) => { - const handler = this._pending.get(msg.id); - if (!handler) { - return; - } - this._pending.delete(msg.id); - if (msg.err) { - const error = new Error(msg.err.message); - if (msg.err.stack) { - error.stack = msg.err.stack; - } - handler.reject(error); - } else { - handler.resolve(msg.res!); - } - }); - w.on('error', err => { - this._logService.error('[DiffComputeService] Worker error', err); - for (const [, handler] of this._pending) { - handler.reject(err); - } - this._pending.clear(); - this._worker = undefined; - this._workerFailures++; - }); - this._worker = w; - } - return this._worker; - } - - override dispose(): void { - if (this._worker) { - this._worker.terminate(); - this._worker = undefined; - } - for (const [, handler] of this._pending) { - handler.reject(new Error('DiffComputeService disposed')); - } - this._pending.clear(); - super.dispose(); - } -} diff --git a/src/vs/platform/agentHost/node/diffWorkerMain.ts b/src/vs/platform/agentHost/node/diffWorkerMain.ts deleted file mode 100644 index 38c460b6..00000000 --- a/src/vs/platform/agentHost/node/diffWorkerMain.ts +++ /dev/null @@ -1,54 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { parentPort } from 'worker_threads'; -import { DefaultLinesDiffComputer } from '../../../editor/common/diff/defaultLinesDiffComputer/defaultLinesDiffComputer.js'; -import type { ILinesDiffComputerOptions } from '../../../editor/common/diff/linesDiffComputer.js'; -import type { IDiffCountResult } from '../common/diffComputeService.js'; - -export function computeDiffCounts(originalText: string, modifiedText: string, timeoutMs: number): IDiffCountResult { - const originalLines = originalText.split(/\r\n|\r|\n/); - const modifiedLines = modifiedText.split(/\r\n|\r|\n/); - const diffComputer = new DefaultLinesDiffComputer(); - const options: ILinesDiffComputerOptions = { - ignoreTrimWhitespace: true, - maxComputationTimeMs: timeoutMs, - computeMoves: false, - }; - const result = diffComputer.computeDiff(originalLines, modifiedLines, options); - - let added = 0; - let removed = 0; - for (const change of result.changes) { - removed += change.original.length; - added += change.modified.length; - } - - return { added, removed }; -} - -function main() { - const port = parentPort; - if (!port) { - throw new Error('This module should only be used in a worker thread.'); - } - - port.on('message', ({ id, fn, args }: { id: number; fn: string; args: unknown[] }) => { - try { - if (fn === 'computeDiffCounts') { - const res = computeDiffCounts(args[0] as string, args[1] as string, args[2] as number); - port.postMessage({ id, res }); - } else { - port.postMessage({ id, err: { message: `Unknown function: ${fn}` } }); - } - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - const stack = err instanceof Error ? err.stack : undefined; - port.postMessage({ id, err: { message, stack } }); - } - }); -} - -main(); diff --git a/src/vs/platform/agentHost/node/gitDiffContent.ts b/src/vs/platform/agentHost/node/gitDiffContent.ts deleted file mode 100644 index 255e99d0..00000000 --- a/src/vs/platform/agentHost/node/gitDiffContent.ts +++ /dev/null @@ -1,63 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { decodeHex, encodeHex, VSBuffer } from '../../../base/common/buffer.js'; -import { basename } from '../../../base/common/path.js'; -import { URI } from '../../../base/common/uri.js'; - -const GIT_BLOB_SCHEME = 'git-blob'; - -/** - * Builds a `git-blob:` URI that references a file blob at a specific git - * commit, scoped to a given session. Resolved by reading the session's - * working directory and shelling out to `git show :`. - * - * The session URI is preserved so the resolver can find the session's - * working directory; the SHA and repository-relative path identify the - * blob to fetch. - */ -export function buildGitBlobUri(sessionUri: string, sha: string, repoRelativePath: string): string { - return URI.from({ - scheme: GIT_BLOB_SCHEME, - authority: encodeHex(VSBuffer.fromString(sessionUri)).toString(), - path: `/${encodeURIComponent(sha)}/${encodeHex(VSBuffer.fromString(repoRelativePath))}/${basename(repoRelativePath)}`, - }).toString(); -} - -/** Parsed fields from a `git-blob:` content URI. */ -export interface IGitBlobUriFields { - readonly sessionUri: string; - readonly sha: string; - readonly repoRelativePath: string; -} - -/** - * Parses a `git-blob:` URI produced by {@link buildGitBlobUri}. - * Returns `undefined` if the URI is not a valid `git-blob:` URI. - */ -export function parseGitBlobUri(raw: string): IGitBlobUriFields | undefined { - let parsed: URI; - try { - parsed = URI.parse(raw); - } catch { - return undefined; - } - if (parsed.scheme !== GIT_BLOB_SCHEME) { - return undefined; - } - const [, sha, encodedPath] = parsed.path.split('/'); - if (!sha || !encodedPath) { - return undefined; - } - try { - return { - sessionUri: decodeHex(parsed.authority).toString(), - sha: decodeURIComponent(sha), - repoRelativePath: decodeHex(encodedPath).toString(), - }; - } catch { - return undefined; - } -} diff --git a/src/vs/platform/agentHost/node/nodeAgentHostStarter.ts b/src/vs/platform/agentHost/node/nodeAgentHostStarter.ts deleted file mode 100644 index ff617e69..00000000 --- a/src/vs/platform/agentHost/node/nodeAgentHostStarter.ts +++ /dev/null @@ -1,126 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { Emitter } from '../../../base/common/event.js'; -import { Disposable, DisposableStore } from '../../../base/common/lifecycle.js'; -import { FileAccess, Schemas } from '../../../base/common/network.js'; -import { Client, IIPCOptions } from '../../../base/parts/ipc/node/ipc.cp.js'; -import { IConfigurationService } from '../../configuration/common/configuration.js'; -import { IEnvironmentService, INativeEnvironmentService } from '../../environment/common/environment.js'; -import { parseAgentHostDebugPort } from '../../environment/node/environmentService.js'; -import { ILogService } from '../../log/common/log.js'; -import { getResolvedShellEnv } from '../../shell/node/shellEnv.js'; -import { IAgentHostConnection, IAgentHostStarter } from '../common/agent.js'; - -/** - * Options for configuring the agent host WebSocket server in the child process. - * When set, the agent host exposes a WebSocket endpoint for external clients. - */ -export interface IAgentHostWebSocketConfig { - /** TCP port to listen on. Mutually exclusive with `socketPath`. */ - readonly port?: string; - /** Unix domain socket / named pipe path. Takes precedence over `port`. */ - readonly socketPath?: string; - /** Host/IP to bind to. */ - readonly host?: string; - /** Connection token value. When set, WebSocket clients must present this token. */ - readonly connectionToken?: string; -} - -/** - * Spawns the agent host as a Node child process (fallback when - * Electron utility process is unavailable, e.g. dev/test). - */ -export class NodeAgentHostStarter extends Disposable implements IAgentHostStarter { - - private _wsConfig: IAgentHostWebSocketConfig | undefined; - - private readonly _onRequestConnection = this._register(new Emitter()); - readonly onRequestConnection = this._onRequestConnection.event; - - constructor( - @IConfigurationService private readonly _configurationService: IConfigurationService, - @IEnvironmentService private readonly _environmentService: INativeEnvironmentService, - @ILogService private readonly _logService: ILogService, - ) { - super(); - } - - /** - * Configures the child process to also start a WebSocket server. - * Must be called before {@link start}. Triggers eager process start - * via {@link onRequestConnection}. - */ - setWebSocketConfig(config: IAgentHostWebSocketConfig): void { - this._wsConfig = config; - // Signal the process manager to start immediately rather than - // waiting for a renderer window to connect. - this._onRequestConnection.fire(); - } - - async start(): Promise { - // Resolve user shell environment so spawned tools/terminals inherit - // PATH and other vars from the user's login shell (macOS/Linux). - const shellEnv = await this._resolveShellEnv(); - - const env: Record = { - ...shellEnv as Record, - VSCODE_ESM_ENTRYPOINT: 'vs/platform/agentHost/node/agentHostMain', - VSCODE_PIPE_LOGGING: 'true', - VSCODE_VERBOSE_LOGGING: 'true', - }; - - // Forward WebSocket server configuration to the child process via env vars - if (this._wsConfig) { - if (this._wsConfig.port) { - env['VSCODE_AGENT_HOST_PORT'] = this._wsConfig.port; - } - if (this._wsConfig.socketPath) { - env['VSCODE_AGENT_HOST_SOCKET_PATH'] = this._wsConfig.socketPath; - } - if (this._wsConfig.host) { - env['VSCODE_AGENT_HOST_HOST'] = this._wsConfig.host; - } - if (this._wsConfig.connectionToken) { - env['VSCODE_AGENT_HOST_CONNECTION_TOKEN'] = this._wsConfig.connectionToken; - } - } - - const opts: IIPCOptions = { - serverName: 'Agent Host', - args: ['--type=agentHost', '--logsPath', this._environmentService.logsHome.with({ scheme: Schemas.file }).fsPath], - env, - }; - - const agentHostDebug = parseAgentHostDebugPort(this._environmentService.args, this._environmentService.isBuilt); - if (agentHostDebug) { - if (agentHostDebug.break && agentHostDebug.port) { - opts.debugBrk = agentHostDebug.port; - } else if (!agentHostDebug.break && agentHostDebug.port) { - opts.debug = agentHostDebug.port; - } - } - - const client = new Client(FileAccess.asFileUri('bootstrap-fork').fsPath, opts); - - const store = new DisposableStore(); - store.add(client); - - return { - client, - store, - onDidProcessExit: client.onDidProcessExit - }; - } - - private async _resolveShellEnv(): Promise { - try { - return await getResolvedShellEnv(this._configurationService, this._logService, this._environmentService.args, process.env); - } catch (error) { - this._logService.error('AgentHostStarter was unable to resolve shell environment', error); - return {}; - } - } -} diff --git a/src/vs/platform/agentHost/node/osc633Parser.ts b/src/vs/platform/agentHost/node/osc633Parser.ts deleted file mode 100644 index 849dc62c..00000000 --- a/src/vs/platform/agentHost/node/osc633Parser.ts +++ /dev/null @@ -1,294 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -/** - * Lightweight parser for OSC 633 (VS Code shell integration) sequences in raw - * PTY output. Designed for the agent host where we don't have a full xterm.js - * instance - it scans data chunks for the sequences, extracts events, and - * removes the sequences from the data stream. - * - * Handles partial sequences that span across data chunk boundaries. - */ - -/** OSC 633 event types we care about. */ -export const enum Osc633EventType { - /** 633;A - Prompt start. Used to detect shell integration is active. */ - PromptStart, - /** 633;B - Command start (where user inputs command). */ - CommandStart, - /** 633;C - Command executed (output begins). */ - CommandExecuted, - /** 633;D[;exitCode] - Command finished. */ - CommandFinished, - /** 633;E;commandLine[;nonce] - Explicit command line. */ - CommandLine, - /** 633;P;Key=Value - Property (e.g. Cwd). */ - Property, -} - -export interface IOsc633PromptStartEvent { - type: Osc633EventType.PromptStart; -} - -export interface IOsc633CommandStartEvent { - type: Osc633EventType.CommandStart; -} - -export interface IOsc633CommandExecutedEvent { - type: Osc633EventType.CommandExecuted; -} - -export interface IOsc633CommandFinishedEvent { - type: Osc633EventType.CommandFinished; - exitCode: number | undefined; -} - -export interface IOsc633CommandLineEvent { - type: Osc633EventType.CommandLine; - commandLine: string; - nonce: string | undefined; -} - -export interface IOsc633PropertyEvent { - type: Osc633EventType.Property; - key: string; - value: string; -} - -export type Osc633Event = - | IOsc633PromptStartEvent - | IOsc633CommandStartEvent - | IOsc633CommandExecutedEvent - | IOsc633CommandFinishedEvent - | IOsc633CommandLineEvent - | IOsc633PropertyEvent; - -export interface IOsc633ParseResult { - /** Data with all OSC 633 sequences stripped. */ - cleanedData: string; - /** Parsed events in order of appearance. */ - events: Osc633Event[]; -} - -/** - * Decode escaped values in OSC 633 messages. - * Handles `\\` -> `\` and `\xAB` -> character with code 0xAB. - */ -function deserializeOscMessage(message: string): string { - if (message.indexOf('\\') === -1) { - return message; - } - return message.replaceAll( - /\\(\\|x([0-9a-f]{2}))/gi, - (_match: string, op: string, hex?: string) => hex ? String.fromCharCode(parseInt(hex, 16)) : op, - ); -} - -function parseOsc633Payload(payload: string): Osc633Event | undefined { - const semiIdx = payload.indexOf(';'); - if ((semiIdx === -1 ? payload.length : semiIdx) !== 1) { - return undefined; - } - - const command = payload[0]; - const argsRaw = semiIdx === -1 ? '' : payload.substring(semiIdx + 1); - - switch (command) { - case 'A': - return { type: Osc633EventType.PromptStart }; - case 'B': - return { type: Osc633EventType.CommandStart }; - case 'C': - return { type: Osc633EventType.CommandExecuted }; - case 'D': { - const exitCode = argsRaw.length > 0 ? parseInt(argsRaw, 10) : undefined; - return { - type: Osc633EventType.CommandFinished, - exitCode: exitCode !== undefined && !isNaN(exitCode) ? exitCode : undefined, - }; - } - case 'E': { - const nonceIdx = argsRaw.indexOf(';'); - const commandLine = deserializeOscMessage(nonceIdx === -1 ? argsRaw : argsRaw.substring(0, nonceIdx)); - const nonce = nonceIdx === -1 ? undefined : argsRaw.substring(nonceIdx + 1); - return { type: Osc633EventType.CommandLine, commandLine, nonce }; - } - case 'P': { - const deserialized = deserializeOscMessage(argsRaw); - const eqIdx = deserialized.indexOf('='); - if (eqIdx === -1) { - return undefined; - } - return { - type: Osc633EventType.Property, - key: deserialized.substring(0, eqIdx), - value: deserialized.substring(eqIdx + 1), - }; - } - default: - return undefined; - } -} - -// OSC introducer is ESC ] (0x1b 0x5d) -const ESC = '\x1b'; -const OSC_START = ESC + ']'; -// Terminators: BEL (0x07) or ST (ESC \) -const BEL = '\x07'; -const ST = ESC + '\\'; - -/** - * Stateful parser that handles data chunks, correctly dealing with - * partial sequences that span multiple chunks. - */ -export class Osc633Parser { - /** Buffer for an incomplete OSC sequence (from ESC] up to but not including the terminator). */ - private _pendingOsc = ''; - /** Whether we are currently accumulating an OSC sequence. */ - private _inOsc = false; - /** Set when the previous chunk ended with ESC inside an OSC body (potential ST start). */ - private _pendingEscInOsc = false; - - /** - * Parse a chunk of PTY data. - * Returns cleaned data (all OSC 633 sequences removed) and extracted events. - */ - parse(data: string): IOsc633ParseResult { - const events: Osc633Event[] = []; - if (!this._inOsc && data.indexOf(OSC_START) === -1) { - return { cleanedData: data, events }; - } - - let cleaned = ''; - let i = 0; - - while (i < data.length) { - if (this._inOsc) { - // Handle ESC that was pending from the previous chunk. - if (this._pendingEscInOsc) { - this._pendingEscInOsc = false; - if (data[i] === '\\') { - // ESC \ = ST terminator, sequence is complete. - i++; - this._inOsc = false; - const payload = this._pendingOsc; - this._pendingOsc = ''; - this._handleOscPayload(payload, events, { value: cleaned, append(s: string) { cleaned = s; } }, ST); - continue; - } - // ESC was not followed by \, malformed: complete the OSC anyway. - this._inOsc = false; - const payload = this._pendingOsc; - this._pendingOsc = ''; - this._handleOscPayload(payload, events, { value: cleaned, append(s: string) { cleaned = s; } }); - continue; - } - - // We're inside an OSC sequence, look for the terminator. - const result = this._consumeOscBody(data, i); - i = result.nextIndex; - if (result.complete) { - this._inOsc = false; - const payload = this._pendingOsc; - this._pendingOsc = ''; - this._handleOscPayload(payload, events, { value: cleaned, append(s: string) { cleaned = s; } }, result.terminator); - } else if (result.pendingEsc) { - this._pendingEscInOsc = true; - } - // If not complete, _pendingOsc has been extended, and we're at end of data. - continue; - } - - // Look for the next ESC ] which starts an OSC sequence - const escIdx = data.indexOf(OSC_START, i); - if (escIdx === -1) { - cleaned += data.substring(i); - i = data.length; - continue; - } - - // Copy everything before the OSC start to cleaned output. - cleaned += data.substring(i, escIdx); - - // Start of OSC: check if it's 633. - i = escIdx + 2; // skip past ESC ] - this._pendingOsc = ''; - this._inOsc = true; - - // Try to consume the OSC body in this same chunk. - const result = this._consumeOscBody(data, i); - i = result.nextIndex; - if (result.complete) { - this._inOsc = false; - const payload = this._pendingOsc; - this._pendingOsc = ''; - // If it's a 633 sequence, extract event; otherwise put it back in cleaned. - this._handleOscPayload(payload, events, { value: cleaned, append(s: string) { cleaned = s; } }, result.terminator); - } else if (result.pendingEsc) { - this._pendingEscInOsc = true; - } - // If not complete, we're at end of data and _pendingOsc is buffered. - } - - return { cleanedData: cleaned, events }; - } - - /** - * Consume characters from the OSC body, appending to _pendingOsc until a - * terminator (BEL or ST) is found. - */ - private _consumeOscBody(data: string, startIdx: number): { nextIndex: number; complete: boolean; pendingEsc?: boolean; terminator?: string } { - const belIdx = data.indexOf(BEL, startIdx); - const escIdx = data.indexOf(ESC, startIdx); - - if (belIdx !== -1 && (escIdx === -1 || belIdx < escIdx)) { - this._pendingOsc += data.substring(startIdx, belIdx); - return { nextIndex: belIdx + 1, complete: true, terminator: BEL }; - } - - if (escIdx !== -1) { - if (escIdx + 1 >= data.length) { - this._pendingOsc += data.substring(startIdx, escIdx); - return { nextIndex: data.length, complete: false, pendingEsc: true }; - } - - this._pendingOsc += data.substring(startIdx, escIdx); - if (data[escIdx + 1] === '\\') { - return { nextIndex: escIdx + 2, complete: true, terminator: ST }; - } - - return { nextIndex: escIdx, complete: true }; - } - - this._pendingOsc += data.substring(startIdx); - return { nextIndex: data.length, complete: false }; - } - - /** - * Process a complete OSC payload. If it's a 633; sequence, extract the - * event. Otherwise, reconstruct the original bytes and pass them through - * to cleaned output. - */ - private _handleOscPayload( - payload: string, - events: Osc633Event[], - cleanedRef: { value: string; append(s: string): void } | undefined, - terminator = BEL, - ): void { - if (payload.startsWith('633;')) { - const oscContent = payload.substring(4); // strip "633;" - const event = parseOsc633Payload(oscContent); - if (event) { - events.push(event); - } - // 633 sequences are always stripped from output - } else { - // Non-633 OSC: put back the original bytes. - if (cleanedRef) { - cleanedRef.append(cleanedRef.value + OSC_START + payload + terminator); - } - } - } -} diff --git a/src/vs/platform/agentHost/node/protocolServerHandler.ts b/src/vs/platform/agentHost/node/protocolServerHandler.ts deleted file mode 100644 index 05344848..00000000 --- a/src/vs/platform/agentHost/node/protocolServerHandler.ts +++ /dev/null @@ -1,757 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { Emitter } from '../../../base/common/event.js'; -import { isJsonRpcResponse } from '../../../base/common/jsonRpcProtocol.js'; -import { Disposable, DisposableStore } from '../../../base/common/lifecycle.js'; -import { hasKey } from '../../../base/common/types.js'; -import { URI } from '../../../base/common/uri.js'; -import { ILogService } from '../../log/common/log.js'; -import { AHPFileSystemProvider } from '../common/agentHostFileSystemProvider.js'; -import { AgentSession, type IAgentService } from '../common/agentService.js'; -import type { CommandMap } from '../common/state/protocol/messages.js'; -import { ActionEnvelope, ActionType, INotification, isSessionAction, isTerminalAction, type SessionAction, type TerminalAction, type IRootConfigChangedAction } from '../common/state/sessionActions.js'; -import { MIN_PROTOCOL_VERSION, PROTOCOL_VERSION } from '../common/state/sessionCapabilities.js'; -import { - AHP_AUTH_REQUIRED, - AHP_PROVIDER_NOT_FOUND, - AHP_SESSION_NOT_FOUND, - AHP_UNSUPPORTED_PROTOCOL_VERSION, - JsonRpcRequest, - isJsonRpcNotification, - isJsonRpcRequest, - JSON_RPC_INTERNAL_ERROR, - JsonRpcErrorCodes, - ProtocolError, - type AhpServerNotification, - type InitializeParams, - type JsonRpcResponse, - type ReconnectParams, - type IStateSnapshot, -} from '../common/state/sessionProtocol.js'; -import { ResponsePartKind, ROOT_STATE_URI, SessionStatus, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, type SessionState } from '../common/state/sessionState.js'; -import type { IProtocolServer, IProtocolTransport } from '../common/state/sessionTransport.js'; -import { AgentHostStateManager } from './agentHostStateManager.js'; - -/** Default capacity of the server-side action replay buffer. */ -const REPLAY_BUFFER_CAPACITY = 1000; - -const CLIENT_TOOL_CALL_DISCONNECT_TIMEOUT = 30_000; - -/** Build a JSON-RPC success response suitable for transport.send(). */ -function jsonRpcSuccess(id: number, result: unknown): JsonRpcResponse { - return { jsonrpc: '2.0', id, result }; -} - -/** Build a JSON-RPC error response suitable for transport.send(). */ -function jsonRpcError(id: number, code: number, message: string, data?: unknown): JsonRpcResponse { - return { jsonrpc: '2.0', id, error: { code, message, ...(data !== undefined ? { data } : {}) } }; -} - -/** Build a JSON-RPC error response from an unknown thrown value, preserving {@link ProtocolError} fields. */ -function jsonRpcErrorFrom(id: number, err: unknown): JsonRpcResponse { - if (err instanceof ProtocolError) { - return jsonRpcError(id, err.code, err.message, err.data); - } - const message = err instanceof Error ? (err.stack ?? err.message) : String(err); - return jsonRpcError(id, JSON_RPC_INTERNAL_ERROR, message); -} - -/** - * Methods handled by the request dispatcher. Excludes `initialize` and - * `reconnect` which are handled during the handshake phase. - */ -type RequestMethod = Exclude; - -/** - * Typed handler map: each key is a request method, each value is a handler - * that receives the correctly-typed params and must return the correctly-typed - * result. The compiler will error if a handler returns the wrong shape. - */ -type RequestHandlerMap = { - [M in RequestMethod]: (client: IConnectedClient, params: CommandMap[M]['params']) => Promise; -}; - -/** - * Represents a connected protocol client with its subscription state. - */ -interface IConnectedClient { - readonly clientId: string; - readonly protocolVersion: number; - readonly transport: IProtocolTransport; - readonly subscriptions: Set; - readonly disposables: DisposableStore; -} - -/** - * Configuration for protocol-level concerns outside of IAgentService. - */ -export interface IProtocolServerConfig { - /** Default directory returned to clients during the initialize handshake. */ - readonly defaultDirectory?: string; -} - -/** - * Server-side handler that manages protocol connections, routes JSON-RPC - * messages to the agent service, and broadcasts actions/notifications - * to subscribed clients. - */ -export class ProtocolServerHandler extends Disposable { - - private readonly _clients = new Map(); - private readonly _replayBuffer: ActionEnvelope[] = []; - private readonly _clientToolCallDisconnectTimeouts = new Map>(); - - private readonly _onDidChangeConnectionCount = this._register(new Emitter()); - - /** Fires with the current client count whenever a client connects or disconnects. */ - readonly onDidChangeConnectionCount = this._onDidChangeConnectionCount.event; - - constructor( - private readonly _agentService: IAgentService, - private readonly _stateManager: AgentHostStateManager, - private readonly _server: IProtocolServer, - private readonly _config: IProtocolServerConfig, - private readonly _clientFileSystemProvider: AHPFileSystemProvider, - @ILogService private readonly _logService: ILogService, - ) { - super(); - - this._register(this._server.onConnection(transport => { - this._handleNewConnection(transport); - })); - - this._register(this._stateManager.onDidEmitEnvelope(envelope => { - this._replayBuffer.push(envelope); - if (this._replayBuffer.length > REPLAY_BUFFER_CAPACITY) { - this._replayBuffer.shift(); - } - this._broadcastAction(envelope); - })); - - this._register(this._stateManager.onDidEmitNotification(notification => { - this._broadcastNotification(notification); - })); - } - - // ---- Connection handling ------------------------------------------------- - - private _handleNewConnection(transport: IProtocolTransport): void { - const disposables = new DisposableStore(); - let client: IConnectedClient | undefined; - - disposables.add(transport.onMessage(msg => { - if (isJsonRpcRequest(msg)) { - this._logService.trace(`[ProtocolServer] request: method=${msg.method} id=${msg.id}`); - - // Handle initialize/reconnect as requests that set up the client - if (!client && msg.method === 'initialize') { - try { - const result = this._handleInitialize(msg.params, transport, disposables); - client = result.client; - transport.send(jsonRpcSuccess(msg.id, result.response)); - } catch (err) { - transport.send(jsonRpcErrorFrom(msg.id, err)); - } - return; - } - if (!client && msg.method === 'reconnect') { - let responsePromise: Promise; - try { - const result = this._handleReconnect(msg.params, transport, disposables); - client = result.client; - responsePromise = result.responsePromise; - } catch (err) { - transport.send(jsonRpcErrorFrom(msg.id, err)); - return; - } - responsePromise.then( - response => transport.send(jsonRpcSuccess(msg.id, response)), - err => transport.send(jsonRpcErrorFrom(msg.id, err)), - ); - return; - } - - if (!client) { - return; - } - this._handleRequest(client, msg.method, msg.params, msg.id); - } else if (isJsonRpcNotification(msg)) { - this._logService.trace(`[ProtocolServer] notification: method=${msg.method}`); - // Notification — fire-and-forget - switch (msg.method) { - case 'unsubscribe': - if (client) { - const resource = msg.params.resource; - if (client.subscriptions.delete(resource)) { - this._agentService.unsubscribe(URI.parse(resource), client.clientId); - } - } - break; - case 'dispatchAction': - if (client) { - this._logService.trace(`[ProtocolServer] dispatchAction: ${JSON.stringify(msg.params.action.type)}`); - const action = msg.params.action as SessionAction | TerminalAction | IRootConfigChangedAction; - if (isSessionAction(action) || isTerminalAction(action) || action.type === ActionType.RootConfigChanged) { - this._agentService.dispatchAction(action, client.clientId, msg.params.clientSeq); - } - } - break; - } - } else if (isJsonRpcResponse(msg)) { - const pending = this._pendingReverseRequests.get(msg.id); - if (pending) { - this._pendingReverseRequests.delete(msg.id); - if (hasKey(msg, { error: true })) { - pending.reject(new Error(msg.error?.message ?? 'Reverse RPC error')); - } else { - pending.resolve(msg.result); - } - } - } - })); - - disposables.add(transport.onClose(() => { - if (client && this._clients.get(client.clientId) === client) { - this._logService.info(`[ProtocolServer] Client disconnected: ${client.clientId}, subscriptions=${client.subscriptions.size}`); - // Treat disconnect as an implicit unsubscribe of every resource the - // client held, so the server-side refcount can drop to zero and any - // idle restored session state can be evicted. - for (const resource of client.subscriptions) { - this._agentService.unsubscribe(URI.parse(resource), client.clientId); - } - client.subscriptions.clear(); - this._clients.delete(client.clientId); - this._rejectPendingReverseRequests(client.clientId); - this._handleClientDisconnected(client.clientId); - this._onDidChangeConnectionCount.fire(this._clients.size); - } - disposables.dispose(); - })); - - disposables.add(transport); - } - - // ---- Handshake handlers ---------------------------------------------------- - - private _handleInitialize( - params: InitializeParams, - transport: IProtocolTransport, - disposables: DisposableStore, - ): { client: IConnectedClient; response: unknown } { - this._logService.info(`[ProtocolServer] Initialize: clientId=${params.clientId}, version=${params.protocolVersion}`); - - if (params.protocolVersion < MIN_PROTOCOL_VERSION) { - throw new ProtocolError( - AHP_UNSUPPORTED_PROTOCOL_VERSION, - `Client protocol version ${params.protocolVersion} is below minimum ${MIN_PROTOCOL_VERSION}`, - ); - } - - const client: IConnectedClient = { - clientId: params.clientId, - protocolVersion: params.protocolVersion, - transport, - subscriptions: new Set(), - disposables, - }; - this._clients.set(params.clientId, client); - this._onDidChangeConnectionCount.fire(this._clients.size); - - disposables.add(this._clientFileSystemProvider.registerAuthority(params.clientId, { - resourceList: (uri) => this._sendReverseRequest(params.clientId, 'resourceList', { uri: uri.toString() }), - resourceRead: (uri) => this._sendReverseRequest(params.clientId, 'resourceRead', { uri: uri.toString() }), - resourceWrite: (params_) => this._sendReverseRequest(params.clientId, 'resourceWrite', params_), - resourceDelete: (params_) => this._sendReverseRequest(params.clientId, 'resourceDelete', params_), - resourceMove: (params_) => this._sendReverseRequest(params.clientId, 'resourceMove', params_), - })); - - - const snapshots: IStateSnapshot[] = []; - if (params.initialSubscriptions) { - for (const uri of params.initialSubscriptions) { - const snapshot = this._stateManager.getSnapshot(uri); - if (snapshot) { - snapshots.push(snapshot); - const key = uri.toString(); - client.subscriptions.add(key); - this._agentService.addSubscriber(URI.parse(key), client.clientId); - this._clearClientToolCallDisconnectTimeout(params.clientId, key); - } - } - } - - return { - client, - response: { - protocolVersion: PROTOCOL_VERSION, - serverSeq: this._stateManager.serverSeq, - snapshots, - defaultDirectory: this._config.defaultDirectory, - }, - }; - } - - private _handleReconnect( - params: ReconnectParams, - transport: IProtocolTransport, - disposables: DisposableStore, - ): { client: IConnectedClient; responsePromise: Promise } { - this._logService.info(`[ProtocolServer] Reconnect: clientId=${params.clientId}, lastSeenSeq=${params.lastSeenServerSeq}`); - - // Synchronously install the client so messages arriving on this transport - // while we restore subscriptions can find a valid client object. The - // reconnect response is only sent once `responsePromise` resolves below. - const client: IConnectedClient = { - clientId: params.clientId, - protocolVersion: PROTOCOL_VERSION, - transport, - subscriptions: new Set(), - disposables, - }; - this._clients.set(params.clientId, client); - this._onDidChangeConnectionCount.fire(this._clients.size); - - const oldestBuffered = this._replayBuffer.length > 0 ? this._replayBuffer[0].serverSeq : this._stateManager.serverSeq; - const canReplay = params.lastSeenServerSeq >= oldestBuffered; - - const responsePromise = this._restoreReconnectSubscriptions(client, params, canReplay); - return { client, responsePromise }; - } - - /** - * Re-establish each of the client's prior subscriptions on the server side. - * Uses {@link IAgentService.subscribe} (rather than a bare `addSubscriber` - * + `getSnapshot`) so any session state that was evicted while the client - * was disconnected is restored. Returns the appropriate reconnect response - * payload — `replay` actions when the client's last-seen seq is still in - * the buffer, otherwise fresh `snapshot`s. - */ - private async _restoreReconnectSubscriptions( - client: IConnectedClient, - params: ReconnectParams, - canReplay: boolean, - ): Promise { - const snapshots = await Promise.all(params.subscriptions.map(async sub => { - const key = sub.toString(); - try { - const snapshot = await this._agentService.subscribe(URI.parse(key), client.clientId); - client.subscriptions.add(key); - this._clearClientToolCallDisconnectTimeout(client.clientId, key); - return snapshot; - } catch (err) { - this._logService.warn(`[ProtocolServer] Reconnect: failed to restore subscription ${key}: ${err instanceof Error ? err.message : String(err)}`); - return undefined; - } - })); - - if (canReplay) { - const actions: ActionEnvelope[] = []; - for (const envelope of this._replayBuffer) { - if (envelope.serverSeq > params.lastSeenServerSeq) { - if (this._isRelevantToClient(client, envelope)) { - actions.push(envelope); - } - } - } - return { type: 'replay', actions }; - } - return { type: 'snapshot', snapshots: snapshots.filter((s): s is IStateSnapshot => s !== undefined) }; - } - - private _handleClientDisconnected(clientId: string): void { - for (const session of this._stateManager.getSessionUris()) { - const state = this._stateManager.getSessionState(session); - const ownsPendingToolCall = state ? this._hasPendingClientToolCall(state, clientId) : false; - if (state?.activeClient?.clientId === clientId) { - this._stateManager.dispatchServerAction({ - type: ActionType.SessionActiveClientChanged, - session, - activeClient: null, - }); - } - if (state?.activeClient?.clientId === clientId || ownsPendingToolCall) { - this._startClientToolCallDisconnectTimeout(clientId, session); - } - } - } - - private _hasPendingClientToolCall(state: ReturnType, clientId: string): boolean { - const activeTurn = state?.activeTurn; - if (!activeTurn) { - return false; - } - return activeTurn.responseParts.some(part => part.kind === ResponsePartKind.ToolCall - && part.toolCall.toolClientId === clientId - && (part.toolCall.status === ToolCallStatus.Streaming || part.toolCall.status === ToolCallStatus.Running || part.toolCall.status === ToolCallStatus.PendingConfirmation)); - } - - private _hasReplacementActiveClientTool(state: SessionState, clientId: string, toolName: string): boolean { - const activeClient = state.activeClient; - return activeClient !== undefined - && activeClient.clientId !== clientId - && activeClient.tools.some(tool => tool.name === toolName); - } - - private _startClientToolCallDisconnectTimeout(clientId: string, session: string): void { - this._clearClientToolCallDisconnectTimeout(clientId, session); - const key = this._clientToolCallDisconnectTimeoutKey(clientId, session); - this._clientToolCallDisconnectTimeouts.set(key, setTimeout(() => { - this._clientToolCallDisconnectTimeouts.delete(key); - this._completeDisconnectedClientToolCalls(clientId, session); - }, CLIENT_TOOL_CALL_DISCONNECT_TIMEOUT)); - } - - private _clearClientToolCallDisconnectTimeout(clientId: string, session: string): void { - const key = this._clientToolCallDisconnectTimeoutKey(clientId, session); - const timeout = this._clientToolCallDisconnectTimeouts.get(key); - if (timeout) { - clearTimeout(timeout); - this._clientToolCallDisconnectTimeouts.delete(key); - } - } - - private _clientToolCallDisconnectTimeoutKey(clientId: string, session: string): string { - return `${clientId}\n${session}`; - } - - private _completeDisconnectedClientToolCalls(clientId: string, session: string): void { - const state = this._stateManager.getSessionState(session); - const activeTurn = state?.activeTurn; - if (!activeTurn) { - return; - } - for (const part of activeTurn.responseParts) { - if (part.kind !== ResponsePartKind.ToolCall) { - continue; - } - const toolCall = part.toolCall; - if (toolCall.toolClientId === clientId && (toolCall.status === ToolCallStatus.Streaming || toolCall.status === ToolCallStatus.Running || toolCall.status === ToolCallStatus.PendingConfirmation)) { - const mayRetryWithReplacementClient = this._hasReplacementActiveClientTool(state, clientId, toolCall.toolName); - if (toolCall.status === ToolCallStatus.Streaming) { - this._stateManager.dispatchServerAction({ - type: ActionType.SessionToolCallReady, - session, - turnId: activeTurn.id, - toolCallId: toolCall.toolCallId, - invocationMessage: toolCall.invocationMessage ?? toolCall.displayName, - confirmed: ToolCallConfirmationReason.NotNeeded, - }); - } - this._stateManager.dispatchServerAction({ - type: ActionType.SessionToolCallComplete, - session, - turnId: activeTurn.id, - toolCallId: toolCall.toolCallId, - result: { - success: false, - pastTenseMessage: `${toolCall.displayName} failed`, - ...(mayRetryWithReplacementClient ? { content: [{ type: ToolResultContentType.Text, text: `The client that was running ${toolCall.displayName} disconnected, but another active client now provides ${toolCall.displayName}. You may try calling the tool again.` }] } : {}), - error: { message: `Client ${clientId} disconnected before completing ${toolCall.displayName}` }, - }, - }); - } - } - } - - // ---- Requests (expect a response) --------------------------------------- - - /** - * Methods handled by the request dispatcher (excludes initialize/reconnect - * which are handled during the handshake phase). - */ - private readonly _requestHandlers: RequestHandlerMap = { - subscribe: async (client, params) => { - try { - const snapshot = await this._agentService.subscribe(URI.parse(params.resource), client.clientId); - client.subscriptions.add(params.resource); - this._clearClientToolCallDisconnectTimeout(client.clientId, params.resource); - return { snapshot }; - } catch (err) { - if (err instanceof ProtocolError) { - throw err; - } - throw new ProtocolError(AHP_SESSION_NOT_FOUND, `Resource not found: ${params.resource}`); - } - }, - createSession: async (_client, params) => { - let createdSession: URI; - // Resolve fork turnId to a 0-based index using the source session's - // turn list in the state manager. - let fork: { session: URI; turnIndex: number; turnId: string } | undefined; - if (params.fork) { - const sourceState = this._stateManager.getSessionState(params.fork.session); - if (!sourceState) { - throw new ProtocolError(AHP_SESSION_NOT_FOUND, `Fork source session not found: ${params.fork.session}`); - } - const turnIndex = sourceState.turns.findIndex(t => t.id === params.fork!.turnId); - if (turnIndex < 0) { - throw new ProtocolError(AHP_SESSION_NOT_FOUND, `Fork turn ID ${params.fork.turnId} not found in session ${params.fork.session}`); - } - fork = { session: URI.parse(params.fork.session), turnIndex, turnId: params.fork.turnId }; - } - // If the client eagerly claimed the active client role, validate - // the clientId matches the connection before forwarding. - if (params.activeClient && params.activeClient.clientId !== _client.clientId) { - throw new ProtocolError(JsonRpcErrorCodes.InvalidParams, `createSession.activeClient.clientId must match the connection's clientId`); - } - try { - createdSession = await this._agentService.createSession({ - provider: params.provider, - model: params.model, - workingDirectory: params.workingDirectory ? URI.parse(params.workingDirectory) : undefined, - session: URI.parse(params.session), - fork, - config: params.config, - activeClient: params.activeClient, - }); - } catch (err) { - if (err instanceof ProtocolError) { - throw err; - } - throw new ProtocolError(AHP_PROVIDER_NOT_FOUND, err instanceof Error ? err.message : String(err)); - } - // Verify the provider honored the client-chosen session URI per the protocol contract - if (createdSession.toString() !== URI.parse(params.session).toString()) { - this._logService.warn(`[ProtocolServer] createSession: provider returned URI ${createdSession.toString()} but client requested ${params.session}`); - } - return null; - }, - disposeSession: async (_client, params) => { - await this._agentService.disposeSession(URI.parse(params.session)); - return null; - }, - resourceWrite: async (_client, params) => { - return this._agentService.resourceWrite(params); - }, - listSessions: async () => { - const sessions = await this._agentService.listSessions(); - const items = sessions.map(s => { - const provider = AgentSession.provider(s.session); - if (!provider) { - throw new Error(`Agent session URI has no provider scheme: ${s.session.toString()}`); - } - // Encode isRead/isArchived as status bitmask flags - let status = s.status ?? SessionStatus.Idle; - if (s.isRead) { - status |= SessionStatus.IsRead; - } - if (s.isArchived) { - status |= SessionStatus.IsArchived; - } - return { - resource: s.session.toString(), - provider, - title: s.summary ?? 'Session', - status, - activity: s.activity, - createdAt: s.startTime, - modifiedAt: s.modifiedTime, - ...(s.project ? { project: { uri: s.project.uri.toString(), displayName: s.project.displayName } } : {}), - model: s.model, - workingDirectory: s.workingDirectory?.toString(), - diffs: s.diffs ? [...s.diffs] : undefined, - }; - }); - return { items }; - }, - resolveSessionConfig: async (_client, params) => { - return this._agentService.resolveSessionConfig({ - provider: params.provider, - workingDirectory: params.workingDirectory ? URI.parse(params.workingDirectory) : undefined, - config: params.config, - }); - }, - sessionConfigCompletions: async (_client, params) => { - return this._agentService.sessionConfigCompletions({ - provider: params.provider, - workingDirectory: params.workingDirectory ? URI.parse(params.workingDirectory) : undefined, - config: params.config, - property: params.property, - query: params.query, - }); - }, - fetchTurns: async (_client, params) => { - const state = this._stateManager.getSessionState(params.session); - if (!state) { - throw new ProtocolError(AHP_SESSION_NOT_FOUND, `Session not found: ${params.session}`); - } - const turns = state.turns; - const limit = Math.min(params.limit ?? 50, 100); - - let endIndex = turns.length; - if (params.before) { - const idx = turns.findIndex(t => t.id === params.before); - if (idx !== -1) { - endIndex = idx; - } - } - - const startIndex = Math.max(0, endIndex - limit); - return { - turns: turns.slice(startIndex, endIndex), - hasMore: startIndex > 0, - }; - }, - resourceList: async (_client, params) => { - return this._agentService.resourceList(URI.parse(params.uri)); - }, - resourceRead: async (_client, params) => { - return this._agentService.resourceRead(URI.parse(params.uri)); - }, - resourceCopy: async (_client, params) => { - return this._agentService.resourceCopy(params); - }, - resourceDelete: async (_client, params) => { - return this._agentService.resourceDelete(params); - }, - resourceMove: async (_client, params) => { - return this._agentService.resourceMove(params); - }, - authenticate: async (_client, params) => { - const result = await this._agentService.authenticate(params); - if (!result.authenticated) { - throw new ProtocolError(AHP_AUTH_REQUIRED, 'Authentication failed for resource: ' + params.resource); - } - return {}; - }, - createTerminal: async (_client, params) => { - await this._agentService.createTerminal(params); - return null; - }, - disposeTerminal: async (_client, params) => { - await this._agentService.disposeTerminal(URI.parse(params.terminal)); - return null; - }, - }; - - - // ---- Reverse RPC (server → client requests) ---------------------------- - - private _reverseRequestId = 0; - private readonly _pendingReverseRequests = new Map void; reject: (reason: unknown) => void }>(); - - /** - * Sends a JSON-RPC request to a connected client and waits for the response. - * Used for reverse-RPC operations like reading client-side files. - * Rejects if the client disconnects or the server is disposed. - */ - private _sendReverseRequest(clientId: string, method: string, params: unknown): Promise { - const client = this._clients.get(clientId); - if (!client) { - return Promise.reject(new Error(`Client ${clientId} is not connected`)); - } - const id = ++this._reverseRequestId; - return new Promise((resolve, reject) => { - this._pendingReverseRequests.set(id, { clientId, resolve: resolve as (value: unknown) => void, reject }); - const request: JsonRpcRequest = { jsonrpc: '2.0', id, method, params }; - client.transport.send(request); - }); - } - - /** - * Rejects and clears all pending reverse-RPC requests for a given client. - */ - private _rejectPendingReverseRequests(clientId: string): void { - for (const [id, pending] of this._pendingReverseRequests) { - if (pending.clientId === clientId) { - this._pendingReverseRequests.delete(id); - pending.reject(new Error(`Client ${clientId} disconnected`)); - } - } - } - - private _handleRequest(client: IConnectedClient, method: string, params: unknown, id: number): void { - const handler = this._requestHandlers.hasOwnProperty(method) ? this._requestHandlers[method as RequestMethod] : undefined; - if (handler) { - (handler as (client: IConnectedClient, params: unknown) => Promise)(client, params).then(result => { - this._logService.trace(`[ProtocolServer] Request '${method}' id=${id} succeeded`); - client.transport.send(jsonRpcSuccess(id, result ?? null)); - }).catch(err => { - this._logService.error(`[ProtocolServer] Request '${method}' failed`, err); - client.transport.send(jsonRpcErrorFrom(id, err)); - }); - return; - } - - // VS Code extension methods (not in the typed protocol maps yet) - const extensionResult = this._handleExtensionRequest(method, params); - if (extensionResult) { - extensionResult.then(result => { - client.transport.send(jsonRpcSuccess(id, result ?? null)); - }).catch(err => { - this._logService.error(`[ProtocolServer] Extension request '${method}' failed`, err); - client.transport.send(jsonRpcErrorFrom(id, err)); - }); - return; - } - - client.transport.send(jsonRpcError(id, JSON_RPC_INTERNAL_ERROR, `Unknown method: ${method}`)); - } - - /** - * Handle VS Code extension methods that are not yet part of the typed - * protocol. Returns a Promise if the method was recognized, undefined - * otherwise. - */ - private _handleExtensionRequest(method: string, _params: unknown): Promise | undefined { - switch (method) { - case 'shutdown': - return this._agentService.shutdown(); - default: - return undefined; - } - } - - // ---- Broadcasting ------------------------------------------------------- - - private _broadcastAction(envelope: ActionEnvelope): void { - this._logService.trace(`[ProtocolServer] Broadcasting action: ${envelope.action.type}`); - const msg: AhpServerNotification<'action'> = { jsonrpc: '2.0', method: 'action', params: envelope }; - for (const client of this._clients.values()) { - if (this._isRelevantToClient(client, envelope)) { - client.transport.send(msg); - } - } - } - - private _broadcastNotification(notification: INotification): void { - const msg: AhpServerNotification<'notification'> = { jsonrpc: '2.0', method: 'notification', params: { notification } }; - for (const client of this._clients.values()) { - client.transport.send(msg); - } - } - - private _isRelevantToClient(client: IConnectedClient, envelope: ActionEnvelope): boolean { - const action = envelope.action; - if (action.type.startsWith('root/')) { - return client.subscriptions.has(ROOT_STATE_URI); - } - if (isSessionAction(action)) { - return client.subscriptions.has(action.session); - } - if (isTerminalAction(action)) { - return client.subscriptions.has(action.terminal); - } - return false; - } - - override dispose(): void { - for (const client of this._clients.values()) { - client.disposables.dispose(); - } - this._clients.clear(); - for (const [, pending] of this._pendingReverseRequests) { - pending.reject(new Error('ProtocolServerHandler disposed')); - } - this._pendingReverseRequests.clear(); - for (const timeout of this._clientToolCallDisconnectTimeouts.values()) { - clearTimeout(timeout); - } - this._clientToolCallDisconnectTimeouts.clear(); - this._replayBuffer.length = 0; - super.dispose(); - } -} diff --git a/src/vs/platform/agentHost/node/serverUrls.ts b/src/vs/platform/agentHost/node/serverUrls.ts deleted file mode 100644 index 7112746b..00000000 --- a/src/vs/platform/agentHost/node/serverUrls.ts +++ /dev/null @@ -1,48 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import * as os from 'os'; - -export interface IResolvedServerUrls { - readonly local: readonly string[]; - readonly network: readonly string[]; -} - -const loopbackHosts = new Set(['localhost', '127.0.0.1', '::1', '0000:0000:0000:0000:0000:0000:0000:0001']); -const wildcardHosts = new Set(['0.0.0.0', '::', '0000:0000:0000:0000:0000:0000:0000:0000']); - -export function resolveServerUrls(host: string | undefined, port: number, networkInterfaces: ReturnType = os.networkInterfaces()): IResolvedServerUrls { - if (host === undefined) { - return { local: [formatWebSocketUrl('localhost', port)], network: [] }; - } - - if (!wildcardHosts.has(host)) { - const url = formatWebSocketUrl(host, port); - return loopbackHosts.has(host) - ? { local: [url], network: [] } - : { local: [], network: [url] }; - } - - const network = new Set(); - for (const netInterface of Object.values(networkInterfaces)) { - for (const detail of netInterface ?? []) { - if (detail.family !== 'IPv4' || detail.internal) { - continue; - } - - network.add(formatWebSocketUrl(detail.address, port)); - } - } - - return { - local: [formatWebSocketUrl('localhost', port)], - network: [...network], - }; -} - -export function formatWebSocketUrl(host: string, port: number): string { - const normalizedHost = host.includes(':') ? `[${host}]` : host; - return `ws://${normalizedHost}:${port}`; -} diff --git a/src/vs/platform/agentHost/node/sessionDataService.ts b/src/vs/platform/agentHost/node/sessionDataService.ts deleted file mode 100644 index 96c17fc7..00000000 --- a/src/vs/platform/agentHost/node/sessionDataService.ts +++ /dev/null @@ -1,156 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { IReference, ReferenceCollection } from '../../../base/common/lifecycle.js'; -import { URI } from '../../../base/common/uri.js'; -import { IFileService } from '../../files/common/files.js'; -import { ILogService } from '../../log/common/log.js'; -import { AgentSession } from '../common/agentService.js'; -import { ISessionDatabase, ISessionDataService, SESSION_DB_FILENAME } from '../common/sessionDataService.js'; -import { SessionDatabase } from './sessionDatabase.js'; - -class SessionDatabaseCollection extends ReferenceCollection { - - /** - * The set of currently-open databases. Mirrors what's held by the - * underlying ref-counted map, but exposed so {@link SessionDataService.whenIdle} - * can iterate without reaching into private state. - */ - readonly liveDatabases = new Set(); - - constructor( - private readonly _getDbPath: (key: string) => string, - private readonly _logService: ILogService, - ) { - super(); - } - - protected createReferencedObject(key: string): ISessionDatabase { - const dbPath = this._getDbPath(key); - this._logService.trace(`[SessionDataService] Opening database: ${dbPath}`); - const db = new SessionDatabase(dbPath); - this.liveDatabases.add(db); - return db; - } - - protected destroyReferencedObject(_key: string, object: ISessionDatabase): void { - this.liveDatabases.delete(object); - object.dispose(); - } -} - -/** - * Implementation of {@link ISessionDataService} that stores per-session data - * under `{userDataPath}/agentSessionData/{sessionId}/`. - */ -export class SessionDataService implements ISessionDataService { - declare readonly _serviceBrand: undefined; - - private readonly _basePath: URI; - private readonly _databases: SessionDatabaseCollection; - - constructor( - userDataPath: URI, - @IFileService private readonly _fileService: IFileService, - @ILogService private readonly _logService: ILogService, - getDbPath?: (key: string) => string, // for testing - ) { - this._basePath = URI.joinPath(userDataPath, 'agentSessionData'); - this._databases = new SessionDatabaseCollection( - getDbPath ?? (key => URI.joinPath(this._basePath, key, SESSION_DB_FILENAME).fsPath), - this._logService, - ); - } - - getSessionDataDir(session: URI): URI { - return this.getSessionDataDirById(AgentSession.id(session)); - } - - getSessionDataDirById(sessionId: string): URI { - const sanitized = sessionId.replace(/[^a-zA-Z0-9_.-]/g, '-'); - return URI.joinPath(this._basePath, sanitized); - } - - private _sanitizedSessionKey(session: URI): string { - return AgentSession.id(session).replace(/[^a-zA-Z0-9_.-]/g, '-'); - } - - openDatabase(session: URI): IReference { - return this._databases.acquire(this._sanitizedSessionKey(session)); - } - - async tryOpenDatabase(session: URI): Promise | undefined> { - const key = this._sanitizedSessionKey(session); - const dbPath = URI.joinPath(this._basePath, key, SESSION_DB_FILENAME); - if (!await this._fileService.exists(dbPath)) { - return undefined; - } - return this._databases.acquire(key); - } - - async deleteSessionData(session: URI): Promise { - const dir = this.getSessionDataDir(session); - try { - if (await this._fileService.exists(dir)) { - await this._fileService.del(dir, { recursive: true }); - this._logService.trace(`[SessionDataService] Deleted session data: ${dir.toString()}`); - } - } catch (err) { - this._logService.warn(`[SessionDataService] Failed to delete session data: ${dir.toString()}`, err); - } - } - - async cleanupOrphanedData(knownSessionIds: Set): Promise { - try { - const exists = await this._fileService.exists(this._basePath); - if (!exists) { - return; - } - - const stat = await this._fileService.resolve(this._basePath); - if (!stat.children) { - return; - } - - const deletions: Promise[] = []; - for (const child of stat.children) { - if (!child.isDirectory) { - continue; - } - const name = child.name; - if (!knownSessionIds.has(name)) { - this._logService.trace(`[SessionDataService] Cleaning up orphaned session data: ${name}`); - deletions.push( - this._fileService.del(child.resource, { recursive: true }).catch(err => { - this._logService.warn(`[SessionDataService] Failed to clean up orphaned data: ${name}`, err); - }) - ); - } - } - - await Promise.all(deletions); - } catch (err) { - this._logService.warn('[SessionDataService] Failed to run orphan cleanup', err); - } - } - - async whenIdle(): Promise { - // Each `SessionDatabase.whenIdle()` already loops internally until - // that DB is quiescent, so the outer loop only needs to handle the - // case where a new DB was opened (and writes queued against it) - // while we were awaiting an earlier pass. - while (true) { - const dbs = [...this._databases.liveDatabases]; - if (dbs.length === 0) { - return; - } - await Promise.all(dbs.map(db => db.whenIdle())); - const newOnes = [...this._databases.liveDatabases].filter(db => !dbs.includes(db)); - if (newOnes.length === 0) { - return; - } - } - } -} diff --git a/src/vs/platform/agentHost/node/sessionDatabase.ts b/src/vs/platform/agentHost/node/sessionDatabase.ts deleted file mode 100644 index 76b41fb7..00000000 --- a/src/vs/platform/agentHost/node/sessionDatabase.ts +++ /dev/null @@ -1,591 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import * as fs from 'fs'; -import { SequencerByKey } from '../../../base/common/async.js'; -import type { Database, RunResult } from '@vscode/sqlite3'; -import type { IFileEditContent, IFileEditRecord, ISessionDatabase } from '../common/sessionDataService.js'; -import { dirname } from '../../../base/common/path.js'; - -/** - * A single numbered migration. Migrations are applied in order of - * {@link version} and tracked via `PRAGMA user_version`. - */ -export interface ISessionDatabaseMigration { - /** Monotonically-increasing version number (1-based). */ - readonly version: number; - /** SQL to execute for this migration. */ - readonly sql: string; -} - -/** - * The set of migrations that define the current session database schema. - * New migrations should be **appended** to this array with the next version - * number. Never reorder or mutate existing entries. - */ -export const sessionDatabaseMigrations: readonly ISessionDatabaseMigration[] = [ - { - version: 1, - sql: [ - `CREATE TABLE IF NOT EXISTS turns ( - id TEXT PRIMARY KEY NOT NULL - )`, - `CREATE TABLE IF NOT EXISTS file_edits ( - turn_id TEXT NOT NULL REFERENCES turns(id) ON DELETE CASCADE, - tool_call_id TEXT NOT NULL, - file_path TEXT NOT NULL, - before_content BLOB NOT NULL, - after_content BLOB NOT NULL, - added_lines INTEGER, - removed_lines INTEGER, - PRIMARY KEY (tool_call_id, file_path) - )`, - ].join(';\n'), - }, - { - version: 2, - sql: `CREATE TABLE IF NOT EXISTS session_metadata ( - key TEXT PRIMARY KEY NOT NULL, - value TEXT NOT NULL - )`, - }, - { - version: 3, - sql: [ - // Recreate file_edits with new columns: edit_type, original_path, - // and nullable before_content/after_content. - `CREATE TABLE file_edits_v3 ( - turn_id TEXT NOT NULL REFERENCES turns(id) ON DELETE CASCADE, - tool_call_id TEXT NOT NULL, - file_path TEXT NOT NULL, - edit_type TEXT NOT NULL DEFAULT 'edit', - original_path TEXT, - before_content BLOB, - after_content BLOB, - added_lines INTEGER, - removed_lines INTEGER, - PRIMARY KEY (tool_call_id, file_path) - )`, - `INSERT INTO file_edits_v3 (turn_id, tool_call_id, file_path, edit_type, before_content, after_content, added_lines, removed_lines) - SELECT turn_id, tool_call_id, file_path, 'edit', before_content, after_content, added_lines, removed_lines FROM file_edits`, - `DROP TABLE file_edits`, - `ALTER TABLE file_edits_v3 RENAME TO file_edits`, - ].join(';\n'), - }, - { - version: 4, - sql: [ - `ALTER TABLE turns ADD COLUMN event_id TEXT`, - `CREATE INDEX IF NOT EXISTS idx_turns_event_id ON turns(event_id)`, - ].join(';\n'), - }, -]; - -// ---- Promise wrappers around callback-based @vscode/sqlite3 API ----------- - -function dbExec(db: Database, sql: string): Promise { - return new Promise((resolve, reject) => { - db.exec(sql, err => err ? reject(err) : resolve()); - }); -} - -function dbRun(db: Database, sql: string, params: unknown[]): Promise<{ changes: number; lastID: number }> { - return new Promise((resolve, reject) => { - db.run(sql, params, function (this: RunResult, err: Error | null) { - if (err) { - return reject(err); - } - resolve({ changes: this.changes, lastID: this.lastID }); - }); - }); -} - -function dbGet(db: Database, sql: string, params: unknown[]): Promise | undefined> { - return new Promise((resolve, reject) => { - db.get(sql, params, (err: Error | null, row: Record | undefined) => { - if (err) { - return reject(err); - } - resolve(row); - }); - }); -} - -function dbAll(db: Database, sql: string, params: unknown[]): Promise[]> { - return new Promise((resolve, reject) => { - db.all(sql, params, (err: Error | null, rows: Record[]) => { - if (err) { - return reject(err); - } - resolve(rows); - }); - }); -} - -function dbClose(db: Database): Promise { - return new Promise((resolve, reject) => { - db.close(err => err ? reject(err) : resolve()); - }); -} - -function dbOpen(path: string): Promise { - return new Promise((resolve, reject) => { - import('@vscode/sqlite3').then(sqlite3 => { - const db = new sqlite3.default.Database(path, (err: Error | null) => { - if (err) { - return reject(err); - } - resolve(db); - }); - }, reject); - }); -} - -/** - * Applies any pending {@link ISessionDatabaseMigration migrations} to a - * database. Migrations whose version is greater than the current - * `PRAGMA user_version` are run inside a serialized transaction. After all - * migrations complete the pragma is updated to the highest applied version. - */ -export async function runMigrations(db: Database, migrations: readonly ISessionDatabaseMigration[]): Promise { - // Enable foreign key enforcement — must be set outside a transaction - // and every time a connection is opened. - await dbExec(db, 'PRAGMA foreign_keys = ON'); - - const row = await dbGet(db, 'PRAGMA user_version', []); - const currentVersion = (row?.user_version as number | undefined) ?? 0; - - const pending = migrations - .filter(m => m.version > currentVersion) - .sort((a, b) => a.version - b.version); - - if (pending.length === 0) { - return; - } - - await dbExec(db, 'BEGIN TRANSACTION'); - try { - for (const migration of pending) { - await dbExec(db, migration.sql); - // PRAGMA cannot be parameterized; the version is a trusted literal. - await dbExec(db, `PRAGMA user_version = ${migration.version}`); - } - await dbExec(db, 'COMMIT'); - } catch (err) { - await dbExec(db, 'ROLLBACK'); - throw err; - } -} - -/** - * A wrapper around a `@vscode/sqlite3` {@link Database} instance with - * lazy initialisation. - * - * The underlying connection is opened on the first async method call - * (not at construction time), allowing the object to be created - * synchronously and shared via a {@link ReferenceCollection}. - * - * Calling {@link dispose} closes the connection. - */ -export class SessionDatabase implements ISessionDatabase { - - protected _dbPromise: Promise | undefined; - protected _closed: Promise | true | undefined; - private readonly _fileEditSequencer = new SequencerByKey(); - - /** - * Serializes `setMetadata` writes per key. `@vscode/sqlite3` runs in - * parallelized mode, so two `db.run()` calls on the same connection - * can be dispatched to the libuv thread pool and complete out of - * submission order. For "last writer wins" keys (notably `configValues` - * via {@link setMetadata}), that meant a fast-following second write - * could be overtaken by the first and silently lose its value — see - * the "Session Config persistence across restarts" integration test. - * Sequencing by key preserves intra-key order while still allowing - * writes for different keys to run concurrently. - */ - private readonly _metadataSequencer = new SequencerByKey(); - - /** - * In-flight write operations. Tracked so {@link whenIdle} can await them - * before the process exits — without this, a `SIGTERM` arriving between - * a fire-and-forget mutating call (e.g. `setMetadata`) being invoked and - * its underlying SQLite query completing would silently drop the write. - * Every public mutating method routes its returned promise through - * {@link _track}; reads (`getMetadata`, `getFileEdits`, ...) skip - * tracking since shutdown does not need to wait for them. - */ - private readonly _pendingWrites = new Set>(); - - constructor( - private readonly _path: string, - private readonly _migrations: readonly ISessionDatabaseMigration[] = sessionDatabaseMigrations, - ) { } - - /** - * Opens (or creates) a SQLite database at {@link path} and applies - * any pending migrations. Only used in tests where synchronous - * construction + immediate readiness is desired. - */ - static async open(path: string, migrations: readonly ISessionDatabaseMigration[] = sessionDatabaseMigrations): Promise { - const inst = new SessionDatabase(path, migrations); - await inst._ensureDb(); - return inst; - } - - protected _ensureDb(): Promise { - if (this._closed) { - return Promise.reject(new Error('SessionDatabase has been disposed')); - } - if (!this._dbPromise) { - this._dbPromise = (async () => { - // Ensure the parent directory exists before SQLite tries to - // create the database file. - await fs.promises.mkdir(dirname(this._path), { recursive: true }); - const db = await dbOpen(this._path); - try { - await runMigrations(db, this._migrations); - } catch (err) { - await dbClose(db); - this._dbPromise = undefined; - throw err; - } - // If dispose() was called while we were opening, close immediately. - if (this._closed) { - await dbClose(db); - throw new Error('SessionDatabase has been disposed'); - } - return db; - })(); - } - return this._dbPromise; - } - - /** - * Returns the names of all user-created tables in the database. - * Useful for testing migration behavior. - */ - async getAllTables(): Promise { - const db = await this._ensureDb(); - const rows = await dbAll(db, `SELECT name FROM sqlite_master WHERE type='table' ORDER BY name`, []); - return rows.map(r => r.name as string); - } - - // ---- Turns ---------------------------------------------------------- - - createTurn(turnId: string): Promise { - return this._track(async () => { - const db = await this._ensureDb(); - await dbRun(db, 'INSERT OR IGNORE INTO turns (id) VALUES (?)', [turnId]); - }); - } - - deleteTurn(turnId: string): Promise { - return this._track(async () => { - const db = await this._ensureDb(); - await dbRun(db, 'DELETE FROM turns WHERE id = ?', [turnId]); - }); - } - - setTurnEventId(turnId: string, eventId: string): Promise { - return this._track(async () => { - const db = await this._ensureDb(); - await dbRun(db, 'INSERT OR IGNORE INTO turns (id) VALUES (?)', [turnId]); - // Only set the event ID if not already set — steering messages - // trigger additional user.message events within the same turn, - // and we must preserve the first (boundary) event ID. - await dbRun(db, 'UPDATE turns SET event_id = ? WHERE id = ? AND event_id IS NULL', [eventId, turnId]); - }); - } - - async getTurnEventId(turnId: string): Promise { - const db = await this._ensureDb(); - const row = await dbGet(db, 'SELECT event_id FROM turns WHERE id = ?', [turnId]); - return row?.event_id as string | undefined ?? undefined; - } - - async getNextTurnEventId(turnId: string): Promise { - const db = await this._ensureDb(); - const row = await dbGet( - db, - `SELECT event_id FROM turns WHERE rowid > (SELECT rowid FROM turns WHERE id = ?) ORDER BY rowid LIMIT 1`, - [turnId], - ); - return row?.event_id as string | undefined ?? undefined; - } - - async getFirstTurnEventId(): Promise { - const db = await this._ensureDb(); - const row = await dbGet(db, 'SELECT event_id FROM turns ORDER BY rowid LIMIT 1', []); - return row?.event_id as string | undefined ?? undefined; - } - - truncateFromTurn(turnId: string): Promise { - return this._track(async () => { - const db = await this._ensureDb(); - // Delete the target turn and all turns inserted after it (by rowid order). - // File edits cascade-delete via the foreign key constraint. - await dbRun(db, - `DELETE FROM turns WHERE rowid >= (SELECT rowid FROM turns WHERE id = ?)`, - [turnId], - ); - }); - } - - deleteTurnsAfter(turnId: string): Promise { - return this._track(async () => { - const db = await this._ensureDb(); - // Delete all turns inserted after the given turn (by rowid order), - // keeping the given turn itself. - // File edits cascade-delete via the foreign key constraint. - await dbRun(db, - `DELETE FROM turns WHERE rowid > (SELECT rowid FROM turns WHERE id = ?)`, - [turnId], - ); - }); - } - - deleteAllTurns(): Promise { - return this._track(async () => { - const db = await this._ensureDb(); - await dbExec(db, 'DELETE FROM turns'); - }); - } - - // ---- File edits ----------------------------------------------------- - - storeFileEdit(edit: IFileEditRecord & IFileEditContent): Promise { - return this._track(() => this._fileEditSequencer.queue(edit.filePath, async () => { - const db = await this._ensureDb(); - // Ensure the turn exists — lazily insert since the turn record - // may not have been created by an explicit createTurn() call. - await dbRun(db, 'INSERT OR IGNORE INTO turns (id) VALUES (?)', [edit.turnId]); - await dbRun( - db, - `INSERT OR REPLACE INTO file_edits - (turn_id, tool_call_id, file_path, edit_type, original_path, before_content, after_content, added_lines, removed_lines) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, - [ - edit.turnId, - edit.toolCallId, - edit.filePath, - edit.kind, - edit.originalPath ?? null, - edit.beforeContent ? Buffer.from(edit.beforeContent) : null, - edit.afterContent ? Buffer.from(edit.afterContent) : null, - edit.addedLines ?? null, - edit.removedLines ?? null, - ], - ); - })); - } - - async getFileEdits(toolCallIds: string[]): Promise { - if (toolCallIds.length === 0) { - return []; - } - const db = await this._ensureDb(); - const placeholders = toolCallIds.map(() => '?').join(','); - const rows = await dbAll( - db, - `SELECT turn_id, tool_call_id, file_path, edit_type, original_path, added_lines, removed_lines - FROM file_edits - WHERE tool_call_id IN (${placeholders}) - ORDER BY rowid`, - toolCallIds, - ); - return rows.map(row => ({ - turnId: row.turn_id as string, - toolCallId: row.tool_call_id as string, - filePath: row.file_path as string, - kind: (row.edit_type as IFileEditRecord['kind']) ?? 'edit', - originalPath: row.original_path as string | undefined ?? undefined, - addedLines: row.added_lines as number | undefined ?? undefined, - removedLines: row.removed_lines as number | undefined ?? undefined, - })); - } - - async getAllFileEdits(): Promise { - const db = await this._ensureDb(); - const rows = await dbAll( - db, - `SELECT turn_id, tool_call_id, file_path, edit_type, original_path, added_lines, removed_lines - FROM file_edits - ORDER BY rowid`, - [], - ); - return rows.map(row => ({ - turnId: row.turn_id as string, - toolCallId: row.tool_call_id as string, - filePath: row.file_path as string, - kind: (row.edit_type as IFileEditRecord['kind']) ?? 'edit', - originalPath: row.original_path as string | undefined ?? undefined, - addedLines: row.added_lines as number | undefined ?? undefined, - removedLines: row.removed_lines as number | undefined ?? undefined, - })); - } - - async getFileEditsByTurn(turnId: string): Promise { - const db = await this._ensureDb(); - const rows = await dbAll( - db, - `SELECT turn_id, tool_call_id, file_path, edit_type, original_path, added_lines, removed_lines - FROM file_edits - WHERE turn_id = ? - ORDER BY rowid`, - [turnId], - ); - return rows.map(row => ({ - turnId: row.turn_id as string, - toolCallId: row.tool_call_id as string, - filePath: row.file_path as string, - kind: (row.edit_type as IFileEditRecord['kind']) ?? 'edit', - originalPath: row.original_path as string | undefined ?? undefined, - addedLines: row.added_lines as number | undefined ?? undefined, - removedLines: row.removed_lines as number | undefined ?? undefined, - })); - } - - async readFileEditContent(toolCallId: string, filePath: string): Promise { - return this._fileEditSequencer.queue(filePath, async () => { - const db = await this._ensureDb(); - const row = await dbGet( - db, - `SELECT before_content, after_content - FROM file_edits - WHERE tool_call_id = ? AND file_path = ?`, - [toolCallId, filePath], - ); - if (!row) { - return undefined; - } - return { - beforeContent: row.before_content ? toUint8Array(row.before_content) : undefined, - afterContent: row.after_content ? toUint8Array(row.after_content) : undefined, - }; - }); - } - - // ---- Session metadata ----------------------------------------------- - - async getMetadata(key: string): Promise { - const db = await this._ensureDb(); - const row = await dbGet(db, 'SELECT value FROM session_metadata WHERE key = ?', [key]); - return row?.value as string | undefined; - } - - async getMetadataObject>(obj: T): Promise<{ [K in keyof T]: string | undefined }> { - const keys = Object.keys(obj) as (keyof T & string)[]; - // eslint-disable-next-line local/code-no-dangerous-type-assertions - const result = {} as { [K in keyof T]: string | undefined }; - if (keys.length === 0) { - return result; - } - const db = await this._ensureDb(); - const placeholders = keys.map(() => '?').join(','); - const rows = await dbAll(db, `SELECT key, value FROM session_metadata WHERE key IN (${placeholders})`, keys); - for (const key of keys) { - result[key] = undefined; - } - for (const row of rows) { - result[row.key as keyof T] = row.value as string; - } - return result; - } - - setMetadata(key: string, value: string): Promise { - return this._track(() => this._metadataSequencer.queue(key, async () => { - const db = await this._ensureDb(); - await dbRun(db, 'INSERT OR REPLACE INTO session_metadata (key, value) VALUES (?, ?)', [key, value]); - })); - } - - remapTurnIds(mapping: ReadonlyMap): Promise { - return this._track(async () => { - const db = await this._ensureDb(); - // Defer FK checks to commit time so we can update turns.id and - // file_edits.turn_id in any order without mid-statement violations. - // This pragma auto-resets after the transaction ends. - await dbExec(db, 'PRAGMA defer_foreign_keys = ON'); - await dbExec(db, 'BEGIN TRANSACTION'); - try { - // Delete turns not present in the mapping (e.g. turns beyond - // the fork point). File edits cascade-delete via FK. - const oldIds = [...mapping.keys()]; - if (oldIds.length > 0) { - const placeholders = oldIds.map(() => '?').join(','); - await dbRun(db, - `DELETE FROM turns WHERE id NOT IN (${placeholders})`, - oldIds, - ); - } - - // Remap the remaining turn IDs to their new values - for (const [oldId, newId] of mapping) { - await dbRun(db, 'UPDATE turns SET id = ? WHERE id = ?', [newId, oldId]); - await dbRun(db, 'UPDATE file_edits SET turn_id = ? WHERE turn_id = ?', [newId, oldId]); - } - await dbExec(db, 'COMMIT'); - } catch (err) { - await dbExec(db, 'ROLLBACK'); - throw err; - } - }); - } - - /** - * Resolves once all currently in-flight write operations have settled. - * Used by graceful shutdown to flush pending fire-and-forget writes - * before the process exits. Should be called from a path where no - * further writes are expected; loops until idle to also drain any - * writes that get queued while we're awaiting. - */ - async whenIdle(): Promise { - while (this._pendingWrites.size > 0) { - await Promise.allSettled([...this._pendingWrites]); - } - } - - async vacuumInto(targetPath: string) { - const db = await this._ensureDb(); - await dbRun(db, 'VACUUM INTO ?', [targetPath]); - } - - /** - * Wrap a mutating operation's promise so {@link whenIdle} can await it. - * Invoke at the **outermost** layer of every public mutating method so - * that any internal awaits (notably `_ensureDb()`) are covered too — - * tracking only the leaf `dbRun`/`dbExec` would miss the window - * between the method being called and the query actually being queued. - */ - private _track(fn: () => Promise): Promise { - const p = fn(); - this._pendingWrites.add(p); - const untrack = () => { this._pendingWrites.delete(p); }; - p.then(untrack, untrack); - return p; - } - - async close() { - await (this._closed ??= this._dbPromise?.then(db => db.close()).catch(() => { }) || true); - } - - dispose(): void { - this.close(); - } -} - -function toUint8Array(value: unknown): Uint8Array { - if (value instanceof Buffer) { - return new Uint8Array(value.buffer, value.byteOffset, value.byteLength); - } - if (value instanceof Uint8Array) { - return value; - } - if (typeof value === 'string') { - return new TextEncoder().encode(value); - } - return new Uint8Array(0); -} diff --git a/src/vs/platform/agentHost/node/sessionDiffAggregator.ts b/src/vs/platform/agentHost/node/sessionDiffAggregator.ts deleted file mode 100644 index 34082680..00000000 --- a/src/vs/platform/agentHost/node/sessionDiffAggregator.ts +++ /dev/null @@ -1,234 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { URI } from '../../../base/common/uri.js'; -import type { IFileEditRecord, ISessionDatabase } from '../common/sessionDataService.js'; -import type { IDiffComputeService } from '../common/diffComputeService.js'; -import { FileEditKind, type ISessionFileDiff } from '../common/state/sessionState.js'; -import { buildSessionDbUri } from './copilot/fileEditTracker.js'; - -function getFileEditUri(diff: ISessionFileDiff): string | undefined { - return diff.after?.uri ?? diff.before?.uri; -} - -function createSessionFileDiff(sessionUri: string, identity: IFileIdentity, added: number, removed: number): ISessionFileDiff { - const hasBefore = identity.firstKind !== FileEditKind.Create; - const hasAfter = identity.lastKind !== FileEditKind.Delete; - return { - ...(hasBefore ? { - before: { - uri: URI.file(identity.firstFilePath).toString(), - content: { uri: buildSessionDbUri(sessionUri, identity.firstToolCallId, identity.firstFilePath, 'before') }, - }, - } : {}), - ...(hasAfter ? { - after: { - uri: URI.file(identity.terminalPath).toString(), - content: { uri: buildSessionDbUri(sessionUri, identity.lastToolCallId, identity.lastFilePath, 'after') }, - }, - } : {}), - diff: { added, removed }, - }; -} - -/** - * Represents a file's identity across renames, tracking its first and last - * snapshots in the session for diff computation. - */ -interface IFileIdentity { - /** The last known URI for this file. */ - terminalPath: string; - /** Tool call ID of the first edit (for fetching "before" content). */ - firstToolCallId: string; - /** File path used in the first edit's database record. */ - firstFilePath: string; - /** The kind of the first edit (Create means no "before" content). */ - firstKind: FileEditKind; - /** Tool call ID of the last edit (for fetching "after" content). */ - lastToolCallId: string; - /** File path used in the last edit's database record. */ - lastFilePath: string; - /** The kind of the last edit (Delete means no "after" content). */ - lastKind: FileEditKind; -} - -/** - * Options for incremental diff computation. When provided, - * {@link computeSessionDiffs} reuses previous diff results for file - * identities that were not touched in the given turn. - */ -export interface IIncrementalDiffOptions { - /** The turn ID that just completed — only identities touched by edits - * in this turn will be recomputed. */ - changedTurnId: string; - /** Previously computed diffs (from the last dispatch). Entries for - * untouched identities are carried over without recomputation. */ - previousDiffs: ISessionFileDiff[]; -} - -/** - * Computes aggregated diff statistics for a session by comparing each file's - * first snapshot to its last snapshot, tracking renames across the chain. - * - * When {@link incremental} is provided, only identities that were touched - * by edits in the given turn are recomputed; all other identities reuse - * the previous diff results. This avoids expensive content fetches and - * diff computations for unchanged files. - * - * Returns an {@link ISessionFileDiff} array with the "last known URI" for each - * file and the total lines added/removed across the session. - */ -export async function computeSessionDiffs( - sessionUri: string, - db: ISessionDatabase, - diffService: IDiffComputeService, - incremental?: IIncrementalDiffOptions, -): Promise { - // In incremental mode, try to fetch only the current turn's edits. - // When the turn only introduces new files (no renames, no re-edits of - // previously changed files), the full edit history is not needed. - let edits: IFileEditRecord[]; - let fastPath = false; - - if (incremental) { - const turnEdits = await db.getFileEditsByTurn(incremental.changedTurnId); - if (turnEdits.length === 0) { - return [...incremental.previousDiffs]; - } - - const previousDiffsUris = new Set(incremental.previousDiffs.map(getFileEditUri)); - const needsFullHistory = turnEdits.some(e => - e.kind === FileEditKind.Rename || - previousDiffsUris.has(URI.file(e.filePath).toString()) - ); - - if (needsFullHistory) { - edits = await db.getAllFileEdits(); - } else { - edits = turnEdits; - fastPath = true; - } - } else { - edits = await db.getAllFileEdits(); - } - - if (edits.length === 0) { - return []; - } - - // Build file identity graph. We need to: - // 1. Track renames: when a file is renamed A→B, its identity follows to B - // 2. Find the first "before" snapshot and last "after" snapshot per identity - - // Maps a file path to its canonical identity key (follows rename chains) - const pathToIdentityKey = new Map(); - // Maps identity keys to their accumulated data - const identities = new Map(); - // Track which identity keys were touched by the incremental turn. - // In fast-path mode all identities are from the current turn, so no tracking needed. - const touchedIdentityKeys = (incremental && !fastPath) ? new Set() : undefined; - - for (const edit of edits) { - let identityKey: string; - - if (edit.kind === FileEditKind.Rename && edit.originalPath) { - // Rename: follow the chain from originalPath to find the identity - identityKey = pathToIdentityKey.get(edit.originalPath) ?? edit.originalPath; - // Update the mapping: the new path now points to the same identity - pathToIdentityKey.set(edit.filePath, identityKey); - // Remove old path mapping (the file no longer exists at that path) - pathToIdentityKey.delete(edit.originalPath); - } else { - // Regular edit, create, or delete: look up or create identity - identityKey = pathToIdentityKey.get(edit.filePath) ?? edit.filePath; - pathToIdentityKey.set(edit.filePath, identityKey); - } - - if (touchedIdentityKeys && edit.turnId === incremental!.changedTurnId) { - touchedIdentityKeys.add(identityKey); - } - - const existing = identities.get(identityKey); - if (!existing) { - // First time seeing this file identity - identities.set(identityKey, { - terminalPath: edit.filePath, - firstToolCallId: edit.toolCallId, - firstFilePath: edit.kind === FileEditKind.Rename && edit.originalPath ? edit.originalPath : edit.filePath, - firstKind: edit.kind, - lastToolCallId: edit.toolCallId, - lastFilePath: edit.filePath, - lastKind: edit.kind, - }); - } else { - // Update last snapshot info and terminal path - existing.terminalPath = edit.filePath; - existing.lastToolCallId = edit.toolCallId; - existing.lastFilePath = edit.filePath; - existing.lastKind = edit.kind; - } - } - - // In incremental slow-path mode, build a lookup map from URI string → - // previous diff so untouched identities can carry over their previous results. - const previousDiffsMap = (incremental && !fastPath) - ? new Map(incremental.previousDiffs.map(d => [getFileEditUri(d), d])) - : undefined; - - // Compute diffs for each file identity - const results: ISessionFileDiff[] = []; - const diffPromises: Promise[] = []; - - for (const [identityKey, identity] of identities) { - // In incremental slow-path mode, skip recomputation for untouched identities - if (touchedIdentityKeys && !touchedIdentityKeys.has(identityKey)) { - const uri = URI.file(identity.terminalPath).toString(); - const prev = previousDiffsMap!.get(uri); - if (prev) { - results.push(prev); - } - // If no previous entry, the file previously had zero net change — skip - continue; - } - - diffPromises.push((async () => { - // Determine "before" text - let beforeText: string; - if (identity.firstKind === FileEditKind.Create) { - beforeText = ''; - } else { - const content = await db.readFileEditContent(identity.firstToolCallId, identity.firstFilePath); - beforeText = content?.beforeContent ? new TextDecoder().decode(content.beforeContent) : ''; - } - - // Determine "after" text - let afterText: string; - if (identity.lastKind === FileEditKind.Delete) { - afterText = ''; - } else { - const content = await db.readFileEditContent(identity.lastToolCallId, identity.lastFilePath); - afterText = content?.afterContent ? new TextDecoder().decode(content.afterContent) : ''; - } - - // Skip files with no net change - if (beforeText === afterText) { - return; - } - - const counts = await diffService.computeDiffCounts(beforeText, afterText); - results.push(createSessionFileDiff(sessionUri, identity, counts.added, counts.removed)); - })()); - } - - await Promise.allSettled(diffPromises); - - // In fast-path mode, carry over previous diffs for untouched files - // (they were not in the identity graph since we only loaded the current turn) - if (fastPath) { - results.push(...incremental!.previousDiffs); - } - - return results; -} diff --git a/src/vs/platform/agentHost/node/sessionPermissions.ts b/src/vs/platform/agentHost/node/sessionPermissions.ts deleted file mode 100644 index 660181bf..00000000 --- a/src/vs/platform/agentHost/node/sessionPermissions.ts +++ /dev/null @@ -1,282 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { match as globMatch } from '../../../base/common/glob.js'; -import { Disposable } from '../../../base/common/lifecycle.js'; -import { extUriBiasedIgnorePathCase, normalizePath } from '../../../base/common/resources.js'; -import { URI } from '../../../base/common/uri.js'; -import { localize } from '../../../nls.js'; -import { ILogService } from '../../log/common/log.js'; -import type { IAgentToolPendingConfirmationSignal } from '../common/agentService.js'; -import { platformSessionSchema } from '../common/agentHostSchema.js'; -import { SessionConfigKey } from '../common/sessionConfigKeys.js'; -import { ConfirmationOptionKind, type ConfirmationOption } from '../common/state/protocol/state.js'; -import { ActionType, type IToolCallReadyAction } from '../common/state/sessionActions.js'; -import { - ResponsePartKind, - ToolCallConfirmationReason, - type URI as ProtocolURI, -} from '../common/state/sessionState.js'; -import { IAgentConfigurationService } from './agentConfigurationService.js'; -import { AgentHostStateManager } from './agentHostStateManager.js'; -import { CommandAutoApprover } from './commandAutoApprover.js'; - -/** - * Event fields needed for auto-approval decisions. - * Matches the subset of {@link IAgentToolPendingConfirmationSignal} used by the - * approval pipeline. - */ -export interface IToolApprovalEvent { - readonly toolCallId: string; - readonly session: URI; - readonly permissionKind?: IAgentToolPendingConfirmationSignal['permissionKind']; - readonly permissionPath?: string; - readonly toolInput?: string; -} - -/** Standard per-tool confirmation options presented to the user. */ -const ALLOW_SESSION_OPTION_ID = 'allow-session'; -const CONFIRMATION_OPTIONS: readonly ConfirmationOption[] = [ - { id: ALLOW_SESSION_OPTION_ID, label: localize('sessionPermissions.allowSession', "Allow in this Session"), kind: ConfirmationOptionKind.Approve, group: 1 }, - { id: 'allow-once', label: localize('sessionPermissions.allowOnce', "Allow Once"), kind: ConfirmationOptionKind.Approve }, - { id: 'skip', label: localize('sessionPermissions.skip', "Skip"), kind: ConfirmationOptionKind.Deny, group: 2 }, -]; - -/** Default write-path glob rules applied to auto-approved edits. */ -const DEFAULT_EDIT_AUTO_APPROVE_PATTERNS: Readonly> = { - '**/*': true, - '**/.vscode/*.json': false, - '**/.git/**': false, - '**/{package.json,server.xml,build.rs,web.config,.gitattributes,.env}': false, - '**/*.{code-workspace,csproj,fsproj,vbproj,vcxproj,proj,targets,props}': false, - '**/*.lock': false, - '**/*-lock.{yaml,json}': false, -}; - -/** - * Single entry point for all tool-call approval logic in the agent host. - * - * Modeled after {@link ILanguageModelToolsConfirmationService} in the - * workbench layer, this manager owns: - * - * - **Auto-approval** (`getAutoApproval`) — checks session-level config, - * per-tool session permissions, read/write path rules, and shell - * command rules. Returns a {@link ToolCallConfirmationReason} when - * the tool should be auto-approved, or `undefined` when user - * confirmation is needed. - * - * - **Confirmation options** (`createToolReadyAction`) — constructs the - * protocol action with the standard "Allow Once / Allow in this - * Session / Skip" options baked in. - * - * - **Post-confirmation side effects** (`handleToolCallConfirmed`) — - * persists the user's choice (e.g. adding a tool to the session - * permissions list). - */ -export class SessionPermissionManager extends Disposable { - - - // ---- Edit auto-approve patterns ----------------------------------------- - - private readonly _commandAutoApprover: CommandAutoApprover; - - constructor( - private readonly _stateManager: AgentHostStateManager, - @IAgentConfigurationService private readonly _configService: IAgentConfigurationService, - @ILogService private readonly _logService: ILogService, - ) { - super(); - this._commandAutoApprover = this._register(new CommandAutoApprover(this._logService)); - } - - /** - * Initializes async resources (tree-sitter WASM) used for shell command - * auto-approval. Await this before any session events can arrive to - * guarantee that {@link getAutoApproval} is fully synchronous. - */ - initialize(): Promise { - return this._commandAutoApprover.initialize(); - } - - // ---- Auto-approval (analogous to getPreConfirmAction) ------------------- - - /** - * Synchronously checks whether a `tool_ready` event should be - * auto-approved. Returns a {@link ToolCallConfirmationReason} when the - * tool call should proceed without user interaction, or `undefined` - * when user confirmation is required. - * - * Checks are evaluated in order: - * 1. Session-level bypass (`autoApprove` / `autopilot` config) - * 2. Per-tool session permissions (`permissions.allow`) - * 3. Read path rules (within working directory) - * 4. Write path rules (within working directory + glob patterns) - * 5. Shell command rules (tree-sitter parsed, default allow/deny) - */ - getAutoApproval(e: IToolApprovalEvent, sessionKey: ProtocolURI): ToolCallConfirmationReason | undefined { - const autoApproveLevel = this._configService.getEffectiveValue(sessionKey, platformSessionSchema, SessionConfigKey.AutoApprove); - const workDir = this._configService.getEffectiveWorkingDirectory(sessionKey); - - // 1. Session-level auto-approve - if (autoApproveLevel === 'autoApprove' || autoApproveLevel === 'autopilot') { - this._logService.trace(`[SessionPermissionManager] Auto-approving tool call (session autoApprove=${autoApproveLevel})`); - return ToolCallConfirmationReason.Setting; - } - - // 2. Per-tool session permissions - if (this._isToolAllowedByPermissions(sessionKey, e.toolCallId)) { - return ToolCallConfirmationReason.Setting; - } - - // 3. Read auto-approval - if (e.permissionKind === 'read' && e.permissionPath) { - if (this._isPathInWorkingDirectory(e.permissionPath, workDir)) { - this._logService.trace(`[SessionPermissionManager] Auto-approving read of ${e.permissionPath}`); - return ToolCallConfirmationReason.NotNeeded; - } - return undefined; - } - - // 4. Write auto-approval - if (e.permissionKind === 'write' && e.permissionPath) { - if (this._isPathInWorkingDirectory(e.permissionPath, workDir) && this._isEditAutoApproved(e.permissionPath)) { - this._logService.trace(`[SessionPermissionManager] Auto-approving write to ${e.permissionPath}`); - return ToolCallConfirmationReason.NotNeeded; - } - return undefined; - } - - // 5. Shell auto-approval - if (e.permissionKind === 'shell' && e.toolInput) { - const result = this._commandAutoApprover.shouldAutoApprove(e.toolInput); - if (result === 'approved') { - this._logService.trace('[SessionPermissionManager] Auto-approving shell command'); - return ToolCallConfirmationReason.NotNeeded; - } - if (result === 'denied') { - this._logService.trace('[SessionPermissionManager] Shell command denied by rule'); - } - return undefined; - } - - return undefined; - } - - // ---- Action construction (analogous to getPreConfirmActions) ------------- - - /** - * Constructs a `SessionToolCallReady` action from an agent - * `pending_confirmation` signal. When the tool needs user confirmation - * (the protocol state carries `confirmationTitle`), the standard - * confirmation options are baked in so clients can render them directly. - */ - createToolReadyAction(e: IAgentToolPendingConfirmationSignal, sessionKey: ProtocolURI, turnId: string): IToolCallReadyAction { - const state = e.state; - if (state.confirmationTitle) { - return { - type: ActionType.SessionToolCallReady, - session: sessionKey, - turnId, - toolCallId: state.toolCallId, - invocationMessage: state.invocationMessage, - toolInput: state.toolInput, - confirmationTitle: state.confirmationTitle, - edits: state.edits, - editable: state.editable, - options: CONFIRMATION_OPTIONS.slice(), - }; - } - return { - type: ActionType.SessionToolCallReady, - session: sessionKey, - turnId, - toolCallId: state.toolCallId, - invocationMessage: state.invocationMessage, - toolInput: state.toolInput, - confirmed: ToolCallConfirmationReason.NotNeeded, - }; - } - - // ---- Post-confirmation side effects ------------------------------------- - - /** - * Handles the side effect of a `SessionToolCallConfirmed` action when the - * user selected "Allow in this Session". Adds the tool to the session's - * permission allow list so future calls are auto-approved. - */ - handleToolCallConfirmed(sessionKey: ProtocolURI, toolCallId: string, selectedOptionId: string | undefined): void { - if (selectedOptionId === ALLOW_SESSION_OPTION_ID) { - const toolName = this._getToolNameForToolCall(sessionKey, toolCallId); - if (toolName) { - this._addToolToSessionPermissions(sessionKey, toolName); - } - } - } - - // ---- Internal helpers --------------------------------------------------- - - private _isPathInWorkingDirectory(filePath: string, workDir: string | undefined): boolean { - if (!workDir) { - return false; - } - const workingDirectory = URI.parse(workDir); - return extUriBiasedIgnorePathCase.isEqualOrParent(normalizePath(URI.file(filePath)), workingDirectory); - } - - private _isEditAutoApproved(filePath: string): boolean { - let approved = true; - for (const [pattern, isApproved] of Object.entries(DEFAULT_EDIT_AUTO_APPROVE_PATTERNS)) { - if (isApproved !== approved && globMatch(pattern, filePath)) { - approved = isApproved; - } - } - return approved; - } - - private _isToolAllowedByPermissions(sessionKey: ProtocolURI, toolCallId: string): boolean { - const toolName = this._getToolNameForToolCall(sessionKey, toolCallId); - if (!toolName) { - return false; - } - // `getEffectiveValue` walks session → parent → host, so sessions - // that haven't materialized their own `permissions` yet transparently - // inherit from the host-level allow/deny lists. - const permissions = this._configService.getEffectiveValue(sessionKey, platformSessionSchema, SessionConfigKey.Permissions); - const allowed = permissions?.allow.includes(toolName) ?? false; - if (allowed) { - this._logService.trace(`[SessionPermissionManager] Auto-approving "${toolName}" via permissions`); - } - return allowed; - } - - private _getToolNameForToolCall(sessionKey: ProtocolURI, toolCallId: string): string | undefined { - const sessionState = this._stateManager.getSessionState(sessionKey); - const parts = sessionState?.activeTurn?.responseParts; - if (!parts) { - return undefined; - } - for (const rp of parts) { - if (rp.kind === ResponsePartKind.ToolCall && rp.toolCall.toolCallId === toolCallId) { - return rp.toolCall.toolName; - } - } - return undefined; - } - - private _addToolToSessionPermissions(sessionKey: ProtocolURI, toolName: string): void { - const permissions = this._configService.getEffectiveValue(sessionKey, platformSessionSchema, SessionConfigKey.Permissions) - ?? { allow: [], deny: [] }; - if (permissions.allow.includes(toolName)) { - return; - } - this._configService.updateSessionConfig(sessionKey, { - [SessionConfigKey.Permissions]: { - allow: [...permissions.allow, toolName], - deny: [...permissions.deny], - }, - }); - this._logService.info(`[SessionPermissionManager] Added "${toolName}" to session permissions for ${sessionKey}`); - } -} diff --git a/src/vs/platform/agentHost/node/shared/copilotApiService.ts b/src/vs/platform/agentHost/node/shared/copilotApiService.ts deleted file mode 100644 index c7c44500..00000000 --- a/src/vs/platform/agentHost/node/shared/copilotApiService.ts +++ /dev/null @@ -1,697 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import type Anthropic from '@anthropic-ai/sdk'; -import { CAPIClient, RequestType, type CCAModel, type IExtensionInformation } from '@vscode/copilot-api'; -import { generateUuid } from '../../../../base/common/uuid.js'; -import { getDevDeviceId, getMachineId } from '../../../../base/node/id.js'; -import { createDecorator } from '../../../instantiation/common/instantiation.js'; -import { ILogService } from '../../../log/common/log.js'; -import { IProductService } from '../../../product/common/productService.js'; - -// #region Types - -/** - * Per-call transport options for all {@link ICopilotApiService} methods. - * - * `headers` are merged into the outgoing CAPI request before security- - * sensitive headers (`Authorization`, `Content-Type`, `X-Request-Id`, - * `OpenAI-Intent`), so callers cannot override those. - * - * `signal` propagates to the outgoing API request but **not** to the - * shared token mint. The mint is deduped across concurrent callers, so - * a single caller's abort must not cancel it for everyone. - */ -export interface ICopilotApiServiceRequestOptions { - readonly headers?: Readonly>; - readonly signal?: AbortSignal; -} - -/** - * Envelope returned by the GitHub `copilot_internal/v2/token` endpoint. - * @see https://docs.github.com/en/rest/copilot - */ -interface ICopilotTokenEnvelope { - readonly token: string; - readonly expires_at: number; - readonly refresh_in: number; - readonly endpoints?: { readonly api?: string }; - readonly sku?: string; -} - -interface ICachedToken { - readonly githubToken: string; - readonly copilotToken: string; - readonly expiresAt: number; -} - -interface ICapiInit { - readonly capiClient: CAPIClient; - readonly tokenUrl: string; -} - -// #endregion - -// #region Constants - -/** - * Sentinel {@link CopilotApiError.status} used when the error came from a - * mid-stream SSE `event: error` frame rather than an HTTP non-2xx response. - * The upstream HTTP status was 200 (the stream had already started); the - * real HTTP status is no longer meaningful, so consumers that need an HTTP - * status code (e.g. when re-emitting before headers are sent) should not - * trust this value. Use `envelope.error.type` instead. - */ -export const COPILOT_API_ERROR_STATUS_STREAMING = 520; - -/** - * Refresh the cached Copilot token this many seconds before its real expiry, - * so an in-flight request never hits a token that expires mid-request. - */ -const TOKEN_REFRESH_BUFFER_SECONDS = 5 * 60; - -const TOKEN_API_VERSION = '2025-04-01'; - -// #endregion - -// #region Errors - -/** - * Thrown by {@link ICopilotApiService} when CAPI returns an Anthropic-format - * API error — either as a non-2xx HTTP response or as a mid-stream - * `event: error` SSE frame. Carries enough information for the Phase 2 - * Claude proxy to re-emit the error passthrough without re-mapping. - * - * Network/transport failures (connection reset, DNS failure, etc.) are - * **not** wrapped as `CopilotApiError` — they propagate as raw `fetch` - * rejections so consumers can distinguish API errors from transport errors. - */ -export class CopilotApiError extends Error { - - /** - * @param status HTTP status from the originating CAPI response, or - * {@link COPILOT_API_ERROR_STATUS_STREAMING} for mid-stream SSE errors. - * @param envelope Anthropic-format error envelope. For HTTP errors with a - * non-conforming body (plain text, malformed JSON, missing fields) this - * is synthesized; for conforming bodies and SSE frames it is the - * server's envelope verbatim. - * @param message Optional override for `Error.message`. Defaults to - * `envelope.error.message`. **Never includes auth tokens.** - */ - constructor( - readonly status: number, - readonly envelope: Anthropic.ErrorResponse, - message?: string, - ) { - super(message ?? envelope.error.message); - this.name = 'CopilotApiError'; - } -} - -/** - * Build a {@link CopilotApiError} from a CAPI HTTP response body. If the - * body parses as a conforming Anthropic envelope, it is used verbatim; - * otherwise a synthetic envelope is constructed with `error.type: - * 'api_error'` and the response body as `error.message` (or status text - * when the body is empty). The returned error's `message` deliberately - * mirrors the original `": "` format so - * existing log-line consumers continue to read identifiably. `prefix` - * defaults to `"CAPI request failed"` (the historical wording for - * `messages`); pass `"CAPI models request failed"` for the `models()` path. - */ -function buildCopilotApiHttpError(status: number, statusText: string, bodyText: string, prefix = 'CAPI request failed'): CopilotApiError { - let envelope: Anthropic.ErrorResponse | undefined; - if (bodyText) { - try { - const parsed = JSON.parse(bodyText) as unknown; - if ( - parsed && typeof parsed === 'object' - && (parsed as { type?: unknown }).type === 'error' - ) { - const err = (parsed as { error?: unknown }).error; - if ( - err && typeof err === 'object' - && typeof (err as { type?: unknown }).type === 'string' - && typeof (err as { message?: unknown }).message === 'string' - ) { - envelope = parsed as Anthropic.ErrorResponse; - } - } - } catch { - // non-JSON body — fall through to synthesis - } - } - if (!envelope) { - envelope = { - type: 'error', - error: { - type: 'api_error', - message: bodyText || `${status} ${statusText}`, - }, - request_id: null, - }; - } - return new CopilotApiError( - status, - envelope, - `${prefix}: ${status} ${statusText} \u2014 ${envelope.error.message}`, - ); -} - -// #endregion - -export type FetchFunction = typeof globalThis.fetch; - -export const ICopilotApiService = createDecorator('copilotApiService'); - -/** - * Foundational gateway between the agent host and GitHub Copilot's CAPI proxy - * for Anthropic-style chat completions and model discovery. - * - * ## Goals - * - * 1. **Single source of truth for CAPI auth.** Callers pass a raw GitHub token - * and never deal with Copilot session token minting, expiry, refresh, or - * invalidation themselves. - * 2. **Stable surface for chat agents.** A small, typed API that abstracts the - * underlying `CAPIClient`, SSE framing, and Anthropic event taxonomy so - * feature code can focus on prompting. - * 3. **Resource-safe streaming.** Async-generator output that fully releases - * the underlying HTTP connection regardless of how the consumer terminates - * iteration (early `break`, thrown error, abort, or natural end-of-stream). - * 4. **Skew- and revocation-tolerant token cache.** Tokens stay cached as long - * as they're usable, are re-minted when the server tells us they're stale - * (`refresh_in`), and are invalidated immediately on `401`/`403` so callers - * self-heal without restarting the host. - * - * ## Non-goals - * - * - Per-conversation history, retry/backoff, or rate-limit handling. Callers - * own request orchestration. - * - GitHub Enterprise auth host derivation. The mint URL comes from - * `IProductService.defaultChatAgent.tokenEntitlementUrl`. See the TODO in - * `_buildCapiInit` for what GHE support would require. - * - * ## Concurrency model - * - * - Multiple in-flight requests for the same GitHub token share a single - * token mint via an in-flight de-dup map (no thundering herd on cold - * start). - * - The token cache holds **one** entry. Callers that alternate between two - * GitHub tokens will pay a mint round-trip on every alternation; this is - * intentional — the agent host is single-tenant in practice. - * - `AbortSignal` is forwarded to the outgoing API request (messages, models) - * but **not** to the shared token mint, so cancellation propagates to the - * caller's own request without affecting concurrent callers sharing the mint. - * - * ## Error semantics - * - * - Network/transport errors propagate as raw `fetch` rejections (e.g. - * connection reset, DNS failure). Consumers can distinguish them from - * API errors by `instanceof CopilotApiError`. - * - Non-2xx responses from CAPI's `messages` and `models` endpoints throw - * {@link CopilotApiError} carrying the HTTP `status` and the parsed - * Anthropic error `envelope` (synthesized if the response body isn't a - * conforming envelope). **Tokens are never embedded in error messages.** - * - Streaming `event: error` SSE frames throw {@link CopilotApiError} with - * `status` set to {@link COPILOT_API_ERROR_STATUS_STREAMING} (the upstream - * HTTP status was 200 and is no longer meaningful) and the server-supplied - * error envelope preserved verbatim. - * - Failures of the internal Copilot token-mint endpoint throw plain - * `Error` (not `CopilotApiError`) with a `"Copilot token minting failed: - * ..."` prefix — token mint is an implementation detail of this service - * and is not part of the Anthropic-shaped CAPI surface. - * - Malformed JSON in an SSE `data:` line is logged and skipped, not thrown. - */ -export interface ICopilotApiService { - - readonly _serviceBrand: undefined; - - /** - * Stream a chat completion as raw Anthropic stream events. - * - * Yields every `Anthropic.MessageStreamEvent` in the order the server - * emits them, **including `message_stop` as the last event** before the - * generator returns. Phase 2 proxy relies on receiving a complete, - * replayable event stream. - * - * @throws on non-2xx status or SSE `error` event. - */ - messages( - githubToken: string, - request: Anthropic.MessageCreateParamsStreaming, - options?: ICopilotApiServiceRequestOptions, - ): AsyncGenerator; - - /** - * Send a chat completion and return the full aggregated response. - * @throws on non-2xx status. - */ - messages( - githubToken: string, - request: Anthropic.MessageCreateParamsNonStreaming, - options?: ICopilotApiServiceRequestOptions, - ): Promise; - - /** - * Count tokens for a hypothetical request. - * - * @throws always — `countTokens` is not supported by CAPI in Phase 1.5. - * Phase 2 proxy maps this to HTTP 501. - */ - countTokens( - githubToken: string, - req: Anthropic.MessageCountTokensParams, - options?: ICopilotApiServiceRequestOptions, - ): Promise; - - /** - * List models available to the GitHub user. - * - * Each {@link CCAModel} carries a `vendor` (e.g. `'Anthropic'`) and - * `supported_endpoints` (e.g. `['/v1/messages']`). Callers filtering for - * Anthropic-format models should match on both fields. - * - * Known CAPI values as of 2026-04-30: - * - `vendor`: `'Anthropic'` (capitalized) - * - `supported_endpoints`: `'/v1/messages'` for Anthropic chat models - */ - models(githubToken: string, options?: ICopilotApiServiceRequestOptions): Promise; -} - -export class CopilotApiService implements ICopilotApiService { - - declare readonly _serviceBrand: undefined; - - private _capiInitPromise: Promise | null = null; - private _cachedToken: ICachedToken | null = null; - private readonly _pendingTokenMints = new Map>(); - private readonly _fetch: FetchFunction; - - constructor( - fetchFn: FetchFunction | undefined, - @ILogService private readonly _logService: ILogService, - @IProductService private readonly _productService: IProductService, - ) { - this._fetch = fetchFn ?? globalThis.fetch; - } - - // #region Public API - - messages( - githubToken: string, - request: Anthropic.MessageCreateParamsStreaming, - options?: ICopilotApiServiceRequestOptions, - ): AsyncGenerator; - messages( - githubToken: string, - request: Anthropic.MessageCreateParamsNonStreaming, - options?: ICopilotApiServiceRequestOptions, - ): Promise; - messages( - githubToken: string, - request: Anthropic.MessageCreateParams, - options?: ICopilotApiServiceRequestOptions, - ): AsyncGenerator | Promise { - if (request.stream) { - return this._messagesStreaming(githubToken, request, options); - } - return this._messagesNonStreaming(githubToken, request, options); - } - - async countTokens( - _githubToken: string, - _req: Anthropic.MessageCountTokensParams, - _options?: ICopilotApiServiceRequestOptions, - ): Promise { - throw new Error('countTokens not supported by CAPI'); - } - - async models(githubToken: string, options?: ICopilotApiServiceRequestOptions): Promise { - const { capiClient, tokenUrl } = await this._getCapiInit(); - const copilotToken = await this._getCopilotToken(githubToken, capiClient, tokenUrl); - - this._logService.debug('[CopilotApiService] GET models'); - - const response = await capiClient.makeRequest( - { - method: 'GET', - headers: { - ...options?.headers, - 'Authorization': `Bearer ${copilotToken}`, - }, - signal: options?.signal, - }, - { type: RequestType.Models }, - ); - - if (!response.ok) { - if (response.status === 401 || response.status === 403) { - this._invalidateCachedToken(githubToken); - } - const text = await response.text().catch(() => ''); - throw buildCopilotApiHttpError(response.status, response.statusText, text, 'CAPI models request failed'); - } - - const json = await response.json(); - return json.data ?? []; - } - - // #endregion - - // #region Lazy Init - - private _getCapiInit(): Promise { - if (!this._capiInitPromise) { - this._capiInitPromise = this._buildCapiInit().catch(err => { - this._capiInitPromise = null; - this._cachedToken = null; - throw err; - }); - } - return this._capiInitPromise; - } - - private async _buildCapiInit(): Promise { - const [machineId, deviceId] = await Promise.all([ - getMachineId(err => this._logService.warn('[CopilotApiService] getMachineId failed', err)), - getDevDeviceId(err => this._logService.warn('[CopilotApiService] getDevDeviceId failed', err)), - ]); - - const extensionInfo: IExtensionInformation = { - name: 'agent-host', - sessionId: generateUuid(), - machineId, - deviceId, - vscodeVersion: this._productService.version, - version: this._productService.version, - buildType: this._productService.quality === 'stable' ? 'prod' : 'dev', - }; - - const fetch = this._fetch; - const capiClient = new CAPIClient(extensionInfo, undefined, { - fetch: (url, options) => fetch(url, { - method: options.method ?? 'GET', - headers: options.headers, - body: options.body, - signal: options.signal as AbortSignal | undefined, - }), - }); - - // TODO(GHE): For GitHub Enterprise users the mint URL must point to - // `api./copilot_internal/v2/token` instead. This - // requires threading the enterprise host URL through `ICopilotApiService` - // (e.g. as an extra parameter on `messages`/`models`, or as a separate - // `create(enterpriseHost?)` factory) and deriving the URL the same way - // `defaultAccount.ts` does for the main workbench auth path. - const tokenUrl = this._productService.defaultChatAgent.tokenEntitlementUrl; - - return { capiClient, tokenUrl }; - } - - // #endregion - - // #region Streaming - - private async *_messagesStreaming( - githubToken: string, - request: Anthropic.MessageCreateParams, - options?: ICopilotApiServiceRequestOptions, - ): AsyncGenerator { - const response = await this._sendRequest(githubToken, request, true, options); - - if (!response.body) { - throw new Error('CAPI response has no body'); - } - - yield* this._readSSE(response.body); - } - - // #endregion - - // #region Non-Streaming - - private async _messagesNonStreaming( - githubToken: string, - request: Anthropic.MessageCreateParams, - options?: ICopilotApiServiceRequestOptions, - ): Promise { - const response = await this._sendRequest(githubToken, request, false, options); - return response.json() as Promise; - } - - // #endregion - - // #region Shared Request - - private async _sendRequest( - githubToken: string, - request: Anthropic.MessageCreateParams, - stream: boolean, - options?: ICopilotApiServiceRequestOptions, - ): Promise { - const { capiClient, tokenUrl } = await this._getCapiInit(); - const copilotToken = await this._getCopilotToken(githubToken, capiClient, tokenUrl); - const requestId = generateUuid(); - - this._logService.debug('[CopilotApiService] POST messages', `model=${request.model} stream=${stream} requestId=${requestId}`); - - const { system, ...rest } = request; - const body = JSON.stringify({ - ...rest, - stream, - // CAPI requires system as a text-block array, not a raw string - ...(system !== undefined - ? { system: typeof system === 'string' ? [{ type: 'text', text: system }] : system } - : {}), - }); - - const response = await capiClient.makeRequest( - { - method: 'POST', - headers: { - ...options?.headers, - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${copilotToken}`, - 'X-Request-Id': requestId, - 'OpenAI-Intent': 'conversation', - }, - body, - signal: options?.signal, - }, - { type: RequestType.ChatMessages }, - ); - if (!response.ok) { - if (response.status === 401 || response.status === 403) { - this._invalidateCachedToken(githubToken); - } - const text = await response.text().catch(() => ''); - throw buildCopilotApiHttpError(response.status, response.statusText, text); - } - - return response; - } - - // #endregion - - // #region Token Minting - - private async _getCopilotToken(githubToken: string, capiClient: CAPIClient, tokenUrl: string): Promise { - const now = Date.now() / 1000; - if ( - this._cachedToken && - this._cachedToken.githubToken === githubToken && - this._cachedToken.expiresAt - now > TOKEN_REFRESH_BUFFER_SECONDS - ) { - return this._cachedToken.copilotToken; - } - - if (!this._pendingTokenMints.has(githubToken)) { - // Omit the caller's signal here: a deduped mint is shared across - // concurrent callers, so aborting one must not cancel the mint for - // the others. Each caller still forwards its signal to the API call. - const mint = this._mintToken(githubToken, capiClient, tokenUrl) - .finally(() => { this._pendingTokenMints.delete(githubToken); }); - this._pendingTokenMints.set(githubToken, mint); - } - return this._pendingTokenMints.get(githubToken)!; - } - - private _invalidateCachedToken(githubToken: string): void { - if (this._cachedToken?.githubToken === githubToken) { - this._cachedToken = null; - } - } - - private async _mintToken(githubToken: string, capiClient: CAPIClient, tokenUrl: string): Promise { - this._logService.debug('[CopilotApiService] Minting new Copilot token'); - - const response = await this._fetch(tokenUrl, { - method: 'GET', - headers: { - 'Authorization': `token ${githubToken}`, - 'X-GitHub-Api-Version': TOKEN_API_VERSION, - }, - }); - - if (!response.ok) { - const text = await response.text().catch(() => ''); - throw new Error(`Copilot token minting failed: ${response.status} ${response.statusText} — ${text}`); - } - - const envelope: ICopilotTokenEnvelope = await response.json(); - - capiClient.updateDomains( - { endpoints: envelope.endpoints ?? {}, sku: envelope.sku ?? '' }, - undefined, - ); - - // Prefer `refresh_in` over `expires_at` so clients with skewed clocks - // don't end up re-minting on every request. Mirrors the behavior in - // extensions/copilot/.../copilotTokenManager.ts. - const nowSeconds = Date.now() / 1000; - const expiresAt = typeof envelope.refresh_in === 'number' - ? nowSeconds + envelope.refresh_in + TOKEN_REFRESH_BUFFER_SECONDS - : envelope.expires_at; - - this._cachedToken = { - githubToken, - copilotToken: envelope.token, - expiresAt, - }; - - this._logService.debug('[CopilotApiService] Token minted, cacheValidUntil:', expiresAt, 'serverExpiresAt:', envelope.expires_at); - - return envelope.token; - } - - // #endregion - - // #region SSE Parsing - - private async *_readSSE(body: ReadableStream): AsyncGenerator { - const reader = body.getReader(); - const decoder = new TextDecoder(); - let buffer = ''; - - try { - while (true) { - const { done, value } = await reader.read(); - if (done) { - break; - } - - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split('\n'); - buffer = lines.pop() ?? ''; - - for (const line of lines) { - const event = this._parseDataLine(line); - if (event !== undefined) { - yield event; - if (event.type === 'message_stop') { - return; - } - } - } - } - - if (buffer.trim()) { - const event = this._parseDataLine(buffer); - if (event !== undefined) { - yield event; - if (event.type === 'message_stop') { - return; - } - } - } - } finally { - // Cancel the underlying stream so the HTTP connection is released - // even when the consumer abandons the generator early (break, throw, - // abort) or the stream ended on `message_stop` with bytes still in - // flight. `releaseLock` alone leaves the body half-read. - try { - await reader.cancel(); - } catch { - // ignore — cancellation is best-effort cleanup - } - reader.releaseLock(); - } - } - - /** - * @returns the parsed stream event, or `undefined` to skip the line. - * @throws on `error` events from the server. - */ - private _parseDataLine(line: string): Anthropic.MessageStreamEvent | undefined { - if (!line.startsWith('data: ')) { - return undefined; - } - - const data = line.slice('data: '.length).trim(); - - let parsed: unknown; - try { - parsed = JSON.parse(data); - } catch { - this._logService.warn('[CopilotApiService] Failed to parse SSE data:', data); - return undefined; - } - - if (typeof parsed !== 'object' || parsed === null) { - return undefined; - } - - const record = parsed as Record; - const type = record.type; - if (typeof type !== 'string') { - return undefined; - } - - if (type === 'error') { - // Preserve the upstream envelope verbatim when it conforms to the - // Anthropic shape (so any extra fields propagate to Phase 2's - // passthrough proxy). Fall back to a clean api_error synthesis - // when fields are missing or `error` is unstructured. - const rawError = (parsed as { error?: unknown }).error; - let envelope: Anthropic.ErrorResponse; - if ( - rawError && typeof rawError === 'object' - && typeof (rawError as { type?: unknown }).type === 'string' - && typeof (rawError as { message?: unknown }).message === 'string' - ) { - envelope = parsed as Anthropic.ErrorResponse; - } else { - let errorMessage: string; - if (typeof rawError === 'string') { - errorMessage = rawError; - } else if (typeof (rawError as { message?: unknown } | undefined)?.message === 'string') { - errorMessage = (rawError as { message: string }).message; - } else { - errorMessage = 'Unknown streaming error'; - } - envelope = { - type: 'error', - error: { type: 'api_error', message: errorMessage }, - request_id: null, - }; - } - throw new CopilotApiError(COPILOT_API_ERROR_STATUS_STREAMING, envelope); - } - - if (!KNOWN_SSE_EVENT_TYPES.has(type)) { - return undefined; - } - - return parsed as Anthropic.MessageStreamEvent; - } - - // #endregion -} - -const KNOWN_SSE_EVENT_TYPES = new Set([ - 'message_start', 'message_delta', 'message_stop', - 'content_block_start', 'content_block_delta', 'content_block_stop', -]); diff --git a/src/vs/platform/agentHost/node/sshRemoteAgentHostHelpers.ts b/src/vs/platform/agentHost/node/sshRemoteAgentHostHelpers.ts deleted file mode 100644 index eee18228..00000000 --- a/src/vs/platform/agentHost/node/sshRemoteAgentHostHelpers.ts +++ /dev/null @@ -1,210 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { ILogService } from '../../log/common/log.js'; - -const LOG_PREFIX = '[SSHRemoteAgentHost]'; - -/** - * Validate that a quality string is safe for bare interpolation in shell commands. - * Quality comes from `productService.quality` (not user input) but we validate - * as defense-in-depth since these values end up in unquoted shell paths (the `~` - * prefix requires shell expansion, so we cannot single-quote the entire path). - */ -export function validateShellToken(value: string, label: string): string { - if (!/^[a-zA-Z0-9._-]+$/.test(value)) { - throw new Error(`Unsafe ${label} value for shell interpolation: ${JSON.stringify(value)}`); - } - return value; -} - -/** Install location for the VS Code CLI on the remote machine. */ -export function getRemoteCLIDir(quality: string): string { - const q = validateShellToken(quality, 'quality'); - return q === 'stable' ? '~/.vscode-cli' : `~/.vscode-cli-${q}`; -} - -export function getRemoteCLIBin(quality: string): string { - const q = validateShellToken(quality, 'quality'); - const binaryName = q === 'stable' ? 'code' : 'code-insiders'; - return `${getRemoteCLIDir(q)}/${binaryName}`; -} - -/** Escape a string for use as a single shell argument (single-quote wrapping). */ -export function shellEscape(s: string): string { - // Wrap in single quotes; escape embedded single quotes as: '\'' - const escaped = s.replace(/'/g, '\'\\\'\''); - return `'${escaped}'`; -} - -export function resolveRemotePlatform(unameS: string, unameM: string): { os: string; arch: string } | undefined { - const os = unameS.trim().toLowerCase(); - const machine = unameM.trim().toLowerCase(); - - let platformOs: string; - if (os === 'linux') { - platformOs = 'linux'; - } else if (os === 'darwin') { - platformOs = 'darwin'; - } else { - return undefined; - } - - let arch: string; - if (machine === 'x86_64' || machine === 'amd64') { - arch = 'x64'; - } else if (machine === 'aarch64' || machine === 'arm64') { - arch = 'arm64'; - } else if (machine === 'armv7l') { - arch = 'armhf'; - } else { - return undefined; - } - - return { os: platformOs, arch }; -} - -export function buildCLIDownloadUrl(os: string, arch: string, quality: string): string { - return `https://update.code.visualstudio.com/latest/cli-${os}-${arch}/${quality}`; -} - -/** Redact connection tokens from log output. */ -export function redactToken(text: string): string { - return text.replace(/\?tkn=[^\s&]+/g, '?tkn=***'); -} - -/** Path to our state file on the remote, recording the agent host's PID/port/token. */ -export function getAgentHostStateFile(quality: string): string { - return `${getRemoteCLIDir(quality)}/.agent-host-state`; -} - -export interface AgentHostState { - readonly pid: number; - readonly port: number; - readonly connectionToken: string | null; -} - -/** - * Validate that a parsed object conforms to the AgentHostState shape. - * Returns the validated state or undefined if the shape is invalid. - */ -function parseAgentHostState(raw: unknown): AgentHostState | undefined { - if (typeof raw !== 'object' || raw === null) { - return undefined; - } - const obj = raw as Record; - if (typeof obj.pid !== 'number' || !Number.isSafeInteger(obj.pid) || obj.pid <= 0) { - return undefined; - } - if (typeof obj.port !== 'number' || !Number.isSafeInteger(obj.port) || obj.port <= 0 || obj.port > 65535) { - return undefined; - } - if (obj.connectionToken !== null && typeof obj.connectionToken !== 'string') { - return undefined; - } - return { pid: obj.pid, port: obj.port, connectionToken: obj.connectionToken as string | null }; -} - -/** - * Abstraction over SSH command execution to enable testing without a real SSH connection. - */ -export interface ISshExec { - (command: string, opts?: { ignoreExitCode?: boolean }): Promise<{ stdout: string; stderr: string; code: number }>; -} - -/** - * Try to find a running agent host on the remote by reading our state file and - * verifying the recorded PID is still alive. - */ -export async function findRunningAgentHost( - exec: ISshExec, - logService: ILogService, - quality: string, -): Promise<{ port: number; connectionToken: string | undefined } | undefined> { - const stateFile = getAgentHostStateFile(quality); - const { stdout, code } = await exec(`cat ${stateFile} 2>/dev/null`, { ignoreExitCode: true }); - if (code !== 0 || !stdout.trim()) { - return undefined; - } - - let state: AgentHostState | undefined; - try { - state = parseAgentHostState(JSON.parse(stdout.trim())); - } catch { - // fall through - } - if (!state) { - logService.info(`${LOG_PREFIX} Invalid agent host state file ${stateFile}, removing`); - await exec(`rm -f ${stateFile}`, { ignoreExitCode: true }); - return undefined; - } - - // Verify the PID is still alive - const { code: killCode } = await exec(`kill -0 ${state.pid} 2>/dev/null`, { ignoreExitCode: true }); - if (killCode !== 0) { - logService.info(`${LOG_PREFIX} Stale agent host state in ${stateFile} (PID ${state.pid} not running), cleaning up`); - await exec(`rm -f ${stateFile}`, { ignoreExitCode: true }); - return undefined; - } - - logService.info(`${LOG_PREFIX} Found running agent host via ${stateFile}: PID ${state.pid}, port ${state.port}`); - return { port: state.port, connectionToken: state.connectionToken ?? undefined }; -} - -/** - * After starting an agent host, record its PID/port/token in a state file on - * the remote so that future connections can reuse the process. - */ -export async function writeAgentHostState( - exec: ISshExec, - logService: ILogService, - quality: string, - pid: number | undefined, - port: number, - connectionToken: string | undefined, -): Promise { - if (!pid) { - logService.info(`${LOG_PREFIX} Agent host PID unknown, state file not written`); - return; - } - - const stateFile = getAgentHostStateFile(quality); - const state: AgentHostState = { pid, port, connectionToken: connectionToken ?? null }; - const json = JSON.stringify(state); - // Remove any existing file first so `>` creates a fresh inode with the - // new umask (overwriting an existing file preserves its old permissions). - // Use a subshell with restrictive umask (077) so the file is created with - // owner-only permissions (0600), protecting the connection token. - // The CLI itself stores its token file with the same permissions. - const result = await exec(`rm -f ${stateFile} && (umask 077 && echo ${shellEscape(json)} > ${stateFile})`, { ignoreExitCode: true }); - if (result.code !== 0) { - logService.warn(`${LOG_PREFIX} Failed to write agent host state to ${stateFile} (exit code ${result.code})${result.stderr ? `: ${result.stderr.trim()}` : ''}`); - return; - } - logService.info(`${LOG_PREFIX} Wrote agent host state to ${stateFile}: PID ${pid}, port ${port}`); -} - -/** - * Kill a remote agent host tracked by our state file and remove the state file. - */ -export async function cleanupRemoteAgentHost( - exec: ISshExec, - logService: ILogService, - quality: string, -): Promise { - const stateFile = getAgentHostStateFile(quality); - const { stdout, code } = await exec(`cat ${stateFile} 2>/dev/null`, { ignoreExitCode: true }); - if (code === 0 && stdout.trim()) { - let state: AgentHostState | undefined; - try { - state = parseAgentHostState(JSON.parse(stdout.trim())); - } catch { /* ignore parse errors */ } - if (state) { - logService.info(`${LOG_PREFIX} Killing remote agent host PID ${state.pid} (from ${stateFile})`); - await exec(`kill ${state.pid} 2>/dev/null`, { ignoreExitCode: true }); - } - } - await exec(`rm -f ${stateFile}`, { ignoreExitCode: true }); -} diff --git a/src/vs/platform/agentHost/node/sshRemoteAgentHostService.ts b/src/vs/platform/agentHost/node/sshRemoteAgentHostService.ts deleted file mode 100644 index 2d808c92..00000000 --- a/src/vs/platform/agentHost/node/sshRemoteAgentHostService.ts +++ /dev/null @@ -1,1012 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import type WebSocket from 'ws'; -import type { AnyAuthMethod, AuthenticationType, ConnectConfig } from 'ssh2'; -import { promises as fsp } from 'fs'; -import * as os from 'os'; -import * as cp from 'child_process'; -import { dirname, join, isAbsolute, basename } from '../../../base/common/path.js'; -import { Emitter, Event } from '../../../base/common/event.js'; -import { Disposable, DisposableMap, toDisposable } from '../../../base/common/lifecycle.js'; -import { URI } from '../../../base/common/uri.js'; -import { localize } from '../../../nls.js'; -import { ILogService } from '../../log/common/log.js'; -import { IProductService } from '../../product/common/productService.js'; -import { - ISSHRemoteAgentHostMainService, - SSHAuthMethod, - type ISSHAgentHostConfig, - type ISSHAgentHostConfigSanitized, - type ISSHConnectProgress, - type ISSHConnectResult, - type ISSHRelayMessage, - type ISSHResolvedConfig, -} from '../common/sshRemoteAgentHost.js'; -import { - buildCLIDownloadUrl, - cleanupRemoteAgentHost, - findRunningAgentHost, - getRemoteCLIBin, - getRemoteCLIDir, - redactToken, - resolveRemotePlatform, - shellEscape, - writeAgentHostState, -} from './sshRemoteAgentHostHelpers.js'; -import { parseSSHConfigHostEntries, parseSSHGOutput, stripSSHComment } from '../common/sshConfigParsing.js'; - -/** Minimal subset of ssh2.ClientChannel used by this module (duplex stream). */ -interface SSHChannel extends NodeJS.ReadWriteStream { - on(event: 'data', listener: (data: Buffer) => void): this; - on(event: 'close', listener: (code: number) => void): this; - on(event: 'error', listener: (err: Error) => void): this; - on(event: string, listener: (...args: unknown[]) => void): this; - stderr: { on(event: 'data', listener: (data: Buffer) => void): void }; - close(): void; -} - -/** Minimal subset of ssh2.Client used by this module. */ -interface SSHClient { - on(event: 'ready', listener: () => void): SSHClient; - on(event: 'error', listener: (err: Error) => void): SSHClient; - on(event: 'close', listener: () => void): SSHClient; - removeListener(event: 'close', listener: () => void): SSHClient; - removeListener(event: 'error', listener: (err: Error) => void): SSHClient; - connect(config: ConnectConfig): void; - exec(command: string, callback: (err: Error | undefined, stream: SSHChannel) => void): SSHClient; - forwardOut(srcIP: string, srcPort: number, dstIP: string, dstPort: number, callback: (err: Error | undefined, channel: SSHChannel) => void): SSHClient; - end(): void; -} - -const LOG_PREFIX = '[SSHRemoteAgentHost]'; - -/** - * One entry in the queue of authentication attempts handed to ssh2's - * `authHandler`. Each attempt corresponds to one of the auth method shapes - * documented at https://www.npmjs.com/package/ssh2#client-methods. - * - * `keyPath` is internal-only metadata for logging — it is stripped before the - * attempt is returned to ssh2. - */ -export type SSHAuthAttempt = - | { readonly type: 'publickey'; readonly username: string; readonly key: Buffer; readonly keyPath: string } - | { readonly type: 'agent'; readonly username: string; readonly agent: string } - | { readonly type: 'password'; readonly username: string; readonly password: string }; - -function describeAuthAttempt(attempt: SSHAuthAttempt): string { - switch (attempt.type) { - case 'publickey': return `publickey ${attempt.keyPath}`; - case 'agent': return 'agent'; - case 'password': return 'password'; - } -} - -/** - * Build an ssh2 `authHandler` callback that walks the given attempts in order, - * filtering by the server-advertised `methodsLeft` when ssh2 provides one. - * Returns `false` when the queue is exhausted, which causes ssh2 to surface - * an authentication failure to the caller. - */ -export function makeAuthHandler( - attempts: readonly SSHAuthAttempt[], - logService: ILogService, -): (methodsLeft: AuthenticationType[] | null, partialSuccess: boolean, callback: (next: AnyAuthMethod | false) => void) => void { - let index = 0; - return (methodsLeft, _partialSuccess, callback) => { - while (index < attempts.length) { - const attempt = attempts[index++]; - // `agent` is a publickey-flavored method at the SSH protocol level — - // servers advertise `publickey`, not `agent`, in `methodsLeft`. - const protocolMethod: AuthenticationType = attempt.type === 'agent' ? 'publickey' : attempt.type; - if (methodsLeft && !methodsLeft.includes(protocolMethod)) { - logService.info(`${LOG_PREFIX} Skipping ${describeAuthAttempt(attempt)} — server only allows ${methodsLeft.join(', ')}`); - continue; - } - logService.info(`${LOG_PREFIX} Trying auth: ${describeAuthAttempt(attempt)}`); - // Strip our internal `keyPath` metadata before handing to ssh2. - if (attempt.type === 'publickey') { - const { keyPath: _kp, ...payload } = attempt; - callback(payload); - } else { - callback(attempt); - } - return; - } - logService.info(`${LOG_PREFIX} No more auth methods to try; giving up`); - callback(false); - }; -} - -function sshExec(client: SSHClient, command: string, opts?: { ignoreExitCode?: boolean }): Promise<{ stdout: string; stderr: string; code: number }> { - return new Promise<{ stdout: string; stderr: string; code: number }>((resolve, reject) => { - client.exec(command, (err: Error | undefined, stream: SSHChannel) => { - if (err) { - reject(err); - return; - } - - let stdout = ''; - let stderr = ''; - let settled = false; - - const finish = (error: Error | undefined, code: number | undefined) => { - if (settled) { - return; - } - settled = true; - if (error) { - reject(error); - return; - } - if (code !== 0 && !opts?.ignoreExitCode) { - reject(new Error(`SSH command failed (exit ${code}): ${command}\nstderr: ${stderr}`)); - } else { - resolve({ stdout, stderr, code: code ?? 0 }); - } - }; - - stream.on('data', (data: Buffer) => { stdout += data.toString(); }); - stream.stderr.on('data', (data: Buffer) => { stderr += data.toString(); }); - stream.on('error', (streamErr: Error) => finish(streamErr, undefined)); - stream.on('close', (code: number) => finish(undefined, code)); - }); - }); -} - -/** Create a bound exec function for the given SSH client. */ -function bindSshExec(client: SSHClient): (command: string, opts?: { ignoreExitCode?: boolean }) => Promise<{ stdout: string; stderr: string; code: number }> { - return (command, opts) => sshExec(client, command, opts); -} - -function startRemoteAgentHost( - client: SSHClient, - logService: ILogService, - quality: string, - commandOverride?: string, -): Promise<{ port: number; connectionToken: string | undefined; pid: number | undefined; stream: SSHChannel }> { - return new Promise((resolve, reject) => { - const baseCmd = commandOverride ?? `${getRemoteCLIBin(quality)} agent host --port 0 --accept-server-license-terms`; - // Wrap in a login shell so the agent host process inherits the - // user's PATH and environment from ~/.bash_profile / ~/.bashrc - // (ssh2 exec runs a non-interactive non-login shell by default). - // Echo the PID so we can record it for process reuse detection. - const cmd = `bash -l -c ${shellEscape(`echo VSCODE_PID=$$ && exec ${baseCmd}`)}`; - logService.info(`${LOG_PREFIX} Starting remote agent host: ${cmd}`); - - client.exec(cmd, (err: Error | undefined, stream: SSHChannel) => { - if (err) { - reject(err); - return; - } - - let resolved = false; - let outputBuf = ''; - let pid: number | undefined; - - const timeout = setTimeout(() => { - if (!resolved) { - resolved = true; - reject(new Error(`${LOG_PREFIX} Timed out waiting for agent host to start.\noutput so far: ${redactToken(outputBuf)}`)); - } - }, 60_000); - - const checkForOutput = () => { - if (pid === undefined) { - const pidMatch = outputBuf.match(/VSCODE_PID=(\d+)/); - if (pidMatch) { - pid = parseInt(pidMatch[1], 10); - logService.info(`${LOG_PREFIX} Remote agent host PID: ${pid}`); - } - } - - if (!resolved) { - const match = outputBuf.match(/ws:\/\/127\.0\.0\.1:(\d+)(?:\?tkn=([^\s&]+))?/); - if (match) { - resolved = true; - clearTimeout(timeout); - const port = parseInt(match[1], 10); - const connectionToken = match[2] || undefined; - logService.info(`${LOG_PREFIX} Remote agent host listening on port ${port}`); - resolve({ port, connectionToken, pid, stream }); - } - } - }; - - stream.stderr.on('data', (data: Buffer) => { - const text = data.toString(); - outputBuf += text; - logService.trace(`${LOG_PREFIX} remote stderr: ${redactToken(text.trimEnd())}`); - checkForOutput(); - }); - - stream.on('data', (data: Buffer) => { - const text = data.toString(); - outputBuf += text; - logService.trace(`${LOG_PREFIX} remote stdout: ${redactToken(text.trimEnd())}`); - checkForOutput(); - }); - - stream.on('error', (streamErr: Error) => { - if (!resolved) { - resolved = true; - clearTimeout(timeout); - reject(streamErr); - } - }); - - stream.on('close', (code: number) => { - if (!resolved) { - resolved = true; - clearTimeout(timeout); - reject(new Error(`${LOG_PREFIX} Agent host process exited with code ${code} before becoming ready.\noutput: ${redactToken(outputBuf)}`)); - } - }); - }); - }); -} - -/** - * Create a WebSocket connection to the remote agent host via an SSH forwarded channel. - * Uses the `ws` library to speak WebSocket over the SSH channel. - * Messages are relayed to the renderer via IPC events. - */ -function createWebSocketRelay( - nativeRequire: NodeJS.Require, - client: SSHClient, - dstHost: string, - dstPort: number, - connectionToken: string | undefined, - logService: ILogService, - onMessage: (data: string) => void, - onClose: () => void, -): Promise<{ send: (data: string) => void; close: () => void }> { - return new Promise((resolve, reject) => { - client.forwardOut('127.0.0.1', 0, dstHost, dstPort, (err: Error | undefined, channel: SSHChannel) => { - if (err) { - reject(err); - return; - } - - const WS = nativeRequire('ws') as typeof WebSocket; - let url = `ws://${dstHost}:${dstPort}`; - if (connectionToken) { - url += `?tkn=${encodeURIComponent(connectionToken)}`; - } - - // The SSH channel is a duplex stream compatible with ws's createConnection, - // but our minimal SSHChannel interface doesn't carry the full Node Duplex shape. - const ws = new WS(url, { createConnection: (() => channel) as unknown as WebSocket.ClientOptions['createConnection'] }); - - ws.on('open', () => { - logService.info(`${LOG_PREFIX} WebSocket relay connected to remote agent host`); - resolve({ - send: (data: string) => { - if (ws.readyState === ws.OPEN) { - ws.send(data); - } - }, - close: () => ws.close(), - }); - }); - - ws.on('message', (data: WebSocket.RawData) => { - if (Array.isArray(data)) { - onMessage(Buffer.concat(data).toString()); - } else if (data instanceof ArrayBuffer) { - onMessage(Buffer.from(new Uint8Array(data)).toString()); - } else { - onMessage(data.toString()); - } - }); - - ws.on('close', onClose); - - ws.on('error', (wsErr: unknown) => { - logService.warn(`${LOG_PREFIX} WebSocket relay error: ${wsErr instanceof Error ? wsErr.message : String(wsErr)}`); - reject(wsErr); - }); - }); - }); -} - -function sanitizeConfig(config: ISSHAgentHostConfig): ISSHAgentHostConfigSanitized { - const { password: _p, privateKeyPath: _k, ...sanitized } = config; - return sanitized; -} - -/** - * State for a single active SSH relay connection. - * Immutable and dispose-once — follows the same pattern as TunnelConnection. - * On reconnect, the old SSHConnection is disposed and a fresh one is created; - * the SSH client can be detached first so only the WebSocket relay is torn down. - */ -class SSHConnection extends Disposable { - private readonly _onDidClose = new Emitter(); - readonly onDidClose = this._onDidClose.event; - - readonly config: ISSHAgentHostConfigSanitized; - private _closed = false; - private _sshClientDetached = false; - private readonly _sshCloseListener = () => { this.dispose(); }; - private readonly _sshErrorListener = () => { this.dispose(); }; - - constructor( - fullConfig: ISSHAgentHostConfig, - readonly connectionId: string, - readonly address: string, - readonly name: string, - readonly connectionToken: string | undefined, - readonly remotePort: number, - readonly sshClient: SSHClient, - private readonly _relay: { send: (data: string) => void; close: () => void }, - private readonly _remoteStream: SSHChannel | undefined, - ) { - super(); - - this.config = sanitizeConfig(fullConfig); - - // Register cleanup first so it fires _onDidClose *before* the Emitter is disposed. - this._register(toDisposable(() => { - if (this._closed) { - return; - } - this._closed = true; - this._relay.close(); - if (!this._sshClientDetached) { - this._remoteStream?.close(); - sshClient.end(); - } - this._onDidClose.fire(); - })); - - this._register(this._onDidClose); - - sshClient.on('close', this._sshCloseListener); - sshClient.on('error', this._sshErrorListener); - } - - /** - * Detach the SSH client from this connection so that `dispose()` - * only closes the WebSocket relay without ending the SSH session. - * Also removes event listeners from the SSH client so the old - * connection object is not retained by the shared client. - */ - detachSshClient(): void { - this._sshClientDetached = true; - this.sshClient.removeListener('close', this._sshCloseListener); - this.sshClient.removeListener('error', this._sshErrorListener); - } - - relaySend(data: string): void { - this._relay.send(data); - } -} - -export class SSHRemoteAgentHostMainService extends Disposable implements ISSHRemoteAgentHostMainService { - declare readonly _serviceBrand: undefined; - - private readonly _onDidChangeConnections = this._register(new Emitter()); - readonly onDidChangeConnections: Event = this._onDidChangeConnections.event; - - private readonly _onDidCloseConnection = this._register(new Emitter()); - readonly onDidCloseConnection: Event = this._onDidCloseConnection.event; - - private readonly _onDidReportConnectProgress = this._register(new Emitter()); - readonly onDidReportConnectProgress: Event = this._onDidReportConnectProgress.event; - - private readonly _onDidRelayMessage = this._register(new Emitter()); - readonly onDidRelayMessage: Event = this._onDidRelayMessage.event; - - private readonly _onDidRelayClose = this._register(new Emitter()); - readonly onDidRelayClose: Event = this._onDidRelayClose.event; - - private readonly _connections = this._register(new DisposableMap()); - - private _nativeRequire: NodeJS.Require | undefined; - - constructor( - @ILogService private readonly _logService: ILogService, - @IProductService private readonly _productService: IProductService, - ) { - super(); - } - - /** - * Lazily load a `require` function for native modules (`ssh2`, `ws`). - * Uses a dynamic `import('node:module')` so the module is only resolved - * when actually needed at runtime — not at file-load time. This matters - * because tests override the methods that call this and never trigger - * the import, avoiding issues with Electron's ESM loader which cannot - * resolve `node:` specifiers. - */ - private async _getNativeRequire(): Promise { - if (!this._nativeRequire) { - const nodeModule = await import('node:module'); - this._nativeRequire = nodeModule.createRequire(import.meta.url); - } - return this._nativeRequire; - } - - async connect(config: ISSHAgentHostConfig, replaceRelay?: boolean): Promise { - const connectionKey = config.sshConfigHost - ? `ssh:${config.sshConfigHost}` - : `${config.username}@${config.host}:${config.port ?? 22}`; - - const existing = this._connections.get(connectionKey); - if (existing) { - if (replaceRelay) { - // Tear down the old relay and create a fresh one, following - // the same dispose-and-recreate pattern as TunnelAgentHostMainService. - // The SSH client is detached so only the WebSocket relay is closed. - this._logService.info(`${LOG_PREFIX} Reconnecting relay for existing SSH tunnel ${connectionKey}`); - const { sshClient, remotePort, connectionToken } = existing; - - // Remove from map and detach SSH client before disposing so - // the old relay's close handler (conn?.dispose()) is a no-op. - this._connections.deleteAndLeak(connectionKey); - existing.detachSshClient(); - existing.dispose(); - - // Create fresh relay and connection. If relay creation fails, - // clean up the detached SSH client so it doesn't leak. - const connectionId = connectionKey; - try { - let conn: SSHConnection | undefined; // eslint-disable-line prefer-const - const relay = await this._createWebSocketRelay( - sshClient, '127.0.0.1', remotePort, connectionToken, - (data: string) => this._onDidRelayMessage.fire({ connectionId, data }), - () => { conn?.dispose(); }, - ); - - conn = new SSHConnection( - config, connectionId, connectionKey, config.name, - connectionToken, remotePort, sshClient, relay, undefined, - ); - - Event.once(conn.onDidClose)(() => { - if (this._connections.get(connectionKey) === conn) { - this._connections.deleteAndDispose(connectionKey); - this._onDidRelayClose.fire(connectionId); - this._onDidCloseConnection.fire(connectionId); - this._onDidChangeConnections.fire(); - } - }); - - this._connections.set(connectionKey, conn); - - return { - connectionId: conn.connectionId, - address: conn.address, - name: conn.name, - connectionToken: conn.connectionToken, - config: conn.config, - sshConfigHost: config.sshConfigHost, - }; - } catch (err) { - sshClient.end(); - this._onDidRelayClose.fire(connectionId); - this._onDidCloseConnection.fire(connectionId); - this._onDidChangeConnections.fire(); - throw err; - } - } - - return { - connectionId: existing.connectionId, - address: existing.address, - name: existing.name, - connectionToken: existing.connectionToken, - config: existing.config, - sshConfigHost: config.sshConfigHost, - }; - } - - this._logService.info(`${LOG_PREFIX} ${replaceRelay ? 'Reconnecting' : 'Connecting'} to ${connectionKey}`); - let sshClient: SSHClient | undefined; - - try { - const reportProgress = (message: string) => { - this._onDidReportConnectProgress.fire({ connectionKey, message }); - }; - - // 1. Establish SSH connection - reportProgress(localize('sshProgressConnecting', "Establishing SSH connection...")); - sshClient = await this._connectSSH(config); - - if (config.remoteAgentHostCommand) { - // Dev override: skip platform detection and CLI install, - // use the provided command directly. - this._logService.info(`${LOG_PREFIX} Using custom agent host command: ${config.remoteAgentHostCommand}`); - } else { - // 2. Detect remote platform - const { stdout: unameS } = await sshExec(sshClient, 'uname -s'); - const { stdout: unameM } = await sshExec(sshClient, 'uname -m'); - const platform = resolveRemotePlatform(unameS, unameM); - if (!platform) { - throw new Error(`${LOG_PREFIX} Unsupported remote platform: ${unameS.trim()} ${unameM.trim()}`); - } - this._logService.info(`${LOG_PREFIX} Remote platform: ${platform.os}-${platform.arch}`); - - // 3. Install CLI if needed - reportProgress(localize('sshProgressInstallingCLI', "Checking remote CLI installation...")); - await this._ensureCLIInstalled(sshClient, platform, reportProgress); - } - - // 4. Check for an already-running agent host on the remote. - // This prevents accumulating orphaned processes when the SSH - // connection drops and we reconnect. - let remotePort: number | undefined; - let connectionToken: string | undefined; - let agentStream: SSHChannel | undefined; - - reportProgress(localize('sshProgressCheckingAgent', "Checking for existing agent host...")); - const exec = bindSshExec(sshClient); - const existingAH = await findRunningAgentHost(exec, this._logService, this._quality); - if (existingAH) { - remotePort = existingAH.port; - connectionToken = existingAH.connectionToken; - } - - if (remotePort === undefined) { - // 5. Start agent-host and capture port/token - reportProgress(localize('sshProgressStartingAgent', "Starting remote agent host...")); - const result = await this._startRemoteAgentHost(sshClient, this._quality, config.remoteAgentHostCommand); - remotePort = result.port; - connectionToken = result.connectionToken; - agentStream = result.stream; - - // Record state for future reuse - await writeAgentHostState(exec, this._logService, this._quality, result.pid, remotePort, connectionToken); - } - - // 6. Connect to remote agent host via WebSocket relay (no local TCP port) - reportProgress(localize('sshProgressForwarding', "Connecting to remote agent host...")); - const connectionId = connectionKey; - let conn: SSHConnection | undefined; // eslint-disable-line prefer-const - let relay: { send: (data: string) => void; close: () => void }; - try { - relay = await this._createWebSocketRelay( - sshClient, '127.0.0.1', remotePort, connectionToken, - (data: string) => this._onDidRelayMessage.fire({ connectionId, data }), - () => { conn?.dispose(); }, - ); - } catch (relayErr) { - if (!existingAH) { - throw relayErr; - } - // The reused agent host is not connectable — kill it and start fresh - const relayErrorMessage = relayErr instanceof Error ? relayErr.message : String(relayErr); - this._logService.warn(`${LOG_PREFIX} Failed to connect to reused agent host on port ${remotePort}: ${relayErrorMessage}. Starting fresh`); - await cleanupRemoteAgentHost(exec, this._logService, this._quality); - - reportProgress(localize('sshProgressStartingAgent', "Starting remote agent host...")); - const result = await this._startRemoteAgentHost(sshClient, this._quality, config.remoteAgentHostCommand); - remotePort = result.port; - connectionToken = result.connectionToken; - agentStream = result.stream; - await writeAgentHostState(exec, this._logService, this._quality, result.pid, remotePort, connectionToken); - - reportProgress(localize('sshProgressForwarding', "Connecting to remote agent host...")); - relay = await this._createWebSocketRelay( - sshClient, '127.0.0.1', remotePort, connectionToken, - (data: string) => this._onDidRelayMessage.fire({ connectionId, data }), - () => { conn?.dispose(); }, - ); - } - - // 7. Create connection object - const address = connectionKey; - conn = new SSHConnection( - config, - connectionId, - address, - config.name, - connectionToken, - remotePort, - sshClient, - relay, - agentStream, - ); - - Event.once(conn.onDidClose)(() => { - if (this._connections.get(connectionKey) === conn) { - this._connections.deleteAndDispose(connectionKey); - this._onDidRelayClose.fire(connectionId); - this._onDidCloseConnection.fire(connectionId); - this._onDidChangeConnections.fire(); - } - }); - - this._connections.set(connectionKey, conn); - sshClient = undefined; // ownership transferred to SSHConnection - - this._onDidChangeConnections.fire(); - - return { - connectionId, - address, - name: config.name, - connectionToken, - config: conn.config, - sshConfigHost: config.sshConfigHost, - }; - - } catch (err) { - sshClient?.end(); - throw err; - } - } - - async disconnect(host: string): Promise { - for (const [key, conn] of this._connections) { - if (key === host || conn.connectionId === host) { - conn.dispose(); - return; - } - } - } - - async relaySend(connectionId: string, message: string): Promise { - for (const conn of this._connections.values()) { - if (conn.connectionId === connectionId) { - conn.relaySend(message); - return; - } - } - } - - async reconnect(sshConfigHost: string, name: string, remoteAgentHostCommand?: string, agentForward?: boolean): Promise { - this._logService.info(`${LOG_PREFIX} Reconnecting via SSH config host: ${sshConfigHost}`); - const resolved = await this.resolveSSHConfig(sshConfigHost); - - // Always use Agent auth — the auth handler will walk through the SSH - // agent and any default identities. If the user pinned a non-default - // `IdentityFile` in their ssh config, surface it as the explicit key - // so it gets tried first. - let privateKeyPath: string | undefined; - if (resolved.identityFile.length > 0 && !SSHRemoteAgentHostMainService._isDefaultKeyPath(resolved.identityFile[0])) { - privateKeyPath = resolved.identityFile[0]; - } - this._logService.info(`${LOG_PREFIX} reconnect: identityFiles=${JSON.stringify(resolved.identityFile)}, explicit key=${privateKeyPath ?? '(none)'}`); - - return this.connect({ - host: resolved.hostname, - port: resolved.port !== 22 ? resolved.port : undefined, - username: resolved.user ?? sshConfigHost, - authMethod: SSHAuthMethod.Agent, - privateKeyPath, - name, - sshConfigHost, - remoteAgentHostCommand, - agentForward: agentForward && resolved.forwardAgent ? true : undefined, - }, /* replaceRelay */ true); - } - - async listSSHConfigHosts(): Promise { - const configPath = join(os.homedir(), '.ssh', 'config'); - try { - const content = await fsp.readFile(configPath, 'utf-8'); - return this._parseSSHConfigHosts(content, dirname(configPath)); - } catch { - this._logService.info(`${LOG_PREFIX} Could not read SSH config at ${configPath}`); - return []; - } - } - - async ensureUserSSHConfig(): Promise { - const sshDir = join(os.homedir(), '.ssh'); - const configPath = join(sshDir, 'config'); - const isPosix = process.platform !== 'win32'; - try { - await fsp.mkdir(sshDir, { recursive: true, mode: isPosix ? 0o700 : undefined }); - } catch (err) { - this._logService.warn(`${LOG_PREFIX} Failed to ensure ~/.ssh directory: ${err}`); - throw err; - } - try { - await fsp.access(configPath); - } catch { - try { - const handle = await fsp.open(configPath, 'a', isPosix ? 0o600 : undefined); - await handle.close(); - } catch (err) { - this._logService.warn(`${LOG_PREFIX} Failed to create ${configPath}: ${err}`); - throw err; - } - } - return URI.file(configPath); - } - - async listSSHConfigFiles(): Promise { - const isWindows = process.platform === 'win32'; - const userConfigPath = join(os.homedir(), '.ssh', 'config'); - const systemConfigPath = isWindows - ? join(process.env['ProgramData'] ?? 'C:\\ProgramData', 'ssh', 'ssh_config') - : '/etc/ssh/ssh_config'; - - const result: URI[] = [URI.file(userConfigPath)]; - try { - await fsp.access(systemConfigPath); - result.push(URI.file(systemConfigPath)); - } catch { - // system config file does not exist — skip - } - return result; - } - - async resolveSSHConfig(host: string): Promise { - return new Promise((resolve, reject) => { - cp.execFile('ssh', ['-G', host], { timeout: 5000 }, (err, stdout) => { - if (err) { - reject(new Error(`${LOG_PREFIX} ssh -G failed for ${host}: ${err.message}`)); - return; - } - const config = this._parseSSHGOutput(stdout); - resolve(config); - }); - }); - } - - private async _parseSSHConfigHosts(content: string, configDir: string, visited?: Set): Promise { - const seen = visited ?? new Set(); - const hosts: string[] = []; - - // Extract hosts from this file directly - hosts.push(...parseSSHConfigHostEntries(content)); - - // Follow Include directives - for (const line of content.split('\n')) { - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith('#')) { - continue; - } - const includeMatch = trimmed.match(/^Include\s+(.+)$/i); - if (!includeMatch) { - continue; - } - - const rawValue = stripSSHComment(includeMatch[1]); - const patterns = rawValue.split(/\s+/).filter(Boolean); - - for (const rawPattern of patterns) { - const pattern = rawPattern.replace(/^~/, os.homedir()); - const resolvedPattern = isAbsolute(pattern) ? pattern : join(configDir, pattern); - - if (seen.has(resolvedPattern)) { - continue; - } - seen.add(resolvedPattern); - - try { - const stat = await fsp.stat(resolvedPattern); - if (stat.isDirectory()) { - const files = await fsp.readdir(resolvedPattern); - for (const file of files) { - try { - const sub = await fsp.readFile(join(resolvedPattern, file), 'utf-8'); - hosts.push(...await this._parseSSHConfigHosts(sub, resolvedPattern, seen)); - } catch { /* skip unreadable files */ } - } - } else { - const sub = await fsp.readFile(resolvedPattern, 'utf-8'); - hosts.push(...await this._parseSSHConfigHosts(sub, dirname(resolvedPattern), seen)); - } - } catch { - const dir = dirname(resolvedPattern); - const base = basename(resolvedPattern); - if (base.includes('*')) { - try { - const files = await fsp.readdir(dir); - for (const file of files) { - const regex = new RegExp('^' + base.replace(/\*/g, '.*') + '$'); - if (regex.test(file)) { - try { - const sub = await fsp.readFile(join(dir, file), 'utf-8'); - hosts.push(...await this._parseSSHConfigHosts(sub, dir, seen)); - } catch { /* skip */ } - } - } - } catch { /* skip unreadable dirs */ } - } - } - } - } - return hosts; - } - - private _parseSSHGOutput(stdout: string): ISSHResolvedConfig { - return parseSSHGOutput(stdout); - } - - protected async _connectSSH( - config: ISSHAgentHostConfig, - ): Promise { - const nativeRequire = await this._getNativeRequire(); - const ssh2Module = nativeRequire('ssh2') as { Client: new () => unknown }; - const SSHClientCtor = ssh2Module.Client; - - const connectConfig: ConnectConfig = { - host: config.host, - port: config.port ?? 22, - username: config.username, - readyTimeout: 30_000, - keepaliveInterval: 15_000, - }; - - const attempts = await this._buildAuthAttempts(config); - this._logService.info(`${LOG_PREFIX} Built ${attempts.length} auth attempt(s): ${attempts.map(a => describeAuthAttempt(a)).join(', ')}`); - // Cast: the ssh2 @types don't model `false` (give-up) for the - // callback nor `null` for the first invocation's `methodsLeft`, - // even though the runtime supports both per the ssh2 docs. - connectConfig.authHandler = makeAuthHandler(attempts, this._logService) as unknown as ConnectConfig['authHandler']; - - if (config.agentForward) { - const agentSock = this._isAgentAvailable(); - if (agentSock) { - // ssh2 needs `connectConfig.agent` set so it knows which local - // agent socket to forward to. Without it, agent forwarding is a - // no-op even if `agentForward: true` is set. - connectConfig.agent = agentSock; - connectConfig.agentForward = true; - this._logService.info(`${LOG_PREFIX} SSH agent forwarding enabled`); - } else { - this._logService.warn(`${LOG_PREFIX} SSH agent forwarding requested, but SSH_AUTH_SOCK is not set; agent forwarding disabled`); - } - } - - return new Promise((resolve, reject) => { - const client = new SSHClientCtor() as SSHClient; - - client.on('ready', () => { - this._logService.info(`${LOG_PREFIX} SSH connection established to ${config.host}`); - resolve(client); - }); - - client.on('error', (err: Error) => { - this._logService.error(`${LOG_PREFIX} SSH connection error: ${err.message}`); - reject(err); - }); - - client.connect(connectConfig); - }); - } - - /** - * Build the ordered list of authentication attempts to feed to ssh2's - * `authHandler`. Mirrors OpenSSH's behavior: try the explicitly configured - * key first (if any), then the SSH agent (if `SSH_AUTH_SOCK` is set), then - * each readable default identity file in turn. This means a host that - * accepts `~/.ssh/id_rsa` still works even if the agent doesn't have it - * loaded — without needing an explicit `IdentityFile` entry in `~/.ssh/config`. - */ - protected async _buildAuthAttempts(config: ISSHAgentHostConfig): Promise { - const attempts: SSHAuthAttempt[] = []; - const username = config.username; - - switch (config.authMethod) { - case SSHAuthMethod.Agent: { - if (config.privateKeyPath) { - const explicit = await this._readKeyFileIfExists(config.privateKeyPath); - if (explicit) { - attempts.push({ type: 'publickey', username, key: explicit, keyPath: config.privateKeyPath }); - } - } - const agentSock = this._isAgentAvailable(); - if (agentSock) { - attempts.push({ type: 'agent', username, agent: agentSock }); - } - for (const keyPath of SSHRemoteAgentHostMainService._defaultKeyPaths) { - if (config.privateKeyPath === keyPath) { - continue; // Already added as the explicit attempt above - } - const contents = await this._readKeyFileIfExists(keyPath); - if (contents) { - attempts.push({ type: 'publickey', username, key: contents, keyPath }); - } - } - break; - } - case SSHAuthMethod.KeyFile: { - // KeyFile mode has no fallbacks — fail fast with a clear error if - // the key is missing or unreadable, rather than letting it surface - // downstream as a generic auth failure. - if (!config.privateKeyPath) { - throw new Error(localize('ssh.keyFileAuthRequiresPath', "Key file authentication requires a private key path.")); - } - const explicit = await this._readKeyFileIfExists(config.privateKeyPath); - if (!explicit) { - throw new Error(localize('ssh.failedToReadPrivateKey', "Failed to read private key file: {0}", config.privateKeyPath)); - } - attempts.push({ type: 'publickey', username, key: explicit, keyPath: config.privateKeyPath }); - break; - } - case SSHAuthMethod.Password: { - if (config.password !== undefined) { - attempts.push({ type: 'password', username, password: config.password }); - } - break; - } - } - - return attempts; - } - - private static readonly _defaultKeyPaths = [ - '~/.ssh/id_ed25519', - '~/.ssh/id_rsa', - '~/.ssh/id_ecdsa', - '~/.ssh/id_dsa', - '~/.ssh/id_xmss', - ]; - - private static _isDefaultKeyPath(keyPath: string): boolean { - return SSHRemoteAgentHostMainService._defaultKeyPaths.includes(keyPath); - } - - /** Test seam: returns the SSH agent socket path, or undefined when no agent is available. */ - protected _isAgentAvailable(): string | undefined { - return process.env['SSH_AUTH_SOCK']; - } - - /** - * Test seam: read a private key file from disk. Returns `undefined` if the - * file doesn't exist; logs and returns `undefined` for any other read error - * so a single broken key doesn't abort the whole auth flow. - */ - protected async _readKeyFileIfExists(keyPath: string): Promise { - const resolved = keyPath.replace(/^~/, os.homedir()); - try { - return await fsp.readFile(resolved); - } catch (error) { - const errorCode = (error as NodeJS.ErrnoException).code; - if (errorCode === 'ENOENT' || errorCode === 'ENOTDIR') { - return undefined; - } - this._logService.warn(`${LOG_PREFIX} Failed to read SSH key file ${resolved}`, error); - return undefined; - } - } - - private get _quality(): string { - return this._productService.quality || 'insider'; - } - - protected _startRemoteAgentHost( - client: SSHClient, quality: string, commandOverride?: string, - ): Promise<{ port: number; connectionToken: string | undefined; pid: number | undefined; stream: SSHChannel }> { - return startRemoteAgentHost(client, this._logService, quality, commandOverride); - } - - protected async _createWebSocketRelay( - client: SSHClient, dstHost: string, dstPort: number, connectionToken: string | undefined, - onMessage: (data: string) => void, onClose: () => void, - ): Promise<{ send: (data: string) => void; close: () => void }> { - const nativeRequire = await this._getNativeRequire(); - return createWebSocketRelay(nativeRequire, client, dstHost, dstPort, connectionToken, this._logService, onMessage, onClose); - } - - private async _ensureCLIInstalled(client: SSHClient, platform: { os: string; arch: string }, reportProgress: (message: string) => void): Promise { - const cliDir = getRemoteCLIDir(this._quality); - const cliBin = getRemoteCLIBin(this._quality); - const { code } = await sshExec(client, `${cliBin} --version`, { ignoreExitCode: true }); - if (code === 0) { - this._logService.info(`${LOG_PREFIX} VS Code CLI already installed on remote`); - return; - } - - reportProgress(localize('sshProgressDownloadingCLI', "Installing VS Code CLI on remote...")); - const url = buildCLIDownloadUrl(platform.os, platform.arch, this._quality); - - const installCmd = [ - `mkdir -p ${cliDir}`, - `curl -fsSL ${shellEscape(url)} | tar xz -C ${cliDir}`, - `chmod +x ${cliBin}`, - ].join(' && '); - - await sshExec(client, installCmd); - this._logService.info(`${LOG_PREFIX} VS Code CLI installed successfully`); - } -} diff --git a/src/vs/platform/agentHost/node/tunnelAgentHostService.ts b/src/vs/platform/agentHost/node/tunnelAgentHostService.ts deleted file mode 100644 index da92f115..00000000 --- a/src/vs/platform/agentHost/node/tunnelAgentHostService.ts +++ /dev/null @@ -1,333 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import type { Tunnel } from '@microsoft/dev-tunnels-contracts'; -import type { TunnelManagementHttpClient } from '@microsoft/dev-tunnels-management'; -import { createHash } from 'crypto'; -import type WebSocket from 'ws'; -import { Emitter, Event } from '../../../base/common/event.js'; -import { Disposable } from '../../../base/common/lifecycle.js'; -import { generateUuid } from '../../../base/common/uuid.js'; -import { ILogService } from '../../log/common/log.js'; -import { - ITunnelAgentHostMainService, - TUNNEL_ADDRESS_PREFIX, - TUNNEL_AGENT_HOST_PORT, - TUNNEL_LAUNCHER_LABEL, - TUNNEL_MIN_PROTOCOL_VERSION, - TunnelTags, - type ITunnelConnectResult, - type ITunnelInfo, - type ITunnelRelayMessage, -} from '../common/tunnelAgentHost.js'; - -const LOG_PREFIX = '[TunnelAgentHost]'; - -/** - * Derive a connection token from a tunnel ID using the same convention - * as the VS Code CLI (see `get_connection_token` in cli/src/commands/tunnels.rs). - */ -function deriveConnectionToken(tunnelId: string): string { - const hash = createHash('sha256'); - hash.update(tunnelId); - let result = hash.digest('base64url'); - if (result.startsWith('-')) { - result = 'a' + result; - } - return result; -} - -/** State for a single active tunnel relay connection. */ -class TunnelConnection extends Disposable { - private readonly _onDidClose = this._register(new Emitter()); - readonly onDidClose = this._onDidClose.event; - - private _closed = false; - - constructor( - readonly connectionId: string, - readonly address: string, - readonly name: string, - readonly connectionToken: string, - private readonly _relay: { send: (data: string) => void; close: () => void }, - private readonly _relayClient: { dispose(): void }, - ) { - super(); - } - - override dispose(): void { - if (!this._closed) { - this._closed = true; - this._relay.close(); - this._relayClient.dispose(); - this._onDidClose.fire(); - } - super.dispose(); - } - - relaySend(data: string): void { - this._relay.send(data); - } -} - -export class TunnelAgentHostMainService extends Disposable implements ITunnelAgentHostMainService { - declare readonly _serviceBrand: undefined; - - private readonly _onDidRelayMessage = this._register(new Emitter()); - readonly onDidRelayMessage: Event = this._onDidRelayMessage.event; - - private readonly _onDidRelayClose = this._register(new Emitter()); - readonly onDidRelayClose: Event = this._onDidRelayClose.event; - - private readonly _connections = new Map(); - - constructor( - @ILogService private readonly _logService: ILogService, - ) { - super(); - } - - async listTunnels(token: string, authProvider: 'github' | 'microsoft', additionalTunnelNames?: string[]): Promise { - const client = await this._createManagementClient(token, authProvider); - const results: ITunnelInfo[] = []; - const seen = new Set(); - - try { - // Enumerate all tunnels with the vscode-server-launcher label - const tunnels = await client.listTunnels(undefined, undefined, { - labels: [TUNNEL_LAUNCHER_LABEL], - requireAllLabels: true, - includePorts: true, - tokenScopes: ['connect'], - }); - - for (const tunnel of tunnels) { - const info = this._parseTunnelInfo(tunnel); - if (info && info.protocolVersion >= TUNNEL_MIN_PROTOCOL_VERSION) { - results.push(info); - seen.add(info.tunnelId); - } - } - } catch (err) { - this._logService.error(`${LOG_PREFIX} Failed to enumerate tunnels`, err); - } - - // Look up additional tunnels by name - if (additionalTunnelNames) { - for (const tunnelName of additionalTunnelNames) { - try { - const [tunnel] = await client.listTunnels(undefined, undefined, { - labels: [tunnelName, TUNNEL_LAUNCHER_LABEL], - requireAllLabels: true, - includePorts: true, - tokenScopes: ['connect'], - limit: 1, - }); - if (tunnel) { - const info = this._parseTunnelInfo(tunnel); - if (info && info.protocolVersion >= TUNNEL_MIN_PROTOCOL_VERSION && !seen.has(info.tunnelId)) { - results.push(info); - seen.add(info.tunnelId); - } - } - } catch (err) { - this._logService.warn(`${LOG_PREFIX} Failed to look up tunnel '${tunnelName}'`, err); - } - } - } - - this._logService.info(`${LOG_PREFIX} Found ${results.length} tunnel(s) with agent host support`); - return results; - } - - async connect(token: string, authProvider: 'github' | 'microsoft', tunnelId: string, clusterId: string): Promise { - // Tear down any existing connection to this tunnel first. - // Each connect() call creates a fresh relay with its own protocol - // session, so the old one must be closed to avoid conflicts. - for (const [id, conn] of this._connections) { - if (conn.address === `${TUNNEL_ADDRESS_PREFIX}${tunnelId}`) { - this._logService.info(`${LOG_PREFIX} Closing existing relay for tunnel ${tunnelId} before reconnecting`); - this._connections.delete(id); - conn.dispose(); - break; - } - } - - const client = await this._createManagementClient(token, authProvider); - const connectionId = generateUuid(); - const address = `${TUNNEL_ADDRESS_PREFIX}${tunnelId}`; - - this._logService.info(`${LOG_PREFIX} Connecting to tunnel ${tunnelId} in cluster ${clusterId}...`); - - // Get the full tunnel with endpoints and access tokens - const tunnel: Tunnel = { tunnelId, clusterId }; - const resolved = await client.getTunnel(tunnel, { - includePorts: true, - tokenScopes: ['connect'], - }); - - if (!resolved) { - throw new Error(`${LOG_PREFIX} Tunnel ${tunnelId} not found`); - } - - // Connect to the tunnel relay - const { TunnelRelayTunnelClient } = await import('@microsoft/dev-tunnels-connections'); - const relayClient = new TunnelRelayTunnelClient(client); - relayClient.acceptLocalConnectionsForForwardedPorts = false; - if (resolved.endpoints) { - relayClient.endpoints = resolved.endpoints; - } - - await relayClient.connect(resolved); - this._logService.info(`${LOG_PREFIX} Tunnel relay connected, waiting for port ${TUNNEL_AGENT_HOST_PORT}...`); - - // Wait for the agent host port to become available - await relayClient.waitForForwardedPort(TUNNEL_AGENT_HOST_PORT); - - // Connect to the forwarded port — returns a Duplex stream - const portStream = await relayClient.connectToForwardedPort(TUNNEL_AGENT_HOST_PORT); - this._logService.info(`${LOG_PREFIX} Connected to forwarded port ${TUNNEL_AGENT_HOST_PORT}`); - - // Derive connection token from tunnel ID (matches CLI convention) - const connectionToken = deriveConnectionToken(tunnelId); - - // Parse display name from tags - const tags = new TunnelTags(resolved.labels); - const name = tags.name || resolved.name || tunnelId; - - // Create WebSocket over the port stream - const relay = await this._createWebSocketRelay( - portStream, - connectionToken, - connectionId, - ); - - const conn = new TunnelConnection( - connectionId, - address, - name, - connectionToken, - relay, - relayClient, - ); - - conn.onDidClose(() => { - this._connections.delete(connectionId); - this._onDidRelayClose.fire(connectionId); - }); - - this._connections.set(connectionId, conn); - return { connectionId, address, name, connectionToken }; - } - - async relaySend(connectionId: string, message: string): Promise { - const conn = this._connections.get(connectionId); - if (conn) { - conn.relaySend(message); - } - } - - async disconnect(connectionId: string): Promise { - const conn = this._connections.get(connectionId); - if (conn) { - conn.dispose(); - } - } - - private async _createManagementClient(token: string, authProvider: 'github' | 'microsoft'): Promise { - const mgmt = await import('@microsoft/dev-tunnels-management'); - const authHeader = authProvider === 'github' ? `github ${token}` : `Bearer ${token}`; - - return new mgmt.TunnelManagementHttpClient( - 'vscode-sessions', - mgmt.ManagementApiVersions.Version20230927preview, - async () => authHeader, - ); - } - - private _parseTunnelInfo(tunnel: Tunnel): ITunnelInfo | undefined { - const labels = tunnel.labels ?? []; - const tags = new TunnelTags(labels); - - if (tags.protocolVersion < TUNNEL_MIN_PROTOCOL_VERSION) { - return undefined; - } - - const tunnelId = tunnel.tunnelId; - const clusterId = tunnel.clusterId; - if (!tunnelId || !clusterId) { - return undefined; - } - - const name = tags.name || tunnel.name || tunnelId; - const rawCount = tunnel.status?.hostConnectionCount; - const hostConnectionCount = typeof rawCount === 'number' ? rawCount : (rawCount?.current ?? 0); - return { - tunnelId, - clusterId, - name, - tags: labels, - protocolVersion: tags.protocolVersion, - hostConnectionCount, - }; - } - - private async _createWebSocketRelay( - portStream: NodeJS.ReadWriteStream, - connectionToken: string, - connectionId: string, - ): Promise<{ send: (data: string) => void; close: () => void }> { - const WS = await import('ws'); - - return new Promise((resolve, reject) => { - // Construct WebSocket URL — the stream is already connected to the right port - let url = `ws://localhost:${TUNNEL_AGENT_HOST_PORT}`; - if (connectionToken) { - url += `?tkn=${encodeURIComponent(connectionToken)}`; - } - - // Create WebSocket over the existing stream from the tunnel relay - const ws = new WS.WebSocket(url, { - createConnection: (() => portStream) as unknown as WebSocket.ClientOptions['createConnection'], - }); - - ws.on('open', () => { - this._logService.info(`${LOG_PREFIX} WebSocket relay connected to agent host via tunnel`); - resolve({ - send: (data: string) => { - if (ws.readyState === ws.OPEN) { - ws.send(data); - } - }, - close: () => ws.close(), - }); - }); - - ws.on('message', (data: WebSocket.RawData) => { - let text: string; - if (Array.isArray(data)) { - text = Buffer.concat(data).toString(); - } else if (data instanceof ArrayBuffer) { - text = Buffer.from(new Uint8Array(data)).toString(); - } else { - text = data.toString(); - } - this._onDidRelayMessage.fire({ connectionId, data: text }); - }); - - ws.on('close', () => { - const conn = this._connections.get(connectionId); - if (conn) { - conn.dispose(); - } - }); - - ws.on('error', (wsErr: unknown) => { - this._logService.warn(`${LOG_PREFIX} WebSocket relay error: ${wsErr instanceof Error ? wsErr.message : String(wsErr)}`); - reject(wsErr); - }); - }); - } -} diff --git a/src/vs/platform/agentHost/node/tunnelHostMainService.ts b/src/vs/platform/agentHost/node/tunnelHostMainService.ts deleted file mode 100644 index 0d7ce15b..00000000 --- a/src/vs/platform/agentHost/node/tunnelHostMainService.ts +++ /dev/null @@ -1,222 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import type { Tunnel } from '@microsoft/dev-tunnels-contracts'; -import type { TunnelManagementHttpClient } from '@microsoft/dev-tunnels-management'; -import { connect } from 'net'; -import { hostname } from 'os'; -import { Emitter, Event } from '../../../base/common/event.js'; -import { Disposable, MutableDisposable } from '../../../base/common/lifecycle.js'; -import { joinPath } from '../../../base/common/resources.js'; -import { IConfigurationService } from '../../configuration/common/configuration.js'; -import { INativeEnvironmentService } from '../../environment/common/environment.js'; -import { ILogger, ILoggerService } from '../../log/common/log.js'; -import { localize } from '../../../nls.js'; -import { CONFIGURATION_KEY_HOST_NAME } from '../../remoteTunnel/common/remoteTunnel.js'; -import { - ITunnelAgentHostHostingService, - PROTOCOL_VERSION_TAG_PREFIX, - TUNNEL_AGENT_HOST_PORT, - TUNNEL_HOST_LOG_ID, - TUNNEL_LAUNCHER_LABEL, - TUNNEL_MIN_PROTOCOL_VERSION, - type ITunnelHostInfo, - type TunnelHostStatus, -} from '../common/tunnelAgentHost.js'; -import type { IAgentHostSocketInfo } from '../common/agentService.js'; - -/** State of a currently hosted tunnel. */ -interface IActiveTunnel { - readonly info: ITunnelHostInfo; - readonly tunnel: Tunnel; - readonly host: { dispose(): void }; - readonly client: TunnelManagementHttpClient; -} - -export class TunnelHostMainService extends Disposable implements ITunnelAgentHostHostingService { - declare readonly _serviceBrand: undefined; - - private readonly _onDidChangeStatus = this._register(new Emitter()); - readonly onDidChangeStatus: Event = this._onDidChangeStatus.event; - - private readonly _activeTunnel = this._register(new MutableDisposable()); - private _active: IActiveTunnel | undefined; - - private readonly _logger: ILogger; - - constructor( - @IConfigurationService private readonly _configurationService: IConfigurationService, - @ILoggerService loggerService: ILoggerService, - @INativeEnvironmentService environmentService: INativeEnvironmentService, - ) { - super(); - - this._logger = this._register(loggerService.createLogger( - joinPath(environmentService.logsHome, `${TUNNEL_HOST_LOG_ID}.log`), - { id: TUNNEL_HOST_LOG_ID, name: localize('tunnelHost.log', "Remote Connections") }, - )); - } - - async startHosting(token: string, authProvider: 'github' | 'microsoft', socketInfo: IAgentHostSocketInfo): Promise { - // Stop any existing tunnel first - if (this._active) { - await this.stopHosting(); - } - - const tunnelName = this._getTunnelName(); - this._logger.info(`Starting tunnel hosting as '${tunnelName}'...`); - - const client = await this._createManagementClient(token, authProvider); - - // Create tunnel with agent host port and appropriate labels - const protocolVersionTag = `${PROTOCOL_VERSION_TAG_PREFIX}${TUNNEL_MIN_PROTOCOL_VERSION}`; - - const newTunnel: Tunnel = { - ports: [{ - portNumber: TUNNEL_AGENT_HOST_PORT, - protocol: 'https', - }], - labels: [TUNNEL_LAUNCHER_LABEL, tunnelName, protocolVersionTag], - }; - - const tunnelRequestOptions = { - tokenScopes: ['host', 'connect'], - includePorts: true, - }; - - const tunnel = await client.createOrUpdateTunnel(newTunnel, tunnelRequestOptions); - this._logger.info(`Tunnel created: ${tunnel.tunnelId} in cluster ${tunnel.clusterId}`); - - // Host the tunnel using TunnelRelayTunnelHost. - // We disable automatic local port forwarding so that we can capture - // the raw data stream and pipe it into the agent host process - // directly, without needing a physical TCP listener on port 31546. - const { TunnelRelayTunnelHost } = await import('@microsoft/dev-tunnels-connections'); - const host = new TunnelRelayTunnelHost(client); - host.forwardConnectionsToLocalPorts = false; - host.trace = (_level: unknown, _eventId: unknown, msg: string) => { - this._logger.debug(`relay: ${msg}`); - }; - - // When a remote client connects to the tunnel port, the SDK fires - // the forwardedPortConnecting event with the port number and an - // SshStream for the connection. We pipe that stream into a - // connection to the local agent host process. - const { socketPath } = socketInfo; - - host.forwardedPortConnecting((e: { port: number; stream: NodeJS.ReadWriteStream }) => { - if (e.port === TUNNEL_AGENT_HOST_PORT) { - this._logger.info(`Incoming connection on port ${TUNNEL_AGENT_HOST_PORT}, piping to local agent host`); - this._pipeToLocalAgentHost(e.stream, socketPath); - } else { - this._logger.warn(`Unexpected port ${e.port}, closing stream`); - e.stream.end?.(); - } - }); - - await host.connect(tunnel); - this._logger.info(`Tunnel relay host connected`); - - const domain = tunnel.ports?.[0]?.portForwardingUris?.[0] ?? `${tunnel.tunnelId}.${tunnel.clusterId}.devtunnels.ms`; - const info: ITunnelHostInfo = { - tunnelName, - tunnelId: tunnel.tunnelId!, - clusterId: tunnel.clusterId!, - domain: typeof domain === 'string' ? domain : `${tunnel.tunnelId}.${tunnel.clusterId}.devtunnels.ms`, - }; - - this._active = { info, tunnel, host, client }; - this._activeTunnel.value = { - dispose: () => { - host.dispose(); - this._active = undefined; - } - }; - - this._onDidChangeStatus.fire({ active: true, info }); - return info; - } - - async stopHosting(): Promise { - if (!this._active) { - return; - } - - const { tunnel, client } = this._active; - this._logger.info(`Stopping tunnel hosting...`); - - // Delete the tunnel from the management service before - // tearing down the local relay so we can retry on failure - try { - await client.deleteTunnel(tunnel); - this._logger.info(`Tunnel deleted`); - } catch (err) { - this._logger.warn(`Failed to delete tunnel`, err); - } - - this._activeTunnel.clear(); - - this._onDidChangeStatus.fire({ active: false }); - } - - async getStatus(): Promise { - if (this._active) { - return { active: true, info: this._active.info }; - } - return { active: false }; - } - - /** - * Get the sanitized tunnel name from configuration or OS hostname. - */ - private _getTunnelName(): string { - let name = this._configurationService.getValue(CONFIGURATION_KEY_HOST_NAME) || hostname(); - name = name.replace(/^-+/g, '').replace(/[^\w-]/g, '').substring(0, 20); - return name || 'vscode'; - } - - private async _createManagementClient(token: string, authProvider: 'github' | 'microsoft'): Promise { - const mgmt = await import('@microsoft/dev-tunnels-management'); - const authHeader = authProvider === 'github' ? `github ${token}` : `Bearer ${token}`; - - return new mgmt.TunnelManagementHttpClient( - 'vscode-sessions', - mgmt.ManagementApiVersions.Version20230927preview, - async () => authHeader, - ); - } - - /** - * Pipe an incoming tunnel stream to the local agent host. - * The SshStream from the dev tunnels SDK is a Node.js duplex stream — we - * connect to the agent host's local socket and bidirectionally pipe data. - */ - private _pipeToLocalAgentHost(incomingStream: NodeJS.ReadWriteStream, socketPath: string): void { - const socket = connect(socketPath); - - socket.on('connect', () => { - this._logger.debug(`Connected to local agent host socket`); - incomingStream.pipe(socket); - socket.pipe(incomingStream); - }); - - socket.on('error', (err) => { - this._logger.error(`Socket error`, err); - incomingStream.end?.(); - }); - - incomingStream.on('error', () => { - socket.destroy(); - }); - } - - override dispose(): void { - if (this._active) { - // Best-effort cleanup on dispose — don't await - this.stopHosting().catch(() => { /* ignore */ }); - } - super.dispose(); - } -} diff --git a/src/vs/platform/agentHost/node/webSocketTransport.ts b/src/vs/platform/agentHost/node/webSocketTransport.ts deleted file mode 100644 index b0ba3292..00000000 --- a/src/vs/platform/agentHost/node/webSocketTransport.ts +++ /dev/null @@ -1,188 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -// WebSocket transport for the sessions process protocol. -// Uses JSON serialization with URI revival for cross-process communication. - -import { Emitter } from '../../../base/common/event.js'; -import { Disposable } from '../../../base/common/lifecycle.js'; -import { connectionTokenQueryName } from '../../../base/common/network.js'; -import { ILogService } from '../../log/common/log.js'; -import { JSON_RPC_PARSE_ERROR, type AhpServerNotification, type JsonRpcResponse, type ProtocolMessage } from '../common/state/sessionProtocol.js'; -import type { IProtocolServer, IProtocolTransport } from '../common/state/sessionTransport.js'; -import type * as wsTypes from 'ws'; -import type * as httpTypes from 'http'; -import type * as urlTypes from 'url'; - -/** - * Options for creating a {@link WebSocketProtocolServer}. - * Provide either `port`+`host` or `socketPath`, not both. - */ -export interface IWebSocketServerOptions { - /** TCP port to listen on. Ignored when {@link socketPath} is set. */ - readonly port?: number; - /** Host/IP to bind to. Defaults to `'127.0.0.1'`. */ - readonly host?: string; - /** Unix domain socket / Windows named pipe path. Takes precedence over port. */ - readonly socketPath?: string; - /** - * Optional token validator. When provided, WebSocket upgrade requests - * must include a valid token in the `tkn` query parameter. - */ - readonly connectionTokenValidate?: (token: unknown) => boolean; -} - -// ---- Per-connection transport ----------------------------------------------- - -/** - * Wraps a single WebSocket connection as an {@link IProtocolTransport}. - * Messages are serialized as JSON with URI revival. - */ -export class WebSocketProtocolTransport extends Disposable implements IProtocolTransport { - - private readonly _onMessage = this._register(new Emitter()); - readonly onMessage = this._onMessage.event; - - private readonly _onClose = this._register(new Emitter()); - readonly onClose = this._onClose.event; - - constructor( - private readonly _ws: wsTypes.WebSocket, - private readonly _WebSocket: typeof wsTypes.WebSocket, - ) { - super(); - - this._ws.on('message', (data: Buffer | string) => { - try { - const text = typeof data === 'string' ? data : data.toString('utf-8'); - const message = JSON.parse(text) as ProtocolMessage; - this._onMessage.fire(message); - } catch { - this.send({ jsonrpc: '2.0', id: null!, error: { code: JSON_RPC_PARSE_ERROR, message: 'Parse error' } }); - } - }); - - this._ws.on('close', () => { - this._onClose.fire(); - }); - - this._ws.on('error', () => { - // Error always precedes close — closing is handled in the close handler. - this._onClose.fire(); - }); - } - - send(message: ProtocolMessage | AhpServerNotification | JsonRpcResponse): void { - if (this._ws.readyState === this._WebSocket.OPEN) { - this._ws.send(JSON.stringify(message)); - } - } - - override dispose(): void { - this._ws.close(); - super.dispose(); - } -} - -// ---- Server ----------------------------------------------------------------- - -/** - * WebSocket server that accepts client connections and wraps each one - * as an {@link IProtocolTransport}. - * - * Use the static {@link create} method to construct — it dynamically imports - * `ws` and `http`/`url` so the modules are only loaded when needed. - */ -export class WebSocketProtocolServer extends Disposable implements IProtocolServer { - - private readonly _wss: wsTypes.WebSocketServer; - private readonly _httpServer: httpTypes.Server | undefined; - private readonly _WebSocket: typeof wsTypes.WebSocket; - - private readonly _onConnection = this._register(new Emitter()); - readonly onConnection = this._onConnection.event; - - get address(): string | undefined { - const addr = this._wss.address(); - if (!addr || typeof addr === 'string') { - return addr ?? undefined; - } - return `${addr.address}:${addr.port}`; - } - - /** - * Creates a new WebSocket protocol server. Dynamically imports `ws`, - * `http`, and `url` so callers don't pay the cost when unused. - */ - static async create( - options: IWebSocketServerOptions | number, - logService: ILogService, - ): Promise { - const [ws, http, url] = await Promise.all([ - import('ws'), - import('http'), - import('url'), - ]); - return new WebSocketProtocolServer(options, logService, ws, http, url); - } - - private constructor( - options: IWebSocketServerOptions | number, - private readonly _logService: ILogService, - ws: typeof wsTypes, - http: typeof httpTypes, - url: typeof urlTypes, - ) { - super(); - - this._WebSocket = ws.WebSocket; - - // Backwards compat: accept a plain port number - const opts: IWebSocketServerOptions = typeof options === 'number' ? { port: options } : options; - const host = opts.host ?? '127.0.0.1'; - - const verifyClient = opts.connectionTokenValidate - ? (info: { req: httpTypes.IncomingMessage }, cb: (res: boolean, code?: number, message?: string) => void) => { - const parsedUrl = url.parse(info.req.url ?? '', true); - const token = parsedUrl.query[connectionTokenQueryName]; - if (!opts.connectionTokenValidate!(token)) { - this._logService.warn('[WebSocketProtocol] Connection rejected: invalid connection token'); - cb(false, 403, 'Forbidden'); - return; - } - cb(true); - } - : undefined; - - if (opts.socketPath) { - // For socket paths, create an HTTP server listening on the path - // and attach the WebSocket server to it. - this._httpServer = http.createServer(); - this._wss = new ws.WebSocketServer({ server: this._httpServer, verifyClient }); - this._httpServer.listen(opts.socketPath, () => { - this._logService.info(`[WebSocketProtocol] Server listening on socket ${opts.socketPath}`); - }); - } else { - this._wss = new ws.WebSocketServer({ port: opts.port, host, verifyClient }); - this._logService.info(`[WebSocketProtocol] Server listening on ${host}:${opts.port}`); - } - - this._wss.on('connection', (wsConn) => { - this._logService.trace('[WebSocketProtocol] New client connection'); - const transport = new WebSocketProtocolTransport(wsConn, this._WebSocket); - this._onConnection.fire(transport); - }); - - this._wss.on('error', (err) => { - this._logService.error('[WebSocketProtocol] Server error', err); - }); - } - - override dispose(): void { - this._wss.close(); - this._httpServer?.close(); - super.dispose(); - } -} diff --git a/src/vs/platform/agentHost/test/auth-rework.md b/src/vs/platform/agentHost/test/auth-rework.md deleted file mode 100644 index 4533c3b4..00000000 --- a/src/vs/platform/agentHost/test/auth-rework.md +++ /dev/null @@ -1,454 +0,0 @@ -# Auth Rework: Standards-Based Authentication for the Agent Host Protocol - -## Problem - -The current authentication mechanism is imperative and VS Code-specific: - -1. The renderer discovers agents via `listAgents()` and checks `IAgentDescriptor.requiresAuth`. -2. It obtains a GitHub OAuth token from VS Code's built-in authentication service. -3. It pushes the token via `setAuthToken(token)` — a fire-and-forget JSON-RPC notification. -4. The agent host fans the token out to all registered `IAgent` providers. - -This couples the agent host to VS Code internals. An external client (CLI tool, web app, another editor) connecting over WebSocket has no way to know _what_ authentication is required, _where_ to get a token, or _what scopes_ are needed. The client must have out-of-band knowledge that "this server needs a GitHub OAuth token." - -## Design Goals - -- **Self-describing**: The server declares its auth requirements so arbitrary clients can discover them without prior knowledge of the server's internals. -- **Standards-aligned**: Use the semantics and vocabulary of RFC 6750 (Bearer Token Usage) and RFC 9728 (OAuth 2.0 Protected Resource Metadata) adapted for JSON-RPC. -- **Challenge-on-failure**: When auth is missing or invalid, the server responds with a structured challenge (like `WWW-Authenticate`) that tells the client exactly what to do. -- **Transport-agnostic**: Works over WebSocket JSON-RPC and MessagePort IPC alike. -- **Multi-provider**: Supports multiple independent auth requirements (e.g. GitHub + a future enterprise IdP) each with their own scopes and authorization servers. -- **Non-breaking migration**: Can coexist with `setAuthToken` during a transition period. - -## Relevant Standards - -### RFC 6750 — Bearer Token Usage - -Defines how bearer tokens are transmitted (`Authorization: Bearer `) and how servers challenge clients when auth is missing or invalid: - -``` -WWW-Authenticate: Bearer realm="example", - error="invalid_token", - error_description="The access token expired" -``` - -Key error codes: `invalid_request`, `invalid_token`, `insufficient_scope`. - -### RFC 9728 — OAuth 2.0 Protected Resource Metadata - -Defines a metadata document that a protected resource publishes to describe itself: - -```json -{ - "resource": "https://resource.example.com", - "authorization_servers": ["https://as.example.com"], - "scopes_supported": ["profile", "email"], - "bearer_methods_supported": ["header"] -} -``` - -Clients discover this metadata either via a well-known URL or via the `resource_metadata` parameter in a `WWW-Authenticate` challenge. This tells the client _where_ to get a token and _what scopes_ to request. - -## Proposed Design - -### Overview - -The authentication flow has three phases, mirroring the HTTP flow from RFC 9728 §5: - -``` -┌─────────┐ ┌──────────────┐ ┌─────────────────┐ -│ Client │ │ Agent Host │ │ Authorization │ -│ │ │ (Server) │ │ Server │ -└────┬─────┘ └──────┬───────┘ └────────┬────────┘ - │ │ │ - │ 1. initialize │ │ - │ ───────────────────────────────────> │ │ - │ │ │ - │ 2. initialize result │ │ - │ { auth: [{ scheme, resource, │ │ - │ authorization_servers, │ │ - │ scopes_supported }] } │ │ - │ <─────────────────────────────────── │ │ - │ │ │ - │ 3. Obtain token from AS │ │ - │ ─────────────────────────────────────────────────────────────────> │ - │ │ │ - │ 4. Token │ │ - │ <───────────────────────────────────────────────────────────────── │ - │ │ │ - │ 5. authenticate { scheme, token } │ │ - │ ───────────────────────────────────> │ │ - │ │ │ - │ 6. { authenticated: true } │ │ - │ <─────────────────────────────────── │ │ - │ │ │ - │ 7. createSession / other commands │ │ - │ ───────────────────────────────────> │ │ -``` - -### Phase 1: Discovery (in `initialize` response) - -The `initialize` result is extended with a `resourceMetadata` field, modeled on RFC 9728 §2: - -```typescript -interface IInitializeResult { - protocolVersion: number; - serverSeq: number; - snapshots: ISnapshot[]; - defaultDirectory?: URI; - - /** RFC 9728-style resource metadata describing auth requirements. */ - resourceMetadata?: IResourceMetadata; -} - -/** - * Describes the agent host as an OAuth 2.0 protected resource. - * Modeled on RFC 9728 (OAuth 2.0 Protected Resource Metadata). - */ -interface IResourceMetadata { - /** - * Identifier for this resource (the agent host). - * Analogous to RFC 9728 `resource`. - */ - resource: string; - - /** - * Independent auth requirements. Each entry describes one - * authentication scheme the server accepts. A client must - * satisfy at least one to use authenticated features. - */ - authSchemes: IAuthScheme[]; -} - -/** - * A single authentication scheme the server accepts. - */ -interface IAuthScheme { - /** - * The auth scheme name. Initially only "bearer" (RFC 6750). - * Future schemes (e.g. "dpop", "device_code") can be added. - */ - scheme: 'bearer'; - - /** - * An opaque identifier for this auth requirement, used to - * correlate `authenticate` calls and challenges. Allows the - * server to require multiple independent tokens (e.g. one - * per agent provider). - * - * Example: "github" for GitHub Copilot auth. - */ - id: string; - - /** - * Human-readable label for display in auth UIs. - * Analogous to RFC 9728 `resource_name`. - */ - label: string; - - /** - * Authorization server issuer identifiers (RFC 8414). - * Tells the client where to obtain tokens. - * Analogous to RFC 9728 `authorization_servers`. - * - * Example: ["https://github.com/login/oauth"] - */ - authorizationServers: string[]; - - /** - * OAuth scopes the server needs. - * Analogous to RFC 9728 `scopes_supported`. - * - * Example: ["read:user", "user:email", "repo", "workflow"] - */ - scopesSupported?: string[]; - - /** - * Whether this auth requirement is mandatory for any - * functionality, or only for specific agents/features. - */ - required?: boolean; -} -``` - -**Why in `initialize`?** RFC 9728 publishes metadata at a well-known URL. In our JSON-RPC world, the `initialize` handshake _is_ the well-known endpoint — it's the first thing every client calls, and it's already where we exchange capabilities. This avoids an extra round-trip and keeps the discovery atomic. - -### Phase 2: Token Delivery (`authenticate` command) - -Replace the fire-and-forget `setAuthToken` notification with a proper JSON-RPC **request** so the client gets confirmation: - -```typescript -/** - * Client → Server request to authenticate. - * Analogous to sending `Authorization: Bearer ` (RFC 6750 §2.1). - */ -interface IAuthenticateParams { - /** - * The auth scheme identifier from the server's resourceMetadata. - * Correlates to IAuthScheme.id. - */ - schemeId: string; - - /** The scheme type (initially always "bearer"). */ - scheme: 'bearer'; - - /** The bearer token value (RFC 6750). */ - token: string; -} - -interface IAuthenticateResult { - /** Whether the token was accepted. */ - authenticated: boolean; -} -``` - -This is a **request** (not a notification) so: -- The client knows immediately if the token was accepted or rejected. -- The server can validate the token before returning success. -- Errors use structured challenges (see Phase 3). - -The client can call `authenticate` multiple times (e.g. when a token is refreshed), and can authenticate for multiple scheme IDs independently. - -### Phase 3: Challenges on Failure - -When a command fails because authentication is missing or invalid, the server returns a JSON-RPC error with structured challenge data in the `data` field, modeled on RFC 6750 §3: - -```typescript -/** - * JSON-RPC error data for authentication failures. - * Modeled on RFC 6750 WWW-Authenticate challenge parameters. - */ -interface IAuthChallenge { - /** The scheme ID that needs (re-)authentication. */ - schemeId: string; - - /** RFC 6750 §3.1 error code. */ - error: 'invalid_request' | 'invalid_token' | 'insufficient_scope'; - - /** Human-readable error description (RFC 6750 §3 error_description). */ - errorDescription?: string; - - /** Required scopes, if the error is insufficient_scope (RFC 6750 §3 scope). */ - scope?: string; -} -``` - -This is returned as the `data` payload of a JSON-RPC error response: - -```json -{ - "jsonrpc": "2.0", - "id": 5, - "error": { - "code": -32007, - "message": "Authentication required", - "data": { - "challenges": [ - { - "schemeId": "github", - "error": "invalid_token", - "errorDescription": "The access token expired" - } - ] - } - } -} -``` - -A dedicated error code (`-32007 AHP_AUTH_REQUIRED`) signals this is an auth error so clients can handle it programmatically without parsing the message string. - -### Phase 4: Auth State Notifications - -The server pushes auth state changes via notifications so clients know when auth expires or the required scopes change: - -```typescript -/** - * Server → Client notification when auth state changes. - */ -interface IAuthStateNotification { - type: 'notify/authRequired'; - - /** The scheme ID whose auth state changed. */ - schemeId: string; - - /** The new state. */ - state: 'authenticated' | 'expired' | 'revoked' | 'required'; - - /** Optional challenge with details (e.g. new scopes needed). */ - challenge?: IAuthChallenge; -} -``` - -This replaces the implicit "push a token whenever you see an account change" model with an explicit server-driven signal. - -## Concrete Example: GitHub Copilot Auth - -### Server-side (CopilotAgent) - -When the Copilot agent registers, it publishes an auth scheme: - -```typescript -// In CopilotAgent.getAuthSchemes(): -[{ - scheme: 'bearer', - id: 'github', - label: 'GitHub', - authorizationServers: ['https://github.com/login/oauth'], - scopesSupported: ['read:user', 'user:email'], - required: true, -}] -``` - -The agent host aggregates auth schemes from all agents into `IInitializeResult.resourceMetadata`. - -### Client-side (VS Code renderer) - -```typescript -// After initialize: -const metadata = initResult.resourceMetadata; -if (metadata) { - for (const scheme of metadata.authSchemes) { - if (scheme.scheme === 'bearer' && scheme.authorizationServers.some( - as => as.includes('github.com') - )) { - // We know how to handle GitHub auth - const token = await this._getGitHubToken(scheme.scopesSupported); - await agentHostService.authenticate({ - schemeId: scheme.id, - scheme: 'bearer', - token, - }); - } - } -} -``` - -### Client-side (generic external client) - -A CLI tool connecting over WebSocket: - -```typescript -const ws = new WebSocket('ws://localhost:3000'); -const initResult = await rpc.request('initialize', { protocolVersion: 1, clientId: 'cli-1' }); - -for (const scheme of initResult.resourceMetadata?.authSchemes ?? []) { - if (scheme.scheme === 'bearer') { - console.log(`Auth required: ${scheme.label}`); - console.log(`Get a token from: ${scheme.authorizationServers[0]}`); - console.log(`Scopes: ${scheme.scopesSupported?.join(', ')}`); - - // Client can use any OAuth library to get the token - const token = await doOAuthFlow(scheme.authorizationServers[0], scheme.scopesSupported); - await rpc.request('authenticate', { schemeId: scheme.id, scheme: 'bearer', token }); - } -} -``` - -## Protocol Changes Summary - -### New JSON-RPC request: `authenticate` - -| Direction | Type | Params | Result | -|---|---|---|---| -| Client → Server | Request | `IAuthenticateParams` | `IAuthenticateResult` | - -### New JSON-RPC error code - -| Code | Name | When | -|---|---|---| -| `-32007` | `AHP_AUTH_REQUIRED` | A command failed because auth is missing or invalid | - -### Extended: `initialize` result - -| Field | Type | Description | -|---|---|---| -| `resourceMetadata` | `IResourceMetadata` | Optional. Auth and resource information. | - -### New notification - -| Type | Direction | When | -|---|---|---| -| `notify/authRequired` | Server → Client | Auth state changed (expired, revoked, new requirements) | - -### Deprecated - -| Item | Replacement | Migration | -|---|---|---| -| `setAuthToken` notification | `authenticate` request | Keep accepting `setAuthToken` for one version, log deprecation | -| `IAgentDescriptor.requiresAuth` | `IResourceMetadata.authSchemes` | Derive from `authSchemes` during transition | - -## Interface Changes in `agentService.ts` - -### `IAgentService` - -```diff - interface IAgentService { -- setAuthToken(token: string): Promise; -+ authenticate(params: IAuthenticateParams): Promise; - } -``` - -### `IAgent` - -```diff - interface IAgent { -- setAuthToken(token: string): Promise; -+ /** Declare auth schemes this agent requires. */ -+ getAuthSchemes(): IAuthScheme[]; -+ /** Authenticate with a specific scheme. Returns true if accepted. */ -+ authenticate(schemeId: string, token: string): Promise; - } -``` - -### `IAgentDescriptor` - -```diff - interface IAgentDescriptor { - readonly provider: AgentProvider; - readonly displayName: string; - readonly description: string; -- readonly requiresAuth: boolean; - } -``` - -`requiresAuth` is removed — clients discover auth requirements from `IResourceMetadata` instead of per-agent descriptors. - -## Design Decisions - -### Why not `WWW-Authenticate` headers literally? - -We're not using HTTP. Embedding RFC 6750's string-encoded header format in JSON-RPC would be awkward. Instead, we use JSON-native equivalents with the same semantics: `IAuthChallenge` mirrors the `WWW-Authenticate` parameters, and `IResourceMetadata` mirrors RFC 9728's metadata document. - -### Why in `initialize` and not a separate `getResourceMetadata` command? - -Fewer round-trips. Every client calls `initialize` first — embedding auth requirements there means the client knows what auth is needed from the very first response. A separate command would add latency and complexity for zero benefit, since the metadata is small and always needed. - -### Why `schemeId` and not just the `scheme` name? - -A server might need multiple bearer tokens from different authorization servers (e.g. GitHub + an enterprise IdP). The `schemeId` lets the client and server correlate tokens to specific requirements. It also makes `authenticate` calls idempotent and unambiguous. - -### Why a request instead of a notification for `authenticate`? - -The current `setAuthToken` is fire-and-forget — the client has no idea if the token was accepted, expired, or for the wrong provider. Making `authenticate` a request with a response lets the client react immediately (retry with different scopes, prompt the user, etc.). - -### What about Device Code / OAuth flows that the server drives? - -This proposal covers the "client already has a token" case (RFC 6750 bearer). For server-driven flows (device code, authorization code with redirect), the `authorizationServers` metadata tells the client which AS to talk to. The actual OAuth flow is client-side — the server just declares requirements. - -A future extension could add an `IAuthScheme` with `scheme: 'device_code'` that includes a device authorization endpoint, letting the server guide the client through a device flow. This is out of scope for the initial implementation. - -## Migration Plan - -1. **Phase A**: Add `resourceMetadata` to `IInitializeResult` and the `authenticate` command. Keep `setAuthToken` working as-is. -2. **Phase B**: Update VS Code renderer to use `authenticate` instead of `setAuthToken`. External clients can start using the new flow. -3. **Phase C**: Remove `setAuthToken`, `requiresAuth`, and the old imperative push model. Bump protocol version. - -## Open Questions - -1. **Token validation**: Should the server validate tokens eagerly on `authenticate` (e.g. call a GitHub API endpoint), or defer validation to when a command actually needs it? Eager validation gives better error messages; deferred is simpler and avoids extra network calls. - -2. **Per-agent vs. global auth**: The current design has one `resourceMetadata` for the whole server. Should auth schemes be per-agent-provider instead? Per-agent gives finer control (e.g. "Copilot needs GitHub, MockAgent needs nothing") but complicates the protocol. The current proposal uses global metadata with `schemeId` correlation, which the server can internally route to the right agent. - -3. **Token refresh**: Should the server expose token expiry information so clients can proactively refresh, or rely on `notify/authRequired` to signal when a refresh is needed? Proactive refresh avoids interruptions but requires the server to parse tokens (which it shouldn't have to for opaque tokens). - -4. **Multiple tokens**: Can a client authenticate multiple scheme IDs simultaneously? (Proposed: yes.) Can multiple clients each send their own token? (Proposed: yes, last-writer-wins per schemeId, which matches current behavior.) diff --git a/src/vs/platform/agentHost/test/common/agentClientUri.test.ts b/src/vs/platform/agentHost/test/common/agentClientUri.test.ts deleted file mode 100644 index f276a58a..00000000 --- a/src/vs/platform/agentHost/test/common/agentClientUri.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import assert from 'assert'; -import { URI } from '../../../../base/common/uri.js'; -import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { AGENT_CLIENT_SCHEME, fromAgentClientUri, toAgentClientUri } from '../../common/agentClientUri.js'; - -suite('Agent Client URI transform', () => { - - ensureNoDisposablesAreLeakedInTestSuite(); - - test('round-trips a file URI', () => { - const original = URI.file('/Users/user/plugins/my-plugin/index.js'); - const wrapped = toAgentClientUri(original, 'client-1'); - - assert.strictEqual(wrapped.scheme, AGENT_CLIENT_SCHEME); - assert.strictEqual(wrapped.authority, 'client-1'); - assert.ok(wrapped.path.startsWith('/file/')); - - const decoded = fromAgentClientUri(wrapped); - assert.strictEqual(decoded.scheme, 'file'); - assert.strictEqual(decoded.path, original.path); - }); - - test('round-trips a URI with authority', () => { - const original = URI.from({ scheme: 'https', authority: 'example.com', path: '/plugins/foo' }); - const wrapped = toAgentClientUri(original, 'c2'); - - const decoded = fromAgentClientUri(wrapped); - assert.strictEqual(decoded.scheme, 'https'); - assert.strictEqual(decoded.authority, 'example.com'); - assert.strictEqual(decoded.path, '/plugins/foo'); - }); - - test('encodes missing authority as dash', () => { - const original = URI.from({ scheme: 'inmemory', path: '/test/file.txt' }); - const wrapped = toAgentClientUri(original, 'c3'); - - assert.ok(wrapped.path.includes('/-/')); - - const decoded = fromAgentClientUri(wrapped); - assert.strictEqual(decoded.scheme, 'inmemory'); - assert.strictEqual(decoded.authority, ''); - assert.strictEqual(decoded.path, '/test/file.txt'); - }); - - test('preserves client ID as authority', () => { - const wrapped = toAgentClientUri(URI.file('/foo'), 'my-client'); - assert.strictEqual(wrapped.authority, 'my-client'); - }); -}); diff --git a/src/vs/platform/agentHost/test/common/agentHostFileSystemProvider.test.ts b/src/vs/platform/agentHost/test/common/agentHostFileSystemProvider.test.ts deleted file mode 100644 index 7ce1c5ef..00000000 --- a/src/vs/platform/agentHost/test/common/agentHostFileSystemProvider.test.ts +++ /dev/null @@ -1,289 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import assert from 'assert'; -import { VSBuffer } from '../../../../base/common/buffer.js'; -import { URI } from '../../../../base/common/uri.js'; -import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { FileType } from '../../../files/common/files.js'; -import { AgentHostFileSystemProvider, agentHostRemotePath, agentHostUri, type IRemoteFilesystemConnection } from '../../common/agentHostFileSystemProvider.js'; -import { AGENT_HOST_LABEL_FORMATTER, AGENT_HOST_SCHEME, agentHostAuthority, fromAgentHostUri, toAgentHostUri } from '../../common/agentHostUri.js'; -import { ContentEncoding, type ResourceListResult, type ResourceReadResult } from '../../common/state/protocol/commands.js'; - -suite('AgentHostFileSystemProvider - URI helpers', () => { - - ensureNoDisposablesAreLeakedInTestSuite(); - - test('agentHostUri builds correct URI', () => { - const uri = agentHostUri('localhost', '/home/user/project'); - assert.strictEqual(uri.scheme, AGENT_HOST_SCHEME); - assert.strictEqual(uri.authority, 'localhost'); - // path encodes file scheme: /file//home/user/project - assert.ok(uri.path.includes('/home/user/project')); - }); - - test('agentHostRemotePath extracts the original path', () => { - const uri = agentHostUri('host', '/some/path'); - assert.strictEqual(agentHostRemotePath(uri), '/some/path'); - }); - - test('agentHostRemotePath round-trips with agentHostUri', () => { - const original = '/home/user/project'; - const uri = agentHostUri('host', original); - assert.strictEqual(agentHostRemotePath(uri), original); - }); -}); - -suite('AgentHostAuthority - encoding', () => { - - ensureNoDisposablesAreLeakedInTestSuite(); - - test('purely alphanumeric address is returned as-is', () => { - assert.strictEqual(agentHostAuthority('localhost'), 'localhost'); - }); - - test('normal host:port address uses human-readable encoding', () => { - assert.strictEqual(agentHostAuthority('localhost:8081'), 'localhost__8081'); - assert.strictEqual(agentHostAuthority('192.168.1.1:8080'), '192.168.1.1__8080'); - assert.strictEqual(agentHostAuthority('my-host:9090'), 'my-host__9090'); - assert.strictEqual(agentHostAuthority('host.name:80'), 'host.name__80'); - }); - - test('address with underscore falls through to base64', () => { - const authority = agentHostAuthority('host_name:8080'); - assert.ok(authority.startsWith('b64-'), `expected base64 for underscore address, got: ${authority}`); - }); - - test('address with exotic characters is base64-encoded', () => { - assert.ok(agentHostAuthority('user@host:8080').startsWith('b64-')); - assert.ok(agentHostAuthority('host with spaces').startsWith('b64-')); - assert.ok(agentHostAuthority('http://myhost:3000').startsWith('b64-')); - }); - - test('ws:// prefix is normalized so authority matches bare address', () => { - assert.strictEqual(agentHostAuthority('ws://127.0.0.1:8080'), agentHostAuthority('127.0.0.1:8080')); - assert.strictEqual(agentHostAuthority('ws://localhost:9090'), agentHostAuthority('localhost:9090')); - }); - - test('different addresses produce different authorities', () => { - const cases = ['localhost:8080', 'localhost:8081', '192.168.1.1:8080', 'host-name:80', 'host.name:80', 'host_name:80', 'user@host:8080']; - const results = cases.map(agentHostAuthority); - const unique = new Set(results); - assert.strictEqual(unique.size, cases.length, 'all authorities must be unique'); - }); - - test('authority is valid in a URI authority position', () => { - const addresses = ['localhost', 'localhost:8081', 'user@host:8080', 'host with spaces', '192.168.1.1:9090']; - for (const address of addresses) { - const authority = agentHostAuthority(address); - const uri = URI.from({ scheme: AGENT_HOST_SCHEME, authority, path: '/test' }); - assert.strictEqual(uri.authority, authority, `authority for '${address}' must round-trip through URI`); - } - }); - - test('authority is valid in a URI scheme position', () => { - const addresses = ['localhost', 'localhost:8081', 'user@host:8080', 'host with spaces']; - for (const address of addresses) { - const authority = agentHostAuthority(address); - const scheme = `remote-${authority}-copilot`; - const uri = URI.from({ scheme, path: '/test' }); - assert.strictEqual(uri.scheme, scheme, `scheme for '${address}' must round-trip through URI`); - } - }); -}); - -suite('toAgentHostUri / fromAgentHostUri', () => { - - ensureNoDisposablesAreLeakedInTestSuite(); - - test('round-trips a file URI', () => { - const original = URI.file('/home/user/project/file.ts'); - const wrapped = toAgentHostUri(original, 'my-server'); - assert.strictEqual(wrapped.scheme, AGENT_HOST_SCHEME); - assert.strictEqual(wrapped.authority, 'my-server'); - - const unwrapped = fromAgentHostUri(wrapped); - assert.strictEqual(unwrapped.scheme, 'file'); - assert.strictEqual(unwrapped.path, original.path); - }); - - test('round-trips a URI with authority', () => { - const original = URI.from({ scheme: 'agenthost-content', authority: 'session1', path: '/snap/before' }); - const wrapped = toAgentHostUri(original, 'remote-host'); - const unwrapped = fromAgentHostUri(wrapped); - assert.strictEqual(unwrapped.scheme, 'agenthost-content'); - assert.strictEqual(unwrapped.authority, 'session1'); - assert.strictEqual(unwrapped.path, '/snap/before'); - }); - - test('local authority returns original URI unchanged', () => { - const original = URI.file('/workspace/test.ts'); - const result = toAgentHostUri(original, 'local'); - assert.strictEqual(result.toString(), original.toString()); - }); - - test('agentHostUri for root path produces valid encoded URI', () => { - const authority = agentHostAuthority('localhost:8089'); - const uri = agentHostUri(authority, '/'); - assert.strictEqual(uri.scheme, AGENT_HOST_SCHEME); - assert.strictEqual(uri.authority, authority); - // The decoded path should be root - assert.strictEqual(fromAgentHostUri(uri).path, '/'); - }); - - test('fromAgentHostUri handles malformed path gracefully', () => { - const uri = URI.from({ scheme: AGENT_HOST_SCHEME, authority: 'host', path: '/file' }); - const result = fromAgentHostUri(uri); - // Should not throw - falls back to extracting scheme only - assert.strictEqual(result.scheme, 'file'); - }); -}); - -suite('AGENT_HOST_LABEL_FORMATTER', () => { - - ensureNoDisposablesAreLeakedInTestSuite(); - - /** - * Replicates the stripPathSegments logic from the label service to - * verify that the formatter's configuration is consistent with the - * URI encoding. - */ - function stripPath(path: string, segments: number): string { - let pos = 0; - for (let i = 0; i < segments; i++) { - const next = path.indexOf('/', pos + 1); - if (next === -1) { - break; - } - pos = next; - } - return path.substring(pos); - } - - test('stripPathSegments matches URI encoding for file URIs', () => { - const authority = agentHostAuthority('localhost:8089'); - const originalPath = '/Users/roblou/code/vscode'; - const encodedUri = agentHostUri(authority, originalPath); - - const stripped = stripPath(encodedUri.path, AGENT_HOST_LABEL_FORMATTER.formatting.stripPathSegments!); - assert.strictEqual(stripped, originalPath); - }); - - test('stripPathSegments matches URI encoding with authority', () => { - const originalUri = URI.from({ scheme: 'agenthost-content', authority: 'myhost', path: '/snap/before' }); - const encodedUri = toAgentHostUri(originalUri, 'remote-host'); - - const stripped = stripPath(encodedUri.path, AGENT_HOST_LABEL_FORMATTER.formatting.stripPathSegments!); - assert.strictEqual(stripped, '/snap/before'); - }); -}); - -suite('AgentHostFileSystemProvider - synthetic content schemes', () => { - - const disposables = ensureNoDisposablesAreLeakedInTestSuite(); - - /** - * Stub connection that records the URIs it's asked about and returns - * canned data, so we can assert on the URIs the provider passes through. - */ - class StubConnection implements IRemoteFilesystemConnection { - readonly readCalls: URI[] = []; - readonly listCalls: URI[] = []; - readResult: ResourceReadResult = { data: 'stub-content', encoding: ContentEncoding.Utf8, contentType: 'text/plain' }; - - async resourceRead(uri: URI): Promise { - this.readCalls.push(uri); - return this.readResult; - } - async resourceList(uri: URI): Promise { - this.listCalls.push(uri); - return { entries: [] }; - } - async resourceWrite(): Promise<{}> { return {}; } - async resourceDelete(): Promise<{}> { return {}; } - async resourceMove(): Promise<{}> { return {}; } - } - - function setup() { - const provider = disposables.add(new AgentHostFileSystemProvider()); - const connection = new StubConnection(); - disposables.add(provider.registerAuthority('local', connection)); - return { provider, connection }; - } - - // Regression: AHPFileSystemProvider.stat() used to fall through to - // _listDirectory(parent) for any URI whose decoded scheme wasn't - // session-db, which fails with "Directory not found" for synthetic - // content URIs that have no real parent directory. The diff editor - // stats every URI before reading it, so this broke "open diff of a - // modified file" entirely. The fix is the scheme allowlist in stat(). - - test('stat returns File for git-blob: URIs without listing the parent', async () => { - const { provider, connection } = setup(); - const inner = URI.from({ scheme: 'git-blob', authority: 'sess1', path: '/sha/encoded/file.ts' }); - const wrapped = toAgentHostUri(inner, 'local'); - - const stat = await provider.stat(wrapped); - - assert.strictEqual(stat.type, FileType.File); - assert.deepStrictEqual(connection.listCalls, [], 'stat must not list a synthetic parent directory'); - }); - - test('stat returns File for session-db: URIs (parity with git-blob)', async () => { - const { provider, connection } = setup(); - const inner = URI.from({ scheme: 'session-db', authority: 'sess1', path: '/snap/some-blob' }); - const wrapped = toAgentHostUri(inner, 'local'); - - const stat = await provider.stat(wrapped); - - assert.strictEqual(stat.type, FileType.File); - assert.deepStrictEqual(connection.listCalls, []); - }); - - test('stat still lists parent for ordinary file: URIs', async () => { - // Use a non-local authority so the URI actually goes through the - // agent-host wrapping (toAgentHostUri short-circuits 'local' - // + file:// to return the URI unchanged). - const provider = disposables.add(new AgentHostFileSystemProvider()); - const connection = new StubConnection(); - disposables.add(provider.registerAuthority('remote', connection)); - const wrapped = agentHostUri('remote', '/some/file.ts'); - - try { - await provider.stat(wrapped); - } catch { - // Either FileNotFound or EntryNotFound is fine — we only - // care that the provider tried to list the parent (rather - // than treating this as a synthetic content URI). - } - assert.strictEqual(connection.listCalls.length, 1); - }); - - test('readFile passes the decoded synthetic URI through to the connection', async () => { - const { provider, connection } = setup(); - const inner = URI.from({ scheme: 'git-blob', authority: 'sess1', path: '/sha/encoded/file.ts' }); - const wrapped = toAgentHostUri(inner, 'local'); - - const bytes = await provider.readFile(wrapped); - - assert.strictEqual(VSBuffer.wrap(bytes).toString(), 'stub-content'); - assert.deepStrictEqual(connection.readCalls.map(u => u.toString()), [inner.toString()]); - }); - - test('full stat-then-read round-trip mirrors the diff editor flow', async () => { - // This is the exact sequence the workbench's TextFileEditorModel - // goes through when DiffEditorInput.createModel resolves: stat - // the URI, then read the file. Pre-fix this combo failed at the - // stat step before readFile was even called. - const { provider } = setup(); - const inner = URI.from({ scheme: 'git-blob', authority: 'sess1', path: '/sha/encoded/file.ts' }); - const wrapped = toAgentHostUri(inner, 'local'); - - const stat = await provider.stat(wrapped); - assert.strictEqual(stat.type, FileType.File); - const bytes = await provider.readFile(wrapped); - assert.strictEqual(VSBuffer.wrap(bytes).toString(), 'stub-content'); - }); -}); diff --git a/src/vs/platform/agentHost/test/common/agentHostSchema.test.ts b/src/vs/platform/agentHost/test/common/agentHostSchema.test.ts deleted file mode 100644 index 84c6526c..00000000 --- a/src/vs/platform/agentHost/test/common/agentHostSchema.test.ts +++ /dev/null @@ -1,313 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import assert from 'assert'; -import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { createSchema, platformSessionSchema, schemaProperty, type AutoApproveLevel, type IPermissionsValue, type SessionMode } from '../../common/agentHostSchema.js'; -import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; -import { JsonRpcErrorCodes, ProtocolError } from '../../common/state/sessionProtocol.js'; - -/** - * Invokes `fn` and returns the thrown {@link ProtocolError}. Avoids - * passing an arrow-function validator to `assert.throws` — the unit-test - * assert shim does `actual instanceof expected` with that validator, and - * arrow functions have no `prototype` property, which WebKit rejects. - */ -function captureProtocolError(fn: () => void): ProtocolError { - try { - fn(); - } catch (err) { - assert.ok(err instanceof ProtocolError, `expected ProtocolError, got: ${err}`); - return err; - } - assert.fail('expected fn to throw, but it did not'); -} - -suite('agentHostSchema', () => { - - ensureNoDisposablesAreLeakedInTestSuite(); - - // ---- schemaProperty / individual validators --------------------------- - - suite('schemaProperty', () => { - - test('validates primitive types', () => { - const str = schemaProperty({ type: 'string', title: 's' }); - assert.strictEqual(str.validate('hello'), true); - assert.strictEqual(str.validate(42), false); - assert.strictEqual(str.validate(undefined), false); - assert.strictEqual(str.validate(null), false); - - const num = schemaProperty({ type: 'number', title: 'n' }); - assert.strictEqual(num.validate(42), true); - assert.strictEqual(num.validate('42'), false); - - const bool = schemaProperty({ type: 'boolean', title: 'b' }); - assert.strictEqual(bool.validate(true), true); - assert.strictEqual(bool.validate(0), false); - }); - - test('enforces enum values', () => { - const prop = schemaProperty<'a' | 'b'>({ - type: 'string', - title: 'letters', - enum: ['a', 'b'], - }); - assert.strictEqual(prop.validate('a'), true); - assert.strictEqual(prop.validate('b'), true); - assert.strictEqual(prop.validate('c'), false); - assert.strictEqual(prop.validate(42), false); - }); - - test('enumDynamic bypasses enum check but keeps type check', () => { - const prop = schemaProperty({ - type: 'string', - title: 'dyn', - enum: ['seed'], - enumDynamic: true, - }); - assert.strictEqual(prop.validate('seed'), true); - assert.strictEqual(prop.validate('anything-else'), true); - assert.strictEqual(prop.validate(42), false); - }); - - test('validates nested objects and required keys', () => { - const prop = schemaProperty<{ name: string; age?: number }>({ - type: 'object', - title: 'person', - properties: { - name: { type: 'string', title: 'name' }, - age: { type: 'number', title: 'age' }, - }, - required: ['name'], - }); - assert.strictEqual(prop.validate({ name: 'alice' }), true); - assert.strictEqual(prop.validate({ name: 'alice', age: 30 }), true); - assert.strictEqual(prop.validate({ age: 30 }), false); - assert.strictEqual(prop.validate({ name: 42 }), false); - assert.strictEqual(prop.validate([]), false); - assert.strictEqual(prop.validate(null), false); - }); - - test('validates arrays with item schema', () => { - const prop = schemaProperty({ - type: 'array', - title: 'names', - items: { type: 'string', title: 'name' }, - }); - assert.strictEqual(prop.validate(['a', 'b']), true); - assert.strictEqual(prop.validate([]), true); - assert.strictEqual(prop.validate(['a', 42]), false); - assert.strictEqual(prop.validate('a'), false); - }); - - test('assertValid throws ProtocolError with offending path for primitive mismatch', () => { - const prop = schemaProperty({ type: 'string', title: 's' }); - const err = captureProtocolError(() => prop.assertValid(42, 'myKey')); - assert.strictEqual(err.code, JsonRpcErrorCodes.InvalidParams); - assert.ok(err.message.includes('myKey'), err.message); - assert.ok(err.message.includes('string'), err.message); - }); - - test('assertValid path annotates array index and nested property', () => { - const prop = schemaProperty<{ allow: string[] }>({ - type: 'object', - title: 'perms', - properties: { - allow: { - type: 'array', - title: 'allow', - items: { type: 'string', title: 'name' }, - }, - }, - }); - const err = captureProtocolError(() => prop.assertValid({ allow: ['ok', 42] }, 'permissions')); - assert.ok(err.message.includes('permissions.allow[1]'), err.message); - assert.ok(err.message.includes('string'), err.message); - }); - - test('assertValid path reports missing required property', () => { - const prop = schemaProperty<{ name: string }>({ - type: 'object', - title: 'person', - properties: { name: { type: 'string', title: 'name' } }, - required: ['name'], - }); - const err = captureProtocolError(() => prop.assertValid({}, 'person')); - assert.ok(err.message.includes('person.name'), err.message); - assert.ok(err.message.toLowerCase().includes('required'), err.message); - }); - - test('assertValid reports enum violation with the allowed set', () => { - const prop = schemaProperty<'a' | 'b'>({ - type: 'string', - title: 'letters', - enum: ['a', 'b'], - }); - const err = captureProtocolError(() => prop.assertValid('c', 'choice')); - assert.ok(err.message.includes('choice'), err.message); - assert.ok(err.message.includes('"a"'), err.message); - assert.ok(err.message.includes('"b"'), err.message); - }); - }); - - // ---- createSchema ------------------------------------------------------ - - suite('createSchema', () => { - - const fixture = () => createSchema({ - name: schemaProperty({ type: 'string', title: 'name' }), - count: schemaProperty({ type: 'number', title: 'count' }), - level: schemaProperty<'low' | 'high'>({ - type: 'string', - title: 'level', - enum: ['low', 'high'], - }), - }); - - test('toProtocol emits a JSON-Schema-compatible object', () => { - const schema = fixture(); - const protocol = schema.toProtocol(); - assert.strictEqual(protocol.type, 'object'); - assert.deepStrictEqual(Object.keys(protocol.properties), ['name', 'count', 'level']); - assert.strictEqual(protocol.properties.name.type, 'string'); - assert.deepStrictEqual(protocol.properties.level.enum, ['low', 'high']); - }); - - test('validate returns false for unknown keys', () => { - const schema = fixture(); - assert.strictEqual(schema.validate('name', 'ok'), true); - assert.strictEqual(schema.validate('name', 42), false); - assert.strictEqual(schema.validate('unknown' as 'name', 'ok'), false); - }); - - test('assertValid throws for unknown keys', () => { - const schema = fixture(); - const err = captureProtocolError(() => schema.assertValid('unknown' as 'name', 'x')); - assert.ok(err.message.includes('unknown'), err.message); - }); - - test('values returns a shallow copy and passes through unknown keys', () => { - const schema = fixture(); - const input = { name: 'alice', count: 3, extra: 'forward-compat' }; - const out = schema.values(input); - assert.notStrictEqual(out, input); - assert.deepStrictEqual(out, input); - }); - - test('values skips undefined entries without throwing', () => { - const schema = fixture(); - const out = schema.values({ name: 'alice' }); - assert.deepStrictEqual(out, { name: 'alice' }); - }); - - test('values throws a path-annotated ProtocolError on invalid entry', () => { - const schema = fixture(); - const err = captureProtocolError(() => schema.values({ name: 42 as unknown as string })); - assert.strictEqual(err.code, JsonRpcErrorCodes.InvalidParams); - assert.ok(err.message.includes('name'), err.message); - }); - - test('definition is preserved for spread-based composition', () => { - const base = createSchema({ - a: schemaProperty({ type: 'string', title: 'a' }), - }); - const extended = createSchema({ - ...base.definition, - b: schemaProperty({ type: 'number', title: 'b' }), - }); - assert.deepStrictEqual(Object.keys(extended.toProtocol().properties), ['a', 'b']); - assert.strictEqual(extended.validate('a', 'hi'), true); - assert.strictEqual(extended.validate('b', 3), true); - }); - }); - - // ---- validateOrDefault ------------------------------------------------- - - suite('validateOrDefault', () => { - - const fixture = () => createSchema({ - name: schemaProperty({ type: 'string', title: 'name' }), - count: schemaProperty({ type: 'number', title: 'count' }), - }); - - test('substitutes defaults for missing or invalid values', () => { - const schema = fixture(); - const defaults = { name: 'default', count: 0 }; - const result = schema.validateOrDefault({ name: 42, count: 5 }, defaults); - assert.deepStrictEqual(result, { name: 'default', count: 5 }); - }); - - test('passes through all-valid values', () => { - const schema = fixture(); - const result = schema.validateOrDefault({ name: 'alice', count: 3 }, { name: 'd', count: 0 }); - assert.deepStrictEqual(result, { name: 'alice', count: 3 }); - }); - - test('uses defaults when input is undefined', () => { - const schema = fixture(); - const result = schema.validateOrDefault(undefined, { name: 'd', count: 7 }); - assert.deepStrictEqual(result, { name: 'd', count: 7 }); - }); - - test('ignores keys not in defaults', () => { - const schema = fixture(); - // @ts-expect-error: test that extra keys not in the defaults are ignored, even if they pass validation. - const result = schema.validateOrDefault({ name: 'a', count: 1, ignored: true }, { name: 'd', count: 0 }); - assert.deepStrictEqual(result, { name: 'a', count: 1 }); - }); - - test('omits schema keys that are missing from both values and defaults', () => { - // Regression coverage for the partial-defaults contract that - // underpins host-level inheritance: if the caller doesn't supply - // a default and no incoming value is valid, the key is left out - // entirely so higher-scope defaults can fill in. - const schema = fixture(); - const result = schema.validateOrDefault({ count: 9 }, { count: 0 }); - assert.deepStrictEqual(result, { count: 9 }); - assert.ok(!result.hasOwnProperty('name'), '`name` should be absent when neither values nor defaults supply it'); - }); - - test('omits schema keys when value is invalid and no default is supplied', () => { - const schema = fixture(); - // @ts-expect-error: test that invalid values are dropped even when the caller doesn't provide a default. - const result = schema.validateOrDefault({ name: 42, count: 3 }, { count: 0 }); - // `name` has no default and the incoming value is invalid → dropped. - assert.deepStrictEqual(result, { count: 3 }); - }); - }); - - // ---- platformSessionSchema sanity -------------------------------------- - - suite('platformSessionSchema', () => { - - test('validates the three autoApprove levels', () => { - const levels: AutoApproveLevel[] = ['default', 'autoApprove', 'autopilot']; - for (const level of levels) { - assert.strictEqual(platformSessionSchema.validate(SessionConfigKey.AutoApprove, level), true, level); - } - assert.strictEqual(platformSessionSchema.validate(SessionConfigKey.AutoApprove, 'bogus'), false); - }); - - test('validates permissions shape', () => { - const ok: IPermissionsValue = { allow: ['read'], deny: [] }; - assert.strictEqual(platformSessionSchema.validate(SessionConfigKey.Permissions, ok), true); - assert.strictEqual(platformSessionSchema.validate(SessionConfigKey.Permissions, { allow: [42], deny: [] }), false); - assert.strictEqual(platformSessionSchema.validate(SessionConfigKey.Permissions, { allow: [] }), true); - }); - - test('validates the agent modes', () => { - const modes: SessionMode[] = ['interactive', 'plan']; - for (const mode of modes) { - assert.strictEqual(platformSessionSchema.validate(SessionConfigKey.Mode, mode), true, mode); - } - // `autopilot` is intentionally NOT in the AHP mode enum \u2014 it's - // modeled on the orthogonal `autoApprove` axis instead. - assert.strictEqual(platformSessionSchema.validate(SessionConfigKey.Mode, 'autopilot'), false); - assert.strictEqual(platformSessionSchema.validate(SessionConfigKey.Mode, 'shell'), false); - assert.strictEqual(platformSessionSchema.validate(SessionConfigKey.Mode, 42), false); - }); - }); -}); diff --git a/src/vs/platform/agentHost/test/common/agentService.test.ts b/src/vs/platform/agentHost/test/common/agentService.test.ts deleted file mode 100644 index 95cc3c8e..00000000 --- a/src/vs/platform/agentHost/test/common/agentService.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import assert from 'assert'; -import { URI } from '../../../../base/common/uri.js'; -import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { AgentSession } from '../../common/agentService.js'; - -suite('AgentSession namespace', () => { - - ensureNoDisposablesAreLeakedInTestSuite(); - - test('uri creates a URI with provider as scheme and id as path', () => { - const session = AgentSession.uri('copilot', 'abc-123'); - assert.strictEqual(session.scheme, 'copilot'); - assert.strictEqual(session.path, '/abc-123'); - }); - - test('id extracts the raw session ID from a session URI', () => { - const session = URI.from({ scheme: 'copilot', path: '/my-session-42' }); - assert.strictEqual(AgentSession.id(session), 'my-session-42'); - }); - - test('uri and id are inverse operations', () => { - const rawId = 'test-session-xyz'; - const session = AgentSession.uri('copilot', rawId); - assert.strictEqual(AgentSession.id(session), rawId); - }); - - test('provider extracts copilot from a copilot-scheme URI', () => { - const session = AgentSession.uri('copilot', 'sess-1'); - assert.strictEqual(AgentSession.provider(session), 'copilot'); - }); -}); diff --git a/src/vs/platform/agentHost/test/common/agentSubscription.test.ts b/src/vs/platform/agentHost/test/common/agentSubscription.test.ts deleted file mode 100644 index ccd2015f..00000000 --- a/src/vs/platform/agentHost/test/common/agentSubscription.test.ts +++ /dev/null @@ -1,652 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import assert from 'assert'; -import { DisposableStore } from '../../../../base/common/lifecycle.js'; -import { URI } from '../../../../base/common/uri.js'; -import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { ActionType, type ActionEnvelope } from '../../common/state/sessionActions.js'; -import { SessionLifecycle, SessionStatus, TerminalClaimKind, type RootState, type SessionState, type TerminalState } from '../../common/state/protocol/state.js'; -import { StateComponents } from '../../common/state/sessionState.js'; -import { AgentSubscriptionManager, RootStateSubscription, SessionStateSubscription, TerminalStateSubscription } from '../../common/state/agentSubscription.js'; - -// Helpers - -function makeRootState(overrides?: Partial): RootState { - return { - agents: [], - activeSessions: 0, - terminals: [], - ...overrides, - }; -} - -function makeSessionState(sessionUri: string, overrides?: Partial): SessionState { - return { - summary: { - resource: sessionUri, - provider: 'copilot', - title: 'Test', - status: SessionStatus.Idle, - createdAt: 1, - modifiedAt: 1, - project: { uri: 'file:///test-project', displayName: 'Test Project' }, - }, - lifecycle: SessionLifecycle.Ready, - turns: [], - ...overrides, - }; -} - -function makeTerminalState(overrides?: Partial): TerminalState { - return { - title: 'bash', - content: [], - claim: { kind: TerminalClaimKind.Client, clientId: 'c1' }, - ...overrides, - }; -} - -function makeEnvelope(action: ActionEnvelope['action'], serverSeq: number, origin?: ActionEnvelope['origin'], rejectionReason?: string): ActionEnvelope { - return { action, serverSeq, origin, rejectionReason }; -} - -const noop = () => { }; -const sessionUri = URI.from({ scheme: 'copilot', path: '/test-session' }).toString(); -const terminalUri = URI.from({ scheme: 'agenthost-terminal', path: '/term1' }).toString(); - -// RootStateSubscription - -suite('RootStateSubscription', () => { - - let disposables: DisposableStore; - - setup(() => { - disposables = new DisposableStore(); - }); - - teardown(() => { - disposables.dispose(); - }); - - ensureNoDisposablesAreLeakedInTestSuite(); - - test('value is undefined before snapshot', () => { - const sub = disposables.add(new RootStateSubscription('c1', noop)); - assert.strictEqual(sub.value, undefined); - assert.strictEqual(sub.verifiedValue, undefined); - }); - - test('handleSnapshot sets value and verifiedValue', () => { - const sub = disposables.add(new RootStateSubscription('c1', noop)); - const state = makeRootState({ activeSessions: 3 }); - sub.handleSnapshot(state, 0); - assert.deepStrictEqual(sub.value, state); - assert.deepStrictEqual(sub.verifiedValue, state); - }); - - test('handleSnapshot fires onDidChange', () => { - const sub = disposables.add(new RootStateSubscription('c1', noop)); - const fired: RootState[] = []; - disposables.add(sub.onDidChange(s => fired.push(s))); - sub.handleSnapshot(makeRootState(), 0); - assert.strictEqual(fired.length, 1); - }); - - test('receiveEnvelope updates state for root actions', () => { - const sub = disposables.add(new RootStateSubscription('c1', noop)); - sub.handleSnapshot(makeRootState(), 0); - sub.receiveEnvelope(makeEnvelope( - { type: ActionType.RootActiveSessionsChanged, activeSessions: 5 }, - 1, - )); - assert.strictEqual((sub.value as RootState).activeSessions, 5); - }); - - test('ignores non-root actions', () => { - const sub = disposables.add(new RootStateSubscription('c1', noop)); - const state = makeRootState(); - sub.handleSnapshot(state, 0); - sub.receiveEnvelope(makeEnvelope( - { type: ActionType.SessionReady, session: sessionUri }, - 1, - )); - assert.deepStrictEqual(sub.value, state); - }); - - test('fires onWillApplyAction and onDidApplyAction around envelope', () => { - const sub = disposables.add(new RootStateSubscription('c1', noop)); - sub.handleSnapshot(makeRootState(), 0); - const events: string[] = []; - disposables.add(sub.onWillApplyAction(() => events.push('will'))); - disposables.add(sub.onDidApplyAction(() => events.push('did'))); - sub.receiveEnvelope(makeEnvelope( - { type: ActionType.RootActiveSessionsChanged, activeSessions: 1 }, - 1, - )); - assert.deepStrictEqual(events, ['will', 'did']); - }); - - test('buffers envelopes before snapshot and replays after', () => { - const sub = disposables.add(new RootStateSubscription('c1', noop)); - // Send envelope before snapshot - sub.receiveEnvelope(makeEnvelope( - { type: ActionType.RootActiveSessionsChanged, activeSessions: 7 }, - 2, - )); - assert.strictEqual(sub.value, undefined); - - // Now apply snapshot with fromSeq=1; envelope at seq 2 should replay - sub.handleSnapshot(makeRootState(), 1); - assert.strictEqual((sub.value! as RootState).activeSessions, 7); - }); - - test('buffered envelopes with serverSeq <= fromSeq are discarded', () => { - const sub = disposables.add(new RootStateSubscription('c1', noop)); - sub.receiveEnvelope(makeEnvelope( - { type: ActionType.RootActiveSessionsChanged, activeSessions: 99 }, - 1, - )); - sub.handleSnapshot(makeRootState({ activeSessions: 0 }), 1); - // Envelope at seq 1 should not replay since fromSeq === 1 - assert.strictEqual((sub.value as RootState).activeSessions, 0); - }); - - test('setError makes value return the error', () => { - const sub = disposables.add(new RootStateSubscription('c1', noop)); - sub.handleSnapshot(makeRootState(), 0); - const err = new Error('failed'); - sub.setError(err); - assert.strictEqual(sub.value, err); - // verifiedValue should still be the state - assert.ok(sub.verifiedValue); - }); -}); - -// SessionStateSubscription - -suite('SessionStateSubscription', () => { - - let disposables: DisposableStore; - let seq: number; - - setup(() => { - disposables = new DisposableStore(); - seq = 0; - }); - - teardown(() => { - disposables.dispose(); - }); - - ensureNoDisposablesAreLeakedInTestSuite(); - - function createSub(uri: string = sessionUri, clientId: string = 'c1'): SessionStateSubscription { - return disposables.add(new SessionStateSubscription(uri, clientId, () => ++seq, noop)); - } - - test('value is undefined before snapshot', () => { - const sub = createSub(); - assert.strictEqual(sub.value, undefined); - }); - - test('handleSnapshot sets value and verifiedValue', () => { - const sub = createSub(); - const state = makeSessionState(sessionUri); - sub.handleSnapshot(state, 0); - assert.deepStrictEqual(sub.value, state); - assert.deepStrictEqual(sub.verifiedValue, state); - }); - - test('applyOptimistic returns clientSeq and updates value but not verifiedValue', () => { - const sub = createSub(); - const state = makeSessionState(sessionUri); - sub.handleSnapshot(state, 0); - - const clientSeq = sub.applyOptimistic({ - type: ActionType.SessionTitleChanged, - session: sessionUri, - title: 'Optimistic', - }); - - assert.strictEqual(clientSeq, 1); - assert.strictEqual((sub.value as SessionState).summary.title, 'Optimistic'); - // verifiedValue should remain unchanged - assert.strictEqual(sub.verifiedValue!.summary.title, 'Test'); - }); - - test('confirmed own action removes pending and updates confirmed', () => { - const sub = createSub(); - sub.handleSnapshot(makeSessionState(sessionUri), 0); - - const clientSeq = sub.applyOptimistic({ - type: ActionType.SessionTitleChanged, - session: sessionUri, - title: 'Optimistic', - }); - - // Server confirms the action - sub.receiveEnvelope(makeEnvelope( - { type: ActionType.SessionTitleChanged, session: sessionUri, title: 'Optimistic' }, - 1, - { clientId: 'c1', clientSeq }, - )); - - // After confirmation, verifiedValue should match - assert.strictEqual(sub.verifiedValue!.summary.title, 'Optimistic'); - // No pending, value falls through to confirmed - assert.strictEqual((sub.value as SessionState).summary.title, 'Optimistic'); - }); - - test('rejected own action removes pending without updating confirmed', () => { - const sub = createSub(); - sub.handleSnapshot(makeSessionState(sessionUri), 0); - - const clientSeq = sub.applyOptimistic({ - type: ActionType.SessionTitleChanged, - session: sessionUri, - title: 'Optimistic', - }); - - // Server rejects the action - sub.receiveEnvelope(makeEnvelope( - { type: ActionType.SessionTitleChanged, session: sessionUri, title: 'Optimistic' }, - 1, - { clientId: 'c1', clientSeq }, - 'denied', - )); - - // Confirmed state unchanged - assert.strictEqual(sub.verifiedValue!.summary.title, 'Test'); - // No more pending, value = confirmed - assert.strictEqual((sub.value as SessionState).summary.title, 'Test'); - }); - - test('foreign action updates confirmed and recomputes optimistic', () => { - const sub = createSub(); - sub.handleSnapshot(makeSessionState(sessionUri), 0); - - // Local optimistic action - sub.applyOptimistic({ - type: ActionType.SessionTitleChanged, - session: sessionUri, - title: 'Local', - }); - - // Foreign action arrives - sub.receiveEnvelope(makeEnvelope( - { type: ActionType.SessionReady, session: sessionUri }, - 1, - { clientId: 'other-client', clientSeq: 1 }, - )); - - // Confirmed state should have SessionReady applied - assert.strictEqual(sub.verifiedValue!.lifecycle, SessionLifecycle.Ready); - // Optimistic should still have 'Local' title on top - assert.strictEqual((sub.value as SessionState).summary.title, 'Local'); - }); - - test('after all pending cleared, value falls through to verifiedValue', () => { - const sub = createSub(); - sub.handleSnapshot(makeSessionState(sessionUri), 0); - - const clientSeq = sub.applyOptimistic({ - type: ActionType.SessionTitleChanged, - session: sessionUri, - title: 'Temp', - }); - - // Confirm the pending action - sub.receiveEnvelope(makeEnvelope( - { type: ActionType.SessionTitleChanged, session: sessionUri, title: 'Temp' }, - 1, - { clientId: 'c1', clientSeq }, - )); - - // value and verifiedValue should be the same object reference - assert.strictEqual(sub.value, sub.verifiedValue); - }); - - test('clearPending resets optimistic state', () => { - const sub = createSub(); - sub.handleSnapshot(makeSessionState(sessionUri), 0); - - sub.applyOptimistic({ - type: ActionType.SessionTitleChanged, - session: sessionUri, - title: 'Pending', - }); - - assert.strictEqual((sub.value as SessionState).summary.title, 'Pending'); - - sub.clearPending(); - - // Should fall back to confirmed - assert.strictEqual((sub.value as SessionState).summary.title, 'Test'); - }); - - test('ignores actions for different session', () => { - const sub = createSub(); - sub.handleSnapshot(makeSessionState(sessionUri), 0); - - sub.receiveEnvelope(makeEnvelope( - { type: ActionType.SessionTitleChanged, session: 'copilot:///other', title: 'Other' }, - 1, - )); - - assert.strictEqual((sub.value as SessionState).summary.title, 'Test'); - }); - - test('buffers envelopes before snapshot and replays after', () => { - const sub = createSub(); - - sub.receiveEnvelope(makeEnvelope( - { type: ActionType.SessionTitleChanged, session: sessionUri, title: 'Buffered' }, - 2, - )); - - assert.strictEqual(sub.value, undefined); - - sub.handleSnapshot(makeSessionState(sessionUri), 1); - - assert.strictEqual((sub.value! as SessionState).summary.title, 'Buffered'); - }); - - test('fires onDidChange on optimistic apply', () => { - const sub = createSub(); - sub.handleSnapshot(makeSessionState(sessionUri), 0); - - const fired: SessionState[] = []; - disposables.add(sub.onDidChange(s => fired.push(s))); - - sub.applyOptimistic({ - type: ActionType.SessionTitleChanged, - session: sessionUri, - title: 'Changed', - }); - - assert.strictEqual(fired.length, 1); - assert.strictEqual(fired[0].summary.title, 'Changed'); - }); -}); - -// TerminalStateSubscription - -suite('TerminalStateSubscription', () => { - - let disposables: DisposableStore; - - setup(() => { - disposables = new DisposableStore(); - }); - - teardown(() => { - disposables.dispose(); - }); - - ensureNoDisposablesAreLeakedInTestSuite(); - - test('accepts terminal actions matching its URI', () => { - const sub = disposables.add(new TerminalStateSubscription(terminalUri, 'c1', noop)); - sub.handleSnapshot(makeTerminalState(), 0); - - sub.receiveEnvelope(makeEnvelope( - { type: ActionType.TerminalData, terminal: terminalUri, data: 'hello' }, - 1, - )); - - assert.deepStrictEqual((sub.value as TerminalState).content, [ - { type: 'unclassified', value: 'hello' }, - ]); - }); - - test('ignores terminal actions for other URIs', () => { - const sub = disposables.add(new TerminalStateSubscription(terminalUri, 'c1', noop)); - sub.handleSnapshot(makeTerminalState(), 0); - - sub.receiveEnvelope(makeEnvelope( - { type: ActionType.TerminalData, terminal: 'agenthost-terminal:///other', data: 'nope' }, - 1, - )); - - assert.deepStrictEqual((sub.value as TerminalState).content, []); - }); - - test('ignores non-terminal actions', () => { - const sub = disposables.add(new TerminalStateSubscription(terminalUri, 'c1', noop)); - sub.handleSnapshot(makeTerminalState(), 0); - - sub.receiveEnvelope(makeEnvelope( - { type: ActionType.RootActiveSessionsChanged, activeSessions: 5 }, - 1, - )); - - assert.deepStrictEqual((sub.value as TerminalState).content, []); - }); - - test('handleSnapshot sets value', () => { - const sub = disposables.add(new TerminalStateSubscription(terminalUri, 'c1', noop)); - const state = makeTerminalState({ title: 'zsh' }); - sub.handleSnapshot(state, 0); - assert.deepStrictEqual(sub.value, state); - }); -}); - -// AgentSubscriptionManager - -suite('AgentSubscriptionManager', () => { - - let disposables: DisposableStore; - let seq: number; - let subscribedResources: string[]; - let unsubscribedResources: string[]; - - setup(() => { - disposables = new DisposableStore(); - seq = 0; - subscribedResources = []; - unsubscribedResources = []; - }); - - teardown(() => { - disposables.dispose(); - }); - - ensureNoDisposablesAreLeakedInTestSuite(); - - function createManager(): AgentSubscriptionManager { - return disposables.add(new AgentSubscriptionManager( - 'c1', - () => ++seq, - noop, - async (resource) => { - subscribedResources.push(resource.toString()); - const key = resource.toString(); - if (key.startsWith('copilot:')) { - return { resource: key, state: makeSessionState(key), fromSeq: 0 }; - } - return { resource: key, state: makeTerminalState(), fromSeq: 0 }; - }, - (resource) => { - unsubscribedResources.push(resource.toString()); - }, - )); - } - - test('rootState is available immediately', () => { - const mgr = createManager(); - assert.ok(mgr.rootState); - assert.strictEqual(mgr.rootState.value, undefined); - }); - - test('handleRootSnapshot initializes root state', () => { - const mgr = createManager(); - const state = makeRootState({ activeSessions: 2 }); - mgr.handleRootSnapshot(state, 0); - assert.deepStrictEqual(mgr.rootState.value, state); - }); - - test('getSubscription returns IReference with subscription', async () => { - const mgr = createManager(); - const uri = URI.parse(sessionUri); - const ref = mgr.getSubscription(StateComponents.Session, uri); - - assert.ok(ref.object); - assert.strictEqual(ref.object.value, undefined); // not yet initialized (async) - - // Wait for async subscribe - await new Promise(r => setTimeout(r, 0)); - - assert.ok(ref.object.value); - ref.dispose(); - }); - - test('second call for same resource increments refcount', async () => { - const mgr = createManager(); - const uri = URI.parse(sessionUri); - const ref1 = mgr.getSubscription(StateComponents.Session, uri); - const ref2 = mgr.getSubscription(StateComponents.Session, uri); - - await new Promise(r => setTimeout(r, 0)); - - // Should be the same subscription object - assert.strictEqual(ref1.object, ref2.object); - - // Disposing one ref should not trigger unsubscribe - ref1.dispose(); - assert.strictEqual(unsubscribedResources.length, 0); - - // Disposing the last ref should trigger unsubscribe - ref2.dispose(); - assert.strictEqual(unsubscribedResources.length, 1); - }); - - test('disposing last ref calls unsubscribe callback', async () => { - const mgr = createManager(); - const uri = URI.parse(sessionUri); - const ref = mgr.getSubscription(StateComponents.Session, uri); - - await new Promise(r => setTimeout(r, 0)); - - ref.dispose(); - assert.ok(unsubscribedResources.includes(sessionUri)); - }); - - test('receiveEnvelope routes to root and all active subscriptions', async () => { - const mgr = createManager(); - mgr.handleRootSnapshot(makeRootState(), 0); - - const uri = URI.parse(sessionUri); - const ref = mgr.getSubscription(StateComponents.Session, uri); - await new Promise(r => setTimeout(r, 0)); - - // Send a root action - mgr.receiveEnvelope(makeEnvelope( - { type: ActionType.RootActiveSessionsChanged, activeSessions: 10 }, - 1, - )); - assert.strictEqual((mgr.rootState.value as RootState).activeSessions, 10); - - // Send a session action - mgr.receiveEnvelope(makeEnvelope( - { type: ActionType.SessionTitleChanged, session: sessionUri, title: 'Routed' }, - 2, - )); - assert.strictEqual((ref.object.value as SessionState).summary.title, 'Routed'); - - ref.dispose(); - }); - - test('creating session subscription for copilot: URI', async () => { - const mgr = createManager(); - const mySessionUri = URI.from({ scheme: 'copilot', path: '/my-session' }); - const ref = mgr.getSubscription(StateComponents.Session, mySessionUri); - await new Promise(r => setTimeout(r, 0)); - - assert.ok(ref.object.value); - assert.ok(subscribedResources.includes(mySessionUri.toString())); - - ref.dispose(); - }); - - test('creating terminal subscription for terminal URI', async () => { - const mgr = createManager(); - const uri = URI.parse(terminalUri); - const ref = mgr.getSubscription(StateComponents.Terminal, uri); - await new Promise(r => setTimeout(r, 0)); - - assert.ok(ref.object.value); - assert.ok(subscribedResources.includes(terminalUri)); - - ref.dispose(); - }); - - test('dispatchOptimistic applies to matching session subscription', async () => { - const mgr = createManager(); - const uri = URI.parse(sessionUri); - const ref = mgr.getSubscription(StateComponents.Session, uri); - await new Promise(r => setTimeout(r, 0)); - - const clientSeq = mgr.dispatchOptimistic({ - type: ActionType.SessionTitleChanged, - session: sessionUri, - title: 'Dispatched', - }); - - assert.ok(clientSeq > 0); - assert.strictEqual((ref.object.value as SessionState).summary.title, 'Dispatched'); - // verifiedValue unchanged - assert.strictEqual(ref.object.verifiedValue!.summary.title, 'Test'); - - ref.dispose(); - }); - - test('dispose clears all subscriptions and calls unsubscribe for each', async () => { - const mgr = createManager(); - - const ref1 = mgr.getSubscription(StateComponents.Session, URI.parse(sessionUri)); - const ref2 = mgr.getSubscription(StateComponents.Terminal, URI.parse(terminalUri)); - await new Promise(r => setTimeout(r, 0)); - - // Remove the manager from disposables so we can dispose it manually - // without double-dispose - disposables.delete(mgr); - mgr.dispose(); - - assert.ok(unsubscribedResources.includes(sessionUri)); - assert.ok(unsubscribedResources.includes(terminalUri)); - - // Clean up refs (already disposed with manager, but safe to call) - ref1.dispose(); - ref2.dispose(); - }); - - test('getSubscriptionUnmanaged returns undefined when no subscription exists', () => { - const mgr = createManager(); - const result = mgr.getSubscriptionUnmanaged(URI.parse('copilot:/nonexistent')); - assert.strictEqual(result, undefined); - }); - - test('getSubscriptionUnmanaged returns existing subscription without affecting refcount', async () => { - const mgr = createManager(); - const uri = URI.parse(sessionUri); - - // Create a subscription via getSubscription - const ref = mgr.getSubscription(StateComponents.Session, uri); - await new Promise(r => setTimeout(r, 0)); - - // Get it unmanaged - const unmanaged = mgr.getSubscriptionUnmanaged(uri); - assert.ok(unmanaged); - assert.strictEqual(unmanaged, ref.object); - - // Dispose the ref. Subscription should be released (refcount was 1) - ref.dispose(); - - // Now unmanaged should return undefined since it was released - const after = mgr.getSubscriptionUnmanaged(uri); - assert.strictEqual(after, undefined); - }); -}); diff --git a/src/vs/platform/agentHost/test/common/commandLineHelpers.test.ts b/src/vs/platform/agentHost/test/common/commandLineHelpers.test.ts deleted file mode 100644 index c9033fe0..00000000 --- a/src/vs/platform/agentHost/test/common/commandLineHelpers.test.ts +++ /dev/null @@ -1,142 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import assert from 'assert'; -import { isWindows } from '../../../../base/common/platform.js'; -import { URI } from '../../../../base/common/uri.js'; -import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { extractCdPrefix, stripRedundantCdPrefix } from '../../common/commandLineHelpers.js'; - -suite('extractCdPrefix', () => { - - ensureNoDisposablesAreLeakedInTestSuite(); - - const cases: Array<{ - name: string; - commandLine: string; - isPowerShell: boolean; - expected: { directory: string; command: string } | undefined; - }> = [ - // bash matches - { name: 'bash: simple cd', commandLine: 'cd /tmp && ls', isPowerShell: false, expected: { directory: '/tmp', command: 'ls' } }, - { name: 'bash: quoted dir', commandLine: 'cd "/path with spaces" && ls -la', isPowerShell: false, expected: { directory: '/path with spaces', command: 'ls -la' } }, - { name: 'bash: extra spaces after &&', commandLine: 'cd /tmp && echo hi', isPowerShell: false, expected: { directory: '/tmp', command: 'echo hi' } }, - - // bash non-matches - { name: 'bash: no cd prefix', commandLine: 'ls -la', isPowerShell: false, expected: undefined }, - { name: 'bash: cd with semicolon (not allowed in bash variant)', commandLine: 'cd /tmp; ls', isPowerShell: false, expected: undefined }, - { name: 'bash: cd alone', commandLine: 'cd /tmp', isPowerShell: false, expected: undefined }, - { name: 'bash: Set-Location not bash', commandLine: 'Set-Location /tmp && ls', isPowerShell: false, expected: undefined }, - - // powershell matches - { name: 'pwsh: cd && ', commandLine: 'cd C:\\foo && dir', isPowerShell: true, expected: { directory: 'C:\\foo', command: 'dir' } }, - { name: 'pwsh: cd ;', commandLine: 'cd C:\\foo; dir', isPowerShell: true, expected: { directory: 'C:\\foo', command: 'dir' } }, - { name: 'pwsh: cd /d', commandLine: 'cd /d C:\\foo && dir', isPowerShell: true, expected: { directory: 'C:\\foo', command: 'dir' } }, - { name: 'pwsh: Set-Location', commandLine: 'Set-Location C:\\foo; dir', isPowerShell: true, expected: { directory: 'C:\\foo', command: 'dir' } }, - { name: 'pwsh: Set-Location -Path', commandLine: 'Set-Location -Path C:\\foo && dir', isPowerShell: true, expected: { directory: 'C:\\foo', command: 'dir' } }, - { name: 'pwsh: quoted dir', commandLine: 'cd "C:\\path with spaces"; dir', isPowerShell: true, expected: { directory: 'C:\\path with spaces', command: 'dir' } }, - { name: 'pwsh: case insensitive', commandLine: 'CD C:\\foo && dir', isPowerShell: true, expected: { directory: 'C:\\foo', command: 'dir' } }, - - // powershell non-matches - { name: 'pwsh: no cd prefix', commandLine: 'dir', isPowerShell: true, expected: undefined }, - { name: 'pwsh: cd alone', commandLine: 'cd C:\\foo', isPowerShell: true, expected: undefined }, - ]; - - for (const tc of cases) { - test(tc.name, () => { - const result = extractCdPrefix(tc.commandLine, tc.isPowerShell); - assert.deepStrictEqual(result, tc.expected); - }); - } -}); - -suite('stripRedundantCdPrefix', () => { - - ensureNoDisposablesAreLeakedInTestSuite(); - - const wd = URI.file('/repo/project'); - - test('rewrites bash command when cd matches working directory', () => { - const params: Record = { command: 'cd /repo/project && npm test' }; - const changed = stripRedundantCdPrefix('bash', params, wd); - assert.strictEqual(changed, true); - assert.strictEqual(params.command, 'npm test'); - }); - - test('rewrites bash command tolerating trailing slash', () => { - const params: Record = { command: 'cd /repo/project/ && ls' }; - const changed = stripRedundantCdPrefix('bash', params, wd); - assert.strictEqual(changed, true); - assert.strictEqual(params.command, 'ls'); - }); - - test('does not rewrite when cd target differs', () => { - const params: Record = { command: 'cd /tmp && ls' }; - const changed = stripRedundantCdPrefix('bash', params, wd); - assert.strictEqual(changed, false); - assert.strictEqual(params.command, 'cd /tmp && ls'); - }); - - test('does not rewrite for non-shell tools', () => { - const params: Record = { command: 'cd /repo/project && ls' }; - const changed = stripRedundantCdPrefix('read_file', params, wd); - assert.strictEqual(changed, false); - assert.strictEqual(params.command, 'cd /repo/project && ls'); - }); - - test('handles missing working directory', () => { - const params: Record = { command: 'cd /repo/project && ls' }; - const changed = stripRedundantCdPrefix('bash', params, undefined); - assert.strictEqual(changed, false); - }); - - test('handles missing parameters', () => { - const changed = stripRedundantCdPrefix('bash', undefined, wd); - assert.strictEqual(changed, false); - }); - - test('handles non-string command', () => { - const params: Record = { command: 42 }; - const changed = stripRedundantCdPrefix('bash', params, wd); - assert.strictEqual(changed, false); - }); - - test('rewrites powershell with semicolon separator', () => { - const params: Record = { command: 'cd /repo/project; dir' }; - const changed = stripRedundantCdPrefix('powershell', params, wd); - assert.strictEqual(changed, true); - assert.strictEqual(params.command, 'dir'); - }); - - test('matches mixed path separators (forward-slash extracted vs native fsPath wd)', () => { - // On Windows, the model may emit `cd C:/repo/project && …` while - // URI.file('C:\\repo\\project').fsPath uses backslashes. The helper - // must normalize separators so the prefix is still recognized. - // On POSIX, `C:\…` is not a meaningful path, so the cross-separator - // test only makes sense on Windows. - if (!isWindows) { - return; - } - const winWd = URI.file('C:\\repo\\project'); - const params: Record = { command: 'cd C:/repo/project && npm test' }; - const changed = stripRedundantCdPrefix('bash', params, winWd); - assert.strictEqual(changed, true); - assert.strictEqual(params.command, 'npm test'); - }); - - test('matches backslash extracted dir against backslash native wd on Windows', () => { - // Inverse direction: the model emits backslashes and the native wd is - // also backslashes. This is the most common Windows case and must - // match without relying on POSIX-shape paths. - if (!isWindows) { - return; - } - const winWd = URI.file('C:\\repo\\project'); - const params: Record = { command: 'cd C:\\repo\\project && ls' }; - const changed = stripRedundantCdPrefix('bash', params, winWd); - assert.strictEqual(changed, true); - assert.strictEqual(params.command, 'ls'); - }); -}); diff --git a/src/vs/platform/agentHost/test/common/sessionTestHelpers.ts b/src/vs/platform/agentHost/test/common/sessionTestHelpers.ts deleted file mode 100644 index 1c59a949..00000000 --- a/src/vs/platform/agentHost/test/common/sessionTestHelpers.ts +++ /dev/null @@ -1,188 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import type { IReference } from '../../../../base/common/lifecycle.js'; -import { Schemas } from '../../../../base/common/network.js'; -import { URI } from '../../../../base/common/uri.js'; -import type { IDiffComputeService, IDiffCountResult } from '../../common/diffComputeService.js'; -import type { IFileEditContent, IFileEditRecord, ISessionDatabase, ISessionDataService } from '../../common/sessionDataService.js'; - -export class TestSessionDatabase implements ISessionDatabase { - private readonly _edits: (IFileEditRecord & IFileEditContent)[] = []; - private readonly _metadata = new Map(); - - getAllFileEditsCalls = 0; - getFileEditsByTurnCalls = 0; - - addEdit(edit: IFileEditRecord & IFileEditContent): void { - this._edits.push(edit); - } - - async createTurn(): Promise { } - - async deleteTurn(turnId: string): Promise { - for (let i = this._edits.length - 1; i >= 0; i--) { - if (this._edits[i].turnId === turnId) { - this._edits.splice(i, 1); - } - } - } - - async storeFileEdit(edit: IFileEditRecord & IFileEditContent): Promise { - const existingIndex = this._edits.findIndex(e => e.toolCallId === edit.toolCallId && e.filePath === edit.filePath); - if (existingIndex >= 0) { - this._edits[existingIndex] = edit; - } else { - this._edits.push(edit); - } - } - - async getFileEdits(toolCallIds: string[]): Promise { - const toolCallIdsSet = new Set(toolCallIds); - return this._toEditRecords(this._edits.filter(e => toolCallIdsSet.has(e.toolCallId))); - } - - async getAllFileEdits(): Promise { - this.getAllFileEditsCalls++; - return this._toEditRecords(this._edits); - } - - async getFileEditsByTurn(turnId: string): Promise { - this.getFileEditsByTurnCalls++; - return this._toEditRecords(this._edits.filter(e => e.turnId === turnId)); - } - - async readFileEditContent(toolCallId: string, filePath: string): Promise { - return this._edits.find(e => e.toolCallId === toolCallId && e.filePath === filePath); - } - - async getMetadata(key: string): Promise { - return this._metadata.get(key); - } - - async getMetadataObject>(obj: T): Promise<{ [K in keyof T]: string | undefined }> { - return Object.fromEntries(Object.keys(obj).map(key => [key, this._metadata.get(key)])) as { [K in keyof T]: string | undefined }; - } - - async setMetadata(key: string, value: string): Promise { - this._metadata.set(key, value); - } - - async close(): Promise { } - - async vacuumInto(_targetPath: string): Promise { } - - dispose(): void { } - - async setTurnEventId(_turnId: string, _eventId: string): Promise { } - - async getTurnEventId(_turnId: string): Promise { return undefined; } - - async getNextTurnEventId(_turnId: string): Promise { return undefined; } - - async getFirstTurnEventId(): Promise { return undefined; } - - async truncateFromTurn(_turnId: string): Promise { } - - async deleteTurnsAfter(_turnId: string): Promise { } - - async deleteAllTurns(): Promise { } - - async remapTurnIds(_mapping: ReadonlyMap): Promise { } - - async whenIdle(): Promise { } - - private _toEditRecords(edits: (IFileEditRecord & IFileEditContent)[]): IFileEditRecord[] { - return edits.map(({ beforeContent: _, afterContent: _2, ...metadata }) => metadata); - } -} - -export class TestDiffComputeService implements IDiffComputeService { - declare readonly _serviceBrand: undefined; - - callCount = 0; - - constructor(private readonly _result?: IDiffCountResult) { } - - async computeDiffCounts(original: string, modified: string): Promise { - this.callCount++; - if (this._result) { - return this._result; - } - - const originalLines = original ? original.split('\n') : []; - const modifiedLines = modified ? modified.split('\n') : []; - return { - added: Math.max(0, modifiedLines.length - originalLines.length), - removed: Math.max(0, originalLines.length - modifiedLines.length), - }; - } -} - -export function createZeroDiffComputeService(): IDiffComputeService { - return new TestDiffComputeService({ added: 0, removed: 0 }); -} - -export function createSessionDataService(database: ISessionDatabase = new TestSessionDatabase()): ISessionDataService { - return { - _serviceBrand: undefined, - getSessionDataDir: session => URI.from({ scheme: Schemas.inMemory, path: `/session-data${session.path}` }), - getSessionDataDirById: sessionId => URI.from({ scheme: Schemas.inMemory, path: `/session-data/${sessionId}` }), - openDatabase: () => createReference(database), - tryOpenDatabase: async () => createReference(database), - deleteSessionData: async () => { }, - cleanupOrphanedData: async () => { }, - whenIdle: async () => { }, - }; -} - -export function createNullSessionDataService(): ISessionDataService { - return { - _serviceBrand: undefined, - getSessionDataDir: session => URI.from({ scheme: Schemas.inMemory, path: `/session-data${session.path}` }), - getSessionDataDirById: sessionId => URI.from({ scheme: Schemas.inMemory, path: `/session-data/${sessionId}` }), - openDatabase: () => { throw new Error('not implemented'); }, - tryOpenDatabase: async () => undefined, - deleteSessionData: async () => { }, - cleanupOrphanedData: async () => { }, - whenIdle: async () => { }, - }; -} - -export function encodeString(text: string): Uint8Array { - return new TextEncoder().encode(text); -} - -/** - * Returns a no-op {@link IAgentHostGitService} suitable for tests that - * exercise the {@link AgentService} but don't care about git state. - * Tests that DO care about git state should pass their own implementation. - */ -export function createNoopGitService(): import('../../node/agentHostGitService.js').IAgentHostGitService { - return { - _serviceBrand: undefined, - isInsideWorkTree: async () => false, - getCurrentBranch: async () => undefined, - getDefaultBranch: async () => undefined, - getBranches: async () => [], - getRepositoryRoot: async () => undefined, - getWorktreeRoots: async () => [], - addWorktree: async () => { }, - addExistingWorktree: async () => { }, - removeWorktree: async () => { }, - branchExists: async () => false, - hasUncommittedChanges: async () => false, - getSessionGitState: async () => undefined, - computeSessionFileDiffs: async () => undefined, - showBlob: async () => undefined, - }; -} - -function createReference(object: T): IReference { - return { - object, - dispose: () => { }, - }; -} diff --git a/src/vs/platform/agentHost/test/common/sshConfigParsing.test.ts b/src/vs/platform/agentHost/test/common/sshConfigParsing.test.ts deleted file mode 100644 index 857bd970..00000000 --- a/src/vs/platform/agentHost/test/common/sshConfigParsing.test.ts +++ /dev/null @@ -1,226 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import assert from 'assert'; -import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { parseSSHConfigHostEntries, parseSSHGOutput } from '../../common/sshConfigParsing.js'; - -suite('SSH Config Parsing', () => { - - ensureNoDisposablesAreLeakedInTestSuite(); - - suite('parseSSHConfigHostEntries', () => { - - test('extracts simple host entries', () => { - const config = [ - 'Host myserver', - ' HostName 10.0.0.1', - ' User admin', - ].join('\n'); - - assert.deepStrictEqual(parseSSHConfigHostEntries(config), ['myserver']); - }); - - test('extracts multiple hosts from a single Host line', () => { - const config = 'Host server1 server2 server3'; - assert.deepStrictEqual(parseSSHConfigHostEntries(config), ['server1', 'server2', 'server3']); - }); - - test('extracts hosts from multiple Host directives', () => { - const config = [ - 'Host work', - ' HostName work.example.com', - '', - 'Host personal', - ' HostName home.example.com', - ].join('\n'); - - assert.deepStrictEqual(parseSSHConfigHostEntries(config), ['work', 'personal']); - }); - - test('skips wildcard hosts', () => { - const config = [ - 'Host *', - ' ForwardAgent yes', - '', - 'Host myserver', - ' HostName 10.0.0.1', - '', - 'Host *.example.com', - ' User admin', - ].join('\n'); - - assert.deepStrictEqual(parseSSHConfigHostEntries(config), ['myserver']); - }); - - test('skips negation patterns', () => { - const config = 'Host !internal myserver'; - assert.deepStrictEqual(parseSSHConfigHostEntries(config), ['myserver']); - }); - - test('skips question mark wildcards', () => { - const config = 'Host server? myserver'; - assert.deepStrictEqual(parseSSHConfigHostEntries(config), ['myserver']); - }); - - test('skips comment lines', () => { - const config = [ - '# This is a comment', - 'Host myserver', - ' # Another comment', - ' HostName 10.0.0.1', - ].join('\n'); - - assert.deepStrictEqual(parseSSHConfigHostEntries(config), ['myserver']); - }); - - test('strips inline comments from Host values', () => { - const config = 'Host myserver # my favorite server'; - assert.deepStrictEqual(parseSSHConfigHostEntries(config), ['myserver']); - }); - - test('handles empty content', () => { - assert.deepStrictEqual(parseSSHConfigHostEntries(''), []); - }); - - test('handles content with only comments and blanks', () => { - const config = [ - '# comment', - '', - ' # indented comment', - '', - ].join('\n'); - - assert.deepStrictEqual(parseSSHConfigHostEntries(config), []); - }); - - test('is case-insensitive for Host keyword', () => { - const config = [ - 'host lower', - 'HOST upper', - 'Host mixed', - ].join('\n'); - - assert.deepStrictEqual(parseSSHConfigHostEntries(config), ['lower', 'upper', 'mixed']); - }); - - test('ignores non-Host directives', () => { - const config = [ - 'Host myserver', - ' HostName 10.0.0.1', - ' User admin', - ' Port 2222', - ' IdentityFile ~/.ssh/mykey', - ' ForwardAgent yes', - ].join('\n'); - - assert.deepStrictEqual(parseSSHConfigHostEntries(config), ['myserver']); - }); - }); - - suite('parseSSHGOutput', () => { - - test('parses standard ssh -G output', () => { - const output = [ - 'hostname 10.0.0.1', - 'user admin', - 'port 22', - 'identityfile ~/.ssh/id_rsa', - 'identityfile ~/.ssh/id_ed25519', - 'forwardagent no', - ].join('\n'); - - assert.deepStrictEqual(parseSSHGOutput(output), { - hostname: '10.0.0.1', - user: 'admin', - port: 22, - identityFile: ['~/.ssh/id_rsa', '~/.ssh/id_ed25519'], - forwardAgent: false, - }); - }); - - test('parses forwardagent yes', () => { - const output = [ - 'hostname example.com', - 'user root', - 'port 22', - 'forwardagent yes', - ].join('\n'); - - const result = parseSSHGOutput(output); - assert.strictEqual(result.forwardAgent, true); - }); - - test('parses non-standard port', () => { - const output = [ - 'hostname example.com', - 'user deploy', - 'port 2222', - ].join('\n'); - - const result = parseSSHGOutput(output); - assert.strictEqual(result.port, 2222); - }); - - test('handles missing user', () => { - const output = [ - 'hostname example.com', - 'port 22', - ].join('\n'); - - const result = parseSSHGOutput(output); - assert.strictEqual(result.user, undefined); - }); - - test('handles empty user', () => { - const output = [ - 'hostname example.com', - 'user ', - 'port 22', - ].join('\n'); - - const result = parseSSHGOutput(output); - assert.strictEqual(result.user, undefined); - }); - - test('defaults port to 22 when missing', () => { - const output = 'hostname example.com\nuser root'; - const result = parseSSHGOutput(output); - assert.strictEqual(result.port, 22); - }); - - test('collects multiple identity files', () => { - const output = [ - 'hostname example.com', - 'port 22', - 'identityfile ~/.ssh/id_rsa', - 'identityfile ~/.ssh/work_key', - 'identityfile ~/.ssh/id_ed25519', - ].join('\n'); - - assert.deepStrictEqual(parseSSHGOutput(output).identityFile, [ - '~/.ssh/id_rsa', - '~/.ssh/work_key', - '~/.ssh/id_ed25519', - ]); - }); - - test('handles empty output', () => { - assert.deepStrictEqual(parseSSHGOutput(''), { - hostname: '', - user: undefined, - port: 22, - identityFile: [], - forwardAgent: false, - }); - }); - - test('handles values with spaces', () => { - const output = 'hostname my host with spaces\nport 22'; - const result = parseSSHGOutput(output); - assert.strictEqual(result.hostname, 'my host with spaces'); - }); - }); -}); diff --git a/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostProtocolClient.test.ts b/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostProtocolClient.test.ts deleted file mode 100644 index 11732718..00000000 --- a/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostProtocolClient.test.ts +++ /dev/null @@ -1,230 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import assert from 'assert'; -import { DeferredPromise } from '../../../../base/common/async.js'; -import { Emitter } from '../../../../base/common/event.js'; -import { Disposable } from '../../../../base/common/lifecycle.js'; -import { URI } from '../../../../base/common/uri.js'; -import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { FileService } from '../../../files/common/fileService.js'; -import { NullLogService } from '../../../log/common/log.js'; -import { RemoteAgentHostProtocolClient, RemoteAgentHostProtocolError } from '../../browser/remoteAgentHostProtocolClient.js'; -import { AhpErrorCodes } from '../../common/state/protocol/errors.js'; -import type { AhpServerNotification, JsonRpcNotification, JsonRpcRequest, JsonRpcResponse, ProtocolMessage } from '../../common/state/sessionProtocol.js'; -import type { IClientTransport, IProtocolTransport } from '../../common/state/sessionTransport.js'; - -type ProtocolTransportMessage = ProtocolMessage | AhpServerNotification | JsonRpcNotification | JsonRpcResponse | JsonRpcRequest; - -class TestProtocolTransport extends Disposable implements IProtocolTransport { - private readonly _onMessage = this._register(new Emitter()); - readonly onMessage = this._onMessage.event; - - private readonly _onClose = this._register(new Emitter()); - readonly onClose = this._onClose.event; - - readonly sentMessages: ProtocolTransportMessage[] = []; - - send(message: ProtocolTransportMessage): void { - this.sentMessages.push(message); - } - - fireMessage(message: ProtocolMessage): void { - this._onMessage.fire(message); - } - - fireClose(): void { - this._onClose.fire(); - } -} - -class TestClientProtocolTransport extends TestProtocolTransport implements IClientTransport { - readonly connectDeferred = new DeferredPromise(); - - connect(): Promise { - return this.connectDeferred.p; - } -} - -class CloseOnDisposeProtocolTransport extends TestProtocolTransport { - override dispose(): void { - this.fireClose(); - super.dispose(); - } -} - -suite('RemoteAgentHostProtocolClient', () => { - const disposables = ensureNoDisposablesAreLeakedInTestSuite(); - - function createClient(transport = disposables.add(new TestProtocolTransport())): { client: RemoteAgentHostProtocolClient; transport: TestProtocolTransport } { - const fileService = disposables.add(new FileService(new NullLogService())); - const client = disposables.add(new RemoteAgentHostProtocolClient('test.example:1234', transport, new NullLogService(), fileService)); - return { client, transport }; - } - - async function assertRemoteProtocolError(promise: Promise, expected: { code: number; message: string; data?: unknown }): Promise { - try { - await promise; - assert.fail('Expected promise to reject'); - } catch (error) { - if (!(error instanceof RemoteAgentHostProtocolError)) { - assert.fail(`Expected RemoteAgentHostProtocolError, got ${String(error)}`); - } - assert.strictEqual(error.code, expected.code); - assert.strictEqual(error.message, expected.message); - assert.deepStrictEqual(error.data, expected.data); - } - } - - test('completes matching response and removes it from pending requests', async () => { - const { client, transport } = createClient(); - const resultPromise = client.resourceList(URI.file('/workspace')); - - assert.deepStrictEqual(transport.sentMessages[0], { - jsonrpc: '2.0', - id: 1, - method: 'resourceList', - params: { uri: URI.file('/workspace').toString() }, - }); - - transport.fireMessage({ jsonrpc: '2.0', id: 1, result: { entries: [] } }); - assert.deepStrictEqual(await resultPromise, { entries: [] }); - - transport.fireMessage({ jsonrpc: '2.0', id: 1, result: { entries: [{ name: 'late', type: 'file' }] } }); - assert.strictEqual(transport.sentMessages.length, 1); - }); - - test('preserves JSON-RPC error code and data', async () => { - const { client, transport } = createClient(); - const resultPromise = client.resourceRead(URI.file('/missing')); - const data = { uri: URI.file('/missing').toString() }; - - transport.fireMessage({ jsonrpc: '2.0', id: 1, error: { code: AhpErrorCodes.NotFound, message: 'Missing resource', data } }); - - await assertRemoteProtocolError(resultPromise, { code: AhpErrorCodes.NotFound, message: 'Missing resource', data }); - }); - - test('ignores response for unknown request id', () => { - const { transport } = createClient(); - - transport.fireMessage({ jsonrpc: '2.0', id: 99, result: null }); - - assert.strictEqual(transport.sentMessages.length, 0); - }); - - test('rejects all pending requests on transport close', async () => { - const { client, transport } = createClient(); - const first = client.resourceList(URI.file('/one')); - const second = client.resourceRead(URI.file('/two')); - let closeCount = 0; - disposables.add(client.onDidClose(() => closeCount++)); - const firstRejected = assertRemoteProtocolError(first, { code: -32000, message: 'Connection closed: test.example:1234' }); - const secondRejected = assertRemoteProtocolError(second, { code: -32000, message: 'Connection closed: test.example:1234' }); - - transport.fireClose(); - transport.fireClose(); - - await firstRejected; - await secondRejected; - assert.strictEqual(closeCount, 1); - }); - - test('rejects pending requests on dispose', async () => { - const { client } = createClient(); - const resultPromise = client.resourceList(URI.file('/workspace')); - const rejected = assertRemoteProtocolError(resultPromise, { code: -32000, message: 'Connection disposed: test.example:1234' }); - - client.dispose(); - - await rejected; - }); - - test('dispose rejection wins when transport emits close while disposing', async () => { - const transport = disposables.add(new CloseOnDisposeProtocolTransport()); - const { client } = createClient(transport); - const resultPromise = client.resourceList(URI.file('/workspace')); - const rejected = assertRemoteProtocolError(resultPromise, { code: -32000, message: 'Connection disposed: test.example:1234' }); - - client.dispose(); - - await rejected; - }); - - test('late response after close does not complete rejected request', async () => { - const { client, transport } = createClient(); - const resultPromise = client.resourceList(URI.file('/workspace')); - const rejected = assertRemoteProtocolError(resultPromise, { code: -32000, message: 'Connection closed: test.example:1234' }); - - transport.fireClose(); - transport.fireMessage({ jsonrpc: '2.0', id: 1, result: { entries: [] } }); - - await rejected; - }); - - test('rejects requests started after transport close', async () => { - const { client, transport } = createClient(); - - transport.fireClose(); - - await assertRemoteProtocolError(client.resourceList(URI.file('/workspace')), { code: -32000, message: 'Connection closed: test.example:1234' }); - assert.strictEqual(transport.sentMessages.length, 0); - }); - - test('rejects requests started after dispose', async () => { - const { client, transport } = createClient(); - - client.dispose(); - - await assertRemoteProtocolError(client.resourceList(URI.file('/workspace')), { code: -32000, message: 'Connection disposed: test.example:1234' }); - assert.strictEqual(transport.sentMessages.length, 0); - }); - - test('rejects connect when transport closes before connect completes', async () => { - const transport = disposables.add(new TestClientProtocolTransport()); - const { client } = createClient(transport); - const rejected = assertRemoteProtocolError(client.connect(), { code: -32000, message: 'Connection closed: test.example:1234' }); - - transport.fireClose(); - transport.connectDeferred.complete(); - - await rejected; - assert.strictEqual(transport.sentMessages.length, 0); - }); - - test('rejects connect when disposed before transport connect completes', async () => { - const transport = disposables.add(new TestClientProtocolTransport()); - const { client } = createClient(transport); - const rejected = assertRemoteProtocolError(client.connect(), { code: -32000, message: 'Connection disposed: test.example:1234' }); - - client.dispose(); - - await rejected; - assert.strictEqual(transport.sentMessages.length, 0); - }); - - test('sends shutdown as a JSON-RPC request shape', async () => { - const { client, transport } = createClient(); - const resultPromise = client.shutdown(); - - assert.deepStrictEqual(transport.sentMessages[0], { - jsonrpc: '2.0', - id: 1, - method: 'shutdown', - params: undefined, - }); - - transport.fireMessage({ jsonrpc: '2.0', id: 1, result: null }); - await resultPromise; - }); - - test('rejects shutdown with structured JSON-RPC error', async () => { - const { client, transport } = createClient(); - const resultPromise = client.shutdown(); - - transport.fireMessage({ jsonrpc: '2.0', id: 1, error: { code: AhpErrorCodes.TurnInProgress, message: 'Turn in progress' } }); - - await assertRemoteProtocolError(resultPromise, { code: AhpErrorCodes.TurnInProgress, message: 'Turn in progress' }); - }); -}); diff --git a/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostService.test.ts b/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostService.test.ts deleted file mode 100644 index 7851f50c..00000000 --- a/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostService.test.ts +++ /dev/null @@ -1,493 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import assert from 'assert'; -import { Emitter, Event } from '../../../../base/common/event.js'; -import { Disposable, DisposableStore, toDisposable } from '../../../../base/common/lifecycle.js'; -import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { ILogService, NullLogService } from '../../../log/common/log.js'; -import { TestInstantiationService } from '../../../instantiation/test/common/instantiationServiceMock.js'; -import { IConfigurationService, type IConfigurationChangeEvent } from '../../../configuration/common/configuration.js'; -import { IInstantiationService } from '../../../instantiation/common/instantiation.js'; -import { RemoteAgentHostService } from '../../browser/remoteAgentHostServiceImpl.js'; -import { parseRemoteAgentHostInput, RemoteAgentHostConnectionStatus, RemoteAgentHostEntryType, RemoteAgentHostsEnabledSettingId, RemoteAgentHostsSettingId, entryToRawEntry, type IRawRemoteAgentHostEntry, type IRemoteAgentHostEntry } from '../../common/remoteAgentHostService.js'; -import { DeferredPromise } from '../../../../base/common/async.js'; - -// ---- Mock protocol client --------------------------------------------------- - -class MockProtocolClient extends Disposable { - private static _nextId = 1; - readonly clientId = `mock-client-${MockProtocolClient._nextId++}`; - - private readonly _onDidClose = this._register(new Emitter()); - readonly onDidClose = this._onDidClose.event; - readonly onDidAction = Event.None; - readonly onDidNotification = Event.None; - - public connectDeferred = new DeferredPromise(); - - constructor(public readonly mockAddress: string) { - super(); - } - - async connect(): Promise { - return this.connectDeferred.p; - } - - fireClose(): void { - this._onDidClose.fire(); - } -} - -// ---- Test configuration service --------------------------------------------- - -class TestConfigurationService { - private readonly _onDidChangeConfiguration = new Emitter>(); - readonly onDidChangeConfiguration = this._onDidChangeConfiguration.event; - - private _entries: IRawRemoteAgentHostEntry[] = []; - private _enabled = true; - - getValue(key?: string): unknown { - if (key === RemoteAgentHostsEnabledSettingId) { - return this._enabled; - } - return this._entries; - } - - inspect(_key: string) { - return { - userValue: this._entries, - }; - } - - async updateValue(_key: string, value: unknown): Promise { - this._entries = (value as IRawRemoteAgentHostEntry[] | undefined) ?? []; - this._onDidChangeConfiguration.fire({ - affectsConfiguration: (key: string) => key === RemoteAgentHostsSettingId || key === RemoteAgentHostsEnabledSettingId, - }); - } - - get entries(): readonly IRawRemoteAgentHostEntry[] { - return this._entries; - } - - setEntries(entries: IRemoteAgentHostEntry[]): void { - this._entries = entries.map(entryToRawEntry).filter((e): e is IRawRemoteAgentHostEntry => e !== undefined); - this._onDidChangeConfiguration.fire({ - affectsConfiguration: (key: string) => key === RemoteAgentHostsSettingId || key === RemoteAgentHostsEnabledSettingId, - }); - } - - setEnabled(enabled: boolean): void { - this._enabled = enabled; - this._onDidChangeConfiguration.fire({ - affectsConfiguration: (key: string) => key === RemoteAgentHostsEnabledSettingId, - }); - } - - dispose(): void { - this._onDidChangeConfiguration.dispose(); - } -} - -suite('RemoteAgentHostService', () => { - - const disposables = new DisposableStore(); - let configService: TestConfigurationService; - let createdClients: MockProtocolClient[]; - let service: RemoteAgentHostService; - - setup(() => { - configService = new TestConfigurationService(); - disposables.add(toDisposable(() => configService.dispose())); - - createdClients = []; - - const instantiationService = disposables.add(new TestInstantiationService()); - instantiationService.stub(ILogService, new NullLogService()); - instantiationService.stub(IConfigurationService, configService as Partial); - - // Mock the instantiation service to capture created protocol clients - const mockInstantiationService: Partial = { - createInstance: (_ctor: unknown, ...args: unknown[]) => { - const client = new MockProtocolClient(args[0] as string); - disposables.add(client); - createdClients.push(client); - return client; - }, - }; - instantiationService.stub(IInstantiationService, mockInstantiationService as Partial); - - service = disposables.add(instantiationService.createInstance(RemoteAgentHostService)); - }); - - teardown(() => disposables.clear()); - ensureNoDisposablesAreLeakedInTestSuite(); - - /** Wait for a connection to reach Connected status. */ - async function waitForConnected(): Promise { - while (!service.connections.some(c => c.status === RemoteAgentHostConnectionStatus.Connected)) { - await Event.toPromise(service.onDidChangeConnections); - } - } - - test('starts with no connections when setting is empty', () => { - assert.deepStrictEqual(service.connections, []); - }); - - test('parses supported remote host inputs', () => { - assert.deepStrictEqual([ - parseRemoteAgentHostInput('Listening on ws://127.0.0.1:8089'), - parseRemoteAgentHostInput('Agent host proxy listening on ws://127.0.0.1:8089'), - parseRemoteAgentHostInput('127.0.0.1:8089'), - parseRemoteAgentHostInput('ws://127.0.0.1:8089'), - parseRemoteAgentHostInput('ws://127.0.0.1:40147?tkn=c9d12867-da33-425e-8d39-0d071e851597'), - parseRemoteAgentHostInput('wss://secure.example.com:443'), - ], [ - { parsed: { address: '127.0.0.1:8089', connectionToken: undefined, suggestedName: '127.0.0.1:8089' } }, - { parsed: { address: '127.0.0.1:8089', connectionToken: undefined, suggestedName: '127.0.0.1:8089' } }, - { parsed: { address: '127.0.0.1:8089', connectionToken: undefined, suggestedName: '127.0.0.1:8089' } }, - { parsed: { address: '127.0.0.1:8089', connectionToken: undefined, suggestedName: '127.0.0.1:8089' } }, - { parsed: { address: '127.0.0.1:40147', connectionToken: 'c9d12867-da33-425e-8d39-0d071e851597', suggestedName: '127.0.0.1:40147' } }, - { parsed: { address: 'wss://secure.example.com', connectionToken: undefined, suggestedName: 'secure.example.com' } }, - ]); - }); - - test('getConnection returns undefined for unknown address', () => { - assert.strictEqual(service.getConnection('ws://unknown:1234'), undefined); - }); - - test('creates connection when setting is updated', async () => { - configService.setEntries([{ name: 'Host 1', connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'ws://host1:8080' } }]); - - // Resolve the connect promise - assert.strictEqual(createdClients.length, 1); - createdClients[0].connectDeferred.complete(); - await waitForConnected(); - - const connected = service.connections.filter(c => c.status === RemoteAgentHostConnectionStatus.Connected); - assert.strictEqual(connected.length, 1); - assert.strictEqual(connected[0].address, 'host1:8080'); - assert.strictEqual(connected[0].name, 'Host 1'); - }); - - test('getConnection returns client after successful connect', async () => { - configService.setEntries([{ name: 'Host 1', connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'ws://host1:8080' } }]); - createdClients[0].connectDeferred.complete(); - await waitForConnected(); - - const connection = service.getConnection('ws://host1:8080'); - assert.ok(connection); - assert.strictEqual(connection.clientId, createdClients[0].clientId); - }); - - test('removes connection when setting entry is removed', async () => { - // Add a connection - configService.setEntries([{ name: 'Host 1', connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'ws://host1:8080' } }]); - createdClients[0].connectDeferred.complete(); - await waitForConnected(); - - // Remove it - const removedEvent = Event.toPromise(service.onDidChangeConnections); - configService.setEntries([]); - await removedEvent; - - assert.strictEqual(service.connections.length, 0); - assert.strictEqual(service.getConnection('ws://host1:8080'), undefined); - }); - - test('fires onDidChangeConnections when connection closes', async () => { - configService.setEntries([{ name: 'Host 1', connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'ws://host1:8080' } }]); - createdClients[0].connectDeferred.complete(); - await waitForConnected(); - - // Simulate connection close — entry transitions to Disconnected - const closedEvent = Event.toPromise(service.onDidChangeConnections); - createdClients[0].fireClose(); - await closedEvent; - - // Connection is still tracked (for reconnect) but getConnection returns undefined - assert.strictEqual(service.getConnection('ws://host1:8080'), undefined); - const entry = service.connections.find(c => c.address === 'host1:8080'); - assert.ok(entry); - assert.strictEqual(entry.status, RemoteAgentHostConnectionStatus.Disconnected); - }); - - test('removes connection on connect failure', async () => { - configService.setEntries([{ name: 'Bad', connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'ws://bad:9999' } }]); - assert.strictEqual(createdClients.length, 1); - - // Fail the connection and wait for the service to react - const connectionChanged = Event.toPromise(service.onDidChangeConnections); - createdClients[0].connectDeferred.error(new Error('Connection refused')); - await connectionChanged; - - assert.strictEqual(service.connections.length, 0); - assert.strictEqual(service.getConnection('ws://bad:9999'), undefined); - }); - - test('manages multiple connections independently', async () => { - configService.setEntries([ - { name: 'Host 1', connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'ws://host1:8080' } }, - { name: 'Host 2', connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'ws://host2:8080' } }, - ]); - - assert.strictEqual(createdClients.length, 2); - createdClients[0].connectDeferred.complete(); - createdClients[1].connectDeferred.complete(); - await waitForConnected(); - - assert.strictEqual(service.connections.filter(c => c.status === RemoteAgentHostConnectionStatus.Connected).length, 2); - - const conn1 = service.getConnection('ws://host1:8080'); - const conn2 = service.getConnection('ws://host2:8080'); - assert.ok(conn1); - assert.ok(conn2); - assert.notStrictEqual(conn1.clientId, conn2.clientId); - }); - - test('does not re-create existing connections on setting update', async () => { - configService.setEntries([{ name: 'Host 1', connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'ws://host1:8080' } }]); - createdClients[0].connectDeferred.complete(); - await waitForConnected(); - - const firstClientId = createdClients[0].clientId; - - // Update setting with same address (but different name) - configService.setEntries([{ name: 'Renamed', connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'ws://host1:8080' } }]); - - // Should NOT have created a second client - assert.strictEqual(createdClients.length, 1); - - // Connection should still work with same client - const conn = service.getConnection('ws://host1:8080'); - assert.ok(conn); - assert.strictEqual(conn.clientId, firstClientId); - - // But name should be updated - const entry = service.connections.find(c => c.address === 'host1:8080'); - assert.strictEqual(entry?.name, 'Renamed'); - }); - - test('addRemoteAgentHost stores the entry and waits for connection', async () => { - const connectionPromise = service.addRemoteAgentHost({ - name: 'Host 1', - connectionToken: 'secret-token', - connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'ws://host1:8080' }, - }); - - assert.deepStrictEqual(configService.entries, [{ - address: 'host1:8080', - name: 'Host 1', - connectionToken: 'secret-token', - }]); - assert.strictEqual(createdClients.length, 1); - - createdClients[0].connectDeferred.complete(); - const connection = await connectionPromise; - - assert.deepStrictEqual(connection, { - address: 'host1:8080', - name: 'Host 1', - clientId: createdClients[0].clientId, - defaultDirectory: undefined, - status: RemoteAgentHostConnectionStatus.Connected, - }); - }); - - test('addRemoteAgentHost updates existing configured entries without reconnecting', async () => { - configService.setEntries([{ name: 'Host 1', connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'ws://host1:8080' } }]); - createdClients[0].connectDeferred.complete(); - await waitForConnected(); - - const connection = await service.addRemoteAgentHost({ - name: 'Updated Host', - connectionToken: 'new-token', - connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'ws://host1:8080' }, - }); - - assert.strictEqual(createdClients.length, 1); - assert.deepStrictEqual(configService.entries, [{ - address: 'host1:8080', - name: 'Updated Host', - connectionToken: 'new-token', - }]); - assert.deepStrictEqual(connection, { - address: 'host1:8080', - name: 'Updated Host', - clientId: createdClients[0].clientId, - defaultDirectory: undefined, - status: RemoteAgentHostConnectionStatus.Connected, - }); - }); - - test('addRemoteAgentHost appends when adding a second host', async () => { - // Add first host - const firstPromise = service.addRemoteAgentHost({ - name: 'Host 1', - connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'host1:8080' }, - }); - createdClients[0].connectDeferred.complete(); - await firstPromise; - - // Add second host - const secondPromise = service.addRemoteAgentHost({ - name: 'Host 2', - connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'host2:9090' }, - }); - createdClients[1].connectDeferred.complete(); - await secondPromise; - - assert.strictEqual(createdClients.length, 2); - assert.deepStrictEqual(configService.entries, [ - { address: 'host1:8080', name: 'Host 1', connectionToken: undefined }, - { address: 'host2:9090', name: 'Host 2', connectionToken: undefined }, - ]); - assert.strictEqual(service.connections.length, 2); - }); - - test('addRemoteAgentHost resolves when connection completes before wait is created', async () => { - // Simulate a fast connect: the mock client resolves synchronously - // during the config change handler, before addRemoteAgentHost has a - // chance to create its DeferredPromise wait. - const originalUpdateValue = configService.updateValue.bind(configService); - configService.updateValue = async (key: string, value: unknown) => { - await originalUpdateValue(key, value); - // Complete the connection synchronously inside the config change callback - if (createdClients.length > 0) { - createdClients[createdClients.length - 1].connectDeferred.complete(); - } - }; - - const connection = await service.addRemoteAgentHost({ - name: 'Fast Host', - connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'fast-host:1234' }, - }); - - assert.strictEqual(connection.address, 'fast-host:1234'); - assert.strictEqual(connection.name, 'Fast Host'); - }); - - test('disabling the enabled setting disconnects all remotes', async () => { - configService.setEntries([{ name: 'Host 1', connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'host1:8080' } }]); - createdClients[0].connectDeferred.complete(); - await waitForConnected(); - assert.strictEqual(service.connections.filter(c => c.status === RemoteAgentHostConnectionStatus.Connected).length, 1); - - configService.setEnabled(false); - - assert.strictEqual(service.connections.length, 0); - }); - - test('addRemoteAgentHost throws when disabled', async () => { - configService.setEnabled(false); - - await assert.rejects( - () => service.addRemoteAgentHost({ name: 'Host 1', connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'host1:8080' } }), - /not enabled/, - ); - }); - - test('re-enabling reconnects configured remotes', async () => { - configService.setEntries([{ name: 'Host 1', connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'host1:8080' } }]); - createdClients[0].connectDeferred.complete(); - await waitForConnected(); - assert.strictEqual(service.connections.filter(c => c.status === RemoteAgentHostConnectionStatus.Connected).length, 1); - - configService.setEnabled(false); - assert.strictEqual(service.connections.length, 0); - - configService.setEnabled(true); - assert.strictEqual(createdClients.length, 2); // new client created - createdClients[1].connectDeferred.complete(); - await waitForConnected(); - assert.strictEqual(service.connections.filter(c => c.status === RemoteAgentHostConnectionStatus.Connected).length, 1); - }); - - test('removeRemoteAgentHost removes entry and disconnects', async () => { - configService.setEntries([ - { name: 'Host 1', connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'ws://host1:8080' } }, - { name: 'Host 2', connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'ws://host2:9090' } }, - ]); - createdClients[0].connectDeferred.complete(); - createdClients[1].connectDeferred.complete(); - await waitForConnected(); - assert.strictEqual(service.connections.filter(c => c.status === RemoteAgentHostConnectionStatus.Connected).length, 2); - - await service.removeRemoteAgentHost('ws://host1:8080'); - - assert.deepStrictEqual(configService.entries, [ - { address: 'ws://host2:9090', name: 'Host 2', connectionToken: undefined }, - ]); - assert.strictEqual(service.connections.filter(c => c.status === RemoteAgentHostConnectionStatus.Connected).length, 1); - assert.strictEqual(service.getConnection('ws://host1:8080'), undefined); - assert.ok(service.getConnection('ws://host2:9090')); - }); - - test('removeRemoteAgentHost normalizes address before removing', async () => { - configService.setEntries([{ name: 'Host 1', connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'host1:8080' } }]); - createdClients[0].connectDeferred.complete(); - await waitForConnected(); - - await service.removeRemoteAgentHost('ws://host1:8080'); - - assert.deepStrictEqual(configService.entries, []); - assert.strictEqual(service.connections.length, 0); - }); - - suite('addManagedConnection', () => { - - // Build a transport disposable that records when it ran. - function makeTransportDisposable(): { disposable: { dispose(): void }; disposed: () => boolean } { - let disposed = false; - return { - disposable: { dispose: () => { disposed = true; } }, - disposed: () => disposed, - }; - } - - // Inject a managed connection (mimicking the SSH/tunnel renderer flow). - async function addManaged(name: string, address: string, transport?: { dispose(): void }) { - const mockClient = disposables.add(new MockProtocolClient(`ws://${address}`)); - return service.addManagedConnection( - { name, connection: { type: RemoteAgentHostEntryType.WebSocket, address } }, - mockClient as unknown as Parameters[1], - transport, - ); - } - - test('disposes transportDisposable when entry is removed via removeRemoteAgentHost', async () => { - const t = makeTransportDisposable(); - await addManaged('Managed', 'managed:1234', t.disposable); - assert.strictEqual(t.disposed(), false); - - await service.removeRemoteAgentHost('ws://managed:1234'); - - assert.strictEqual(t.disposed(), true, 'transport disposable runs when entry is removed'); - assert.strictEqual(service.getConnection('ws://managed:1234'), undefined); - }); - - test('disposes previous transportDisposable when entry is replaced', async () => { - const t1 = makeTransportDisposable(); - await addManaged('Managed', 'managed:1234', t1.disposable); - - const t2 = makeTransportDisposable(); - await addManaged('Managed', 'managed:1234', t2.disposable); - - assert.strictEqual(t1.disposed(), true, 'first transport disposable runs when entry is replaced'); - assert.strictEqual(t2.disposed(), false, 'second transport disposable is still alive'); - }); - - test('disposes transportDisposable when service itself is disposed', async () => { - const t = makeTransportDisposable(); - await addManaged('Managed', 'managed:1234', t.disposable); - - service.dispose(); - - assert.strictEqual(t.disposed(), true, 'transport disposable runs when service is disposed'); - }); - }); -}); diff --git a/src/vs/platform/agentHost/test/electron-browser/sshRelayTransport.test.ts b/src/vs/platform/agentHost/test/electron-browser/sshRelayTransport.test.ts deleted file mode 100644 index f66049b2..00000000 --- a/src/vs/platform/agentHost/test/electron-browser/sshRelayTransport.test.ts +++ /dev/null @@ -1,177 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import assert from 'assert'; -import { Emitter, Event } from '../../../../base/common/event.js'; -import { DisposableStore } from '../../../../base/common/lifecycle.js'; -import { URI } from '../../../../base/common/uri.js'; -import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { SSHRelayTransport } from '../../electron-browser/sshRelayTransport.js'; -import type { ISSHRelayMessage, ISSHRemoteAgentHostMainService, ISSHConnectProgress, ISSHConnectResult, ISSHAgentHostConfig, ISSHResolvedConfig } from '../../common/sshRemoteAgentHost.js'; - -/** - * Minimal mock of ISSHRemoteAgentHostMainService for testing the relay transport. - */ -class MockSSHMainService { - private readonly _onDidRelayMessage = new Emitter(); - readonly onDidRelayMessage = this._onDidRelayMessage.event; - - private readonly _onDidRelayClose = new Emitter(); - readonly onDidRelayClose = this._onDidRelayClose.event; - - readonly onDidChangeConnections = Event.None; - readonly onDidCloseConnection = Event.None; - readonly onDidReportConnectProgress = Event.None as Event; - - readonly sentMessages: { connectionId: string; message: string }[] = []; - - async relaySend(connectionId: string, message: string): Promise { - this.sentMessages.push({ connectionId, message }); - } - - async connect(_config: ISSHAgentHostConfig): Promise { - throw new Error('Not implemented'); - } - async disconnect(_host: string): Promise { } - async listSSHConfigHosts(): Promise { return []; } - async ensureUserSSHConfig(): Promise { return URI.file('/tmp/ssh-config'); } - async listSSHConfigFiles(): Promise { return [URI.file('/tmp/ssh-config')]; } - async resolveSSHConfig(_host: string): Promise { - throw new Error('Not implemented'); - } - async reconnect(_sshConfigHost: string, _name: string): Promise { - throw new Error('Not implemented'); - } - - // Test helpers - fireRelayMessage(msg: ISSHRelayMessage): void { - this._onDidRelayMessage.fire(msg); - } - - fireRelayClose(connectionId: string): void { - this._onDidRelayClose.fire(connectionId); - } - - dispose(): void { - this._onDidRelayMessage.dispose(); - this._onDidRelayClose.dispose(); - } -} - -suite('SSHRelayTransport', () => { - - const disposables = new DisposableStore(); - let mockService: MockSSHMainService; - - setup(() => { - mockService = new MockSSHMainService(); - disposables.add({ dispose: () => mockService.dispose() }); - }); - - teardown(() => disposables.clear()); - ensureNoDisposablesAreLeakedInTestSuite(); - - test('receives messages matching connectionId', () => { - const transport = disposables.add(new SSHRelayTransport('conn-1', mockService as unknown as ISSHRemoteAgentHostMainService)); - - const received: unknown[] = []; - disposables.add(transport.onMessage(msg => received.push(msg))); - - mockService.fireRelayMessage({ connectionId: 'conn-1', data: '{"jsonrpc":"2.0","id":1}' }); - - assert.strictEqual(received.length, 1); - assert.deepStrictEqual(received[0], { jsonrpc: '2.0', id: 1 }); - }); - - test('ignores messages for other connectionIds', () => { - const transport = disposables.add(new SSHRelayTransport('conn-1', mockService as unknown as ISSHRemoteAgentHostMainService)); - - const received: unknown[] = []; - disposables.add(transport.onMessage(msg => received.push(msg))); - - mockService.fireRelayMessage({ connectionId: 'conn-2', data: '{"jsonrpc":"2.0","id":1}' }); - - assert.strictEqual(received.length, 0); - }); - - test('drops malformed JSON messages', () => { - const transport = disposables.add(new SSHRelayTransport('conn-1', mockService as unknown as ISSHRemoteAgentHostMainService)); - - const received: unknown[] = []; - disposables.add(transport.onMessage(msg => received.push(msg))); - - // Should not throw - mockService.fireRelayMessage({ connectionId: 'conn-1', data: 'not-json{{{' }); - - assert.strictEqual(received.length, 0); - }); - - test('fires onClose when relay closes for matching connectionId', () => { - const transport = disposables.add(new SSHRelayTransport('conn-1', mockService as unknown as ISSHRemoteAgentHostMainService)); - - let closed = false; - disposables.add(transport.onClose(() => { closed = true; })); - - mockService.fireRelayClose('conn-1'); - - assert.strictEqual(closed, true); - }); - - test('does not fire onClose for other connectionIds', () => { - const transport = disposables.add(new SSHRelayTransport('conn-1', mockService as unknown as ISSHRemoteAgentHostMainService)); - - let closed = false; - disposables.add(transport.onClose(() => { closed = true; })); - - mockService.fireRelayClose('conn-2'); - - assert.strictEqual(closed, false); - }); - - test('send() calls relaySend with correct connectionId', async () => { - const transport = disposables.add(new SSHRelayTransport('conn-1', mockService as unknown as ISSHRemoteAgentHostMainService)); - - const msg = { jsonrpc: '2.0' as const, method: 'test', id: 42 }; - transport.send(msg as never); - - // Give the async relaySend a tick to register - await new Promise(r => queueMicrotask(r)); - - assert.strictEqual(mockService.sentMessages.length, 1); - assert.strictEqual(mockService.sentMessages[0].connectionId, 'conn-1'); - assert.deepStrictEqual(JSON.parse(mockService.sentMessages[0].message), msg); - }); - - test('receives multiple messages in order', () => { - const transport = disposables.add(new SSHRelayTransport('conn-1', mockService as unknown as ISSHRemoteAgentHostMainService)); - - const received: unknown[] = []; - disposables.add(transport.onMessage(msg => received.push(msg))); - - mockService.fireRelayMessage({ connectionId: 'conn-1', data: '{"id":1}' }); - mockService.fireRelayMessage({ connectionId: 'conn-1', data: '{"id":2}' }); - mockService.fireRelayMessage({ connectionId: 'conn-1', data: '{"id":3}' }); - - assert.strictEqual(received.length, 3); - assert.deepStrictEqual(received, [{ id: 1 }, { id: 2 }, { id: 3 }]); - }); - - test('no events after dispose', () => { - const transport = disposables.add(new SSHRelayTransport('conn-1', mockService as unknown as ISSHRemoteAgentHostMainService)); - - const received: unknown[] = []; - let closed = false; - disposables.add(transport.onMessage(msg => received.push(msg))); - disposables.add(transport.onClose(() => { closed = true; })); - - transport.dispose(); - - mockService.fireRelayMessage({ connectionId: 'conn-1', data: '{"id":1}' }); - mockService.fireRelayClose('conn-1'); - - assert.strictEqual(received.length, 0); - assert.strictEqual(closed, false); - }); -}); diff --git a/src/vs/platform/agentHost/test/electron-browser/sshRemoteAgentHostService.test.ts b/src/vs/platform/agentHost/test/electron-browser/sshRemoteAgentHostService.test.ts deleted file mode 100644 index c44c04a6..00000000 --- a/src/vs/platform/agentHost/test/electron-browser/sshRemoteAgentHostService.test.ts +++ /dev/null @@ -1,306 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import assert from 'assert'; -import { DeferredPromise } from '../../../../base/common/async.js'; -import { Emitter, Event } from '../../../../base/common/event.js'; -import { Disposable, DisposableStore, IDisposable } from '../../../../base/common/lifecycle.js'; -import { URI } from '../../../../base/common/uri.js'; -import type { IChannel } from '../../../../base/parts/ipc/common/ipc.js'; -import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { TestInstantiationService } from '../../../instantiation/test/common/instantiationServiceMock.js'; -import { ILogService, NullLogService } from '../../../log/common/log.js'; -import { IConfigurationService } from '../../../configuration/common/configuration.js'; -import { IInstantiationService } from '../../../instantiation/common/instantiation.js'; -import { ISharedProcessService } from '../../../ipc/electron-browser/services.js'; -import { IRemoteAgentHostService } from '../../common/remoteAgentHostService.js'; -import type { IAgentConnection } from '../../common/agentService.js'; -import type { - ISSHAgentHostConfig, - ISSHConnectResult, - ISSHRelayMessage, - ISSHResolvedConfig, -} from '../../common/sshRemoteAgentHost.js'; -import { SSHRemoteAgentHostService } from '../../electron-browser/sshRemoteAgentHostServiceImpl.js'; - -/** - * In-renderer mock of the shared-process SSH service. Exposes the same - * surface that the renderer accesses through ProxyChannel, plus a small - * test API to drive close events and inspect calls. - */ -class MockSSHMainService { - private readonly _onDidChangeConnections = new Emitter(); - readonly onDidChangeConnections = this._onDidChangeConnections.event; - - private readonly _onDidCloseConnection = new Emitter(); - readonly onDidCloseConnection = this._onDidCloseConnection.event; - - private readonly _onDidReportConnectProgress = new Emitter<{ connectionKey: string; message: string }>(); - readonly onDidReportConnectProgress = this._onDidReportConnectProgress.event; - - private readonly _onDidRelayMessage = new Emitter(); - readonly onDidRelayMessage = this._onDidRelayMessage.event; - - private readonly _onDidRelayClose = new Emitter(); - readonly onDidRelayClose = this._onDidRelayClose.event; - - readonly disconnectCalls: string[] = []; - private _nextConnectionId = 1; - - connectResult: Partial | undefined; - - async connect(config: ISSHAgentHostConfig): Promise { - const connectionId = this.connectResult?.connectionId ?? `conn-${this._nextConnectionId++}`; - return { - connectionId, - address: this.connectResult?.address ?? `ssh:${config.host}`, - name: config.name, - connectionToken: 'test-token', - config: { host: config.host, username: config.username, authMethod: config.authMethod, name: config.name, sshConfigHost: config.sshConfigHost }, - sshConfigHost: config.sshConfigHost, - }; - } - - async reconnect(sshConfigHost: string, name: string): Promise { - return { - connectionId: `conn-${this._nextConnectionId++}`, - address: `ssh:${sshConfigHost}`, - name, - connectionToken: 'test-token', - config: { host: sshConfigHost, username: 'u', authMethod: 0 as never, name, sshConfigHost }, - sshConfigHost, - }; - } - - async relaySend(_connectionId: string, _message: string): Promise { /* no-op */ } - - async disconnect(connectionId: string): Promise { - this.disconnectCalls.push(connectionId); - } - - async listSSHConfigHosts(): Promise { return []; } - async ensureUserSSHConfig(): Promise { return URI.file('/tmp/ssh-config'); } - async listSSHConfigFiles(): Promise { return [URI.file('/tmp/ssh-config')]; } - async resolveSSHConfig(_host: string): Promise { - return { hostname: '', user: undefined, port: 22, identityFile: [], forwardAgent: false }; - } - - dispose(): void { - this._onDidChangeConnections.dispose(); - this._onDidCloseConnection.dispose(); - this._onDidReportConnectProgress.dispose(); - this._onDidRelayMessage.dispose(); - this._onDidRelayClose.dispose(); - } -} - -/** Adapt a mock service object to the IChannel surface ProxyChannel expects. */ -function asChannel(target: object): IChannel { - return { - call: async (method: string, args?: unknown): Promise => { - const fn = (target as Record)[method]; - if (typeof fn !== 'function') { - throw new Error(`MockChannel: no method ${method}`); - } - return (fn as (...a: unknown[]) => Promise).apply(target, (args as unknown[]) ?? []); - }, - listen: (event: string): Event => { - const ev = (target as Record)[event]; - if (typeof ev !== 'function') { - throw new Error(`MockChannel: no event ${event}`); - } - return ev as Event; - }, - }; -} - -/** Captures addManagedConnection calls so tests can inspect transportDisposable. */ -class MockRemoteAgentHostService extends Disposable { - readonly added: Array<{ address: string; transport?: IDisposable }> = []; - private readonly _entries = new Map void } }>(); - - async addManagedConnection(entry: { name: string; connection: { address?: string; sshConfigHost?: string } }, client: IAgentConnection, transportDisposable?: IDisposable): Promise { - const address = entry.connection.address ?? `ssh:${entry.connection.sshConfigHost}`; - this.added.push({ address, transport: transportDisposable }); - this._entries.set(address, { client: client as { dispose?: () => void }, transport: transportDisposable }); - return { address, name: entry.name, clientId: 'mock', defaultDirectory: undefined, status: 0 }; - } - - /** Simulate user clicking "Remove Remote": disposes the per-entry store, which runs the transport disposable. */ - removeEntry(address: string): void { - const e = this._entries.get(address); - if (!e) { - return; - } - this._entries.delete(address); - e.client.dispose?.(); - e.transport?.dispose(); - } - - override dispose(): void { - // Dispose any still-registered entries (mirrors the per-entry store cleanup - // done by the real RemoteAgentHostService when it itself is disposed). - for (const [, e] of this._entries) { - e.client.dispose?.(); - e.transport?.dispose(); - } - this._entries.clear(); - super.dispose(); - } -} - -class MockProtocolClient extends Disposable { - readonly clientId = 'mock-protocol-client'; - readonly onDidClose = Event.None; - readonly onDidAction = Event.None; - readonly onDidNotification = Event.None; - readonly connectDeferred = new DeferredPromise(); - async connect(): Promise { return this.connectDeferred.p; } - registerOwned(d: T): T { return this._register(d); } -} - -class TestConfigurationService { - readonly onDidChangeConfiguration = Event.None; - getValue(): unknown { return undefined; } -} - -suite('SSHRemoteAgentHostService (renderer)', () => { - - const disposables = new DisposableStore(); - let mainService: MockSSHMainService; - let remoteAgentHostService: MockRemoteAgentHostService; - let createdClients: MockProtocolClient[]; - let waitForClient: (index: number) => Promise; - let service: SSHRemoteAgentHostService; - - setup(() => { - mainService = new MockSSHMainService(); - disposables.add({ dispose: () => mainService.dispose() }); - remoteAgentHostService = disposables.add(new MockRemoteAgentHostService()); - createdClients = []; - - const sharedProcessService: Partial = { - getChannel: () => asChannel(mainService), - }; - - const instantiationService = disposables.add(new TestInstantiationService()); - instantiationService.stub(ILogService, new NullLogService()); - instantiationService.stub(IConfigurationService, new TestConfigurationService() as Partial); - instantiationService.stub(ISharedProcessService, sharedProcessService as ISharedProcessService); - instantiationService.stub(IRemoteAgentHostService, remoteAgentHostService as Partial); - - const clientWaiters: DeferredPromise[] = []; - waitForClient = (index: number): Promise => { - if (createdClients[index]) { - return Promise.resolve(createdClients[index]); - } - return (clientWaiters[index] ??= new DeferredPromise()).p; - }; - - const inner: Partial = { - createInstance: (_ctor: unknown, ...args: unknown[]) => { - const c = new MockProtocolClient(); - // The real RemoteAgentHostProtocolClient owns the transport disposable - // it's constructed with; mirror that here so SSHRelayTransport doesn't leak. - const transport = args[1] as IDisposable | undefined; - if (transport) { - c.registerOwned(transport); - } - disposables.add(c); - const index = createdClients.length; - createdClients.push(c); - clientWaiters[index]?.complete(c); - return c; - }, - }; - instantiationService.stub(IInstantiationService, inner as Partial); - - service = disposables.add(instantiationService.createInstance(SSHRemoteAgentHostService)); - }); - - teardown(() => disposables.clear()); - ensureNoDisposablesAreLeakedInTestSuite(); - - const sampleConfig: ISSHAgentHostConfig = { - host: 'remote.example', - username: 'user', - authMethod: 0 as never, - name: 'My Remote', - sshConfigHost: 'remote.example', - }; - - /** Wait until the renderer has created its protocol client, then resolve its handshake. */ - async function awaitClientThenResolve(index: number): Promise { - const client = await waitForClient(index); - client.connectDeferred.complete(); - } - - test('connect registers a managed connection with a transport disposable', async () => { - const connectPromise = service.connect(sampleConfig); - await awaitClientThenResolve(0); - const handle = await connectPromise; - - assert.strictEqual(remoteAgentHostService.added.length, 1); - assert.strictEqual(remoteAgentHostService.added[0].address, 'ssh:remote.example'); - assert.ok(remoteAgentHostService.added[0].transport, 'a transport disposable is passed so removal can tear down the SSH tunnel'); - assert.strictEqual(service.connections.length, 1); - assert.strictEqual(handle.localAddress, 'ssh:remote.example'); - }); - - test('removing the entry tears down the SSH tunnel and the renderer-side handle', async () => { - const connectPromise = service.connect(sampleConfig); - await awaitClientThenResolve(0); - await connectPromise; - - assert.strictEqual(mainService.disconnectCalls.length, 0); - assert.strictEqual(service.connections.length, 1); - - // Simulate the user clicking "Remove Remote": IRemoteAgentHostService - // disposes the per-entry store, which runs our transport disposable. - remoteAgentHostService.removeEntry('ssh:remote.example'); - - assert.deepStrictEqual(mainService.disconnectCalls, ['conn-1'], 'main-process tunnel is told to disconnect'); - assert.strictEqual(service.connections.length, 0, 'renderer-side handle is dropped'); - }); - - test('connect after removal does not reuse the previous handle', async () => { - // First connect → entry registered, then removed. - const c1 = service.connect(sampleConfig); - await awaitClientThenResolve(0); - await c1; - remoteAgentHostService.removeEntry('ssh:remote.example'); - assert.strictEqual(service.connections.length, 0); - - // Second connect → main returns a new connectionId; renderer creates - // a fresh handle and registers a new managed entry. - mainService.connectResult = { connectionId: 'conn-2', address: 'ssh:remote.example' }; - const c2 = service.connect(sampleConfig); - await awaitClientThenResolve(1); - await c2; - - assert.strictEqual(service.connections.length, 1); - assert.strictEqual(remoteAgentHostService.added.length, 2, 'each connect produces a fresh managed-connection registration'); - }); - - test('main-process onDidCloseConnection cleans up renderer handle without double-disconnecting', async () => { - const connectPromise = service.connect(sampleConfig); - await awaitClientThenResolve(0); - await connectPromise; - assert.strictEqual(service.connections.length, 1); - - // Simulate main process closing the connection on its own (e.g. SSH dropped). - // We can't directly fire on the wrapped emitter through the channel because - // ProxyChannel is one-directional; instead we trigger via the mock service - // emitter that the renderer subscribed to. - (mainService as unknown as { _onDidCloseConnection: Emitter })._onDidCloseConnection.fire('conn-1'); - - assert.strictEqual(service.connections.length, 0, 'handle dropped on main close'); - // Removing the (already-gone) entry shouldn't trigger another disconnect call. - remoteAgentHostService.removeEntry('ssh:remote.example'); - // One disconnect from the transport disposable is fine; we just want to make - // sure we're not at risk of issuing a second one against a stale id. - assert.ok(mainService.disconnectCalls.length <= 1, 'no duplicate disconnect against a stale connectionId'); - }); -}); diff --git a/src/vs/platform/agentHost/test/node/AGENTS.md b/src/vs/platform/agentHost/test/node/AGENTS.md deleted file mode 100644 index 4ee88272..00000000 --- a/src/vs/platform/agentHost/test/node/AGENTS.md +++ /dev/null @@ -1,3 +0,0 @@ -# Agent host unit tests - -For tests in this area that touch the SessionDatabase, they MUST use an in-memory database, not a real database file on disk. Use `SessionDatabase.open(':memory:')` and see the examples from existing tests. diff --git a/src/vs/platform/agentHost/test/node/agentConfigurationService.test.ts b/src/vs/platform/agentHost/test/node/agentConfigurationService.test.ts deleted file mode 100644 index 09db742e..00000000 --- a/src/vs/platform/agentHost/test/node/agentConfigurationService.test.ts +++ /dev/null @@ -1,172 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import assert from 'assert'; -import { DisposableStore } from '../../../../base/common/lifecycle.js'; -import { URI } from '../../../../base/common/uri.js'; -import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { NullLogService } from '../../../log/common/log.js'; -import { createSchema, schemaProperty } from '../../common/agentHostSchema.js'; -import type { SessionConfigState, RootConfigState } from '../../common/state/protocol/state.js'; -import { buildSubagentSessionUri, SessionStatus, type SessionSummary } from '../../common/state/sessionState.js'; -import { AgentConfigurationService } from '../../node/agentConfigurationService.js'; -import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; - -suite('AgentConfigurationService', () => { - - const disposables = new DisposableStore(); - let manager: AgentHostStateManager; - let service: AgentConfigurationService; - - const schema = createSchema({ - level: schemaProperty<'low' | 'high'>({ - type: 'string', - title: 'level', - enum: ['low', 'high'], - }), - limit: schemaProperty({ type: 'number', title: 'limit' }), - }); - - function seedSessionConfig(sessionUri: string, values: Record): void { - const state = manager.getSessionState(sessionUri); - assert.ok(state, `Session not found: ${sessionUri}`); - const mutable = state as { config?: SessionConfigState }; - mutable.config = { - schema: schema.toProtocol(), - values, - }; - } - - function seedRootConfig(values: Record): void { - const rootMutable = manager.rootState as { config?: RootConfigState }; - rootMutable.config = { - schema: schema.toProtocol(), - values, - }; - } - - function makeSummary(resource: string, workingDirectory?: string): SessionSummary { - return { - resource, - provider: 'copilot', - title: 't', - status: SessionStatus.Idle, - createdAt: Date.now(), - modifiedAt: Date.now(), - project: { uri: 'file:///project', displayName: 'Project' }, - workingDirectory, - }; - } - - setup(() => { - manager = disposables.add(new AgentHostStateManager(new NullLogService())); - service = disposables.add(new AgentConfigurationService(manager, new NullLogService())); - }); - - teardown(() => disposables.clear()); - - ensureNoDisposablesAreLeakedInTestSuite(); - - // ---- getEffectiveValue ------------------------------------------------ - - suite('getEffectiveValue', () => { - - test('returns session value when present', () => { - const uri = URI.from({ scheme: 'copilot', path: '/a' }).toString(); - manager.createSession(makeSummary(uri)); - seedSessionConfig(uri, { level: 'high' }); - assert.strictEqual(service.getEffectiveValue(uri, schema, 'level'), 'high'); - }); - - test('falls back to host value when session does not provide the key', () => { - const uri = URI.from({ scheme: 'copilot', path: '/a' }).toString(); - manager.createSession(makeSummary(uri)); - seedSessionConfig(uri, { limit: 5 }); - seedRootConfig({ level: 'low' }); - assert.strictEqual(service.getEffectiveValue(uri, schema, 'level'), 'low'); - }); - - test('inherits from parent subagent session', () => { - const parent = URI.from({ scheme: 'copilot', path: '/parent' }).toString(); - manager.createSession(makeSummary(parent)); - seedSessionConfig(parent, { level: 'high' }); - - const child = buildSubagentSessionUri(parent, 'toolcall-1'); - manager.createSession(makeSummary(child)); - - assert.strictEqual(service.getEffectiveValue(child, schema, 'level'), 'high'); - }); - - test('session value takes precedence over parent and host', () => { - const parent = URI.from({ scheme: 'copilot', path: '/parent' }).toString(); - manager.createSession(makeSummary(parent)); - seedSessionConfig(parent, { level: 'high' }); - - const child = buildSubagentSessionUri(parent, 'tc-2'); - manager.createSession(makeSummary(child)); - seedSessionConfig(child, { level: 'low' }); - seedRootConfig({ level: 'high' }); - - assert.strictEqual(service.getEffectiveValue(child, schema, 'level'), 'low'); - }); - - test('skips layers whose value fails schema validation and falls through', () => { - const uri = URI.from({ scheme: 'copilot', path: '/a' }).toString(); - manager.createSession(makeSummary(uri)); - seedSessionConfig(uri, { level: 'bogus' }); - seedRootConfig({ level: 'high' }); - assert.strictEqual(service.getEffectiveValue(uri, schema, 'level'), 'high'); - }); - - test('returns undefined when no layer provides a valid value', () => { - const uri = URI.from({ scheme: 'copilot', path: '/a' }).toString(); - manager.createSession(makeSummary(uri)); - seedSessionConfig(uri, {}); - assert.strictEqual(service.getEffectiveValue(uri, schema, 'level'), undefined); - }); - }); - - // ---- getEffectiveWorkingDirectory ------------------------------------- - - suite('getEffectiveWorkingDirectory', () => { - - test('returns session working directory when set', () => { - const uri = URI.from({ scheme: 'copilot', path: '/a' }).toString(); - manager.createSession(makeSummary(uri, 'file:///work')); - assert.strictEqual(service.getEffectiveWorkingDirectory(uri), 'file:///work'); - }); - - test('falls back to parent session working directory for subagents', () => { - const parent = URI.from({ scheme: 'copilot', path: '/parent' }).toString(); - manager.createSession(makeSummary(parent, 'file:///work/parent')); - - const child = buildSubagentSessionUri(parent, 'tc-3'); - manager.createSession(makeSummary(child)); - assert.strictEqual(service.getEffectiveWorkingDirectory(child), 'file:///work/parent'); - }); - - test('returns undefined when neither layer has a working directory', () => { - const uri = URI.from({ scheme: 'copilot', path: '/a' }).toString(); - manager.createSession(makeSummary(uri)); - assert.strictEqual(service.getEffectiveWorkingDirectory(uri), undefined); - }); - }); - - // ---- updateSessionConfig ---------------------------------------------- - - suite('updateSessionConfig', () => { - - test('merges the patch into the session config values', () => { - const uri = URI.from({ scheme: 'copilot', path: '/a' }).toString(); - manager.createSession(makeSummary(uri)); - seedSessionConfig(uri, { level: 'low', limit: 1 }); - - service.updateSessionConfig(uri, { limit: 42 }); - - const state = manager.getSessionState(uri); - assert.deepStrictEqual(state?.config?.values, { level: 'low', limit: 42 }); - }); - }); -}); diff --git a/src/vs/platform/agentHost/test/node/agentHostGitService.integrationTest.ts b/src/vs/platform/agentHost/test/node/agentHostGitService.integrationTest.ts deleted file mode 100644 index 195e81bd..00000000 --- a/src/vs/platform/agentHost/test/node/agentHostGitService.integrationTest.ts +++ /dev/null @@ -1,333 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -/** - * Integration tests for {@link AgentHostGitService} that spawn real `git` against - * temporary on-disk repositories. Kept out of the unit-test suite because they - * require `git` on PATH and do real filesystem and process work — same split as - * the git extension (pure parser tests in `git.test.ts`, on-disk tests in - * `smoke.test.ts`). - * - * Run via `scripts/test-integration.sh`. - */ - -import assert from 'assert'; -import * as cp from 'child_process'; -import { mkdtempSync, rmSync } from 'fs'; -import { tmpdir } from 'os'; -import { NullLogService } from '../../../log/common/log.js'; -import { join } from '../../../../base/common/path.js'; -import { URI } from '../../../../base/common/uri.js'; -import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { INativeEnvironmentService } from '../../../environment/common/environment.js'; -import { FileService } from '../../../files/common/fileService.js'; -import { Schemas } from '../../../../base/common/network.js'; -import { DiskFileSystemProvider } from '../../../files/node/diskFileSystemProvider.js'; -import { DisposableStore } from '../../../../base/common/lifecycle.js'; -import { AgentHostGitService } from '../../node/agentHostGitService.js'; - -function createGitService(disposables: Pick): AgentHostGitService { - const logService = new NullLogService(); - const fileService = disposables.add(new FileService(logService)); - disposables.add(fileService.registerProvider(Schemas.file, disposables.add(new DiskFileSystemProvider(logService)))); - const env: Partial = { tmpDir: URI.file(tmpdir()) }; - return new AgentHostGitService(fileService, env as INativeEnvironmentService); -} - -suite('AgentHostGitService - getSessionGitState (real git)', () => { - const disposables = ensureNoDisposablesAreLeakedInTestSuite(); - - // Skip the on-disk git tests when `git` is not on PATH (e.g. minimal CI). - const hasGit = (() => { - try { cp.execFileSync('git', ['--version'], { stdio: 'ignore' }); return true; } catch { return false; } - })(); - - let tmpRoot: string | undefined; - let svc: AgentHostGitService | undefined; - - setup(() => { - tmpRoot = undefined; - svc = createGitService(disposables); - }); - - teardown(() => { - if (tmpRoot) { - rmSync(tmpRoot, { recursive: true, force: true }); - } - }); - - function initRepo(opts?: { remote?: string; baseBranch?: string }): string { - tmpRoot = mkdtempSync(join(tmpdir(), 'agent-host-git-')); - const env = { ...process.env, GIT_AUTHOR_NAME: 't', GIT_AUTHOR_EMAIL: 't@t', GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@t' }; - const run = (...args: string[]) => cp.execFileSync('git', args, { cwd: tmpRoot!, env, stdio: 'pipe' }); - run('init', '-q', '-b', opts?.baseBranch ?? 'main'); - run('commit', '-q', '--allow-empty', '-m', 'initial'); - if (opts?.remote) { - run('remote', 'add', 'origin', opts.remote); - } - return tmpRoot!; - } - - (hasGit ? test : test.skip)('returns undefined for a non-git directory', async () => { - const dir = mkdtempSync(join(tmpdir(), 'agent-host-nongit-')); - tmpRoot = dir; - const result = await svc!.getSessionGitState(URI.file(dir)); - assert.strictEqual(result, undefined); - }); - - (hasGit ? test : test.skip)('reports branch, github remote and clean state for a fresh repo', async () => { - const dir = initRepo({ remote: 'https://github.com/owner/repo.git' }); - const result = await svc!.getSessionGitState(URI.file(dir)); - assert.ok(result, 'expected git state'); - assert.strictEqual(result.branchName, 'main'); - assert.strictEqual(result.hasGitHubRemote, true); - assert.strictEqual(result.uncommittedChanges, 0); - // No upstream configured for the fresh local branch. - assert.strictEqual(result.upstreamBranchName, undefined); - assert.strictEqual(result.outgoingChanges, undefined); - assert.strictEqual(result.incomingChanges, undefined); - }); - - (hasGit ? test : test.skip)('counts uncommitted changes', async () => { - const dir = initRepo({ remote: 'git@gitlab.com:owner/repo.git' }); - const fs = await import('fs/promises'); - await fs.writeFile(join(dir, 'a.txt'), 'hello'); - await fs.writeFile(join(dir, 'b.txt'), 'world'); - const result = await svc!.getSessionGitState(URI.file(dir)); - assert.ok(result); - assert.strictEqual(result.uncommittedChanges, 2); - assert.strictEqual(result.hasGitHubRemote, false); - }); - - (hasGit ? test : test.skip)('reports outgoingChanges relative to base branch when local branch has no upstream', async () => { - // Create a bare "remote" repo and set up the working repo so that - // `refs/remotes/origin/HEAD` exists (required for baseBranchName parsing). - const remoteDir = mkdtempSync(join(tmpdir(), 'agent-host-remote-')); - const env = { ...process.env, GIT_AUTHOR_NAME: 't', GIT_AUTHOR_EMAIL: 't@t', GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@t' }; - try { - cp.execFileSync('git', ['init', '-q', '--bare', '-b', 'main'], { cwd: remoteDir, env, stdio: 'pipe' }); - tmpRoot = mkdtempSync(join(tmpdir(), 'agent-host-git-')); - const run = (...args: string[]) => cp.execFileSync('git', args, { cwd: tmpRoot!, env, stdio: 'pipe' }); - run('init', '-q', '-b', 'main'); - run('commit', '-q', '--allow-empty', '-m', 'initial'); - run('remote', 'add', 'origin', `https://github.com/owner/repo.git`); - // Use a separate "upload" remote pointing at the bare repo to populate - // the origin/main remote-tracking ref without changing the GitHub URL - // we're testing for hasGitHubRemote detection. - run('remote', 'add', 'tmp', remoteDir); - run('push', '-q', 'tmp', 'main:main'); - // Create the origin/main ref locally without any network round-trip. - run('update-ref', 'refs/remotes/origin/main', 'refs/heads/main'); - run('symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/main'); - - // Branch off and add two commits without setting an upstream. - run('checkout', '-q', '-b', 'feature', '--no-track'); - run('commit', '-q', '--allow-empty', '-m', 'one'); - run('commit', '-q', '--allow-empty', '-m', 'two'); - - const result = await svc!.getSessionGitState(URI.file(tmpRoot!)); - assert.ok(result, 'expected git state'); - assert.strictEqual(result.branchName, 'feature'); - assert.strictEqual(result.baseBranchName, 'main'); - assert.strictEqual(result.upstreamBranchName, undefined); - assert.strictEqual(result.outgoingChanges, 2); - assert.strictEqual(result.uncommittedChanges, 0); - } finally { - rmSync(remoteDir, { recursive: true, force: true }); - } - }); -}); - -suite('AgentHostGitService - computeSessionFileDiffs (real git)', () => { - const disposables = ensureNoDisposablesAreLeakedInTestSuite(); - - const hasGit = (() => { - try { cp.execFileSync('git', ['--version'], { stdio: 'ignore' }); return true; } catch { return false; } - })(); - - let tmpRoot: string | undefined; - let svc: AgentHostGitService | undefined; - - setup(() => { - tmpRoot = undefined; - svc = createGitService(disposables); - }); - - teardown(() => { - if (tmpRoot) { - rmSync(tmpRoot, { recursive: true, force: true }); - } - }); - - function initRepo(): { dir: string; run: (...args: string[]) => Buffer } { - tmpRoot = mkdtempSync(join(tmpdir(), 'agent-host-diff-')); - const env = { ...process.env, GIT_AUTHOR_NAME: 't', GIT_AUTHOR_EMAIL: 't@t', GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@t' }; - const run = (...args: string[]) => cp.execFileSync('git', args, { cwd: tmpRoot!, env, stdio: 'pipe' }); - run('init', '-q', '-b', 'main'); - return { dir: tmpRoot!, run }; - } - - (hasGit ? test : test.skip)('returns undefined for a non-git directory', async () => { - const dir = mkdtempSync(join(tmpdir(), 'agent-host-nongit-diff-')); - tmpRoot = dir; - const result = await svc!.computeSessionFileDiffs(URI.file(dir), { sessionUri: 'copilot:/s' }); - assert.strictEqual(result, undefined); - }); - - (hasGit ? test : test.skip)('reports modified, added (untracked) and deleted files against HEAD', async () => { - const fs = await import('fs/promises'); - const { dir, run } = initRepo(); - await fs.writeFile(join(dir, 'kept.txt'), 'one\ntwo\nthree\n'); - await fs.writeFile(join(dir, 'gone.txt'), 'bye\n'); - run('add', '.'); - run('commit', '-q', '-m', 'init'); - - // Modify, add (untracked), delete. - await fs.writeFile(join(dir, 'kept.txt'), 'one\ntwo\nthree\nfour\n'); - await fs.writeFile(join(dir, 'fresh.txt'), 'hello\n'); - await fs.unlink(join(dir, 'gone.txt')); - - const result = await svc!.computeSessionFileDiffs(URI.file(dir), { sessionUri: 'copilot:/s' }); - assert.ok(result, 'expected diffs'); - const byPath = new Map(result.map(d => [d.after?.uri ?? d.before?.uri, d])); - - // Find by basename to be robust against path normalization differences (e.g. macOS /private prefix). - const findByBasename = (name: string) => result.find(d => { - const u = d.after?.uri ?? d.before?.uri; - return typeof u === 'string' && u.endsWith('/' + name); - }); - - const kept = findByBasename('kept.txt'); - assert.ok(kept?.before && kept.after, `modified file should have before+after; result=${JSON.stringify(result.map(d => ({ a: d.after?.uri, b: d.before?.uri })))}`); - assert.deepStrictEqual(kept!.diff, { added: 1, removed: 0 }); - assert.ok(kept!.before!.content.uri.startsWith('git-blob://'), 'before content should be a git-blob: URI'); - - const fresh = findByBasename('fresh.txt'); - assert.ok(fresh?.after && !fresh.before, 'untracked file should have only after'); - - const gone = findByBasename('gone.txt'); - assert.ok(gone?.before && !gone.after, 'deleted file should have only before'); - void byPath; - }); - - (hasGit ? test : test.skip)('anchors against the merge-base of the requested base branch', async () => { - const fs = await import('fs/promises'); - const { dir, run } = initRepo(); - await fs.writeFile(join(dir, 'a.txt'), 'a\n'); - run('add', '.'); - run('commit', '-q', '-m', 'init'); - // Branch off, then advance main behind us so merge-base != HEAD. - run('checkout', '-q', '-b', 'feature'); - await fs.writeFile(join(dir, 'b.txt'), 'b\n'); - run('add', '.'); - run('commit', '-q', '-m', 'add b on feature'); - - const result = await svc!.computeSessionFileDiffs(URI.file(dir), { sessionUri: 'copilot:/s', baseBranch: 'main' }); - assert.ok(result, 'expected diffs'); - // `b.txt` was committed on `feature` after branching from `main`, so - // it must show up in the merge-base diff even though there are no - // uncommitted changes in the working tree. - const paths = result.map(d => (d.after?.uri ?? d.before?.uri)); - assert.ok(paths.some(p => p?.endsWith('b.txt')), `expected b.txt in diff; got ${paths.join(', ')}`); - }); - - (hasGit ? test : test.skip)('returns no diffs for a clean repo', async () => { - const fs = await import('fs/promises'); - const { dir, run } = initRepo(); - await fs.writeFile(join(dir, 'a.txt'), 'a\n'); - run('add', '.'); - run('commit', '-q', '-m', 'init'); - - const result = await svc!.computeSessionFileDiffs(URI.file(dir), { sessionUri: 'copilot:/s' }); - assert.deepStrictEqual(result, []); - }); - - (hasGit ? test : test.skip)('handles an empty repo (no HEAD) by treating files as added', async () => { - const fs = await import('fs/promises'); - const { dir } = initRepo(); - await fs.writeFile(join(dir, 'first.txt'), 'hello\n'); - - const result = await svc!.computeSessionFileDiffs(URI.file(dir), { sessionUri: 'copilot:/s' }); - assert.ok(result, 'expected diffs'); - assert.strictEqual(result.length, 1); - assert.ok(result[0].after && !result[0].before, 'untracked file in empty repo should be an addition'); - }); - - (hasGit ? test : test.skip)('showBlob retrieves committed content', async () => { - const fs = await import('fs/promises'); - const { dir, run } = initRepo(); - await fs.writeFile(join(dir, 'a.txt'), 'original\n'); - run('add', '.'); - run('commit', '-q', '-m', 'init'); - const sha = cp.execFileSync('git', ['rev-parse', 'HEAD'], { cwd: dir, encoding: 'utf8' }).trim(); - await fs.writeFile(join(dir, 'a.txt'), 'changed\n'); - - const blob = await svc!.showBlob(URI.file(dir), sha, 'a.txt'); - assert.ok(blob); - assert.strictEqual(blob.toString(), 'original\n'); - }); -}); - -suite('AgentHostGitService - worktree helpers (real git)', () => { - const disposables = ensureNoDisposablesAreLeakedInTestSuite(); - - const hasGit = (() => { - try { cp.execFileSync('git', ['--version'], { stdio: 'ignore' }); return true; } catch { return false; } - })(); - - let tmpRoot: string | undefined; - let svc: AgentHostGitService | undefined; - const env = { ...process.env, GIT_AUTHOR_NAME: 't', GIT_AUTHOR_EMAIL: 't@t', GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@t' }; - - setup(() => { - tmpRoot = undefined; - svc = createGitService(disposables); - }); - - teardown(() => { - if (tmpRoot) { - rmSync(tmpRoot, { recursive: true, force: true }); - } - }); - - function initRepo(): string { - tmpRoot = mkdtempSync(join(tmpdir(), 'agent-host-git-wt-')); - const run = (...args: string[]) => cp.execFileSync('git', args, { cwd: tmpRoot!, env, stdio: 'pipe' }); - run('init', '-q', '-b', 'main'); - run('commit', '-q', '--allow-empty', '-m', 'initial'); - return tmpRoot!; - } - - (hasGit ? test : test.skip)('branchExists reports true for HEAD branch and false for missing branches', async () => { - const dir = initRepo(); - assert.strictEqual(await svc!.branchExists(URI.file(dir), 'main'), true); - assert.strictEqual(await svc!.branchExists(URI.file(dir), 'does-not-exist'), false); - }); - - (hasGit ? test : test.skip)('hasUncommittedChanges flips with untracked and committed work', async () => { - const dir = initRepo(); - assert.strictEqual(await svc!.hasUncommittedChanges(URI.file(dir)), false); - const fs = await import('fs/promises'); - await fs.writeFile(join(dir, 'a.txt'), 'hello'); - assert.strictEqual(await svc!.hasUncommittedChanges(URI.file(dir)), true); - cp.execFileSync('git', ['add', 'a.txt'], { cwd: dir, env, stdio: 'pipe' }); - cp.execFileSync('git', ['commit', '-q', '-m', 'add a'], { cwd: dir, env, stdio: 'pipe' }); - assert.strictEqual(await svc!.hasUncommittedChanges(URI.file(dir)), false); - }); - - (hasGit ? test : test.skip)('addExistingWorktree attaches a worktree for an existing branch (no -b)', async () => { - const dir = initRepo(); - cp.execFileSync('git', ['branch', 'feature'], { cwd: dir, env, stdio: 'pipe' }); - const wtPath = join(dir, '..', `wt-${Date.now()}`); - try { - await svc!.addExistingWorktree(URI.file(dir), URI.file(wtPath), 'feature'); - const fs = await import('fs/promises'); - const stat = await fs.stat(wtPath); - assert.ok(stat.isDirectory(), 'worktree directory should exist'); - } finally { - rmSync(wtPath, { recursive: true, force: true }); - } - }); -}); diff --git a/src/vs/platform/agentHost/test/node/agentHostGitService.test.ts b/src/vs/platform/agentHost/test/node/agentHostGitService.test.ts deleted file mode 100644 index 957c43f0..00000000 --- a/src/vs/platform/agentHost/test/node/agentHostGitService.test.ts +++ /dev/null @@ -1,196 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import assert from 'assert'; -import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { EMPTY_TREE_OBJECT, getBranchCompletions, parseDefaultBranchRef, parseGitDiffRawNumstat, parseGitStatusV2, parseHasGitHubRemote, parseUntrackedPaths } from '../../node/agentHostGitService.js'; -import { buildGitBlobUri } from '../../node/gitDiffContent.js'; -import { URI } from '../../../../base/common/uri.js'; - -suite('AgentHostGitService', () => { - ensureNoDisposablesAreLeakedInTestSuite(); - - test('sorts common branch names to the top before applying limit', () => { - assert.deepStrictEqual( - getBranchCompletions(['feature/recent', 'release', 'master', 'main', 'feature/older'], { limit: 3 }), - ['main', 'master', 'feature/recent'], - ); - }); - - test('preserves git order for non-common branches', () => { - assert.deepStrictEqual( - getBranchCompletions(['feature/recent', 'release', 'feature/older']), - ['feature/recent', 'release', 'feature/older'], - ); - }); - - test('filters before sorting common branch names', () => { - assert.deepStrictEqual( - getBranchCompletions(['feature/recent', 'master', 'main', 'maintenance'], { query: 'ma' }), - ['main', 'master', 'maintenance'], - ); - }); - - suite('parseGitStatusV2', () => { - test('parses a clean checkout with upstream', () => { - const out = [ - '# branch.oid 0123456789abcdef0123456789abcdef01234567', - '# branch.head main', - '# branch.upstream origin/main', - '# branch.ab +0 -0', - ].join('\n'); - assert.deepStrictEqual(parseGitStatusV2(out), { - branchName: 'main', - upstreamBranchName: 'origin/main', - outgoingChanges: 0, - incomingChanges: 0, - uncommittedChanges: 0, - }); - }); - - test('parses a dirty branch ahead and behind upstream', () => { - const out = [ - '# branch.oid 0123456789abcdef0123456789abcdef01234567', - '# branch.head feature', - '# branch.upstream origin/feature', - '# branch.ab +3 -2', - '1 .M N... 100644 100644 100644 abc abc src/a.ts', - '2 R. N... 100644 100644 100644 abc abc R100 src/b.ts\tsrc/old-b.ts', - '? src/untracked.ts', - ].join('\n'); - assert.deepStrictEqual(parseGitStatusV2(out), { - branchName: 'feature', - upstreamBranchName: 'origin/feature', - outgoingChanges: 3, - incomingChanges: 2, - uncommittedChanges: 3, - }); - }); - - test('treats (detached) HEAD as no branch and omits upstream/ab when absent', () => { - const out = [ - '# branch.oid 0123456789abcdef0123456789abcdef01234567', - '# branch.head (detached)', - ].join('\n'); - assert.deepStrictEqual(parseGitStatusV2(out), { - branchName: undefined, - upstreamBranchName: undefined, - outgoingChanges: undefined, - incomingChanges: undefined, - uncommittedChanges: 0, - }); - }); - - test('returns empty object for undefined input', () => { - assert.deepStrictEqual(parseGitStatusV2(undefined), {}); - }); - }); - - suite('parseHasGitHubRemote', () => { - test('detects ssh github remote', () => { - assert.strictEqual(parseHasGitHubRemote('origin\tgit@github.com:owner/repo.git (fetch)\n'), true); - }); - test('detects https github remote', () => { - assert.strictEqual(parseHasGitHubRemote('origin\thttps://github.com/owner/repo.git (fetch)\n'), true); - }); - test('returns false for non-github remotes', () => { - assert.strictEqual(parseHasGitHubRemote('origin\thttps://gitlab.com/owner/repo.git (fetch)\n'), false); - }); - test('returns false when there are no remotes', () => { - assert.strictEqual(parseHasGitHubRemote(''), false); - }); - test('returns undefined when probe failed (output absent)', () => { - assert.strictEqual(parseHasGitHubRemote(undefined), undefined); - }); - }); - - suite('parseDefaultBranchRef', () => { - test('strips refs/remotes/origin/ prefix', () => { - assert.strictEqual(parseDefaultBranchRef('refs/remotes/origin/main\n'), 'main'); - }); - test('returns the ref as-is when prefix is not present', () => { - assert.strictEqual(parseDefaultBranchRef('main'), 'main'); - }); - test('returns undefined for empty/missing output', () => { - assert.strictEqual(parseDefaultBranchRef(undefined), undefined); - assert.strictEqual(parseDefaultBranchRef(' '), undefined); - }); - }); - - suite('parseUntrackedPaths', () => { - test('returns empty for empty/undefined output', () => { - assert.deepStrictEqual(parseUntrackedPaths(undefined), []); - assert.deepStrictEqual(parseUntrackedPaths(''), []); - }); - - test('extracts untracked entries and skips others', () => { - // `git status --porcelain=v1 -z` emits NUL-separated entries; the - // rename entry includes a second NUL-separated "from" path that - // must be skipped. - const out = '?? new.txt\x00 M edited.txt\x00R to.txt\x00from.txt\x00?? other.txt\x00'; - assert.deepStrictEqual(parseUntrackedPaths(out), ['new.txt', 'other.txt']); - }); - }); - - suite('parseGitDiffRawNumstat', () => { - const root = URI.file('/repo'); - const sessionUri = 'copilot:/abc'; - const sha = 'cafe1234cafe1234cafe1234cafe1234cafe1234'; - - test('parses an add, modify, delete and rename in a single stream', () => { - // Format: alternating `--raw` and `--numstat` segments separated by - // NUL bytes. Renames have an extra path segment in both halves. - const segments: string[] = [ - ':100644 100644 0000000 1111111 M', 'modified.ts', - ':000000 100644 0000000 2222222 A', 'added.ts', - ':100644 000000 3333333 0000000 D', 'deleted.ts', - ':100644 100644 4444444 5555555 R100', 'old/path.ts', 'new/path.ts', - '5\t2\tmodified.ts', - '10\t0\tadded.ts', - '0\t7\tdeleted.ts', - '3\t3\t', 'old/path.ts', 'new/path.ts', - '', - ]; - const out = segments.join('\x00'); - const diffs = parseGitDiffRawNumstat(out, root, sessionUri, sha); - assert.deepStrictEqual(diffs, [ - { - before: { uri: 'file:///repo/modified.ts', content: { uri: buildGitBlobUri(sessionUri, sha, 'modified.ts') } }, - after: { uri: 'file:///repo/modified.ts', content: { uri: 'file:///repo/modified.ts' } }, - diff: { added: 5, removed: 2 }, - }, - { - after: { uri: 'file:///repo/added.ts', content: { uri: 'file:///repo/added.ts' } }, - diff: { added: 10, removed: 0 }, - }, - { - before: { uri: 'file:///repo/deleted.ts', content: { uri: buildGitBlobUri(sessionUri, sha, 'deleted.ts') } }, - diff: { added: 0, removed: 7 }, - }, - { - before: { uri: 'file:///repo/old/path.ts', content: { uri: buildGitBlobUri(sessionUri, sha, 'old/path.ts') } }, - after: { uri: 'file:///repo/new/path.ts', content: { uri: 'file:///repo/new/path.ts' } }, - diff: { added: 3, removed: 3 }, - }, - ]); - }); - - test('treats `-` numstat values (binary) as zero', () => { - const out = [':100644 100644 0 0 M', 'image.png', '-\t-\timage.png', ''].join('\x00'); - const diffs = parseGitDiffRawNumstat(out, root, sessionUri, sha); - assert.strictEqual(diffs.length, 1); - assert.deepStrictEqual(diffs[0].diff, { added: 0, removed: 0 }); - }); - - test('returns empty for empty input', () => { - assert.deepStrictEqual(parseGitDiffRawNumstat('', root, sessionUri, sha), []); - }); - }); - - test('exports the well-known empty-tree object SHA', () => { - assert.strictEqual(EMPTY_TREE_OBJECT, '4b825dc642cb6eb9a060e54bf8d69288fbee4904'); - }); -}); - diff --git a/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts b/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts deleted file mode 100644 index cdbdd9b7..00000000 --- a/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts +++ /dev/null @@ -1,414 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import assert from 'assert'; -import { DisposableStore } from '../../../../base/common/lifecycle.js'; -import { URI } from '../../../../base/common/uri.js'; -import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { runWithFakedTimers } from '../../../../base/test/common/timeTravelScheduler.js'; -import { NullLogService } from '../../../log/common/log.js'; -import { ActionType, NotificationType, type ActionEnvelope, type INotification } from '../../common/state/sessionActions.js'; -import { SessionSummary, ResponsePartKind, ROOT_STATE_URI, SessionLifecycle, SessionStatus, TurnState, buildSubagentSessionUri, isSubagentSession, parseSubagentSessionUri, type MarkdownResponsePart, type SessionState } from '../../common/state/sessionState.js'; -import { type SessionSummaryChangedNotification } from '../../common/state/protocol/notifications.js'; -import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; - -suite('AgentHostStateManager', () => { - - let disposables: DisposableStore; - let manager: AgentHostStateManager; - const sessionUri = URI.from({ scheme: 'copilot', path: '/test-session' }).toString(); - - function makeSessionSummary(resource?: string): SessionSummary { - return { - resource: resource ?? sessionUri, - provider: 'copilot', - title: 'Test', - status: SessionStatus.Idle, - createdAt: Date.now(), - modifiedAt: Date.now(), - project: { uri: 'file:///test-project', displayName: 'Test Project' }, - }; - } - - setup(() => { - disposables = new DisposableStore(); - manager = disposables.add(new AgentHostStateManager(new NullLogService())); - }); - - teardown(() => { - disposables.dispose(); - }); - - ensureNoDisposablesAreLeakedInTestSuite(); - - test('createSession creates initial state with lifecycle Creating', () => { - const state = manager.createSession(makeSessionSummary()); - assert.strictEqual(state.lifecycle, SessionLifecycle.Creating); - assert.strictEqual(state.turns.length, 0); - assert.strictEqual(state.activeTurn, undefined); - assert.strictEqual(state.summary.resource.toString(), sessionUri.toString()); - }); - - test('getSnapshot returns undefined for unknown session', () => { - const unknown = URI.from({ scheme: 'copilot', path: '/unknown' }).toString(); - const snapshot = manager.getSnapshot(unknown); - assert.strictEqual(snapshot, undefined); - }); - - test('getSnapshot returns root snapshot', () => { - const snapshot = manager.getSnapshot(ROOT_STATE_URI); - assert.ok(snapshot); - assert.strictEqual(snapshot.resource.toString(), ROOT_STATE_URI.toString()); - const root = snapshot.state as { agents: unknown[]; activeSessions: number; config?: { values?: Record } }; - assert.deepStrictEqual(root.agents, []); - assert.strictEqual(root.activeSessions, 0); - // Host config is seeded with the platform root schema and defaults. - assert.ok(root.config, 'root state should include a seeded config'); - }); - - test('getSnapshot returns session snapshot after creation', () => { - manager.createSession(makeSessionSummary()); - const snapshot = manager.getSnapshot(sessionUri); - assert.ok(snapshot); - assert.strictEqual(snapshot.resource.toString(), sessionUri.toString()); - assert.strictEqual((snapshot.state as SessionState).lifecycle, SessionLifecycle.Creating); - }); - - test('dispatchServerAction applies action and emits envelope', () => { - manager.createSession(makeSessionSummary()); - - const envelopes: ActionEnvelope[] = []; - disposables.add(manager.onDidEmitEnvelope(e => envelopes.push(e))); - - manager.dispatchServerAction({ - type: ActionType.SessionReady, - session: sessionUri, - }); - - const state = manager.getSessionState(sessionUri); - assert.ok(state); - assert.strictEqual(state.lifecycle, SessionLifecycle.Ready); - - assert.strictEqual(envelopes.length, 1); - assert.strictEqual(envelopes[0].action.type, ActionType.SessionReady); - assert.strictEqual(envelopes[0].serverSeq, 1); - assert.strictEqual(envelopes[0].origin, undefined); - }); - - test('serverSeq increments monotonically', () => { - manager.createSession(makeSessionSummary()); - - const envelopes: ActionEnvelope[] = []; - disposables.add(manager.onDidEmitEnvelope(e => envelopes.push(e))); - - manager.dispatchServerAction({ type: ActionType.SessionReady, session: sessionUri }); - manager.dispatchServerAction({ type: ActionType.SessionTitleChanged, session: sessionUri, title: 'Updated' }); - - assert.strictEqual(envelopes.length, 2); - assert.strictEqual(envelopes[0].serverSeq, 1); - assert.strictEqual(envelopes[1].serverSeq, 2); - assert.ok(envelopes[1].serverSeq > envelopes[0].serverSeq); - }); - - test('dispatchClientAction includes origin in envelope', () => { - manager.createSession(makeSessionSummary()); - - const envelopes: ActionEnvelope[] = []; - disposables.add(manager.onDidEmitEnvelope(e => envelopes.push(e))); - - const origin = { clientId: 'renderer-1', clientSeq: 42 }; - manager.dispatchClientAction( - { type: ActionType.SessionReady, session: sessionUri }, - origin, - ); - - assert.strictEqual(envelopes.length, 1); - assert.deepStrictEqual(envelopes[0].origin, origin); - }); - - test('removeSession clears state without notification', () => { - manager.createSession(makeSessionSummary()); - - const notifications: INotification[] = []; - disposables.add(manager.onDidEmitNotification(n => notifications.push(n))); - - manager.removeSession(sessionUri); - - assert.strictEqual(manager.getSessionState(sessionUri), undefined); - assert.strictEqual(manager.getSnapshot(sessionUri), undefined); - assert.strictEqual(notifications.length, 0); - }); - - test('deleteSession clears state and emits notification', () => { - manager.createSession(makeSessionSummary()); - - const notifications: INotification[] = []; - disposables.add(manager.onDidEmitNotification(n => notifications.push(n))); - - manager.deleteSession(sessionUri); - - assert.strictEqual(manager.getSessionState(sessionUri), undefined); - assert.strictEqual(manager.getSnapshot(sessionUri), undefined); - assert.strictEqual(notifications.length, 1); - assert.strictEqual(notifications[0].type, NotificationType.SessionRemoved); - }); - - test('createSession emits sessionAdded notification', () => { - const notifications: INotification[] = []; - disposables.add(manager.onDidEmitNotification(n => notifications.push(n))); - - manager.createSession(makeSessionSummary()); - - assert.strictEqual(notifications.length, 1); - assert.strictEqual(notifications[0].type, NotificationType.SessionAdded); - }); - - test('getActiveTurnId returns active turn id after turnStarted', () => { - manager.createSession(makeSessionSummary()); - manager.dispatchServerAction({ type: ActionType.SessionReady, session: sessionUri }); - - assert.strictEqual(manager.getActiveTurnId(sessionUri), undefined); - - manager.dispatchServerAction({ - type: ActionType.SessionTurnStarted, - session: sessionUri, - turnId: 'turn-1', - userMessage: { text: 'hello' }, - }); - - assert.strictEqual(manager.getActiveTurnId(sessionUri), 'turn-1'); - }); - - test('root state starts with activeSessions: 0', () => { - const snapshot = manager.getSnapshot(ROOT_STATE_URI); - assert.ok(snapshot); - const root = snapshot.state as { agents: unknown[]; activeSessions: number }; - assert.deepStrictEqual(root.agents, []); - assert.strictEqual(root.activeSessions, 0); - }); - - test('turnStarted dispatches root/activeSessionsChanged with correct count', () => { - manager.createSession(makeSessionSummary()); - manager.dispatchServerAction({ type: ActionType.SessionReady, session: sessionUri }); - - const envelopes: ActionEnvelope[] = []; - disposables.add(manager.onDidEmitEnvelope(e => envelopes.push(e))); - - manager.dispatchServerAction({ - type: ActionType.SessionTurnStarted, - session: sessionUri, - turnId: 'turn-1', - userMessage: { text: 'hello' }, - }); - - const activeChanged = envelopes.filter(e => e.action.type === ActionType.RootActiveSessionsChanged); - assert.strictEqual(activeChanged.length, 1); - assert.strictEqual((activeChanged[0].action as { activeSessions: number }).activeSessions, 1); - assert.strictEqual(manager.rootState.activeSessions, 1); - }); - - test('turnComplete dispatches root/activeSessionsChanged back to 0', () => { - manager.createSession(makeSessionSummary()); - manager.dispatchServerAction({ type: ActionType.SessionReady, session: sessionUri }); - manager.dispatchServerAction({ - type: ActionType.SessionTurnStarted, - session: sessionUri, - turnId: 'turn-1', - userMessage: { text: 'hello' }, - }); - - const envelopes: ActionEnvelope[] = []; - disposables.add(manager.onDidEmitEnvelope(e => envelopes.push(e))); - - manager.dispatchServerAction({ - type: ActionType.SessionTurnComplete, - session: sessionUri, - turnId: 'turn-1', - }); - - const activeChanged = envelopes.filter(e => e.action.type === ActionType.RootActiveSessionsChanged); - assert.strictEqual(activeChanged.length, 1); - assert.strictEqual((activeChanged[0].action as { activeSessions: number }).activeSessions, 0); - assert.strictEqual(manager.rootState.activeSessions, 0); - }); - - test('activeSessions reflects concurrent turn count across sessions', () => { - const session2Uri = URI.from({ scheme: 'copilot', path: '/test-session-2' }).toString(); - manager.createSession(makeSessionSummary(sessionUri)); - manager.createSession(makeSessionSummary(session2Uri)); - manager.dispatchServerAction({ type: ActionType.SessionReady, session: sessionUri }); - manager.dispatchServerAction({ type: ActionType.SessionReady, session: session2Uri }); - - manager.dispatchServerAction({ - type: ActionType.SessionTurnStarted, - session: sessionUri, - turnId: 'turn-1', - userMessage: { text: 'a' }, - }); - manager.dispatchServerAction({ - type: ActionType.SessionTurnStarted, - session: session2Uri, - turnId: 'turn-2', - userMessage: { text: 'b' }, - }); - assert.strictEqual(manager.rootState.activeSessions, 2); - - manager.dispatchServerAction({ - type: ActionType.SessionTurnComplete, - session: sessionUri, - turnId: 'turn-1', - }); - assert.strictEqual(manager.rootState.activeSessions, 1); - - manager.dispatchServerAction({ - type: ActionType.SessionTurnComplete, - session: session2Uri, - turnId: 'turn-2', - }); - assert.strictEqual(manager.rootState.activeSessions, 0); - }); - - test('restoreSession creates session in Ready state with pre-populated turns', () => { - const turns = [ - { - id: 'turn-1', - userMessage: { text: 'hello' }, - responseParts: [{ kind: ResponsePartKind.Markdown, id: 'p1', content: 'world' } satisfies MarkdownResponsePart], - usage: undefined, - state: TurnState.Complete, - }, - ]; - - const state = manager.restoreSession(makeSessionSummary(), turns); - assert.strictEqual(state.lifecycle, SessionLifecycle.Ready); - assert.strictEqual(state.turns.length, 1); - assert.strictEqual(state.turns[0].userMessage.text, 'hello'); - assert.strictEqual((state.turns[0].responseParts[0] as MarkdownResponsePart).content, 'world'); - }); - - test('restoreSession returns existing state for duplicate session', () => { - manager.createSession(makeSessionSummary()); - const existing = manager.getSessionState(sessionUri); - - const state = manager.restoreSession(makeSessionSummary(), []); - assert.strictEqual(state, existing); - }); - - test('restoreSession does not emit sessionAdded notification', () => { - const notifications: INotification[] = []; - disposables.add(manager.onDidEmitNotification(n => notifications.push(n))); - - manager.restoreSession(makeSessionSummary(), []); - - assert.strictEqual(notifications.length, 0, 'should not emit notification for restored sessions'); - }); - - test('emits sessionSummaryChanged when summary changes', () => { - return runWithFakedTimers({ useFakeTimers: true }, async () => { - manager.createSession(makeSessionSummary()); - manager.dispatchServerAction({ type: ActionType.SessionReady, session: sessionUri }); - - const notifications: INotification[] = []; - disposables.add(manager.onDidEmitNotification(n => notifications.push(n))); - - manager.dispatchServerAction({ type: ActionType.SessionTitleChanged, session: sessionUri, title: 'New Title' }); - - // Should not fire synchronously (debounced) - assert.strictEqual(notifications.filter(n => n.type === NotificationType.SessionSummaryChanged).length, 0); - - // Advance past debounce - await new Promise(r => setTimeout(r, 150)); - - const changed = notifications.filter(n => n.type === NotificationType.SessionSummaryChanged); - assert.strictEqual(changed.length, 1); - const notification = changed[0] as SessionSummaryChangedNotification; - assert.strictEqual(notification.session, sessionUri); - assert.strictEqual(notification.changes.title, 'New Title'); - assert.strictEqual(notification.changes.status, undefined, 'unchanged fields should be omitted'); - }); - }); - - test('coalesces multiple summary changes into one notification', () => { - return runWithFakedTimers({ useFakeTimers: true }, async () => { - manager.createSession(makeSessionSummary()); - manager.dispatchServerAction({ type: ActionType.SessionReady, session: sessionUri }); - - const notifications: INotification[] = []; - disposables.add(manager.onDidEmitNotification(n => notifications.push(n))); - - manager.dispatchServerAction({ type: ActionType.SessionTitleChanged, session: sessionUri, title: 'First' }); - manager.dispatchServerAction({ type: ActionType.SessionTitleChanged, session: sessionUri, title: 'Second' }); - - await new Promise(r => setTimeout(r, 150)); - - const changed = notifications.filter(n => n.type === NotificationType.SessionSummaryChanged); - assert.strictEqual(changed.length, 1, 'should coalesce into one notification'); - assert.strictEqual((changed[0] as SessionSummaryChangedNotification).changes.title, 'Second'); - }); - }); - - test('does not emit sessionSummaryChanged when summary is unchanged', () => { - return runWithFakedTimers({ useFakeTimers: true }, async () => { - manager.createSession(makeSessionSummary()); - manager.dispatchServerAction({ type: ActionType.SessionReady, session: sessionUri }); - - const notifications: INotification[] = []; - disposables.add(manager.onDidEmitNotification(n => notifications.push(n))); - - // SessionReady changes lifecycle, not summary — so no summary notification - await new Promise(r => setTimeout(r, 150)); - - const changed = notifications.filter(n => n.type === NotificationType.SessionSummaryChanged); - assert.strictEqual(changed.length, 0); - }); - }); - - test('does not emit sessionSummaryChanged for deleted session', () => { - return runWithFakedTimers({ useFakeTimers: true }, async () => { - manager.createSession(makeSessionSummary()); - manager.dispatchServerAction({ type: ActionType.SessionReady, session: sessionUri }); - - const notifications: INotification[] = []; - disposables.add(manager.onDidEmitNotification(n => notifications.push(n))); - - manager.dispatchServerAction({ type: ActionType.SessionTitleChanged, session: sessionUri, title: 'New Title' }); - manager.deleteSession(sessionUri); - - await new Promise(r => setTimeout(r, 150)); - - const changed = notifications.filter(n => n.type === NotificationType.SessionSummaryChanged); - assert.strictEqual(changed.length, 0, 'should not emit for deleted sessions'); - }); - }); -}); - -suite('Subagent URI helpers', () => { - - ensureNoDisposablesAreLeakedInTestSuite(); - - test('buildSubagentSessionUri creates correct URI', () => { - assert.strictEqual( - buildSubagentSessionUri('copilot:/session-1', 'tc-1'), - 'copilot:/session-1/subagent/tc-1', - ); - }); - - test('parseSubagentSessionUri extracts parent and toolCallId', () => { - const parsed = parseSubagentSessionUri('copilot:/session-1/subagent/tc-1'); - assert.deepStrictEqual(parsed, { - parentSession: 'copilot:/session-1', - toolCallId: 'tc-1', - }); - }); - - test('parseSubagentSessionUri returns undefined for non-subagent URIs', () => { - assert.strictEqual(parseSubagentSessionUri('copilot:/session-1'), undefined); - }); - - test('isSubagentSession identifies subagent URIs', () => { - assert.strictEqual(isSubagentSession('copilot:/session-1/subagent/tc-1'), true); - assert.strictEqual(isSubagentSession('copilot:/session-1'), false); - }); -}); diff --git a/src/vs/platform/agentHost/test/node/agentHostTerminalManager.test.ts b/src/vs/platform/agentHost/test/node/agentHostTerminalManager.test.ts deleted file mode 100644 index 3df40c8c..00000000 --- a/src/vs/platform/agentHost/test/node/agentHostTerminalManager.test.ts +++ /dev/null @@ -1,380 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import assert from 'assert'; -import { DisposableStore } from '../../../../base/common/lifecycle.js'; -import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { ActionType, StateAction } from '../../common/state/protocol/actions.js'; -import { TerminalContentPart } from '../../common/state/protocol/state.js'; -import { Osc633Event, Osc633EventType, Osc633Parser } from '../../node/osc633Parser.js'; - -/** - * Tests for the command detection integration in AgentHostTerminalManager. - * - * Since AgentHostTerminalManager.createTerminal requires node-pty, these tests - * exercise the data-handling logic (OSC parsing → action dispatch → content - * tracking) in isolation by simulating the internal flow. - */ - -// ── Helpers to simulate the terminal manager's data pipeline ───────── - -/** Minimal command tracker mirroring AgentHostTerminalManager's ICommandTracker. */ -interface ITestCommandTracker { - readonly parser: Osc633Parser; - readonly nonce: string; - commandCounter: number; - detectionAvailableEmitted: boolean; - pendingCommandLine?: string; - activeCommandId?: string; - activeCommandTimestamp?: number; -} - -/** - * Simplified version of AgentHostTerminalManager's data handling pipeline - * that can be tested without node-pty or a real AgentHostStateManager. - */ -class TestTerminalDataHandler { - readonly dispatched: StateAction[] = []; - content: TerminalContentPart[] = []; - cwd = '/home/user'; - - constructor( - readonly uri: string, - readonly tracker: ITestCommandTracker, - ) { } - - /** Simulates AgentHostTerminalManager._handlePtyData */ - handlePtyData(rawData: string): string { - const parseResult = this.tracker.parser.parse(rawData); - const cleanedData = parseResult.cleanedData; - - for (const event of parseResult.events) { - this._handleOsc633Event(event); - } - - if (cleanedData.length > 0) { - this._appendToContent(cleanedData); - } - - return cleanedData; - } - - private _handleOsc633Event(event: Osc633Event): void { - if (!this.tracker.detectionAvailableEmitted) { - this.tracker.detectionAvailableEmitted = true; - this.dispatched.push({ - type: ActionType.TerminalCommandDetectionAvailable, - terminal: this.uri, - }); - } - - switch (event.type) { - case Osc633EventType.CommandLine: { - if (event.nonce === this.tracker.nonce) { - this.tracker.pendingCommandLine = event.commandLine; - } - break; - } - case Osc633EventType.CommandExecuted: { - const commandId = `cmd-${++this.tracker.commandCounter}`; - const commandLine = this.tracker.pendingCommandLine ?? ''; - const timestamp = Date.now(); - this.tracker.pendingCommandLine = undefined; - this.tracker.activeCommandId = commandId; - this.tracker.activeCommandTimestamp = timestamp; - - this.content.push({ - type: 'command', - commandId, - commandLine, - output: '', - timestamp, - isComplete: false, - }); - - this.dispatched.push({ - type: ActionType.TerminalCommandExecuted, - terminal: this.uri, - commandId, - commandLine, - timestamp, - }); - break; - } - case Osc633EventType.CommandFinished: { - const finishedCommandId = this.tracker.activeCommandId; - if (!finishedCommandId) { - break; - } - const durationMs = this.tracker.activeCommandTimestamp !== undefined - ? Date.now() - this.tracker.activeCommandTimestamp - : undefined; - - for (const part of this.content) { - if (part.type === 'command' && part.commandId === finishedCommandId) { - part.isComplete = true; - part.exitCode = event.exitCode; - part.durationMs = durationMs; - break; - } - } - - this.tracker.activeCommandId = undefined; - this.tracker.activeCommandTimestamp = undefined; - - this.dispatched.push({ - type: ActionType.TerminalCommandFinished, - terminal: this.uri, - commandId: finishedCommandId, - exitCode: event.exitCode, - durationMs, - }); - break; - } - case Osc633EventType.Property: { - if (event.key === 'Cwd') { - this.cwd = event.value; - this.dispatched.push({ - type: ActionType.TerminalCwdChanged, - terminal: this.uri, - cwd: event.value, - }); - } - break; - } - } - } - - private _appendToContent(data: string): void { - const tail = this.content.length > 0 ? this.content[this.content.length - 1] : undefined; - if (tail && tail.type === 'command' && !tail.isComplete) { - tail.output += data; - } else if (tail && tail.type === 'unclassified') { - tail.value += data; - } else { - this.content.push({ type: 'unclassified', value: data }); - } - } -} - -function osc633(payload: string): string { - return `\x1b]633;${payload}\x07`; -} - -function createHandler(nonce = 'test-nonce'): TestTerminalDataHandler { - return new TestTerminalDataHandler('terminal://test', { - parser: new Osc633Parser(), - nonce, - commandCounter: 0, - detectionAvailableEmitted: false, - }); -} - -suite('AgentHostTerminalManager – command detection integration', () => { - - const disposables = new DisposableStore(); - teardown(() => disposables.clear()); - ensureNoDisposablesAreLeakedInTestSuite(); - - test('TerminalCommandDetectionAvailable is dispatched on first OSC 633', () => { - const handler = createHandler(); - - handler.handlePtyData(osc633('A')); - - assert.strictEqual(handler.dispatched.length, 1); - assert.strictEqual(handler.dispatched[0].type, ActionType.TerminalCommandDetectionAvailable); - }); - - test('TerminalCommandDetectionAvailable is dispatched only once', () => { - const handler = createHandler(); - - handler.handlePtyData(osc633('A')); - handler.handlePtyData(osc633('B')); - handler.handlePtyData(osc633('A')); - - const detectionActions = handler.dispatched.filter( - a => a.type === ActionType.TerminalCommandDetectionAvailable - ); - assert.strictEqual(detectionActions.length, 1); - }); - - test('full command lifecycle dispatches correct actions', () => { - const handler = createHandler(); - - // Shell prompt - handler.handlePtyData(`${osc633('A')}$ ${osc633('B')}`); - // Command entered, shell reports command line and executes - handler.handlePtyData(`${osc633('E;echo\\x20hello;test-nonce')}${osc633('C')}`); - // Command output - handler.handlePtyData('hello\r\n'); - // Command finishes - handler.handlePtyData(osc633('D;0')); - - const actions = handler.dispatched; - // Expect: DetectionAvailable, CommandExecuted, CommandFinished - assert.strictEqual(actions[0].type, ActionType.TerminalCommandDetectionAvailable); - - const executed = actions.find(a => a.type === ActionType.TerminalCommandExecuted); - assert.ok(executed); - assert.strictEqual(executed.commandId, 'cmd-1'); - assert.strictEqual(executed.commandLine, 'echo hello'); - - const finished = actions.find(a => a.type === ActionType.TerminalCommandFinished); - assert.ok(finished); - assert.strictEqual(finished.commandId, 'cmd-1'); - assert.strictEqual(finished.exitCode, 0); - }); - - test('content parts are structured correctly after command lifecycle', () => { - const handler = createHandler(); - - // Prompt output (before command) - handler.handlePtyData(`${osc633('A')}user@host:~ $ ${osc633('B')}`); - // Command line + execute - handler.handlePtyData(`${osc633('E;ls;test-nonce')}${osc633('C')}`); - // Command output - handler.handlePtyData('file1\nfile2\n'); - // Command finishes - handler.handlePtyData(osc633('D;0')); - // New prompt - handler.handlePtyData(`${osc633('A')}user@host:~ $ `); - - assert.deepStrictEqual(handler.content.map(p => ({ - type: p.type, - ...(p.type === 'unclassified' ? { value: p.value } : { - commandId: p.commandId, - commandLine: p.commandLine, - output: p.output, - isComplete: p.isComplete, - exitCode: p.exitCode, - }), - })), [ - { type: 'unclassified', value: 'user@host:~ $ ' }, - { - type: 'command', - commandId: 'cmd-1', - commandLine: 'ls', - output: 'file1\nfile2\n', - isComplete: true, - exitCode: 0, - }, - { type: 'unclassified', value: 'user@host:~ $ ' }, - ]); - }); - - test('nonce validation rejects untrusted command lines', () => { - const handler = createHandler('my-secret-nonce'); - - // Malicious output containing a fake command line with wrong nonce - handler.handlePtyData(osc633('E;rm\\x20-rf\\x20/;wrong-nonce')); - handler.handlePtyData(osc633('C')); - - const executed = handler.dispatched.find(a => a.type === ActionType.TerminalCommandExecuted); - assert.ok(executed); - // Command line should be empty because the nonce didn't match - assert.strictEqual(executed.commandLine, ''); - }); - - test('nonce validation accepts trusted command lines', () => { - const handler = createHandler('my-secret-nonce'); - - handler.handlePtyData(osc633('E;echo\\x20safe;my-secret-nonce')); - handler.handlePtyData(osc633('C')); - - const executed = handler.dispatched.find(a => a.type === ActionType.TerminalCommandExecuted); - assert.ok(executed); - assert.strictEqual(executed.commandLine, 'echo safe'); - }); - - test('multiple sequential commands get sequential IDs', () => { - const handler = createHandler(); - - // First command - handler.handlePtyData(`${osc633('E;cmd1;test-nonce')}${osc633('C')}`); - handler.handlePtyData(osc633('D;0')); - - // Second command - handler.handlePtyData(`${osc633('E;cmd2;test-nonce')}${osc633('C')}`); - handler.handlePtyData(osc633('D;1')); - - const executed = handler.dispatched.filter(a => a.type === ActionType.TerminalCommandExecuted); - assert.strictEqual(executed.length, 2); - assert.strictEqual(executed[0].commandId, 'cmd-1'); - assert.strictEqual(executed[0].commandLine, 'cmd1'); - assert.strictEqual(executed[1].commandId, 'cmd-2'); - assert.strictEqual(executed[1].commandLine, 'cmd2'); - - const finished = handler.dispatched.filter(a => a.type === ActionType.TerminalCommandFinished); - assert.strictEqual(finished.length, 2); - assert.strictEqual(finished[0].commandId, 'cmd-1'); - assert.strictEqual(finished[0].exitCode, 0); - assert.strictEqual(finished[1].commandId, 'cmd-2'); - assert.strictEqual(finished[1].exitCode, 1); - }); - - test('CWD property dispatches TerminalCwdChanged', () => { - const handler = createHandler(); - - handler.handlePtyData(osc633('P;Cwd=/new/working/dir')); - - const cwdAction = handler.dispatched.find(a => a.type === ActionType.TerminalCwdChanged); - assert.ok(cwdAction); - assert.strictEqual(cwdAction.cwd, '/new/working/dir'); - assert.strictEqual(handler.cwd, '/new/working/dir'); - }); - - test('OSC 633 sequences are stripped from cleaned output', () => { - const handler = createHandler(); - - const cleaned = handler.handlePtyData( - `before${osc633('A')}prompt${osc633('B')}${osc633('E;ls;test-nonce')}${osc633('C')}output${osc633('D;0')}after` - ); - - assert.strictEqual(cleaned, 'beforepromptoutputafter'); - }); - - test('data without shell integration passes through unmodified', () => { - const handler = new TestTerminalDataHandler('terminal://test', { - parser: new Osc633Parser(), - nonce: 'nonce', - commandCounter: 0, - detectionAvailableEmitted: false, - }); - - const data = 'regular terminal output with \x1b[31mcolors\x1b[0m'; - const cleaned = handler.handlePtyData(data); - - assert.strictEqual(cleaned, data); - assert.deepStrictEqual(handler.content, [ - { type: 'unclassified', value: data }, - ]); - assert.deepStrictEqual(handler.dispatched, []); - }); - - test('CommandFinished without active command is ignored', () => { - const handler = createHandler(); - - // Emit a PromptStart to trigger detection available, then finish without execute - handler.handlePtyData(osc633('A')); - handler.handlePtyData(osc633('D;0')); - - const finished = handler.dispatched.filter(a => a.type === ActionType.TerminalCommandFinished); - assert.strictEqual(finished.length, 0); - }); - - test('command output is accumulated in the command content part', () => { - const handler = createHandler(); - - handler.handlePtyData(`${osc633('E;test;test-nonce')}${osc633('C')}`); - handler.handlePtyData('line1\r\n'); - handler.handlePtyData('line2\r\n'); - handler.handlePtyData('line3\r\n'); - handler.handlePtyData(osc633('D;0')); - - const cmdParts = handler.content.filter(p => p.type === 'command'); - assert.strictEqual(cmdParts.length, 1); - assert.strictEqual(cmdParts[0].type === 'command' && cmdParts[0].output, 'line1\r\nline2\r\nline3\r\n'); - }); -}); diff --git a/src/vs/platform/agentHost/test/node/agentPluginManager.test.ts b/src/vs/platform/agentHost/test/node/agentPluginManager.test.ts deleted file mode 100644 index 7ac15fc5..00000000 --- a/src/vs/platform/agentHost/test/node/agentPluginManager.test.ts +++ /dev/null @@ -1,175 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import assert from 'assert'; -import { VSBuffer } from '../../../../base/common/buffer.js'; -import { DisposableStore } from '../../../../base/common/lifecycle.js'; -import { Schemas } from '../../../../base/common/network.js'; -import { URI } from '../../../../base/common/uri.js'; -import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { FileService } from '../../../files/common/fileService.js'; -import { InMemoryFileSystemProvider } from '../../../files/common/inMemoryFilesystemProvider.js'; -import { NullLogService } from '../../../log/common/log.js'; -import { AGENT_CLIENT_SCHEME, toAgentClientUri } from '../../common/agentClientUri.js'; -import { CustomizationStatus, type CustomizationRef, type SessionCustomization } from '../../common/state/sessionState.js'; -import { AgentPluginManager } from '../../node/agentPluginManager.js'; - -suite('AgentPluginManager', () => { - - const disposables = new DisposableStore(); - let fileService: FileService; - let manager: AgentPluginManager; - const basePath = URI.from({ scheme: Schemas.inMemory, path: '/userData' }); - - setup(() => { - fileService = disposables.add(new FileService(new NullLogService())); - disposables.add(fileService.registerProvider(Schemas.inMemory, disposables.add(new InMemoryFileSystemProvider()))); - disposables.add(fileService.registerProvider(AGENT_CLIENT_SCHEME, disposables.add(new InMemoryFileSystemProvider()))); - manager = new AgentPluginManager(basePath, fileService, new NullLogService()); - }); - - teardown(() => disposables.clear()); - ensureNoDisposablesAreLeakedInTestSuite(); - - function pluginUri(name: string): string { - return URI.from({ scheme: Schemas.inMemory, path: `/plugins/${name}` }).toString(); - } - - function makeRef(name: string, nonce?: string): CustomizationRef { - return { uri: pluginUri(name), displayName: `Plugin ${name}`, nonce }; - } - - async function seedPluginDir(name: string, files: Record): Promise { - const originalUri = URI.from({ scheme: Schemas.inMemory, path: `/plugins/${name}` }); - const agentClientDir = toAgentClientUri(originalUri, 'test-client'); - await fileService.createFolder(agentClientDir); - for (const [fileName, content] of Object.entries(files)) { - await fileService.writeFile(URI.joinPath(agentClientDir, fileName), VSBuffer.fromString(content)); - } - } - - // ---- syncCustomizations ------------------------------------------------- - - suite('syncCustomizations', () => { - - test('returns loaded status and pluginDir for each synced plugin', async () => { - await seedPluginDir('alpha', { 'index.js': 'a' }); - await seedPluginDir('beta', { 'index.js': 'b' }); - - const results = await manager.syncCustomizations('test-client', [ - makeRef('alpha', 'n1'), - makeRef('beta', 'n2'), - ]); - assert.strictEqual(results[0].customization.status, CustomizationStatus.Loaded); - assert.ok(results[0].pluginDir, 'should have pluginDir'); - assert.strictEqual(results[1].customization.status, CustomizationStatus.Loaded); - assert.ok(results[1].pluginDir, 'should have pluginDir'); - }); - - test('returns error status without pluginDir when source missing', async () => { - const results = await manager.syncCustomizations('test-client', [makeRef('nonexistent')]); - - assert.strictEqual(results.length, 1); - assert.strictEqual(results[0].customization.status, CustomizationStatus.Error); - assert.ok(results[0].customization.statusMessage); - assert.strictEqual(results[0].pluginDir, undefined); - }); - - test('mixes loaded and error results', async () => { - await seedPluginDir('good', { 'index.js': 'ok' }); - - const results = await manager.syncCustomizations('test-client', [ - makeRef('good', 'n1'), - makeRef('missing'), - ]); - assert.strictEqual(results[1].customization.status, CustomizationStatus.Error); - assert.strictEqual(results[1].pluginDir, undefined); - }); - - test('fires progress callback with loading, then loaded', async () => { - await seedPluginDir('prog', { 'index.js': 'content' }); - - const progressCalls: SessionCustomization[][] = []; - await manager.syncCustomizations('test-client', [makeRef('prog', 'n1')], statuses => { - progressCalls.push(statuses); - }); - - // At least two calls: initial loading + final loaded - assert.ok(progressCalls.length >= 2, `expected at least 2 progress calls, got ${progressCalls.length}`); - assert.strictEqual(progressCalls[0][0].status, CustomizationStatus.Loading); - assert.strictEqual(progressCalls[progressCalls.length - 1][0].status, CustomizationStatus.Loaded); - }); - - test('skips copy when nonce matches', async () => { - await seedPluginDir('cached', { 'index.js': 'v1' }); - const ref = makeRef('cached', 'nonce-abc'); - - const result1 = await manager.syncCustomizations('test-client', [ref]); - assert.ok(result1[0].pluginDir); - - // Second sync with same nonce should still succeed (from cache) - const result2 = await manager.syncCustomizations('test-client', [ref]); - assert.ok(result2[0].pluginDir); - assert.strictEqual(result1[0].pluginDir!.toString(), result2[0].pluginDir!.toString()); - }); - - test('serializes concurrent syncs of the same URI', async () => { - await seedPluginDir('concurrent', { 'index.js': 'v1' }); - const ref = makeRef('concurrent', 'n1'); - - // Fire two syncs concurrently - const [r1, r2] = await Promise.all([ - manager.syncCustomizations('test-client', [ref]), - manager.syncCustomizations('test-client', [ref]), - ]); - - // Both should succeed without error - assert.strictEqual(r1[0].customization.status, CustomizationStatus.Loaded); - assert.strictEqual(r2[0].customization.status, CustomizationStatus.Loaded); - }); - }); - - // ---- LRU eviction ------------------------------------------------------- - - suite('LRU eviction', () => { - - test('evicts least recently used plugins when limit exceeded', async () => { - const smallManager = new AgentPluginManager(basePath, fileService, new NullLogService(), 3); - - for (let i = 1; i <= 4; i++) { - await seedPluginDir(`plugin-${i}`, { 'index.js': `p${i}` }); - await smallManager.syncCustomizations('test-client', [makeRef(`plugin-${i}`, `n${i}`)]); - } - - // The evicted dir should no longer exist on disk (cache.json + 3 plugin dirs) - const evictedDir = URI.joinPath(basePath, 'agentPlugins'); - const listing = await fileService.resolve(evictedDir); - assert.ok(listing.children); - const pluginDirs = listing.children.filter(c => c.isDirectory); - assert.strictEqual(pluginDirs.length, 3, 'should have exactly 3 plugin dirs after eviction'); - }); - }); - - // ---- cache persistence -------------------------------------------------- - - suite('cache persistence', () => { - - test('restores nonce cache from disk on new manager instance', async () => { - await seedPluginDir('persist1', { 'index.js': 'v1' }); - const ref = makeRef('persist1', 'nonce-persist'); - - // Sync with first manager - await manager.syncCustomizations('test-client', [ref]); - - // Create a new manager pointing to the same base path - const manager2 = new AgentPluginManager(basePath, fileService, new NullLogService()); - const result = await manager2.syncCustomizations('test-client', [ref]); - - // Should be loaded from cache (nonce match), not error - assert.strictEqual(result[0].customization.status, CustomizationStatus.Loaded); - assert.ok(result[0].pluginDir); - }); - }); -}); diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts deleted file mode 100644 index 0a6edeba..00000000 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ /dev/null @@ -1,1065 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import assert from 'assert'; -import { mkdtempSync, readFileSync, rmSync } from 'fs'; -import { tmpdir } from 'os'; -import { fileURLToPath } from 'url'; -import { VSBuffer } from '../../../../base/common/buffer.js'; -import { DisposableStore, IReference, toDisposable } from '../../../../base/common/lifecycle.js'; -import { Schemas } from '../../../../base/common/network.js'; -import { joinPath } from '../../../../base/common/resources.js'; -import { URI } from '../../../../base/common/uri.js'; -import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { hasKey } from '../../../../base/common/types.js'; -import { NullLogService } from '../../../log/common/log.js'; -import { FileService } from '../../../files/common/fileService.js'; -import { InMemoryFileSystemProvider } from '../../../files/common/inMemoryFilesystemProvider.js'; -import { AgentSession } from '../../common/agentService.js'; -import { ISessionDatabase, ISessionDataService } from '../../common/sessionDataService.js'; -import { SessionDatabase } from '../../node/sessionDatabase.js'; -import { ActionType, ActionEnvelope } from '../../common/state/sessionActions.js'; -import { SessionActiveClient, ResponsePartKind, SessionLifecycle, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, TurnState, buildSubagentSessionUri, type MarkdownResponsePart, type ToolCallCompletedState, type ToolCallResponsePart } from '../../common/state/sessionState.js'; -import { IProductService } from '../../../product/common/productService.js'; -import { AgentService } from '../../node/agentService.js'; -import { MockAgent, ScriptedMockAgent } from './mockAgent.js'; -import { mapSessionEventsToHistoryRecords } from './historyRecordFixtures.js'; -import { type ISessionEvent } from '../../node/copilot/mapSessionEvents.js'; -import { createNoopGitService, createSessionDataService } from '../common/sessionTestHelpers.js'; - -/** - * Loads a JSONL fixture of raw Copilot SDK events, runs them through - * {@link mapSessionEventsToHistoryRecords}, and returns the result - * suitable for setting on {@link MockAgent.sessionMessages}. Tests the - * full pipeline: SDK events → IHistoryRecord → buildTurnsFromHistory → - * Turn[]. - * - * Fixture files live in `test-cases/` and are sanitized copies of real - * `events.jsonl` files from `~/.copilot/session-state/`. - */ -async function loadFixtureMessages(fixtureName: string, session: URI) { - // Resolve the fixture from the source tree (test-cases/ is not compiled to out/) - const thisFile = fileURLToPath(import.meta.url); - // Navigate from out/vs/... to src/vs/... by replacing the out/ prefix. - // Use a regex that handles both / and \ separators for Windows compat. - const srcFile = thisFile.replace(/[/\\]out[/\\]/, (m) => m.replace('out', 'src')); - const lastSep = Math.max(srcFile.lastIndexOf('/'), srcFile.lastIndexOf('\\')); - const fixtureDir = srcFile.substring(0, lastSep); - const sep = srcFile.includes('\\') ? '\\' : '/'; - const raw = readFileSync(`${fixtureDir}${sep}test-cases${sep}${fixtureName}`, 'utf-8'); - const events: ISessionEvent[] = raw.trim().split('\n').map(line => JSON.parse(line)); - return mapSessionEventsToHistoryRecords(session, undefined, events); -} - -suite('AgentService (node dispatcher)', () => { - - const disposables = new DisposableStore(); - let service: AgentService; - let copilotAgent: MockAgent; - let fileService: FileService; - let nullSessionDataService: ISessionDataService; - - setup(async () => { - nullSessionDataService = { - _serviceBrand: undefined, - getSessionDataDir: () => URI.parse('inmemory:/session-data'), - getSessionDataDirById: () => URI.parse('inmemory:/session-data'), - openDatabase: () => { throw new Error('not implemented'); }, - tryOpenDatabase: async () => undefined, - deleteSessionData: async () => { }, - cleanupOrphanedData: async () => { }, - whenIdle: async () => { }, - }; - - fileService = disposables.add(new FileService(new NullLogService())); - disposables.add(fileService.registerProvider(Schemas.inMemory, disposables.add(new InMemoryFileSystemProvider()))); - - // Seed a directory for browseDirectory tests - await fileService.createFolder(URI.from({ scheme: Schemas.inMemory, path: '/testDir' })); - await fileService.writeFile(URI.from({ scheme: Schemas.inMemory, path: '/testDir/file.txt' }), VSBuffer.fromString('hello')); - - service = disposables.add(new AgentService(new NullLogService(), fileService, nullSessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService())); - copilotAgent = new MockAgent('copilot'); - disposables.add(toDisposable(() => copilotAgent.dispose())); - }); - - teardown(() => disposables.clear()); - ensureNoDisposablesAreLeakedInTestSuite(); - - // ---- Provider registration ------------------------------------------ - - suite('registerProvider', () => { - - test('registers a provider successfully', () => { - service.registerProvider(copilotAgent); - // No throw - success - }); - - test('throws on duplicate provider registration', () => { - service.registerProvider(copilotAgent); - const duplicate = new MockAgent('copilot'); - disposables.add(toDisposable(() => duplicate.dispose())); - assert.throws(() => service.registerProvider(duplicate), /already registered/); - }); - - test('maps progress events to protocol actions via onDidAction', async () => { - service.registerProvider(copilotAgent); - const session = await service.createSession({ provider: 'copilot' }); - - // Start a turn so there's an active turn to map events to - service.dispatchAction( - { type: ActionType.SessionTurnStarted, session: session.toString(), turnId: 'turn-1', userMessage: { text: 'hello' } }, - 'test-client', 1, - ); - - const envelopes: ActionEnvelope[] = []; - disposables.add(service.onDidAction(e => envelopes.push(e))); - - copilotAgent.fireProgress({ - kind: 'action', session, - action: { type: ActionType.SessionResponsePart, session: session.toString(), turnId: 'turn-1', part: { kind: ResponsePartKind.Markdown, id: 'msg-1', content: 'hello' } }, - }); - assert.ok(envelopes.some(e => e.action.type === ActionType.SessionResponsePart)); - }); - }); - - // ---- createSession -------------------------------------------------- - - suite('dispatchAction', () => { - - test('applies and persists root config changes from clients', async () => { - const tempDir = URI.file(mkdtempSync(`${tmpdir()}/agent-host-config-`)); - try { - const rootConfigResource = joinPath(tempDir, 'agent-host-config.json'); - const svc = disposables.add(new AgentService(new NullLogService(), fileService, nullSessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService(), rootConfigResource)); - const agent = new MockAgent('copilot'); - disposables.add(toDisposable(() => agent.dispose())); - svc.registerProvider(agent); - - const customization = { uri: 'file:///plugin-a', displayName: 'Plugin A' }; - svc.dispatchAction({ - type: ActionType.RootConfigChanged, - config: { customizations: [customization] }, - }, 'test-client', 1); - - let persisted = false; - for (let attempt = 0; attempt < 20; attempt++) { - try { - const parsed = JSON.parse(readFileSync(rootConfigResource.fsPath, 'utf8')); - assert.deepStrictEqual( - parsed.customizations, - [customization], - ); - persisted = true; - break; - } catch { - // Wait for the serialized root-config write to complete. - } - if (attempt === 19) { - break; - } - await new Promise(resolve => setTimeout(resolve, 5)); - } - - assert.ok(persisted, 'should persist the root config change'); - } finally { - rmSync(tempDir.fsPath, { recursive: true, force: true }); - } - }); - }); - - suite('createSession', () => { - - test('creates session via specified provider', async () => { - service.registerProvider(copilotAgent); - - const session = await service.createSession({ provider: 'copilot' }); - assert.strictEqual(AgentSession.provider(session), 'copilot'); - }); - - test('honors requested session URI', async () => { - service.registerProvider(copilotAgent); - - const requestedSession = AgentSession.uri('copilot', 'requested-session'); - const session = await service.createSession({ provider: 'copilot', session: requestedSession }); - assert.strictEqual(session.toString(), requestedSession.toString()); - }); - - test('scripted mock agent honors requested session URI', async () => { - const agent = new ScriptedMockAgent(); - disposables.add(toDisposable(() => agent.dispose())); - - const requestedSession = AgentSession.uri('mock', 'requested-session'); - const result = await agent.createSession({ session: requestedSession }); - const sessions = await agent.listSessions(); - - assert.deepStrictEqual({ - created: result.session.toString(), - listed: sessions.some(s => s.session.toString() === requestedSession.toString()), - }, { - created: requestedSession.toString(), - listed: true, - }); - }); - - test('uses default provider when none specified', async () => { - service.registerProvider(copilotAgent); - - const session = await service.createSession(); - assert.strictEqual(AgentSession.provider(session), 'copilot'); - }); - - test('throws when no providers are registered at all', async () => { - await assert.rejects(() => service.createSession(), /No agent provider/); - }); - }); - - // ---- disposeSession ------------------------------------------------- - - suite('disposeSession', () => { - - test('dispatches to the correct provider and cleans up tracking', async () => { - service.registerProvider(copilotAgent); - - const session = await service.createSession({ provider: 'copilot' }); - await service.disposeSession(session); - - assert.strictEqual(copilotAgent.disposeSessionCalls.length, 1); - }); - - test('is a no-op for unknown sessions', async () => { - service.registerProvider(copilotAgent); - const unknownSession = URI.from({ scheme: 'unknown', path: '/nope' }); - - // Should not throw - await service.disposeSession(unknownSession); - }); - }); - - // ---- listSessions / listModels -------------------------------------- - - suite('aggregation', () => { - - test('listSessions aggregates sessions from all providers', async () => { - service.registerProvider(copilotAgent); - - await service.createSession({ provider: 'copilot' }); - - const sessions = await service.listSessions(); - assert.strictEqual(sessions.length, 1); - }); - - test('listSessions overlays custom title from session database', async () => { - // Pre-seed a custom title in an in-memory database - const db = disposables.add(await SessionDatabase.open(':memory:')); - await db.setMetadata('customTitle', 'My Custom Title'); - - const sessionId = 'test-session-abc'; - const sessionUri = AgentSession.uri('copilot', sessionId); - - const sessionDataService: ISessionDataService = { - _serviceBrand: undefined, - getSessionDataDir: () => URI.parse('inmemory:/session-data'), - getSessionDataDirById: () => URI.parse('inmemory:/session-data'), - openDatabase: (): IReference => ({ - object: db, - dispose: () => { }, - }), - tryOpenDatabase: async (): Promise | undefined> => ({ - object: db, - dispose: () => { }, - }), - deleteSessionData: async () => { }, - cleanupOrphanedData: async () => { }, - whenIdle: async () => { }, - }; - - // Create a mock that returns a session with that ID - const agent = new MockAgent('copilot'); - disposables.add(toDisposable(() => agent.dispose())); - agent.sessionMetadataOverrides = { summary: 'SDK Title' }; - // Manually add the session to the mock - (agent as unknown as { _sessions: Map })._sessions.set(sessionId, sessionUri); - - const svc = disposables.add(new AgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService())); - svc.registerProvider(agent); - - const sessions = await svc.listSessions(); - assert.strictEqual(sessions.length, 1); - assert.strictEqual(sessions[0].summary, 'My Custom Title'); - }); - - test('listSessions uses SDK title when no custom title exists', async () => { - service.registerProvider(copilotAgent); - copilotAgent.sessionMetadataOverrides = { summary: 'Auto-generated Title' }; - - await service.createSession({ provider: 'copilot' }); - - const sessions = await service.listSessions(); - assert.strictEqual(sessions.length, 1); - assert.strictEqual(sessions[0].summary, 'Auto-generated Title'); - }); - - test('listSessions overlays live state manager title over SDK title', async () => { - service.registerProvider(copilotAgent); - - const session = await service.createSession({ provider: 'copilot' }); - - // Simulate immediate title change via state manager - service.stateManager.dispatchServerAction({ - type: ActionType.SessionTitleChanged, - session: session.toString(), - title: 'User first message', - }); - - const sessions = await service.listSessions(); - assert.strictEqual(sessions.length, 1); - assert.strictEqual(sessions[0].summary, 'User first message'); - }); - - test('createSession attaches git state into state _meta when working directory is present', async () => { - const workingDirectory = URI.file('/workspace/repo'); - const gitState = { - hasGitHubRemote: true, - branchName: 'feature/x', - baseBranchName: 'main', - upstreamBranchName: 'origin/feature/x', - incomingChanges: 1, - outgoingChanges: 2, - uncommittedChanges: 3, - }; - const calls: string[] = []; - const gitService = { - _serviceBrand: undefined, - isInsideWorkTree: async () => true, - getCurrentBranch: async () => undefined, - getDefaultBranch: async () => undefined, - getBranches: async () => [], - getRepositoryRoot: async () => undefined, - getWorktreeRoots: async () => [], - addWorktree: async () => { }, - addExistingWorktree: async () => { }, - removeWorktree: async () => { }, - branchExists: async () => false, - hasUncommittedChanges: async () => false, - getSessionGitState: async (uri: URI) => { calls.push(uri.fsPath); return gitState; }, - computeSessionFileDiffs: async () => undefined, - showBlob: async () => undefined, - }; - const localService = disposables.add(new AgentService(new NullLogService(), fileService, nullSessionDataService, { _serviceBrand: undefined } as IProductService, gitService)); - const agent = new MockAgent('copilot'); - disposables.add(toDisposable(() => agent.dispose())); - agent.resolvedWorkingDirectory = workingDirectory; - agent.sessionMetadataOverrides = { workingDirectory }; - localService.registerProvider(agent); - - const session = await localService.createSession({ provider: 'copilot' }); - - // _attachGitState is fire-and-forget; drain microtasks until the - // git service's promise has resolved and setSessionMeta has run. - for (let i = 0; i < 5; i++) { - await Promise.resolve(); - } - - const sessions = await localService.listSessions(); - assert.strictEqual(sessions.length, 1); - assert.deepStrictEqual(calls, [workingDirectory.fsPath]); - assert.deepStrictEqual( - localService.stateManager.getSessionState(session.toString())?._meta, - { git: gitState }, - ); - }); - - test('createSession skips git overlay when no working directory or no git state', async () => { - const gitService = { - _serviceBrand: undefined, - isInsideWorkTree: async () => false, - getCurrentBranch: async () => undefined, - getDefaultBranch: async () => undefined, - getBranches: async () => [], - getRepositoryRoot: async () => undefined, - getWorktreeRoots: async () => [], - addWorktree: async () => { }, - addExistingWorktree: async () => { }, - removeWorktree: async () => { }, - branchExists: async () => false, - hasUncommittedChanges: async () => false, - getSessionGitState: async () => undefined, - computeSessionFileDiffs: async () => undefined, - showBlob: async () => undefined, - }; - const localService = disposables.add(new AgentService(new NullLogService(), fileService, nullSessionDataService, { _serviceBrand: undefined } as IProductService, gitService)); - const agent = new MockAgent('copilot'); - disposables.add(toDisposable(() => agent.dispose())); - // No resolvedWorkingDirectory set on the mock. - localService.registerProvider(agent); - - const session = await localService.createSession({ provider: 'copilot' }); - for (let i = 0; i < 5; i++) { - await Promise.resolve(); - } - const sessions = await localService.listSessions(); - - assert.strictEqual(sessions.length, 1); - assert.strictEqual(localService.stateManager.getSessionState(session.toString())?._meta, undefined); - }); - - test('subscribe lazily attaches git state when an existing session has no _meta.git', async () => { - // Regression test: previously AgentService was constructed without - // a git service, so _attachGitState always bailed and `_meta.git` - // was never populated. This test ensures the lazy-fire path on - // subscribe() actually invokes the git service and writes git - // state into the session's `_meta`. - const workingDirectory = URI.file('/workspace/repo'); - const gitState = { - hasGitHubRemote: false, - branchName: 'feature/lazy', - baseBranchName: 'main', - upstreamBranchName: undefined, - incomingChanges: 0, - outgoingChanges: 0, - uncommittedChanges: 0, - }; - const calls: string[] = []; - const gitService = createNoopGitService(); - gitService.getSessionGitState = async (uri: URI) => { calls.push(uri.fsPath); return gitState; }; - const localService = disposables.add(new AgentService(new NullLogService(), fileService, nullSessionDataService, { _serviceBrand: undefined } as IProductService, gitService)); - const agent = new MockAgent('copilot'); - disposables.add(toDisposable(() => agent.dispose())); - agent.resolvedWorkingDirectory = workingDirectory; - agent.sessionMetadataOverrides = { workingDirectory }; - localService.registerProvider(agent); - - // Seed a session and clear its _meta so subscribe must lazily - // recompute git state. - const session = await localService.createSession({ provider: 'copilot' }); - for (let i = 0; i < 5; i++) { - await Promise.resolve(); - } - localService.stateManager.setSessionMeta(session.toString(), undefined); - calls.length = 0; - - await localService.subscribe(session, 'client-1'); - for (let i = 0; i < 5; i++) { - await Promise.resolve(); - } - - assert.deepStrictEqual(calls, [workingDirectory.fsPath]); - assert.deepStrictEqual( - localService.stateManager.getSessionState(session.toString())?._meta, - { git: gitState }, - ); - }); - - test('createSession stores live session config', async () => { - service.registerProvider(copilotAgent); - - const config = { isolation: 'worktree', branch: 'feature/config' }; - const session = await service.createSession({ provider: 'copilot', config }); - - assert.deepStrictEqual(service.stateManager.getSessionState(session.toString())?.config?.values, config); - }); - - test('seeds activeClient into the initial session state when provided', async () => { - service.registerProvider(copilotAgent); - - const envelopes: ActionEnvelope[] = []; - disposables.add(service.onDidAction(env => envelopes.push(env))); - - const activeClient: SessionActiveClient = { - clientId: 'client-eager', - tools: [{ name: 't1', description: 'd', inputSchema: { type: 'object' } }], - customizations: [{ uri: 'file:///plugin-a', displayName: 'A' }], - }; - const session = await service.createSession({ provider: 'copilot', activeClient }); - - assert.deepStrictEqual({ - activeClient: service.stateManager.getSessionState(session.toString())?.activeClient, - dispatchedActiveClientChanged: envelopes.some(e => e.action.type === ActionType.SessionActiveClientChanged), - }, { - activeClient, - dispatchedActiveClientChanged: false, - }); - }); - - test('omits activeClient from the initial session state when not provided', async () => { - service.registerProvider(copilotAgent); - - const session = await service.createSession({ provider: 'copilot' }); - - assert.strictEqual(service.stateManager.getSessionState(session.toString())?.activeClient, undefined); - }); - }); - - // ---- authenticate --------------------------------------------------- - - suite('authenticate', () => { - - test('routes token to provider matching the resource', async () => { - service.registerProvider(copilotAgent); - - const result = await service.authenticate({ resource: 'https://api.github.com', token: 'ghp_test123' }); - - assert.deepStrictEqual(result, { authenticated: true }); - assert.deepStrictEqual(copilotAgent.authenticateCalls, [{ resource: 'https://api.github.com', token: 'ghp_test123' }]); - }); - - test('returns not authenticated for unknown resource', async () => { - service.registerProvider(copilotAgent); - - const result = await service.authenticate({ resource: 'https://unknown.example.com', token: 'tok' }); - - assert.deepStrictEqual(result, { authenticated: false }); - assert.strictEqual(copilotAgent.authenticateCalls.length, 0); - }); - }); - - // ---- shutdown ------------------------------------------------------- - - suite('shutdown', () => { - - test('shuts down all providers', async () => { - let copilotShutdown = false; - copilotAgent.shutdown = async () => { copilotShutdown = true; }; - - service.registerProvider(copilotAgent); - - await service.shutdown(); - assert.ok(copilotShutdown); - }); - }); - - // ---- restoreSession ------------------------------------------------- - - suite('restoreSession', () => { - - test('restores a session with message history', async () => { - service.registerProvider(copilotAgent); - const { session } = await copilotAgent.createSession(); - const sessions = await copilotAgent.listSessions(); - const sessionResource = sessions[0].session; - - copilotAgent.sessionMessages = [ - { type: 'message', session, role: 'user', messageId: 'msg-1', content: 'Hello', toolRequests: [] }, - { type: 'message', session, role: 'assistant', messageId: 'msg-2', content: 'Hi there!', toolRequests: [] }, - ]; - - await service.restoreSession(sessionResource); - - const state = service.stateManager.getSessionState(sessionResource.toString()); - assert.ok(state, 'session should be in state manager'); - assert.strictEqual(state!.lifecycle, SessionLifecycle.Ready); - assert.strictEqual(state!.turns.length, 1); - assert.strictEqual(state!.turns[0].userMessage.text, 'Hello'); - const mdPart = state!.turns[0].responseParts.find((p): p is MarkdownResponsePart => p.kind === ResponsePartKind.Markdown); - assert.ok(mdPart); - assert.strictEqual(mdPart.content, 'Hi there!'); - assert.strictEqual(state!.turns[0].state, TurnState.Complete); - }); - - test('restores a session with tool calls', async () => { - service.registerProvider(copilotAgent); - const { session } = await copilotAgent.createSession(); - const sessions = await copilotAgent.listSessions(); - const sessionResource = sessions[0].session; - - copilotAgent.sessionMessages = [ - { type: 'message', session, role: 'user', messageId: 'msg-1', content: 'Run a command', toolRequests: [] }, - { type: 'message', session, role: 'assistant', messageId: 'msg-2', content: 'I will run a command.', toolRequests: [{ toolCallId: 'tc-1', name: 'shell' }] }, - { type: 'tool_start', session, toolCallId: 'tc-1', toolName: 'shell', displayName: 'Shell', invocationMessage: 'Running command...' }, - { type: 'tool_complete', session, toolCallId: 'tc-1', result: { success: true, pastTenseMessage: 'Ran command', content: [{ type: ToolResultContentType.Text, text: 'output' }] } }, - { type: 'message', session, role: 'assistant', messageId: 'msg-3', content: 'Done!', toolRequests: [] }, - ]; - - await service.restoreSession(sessionResource); - - const state = service.stateManager.getSessionState(sessionResource.toString()); - assert.ok(state); - const turn = state!.turns[0]; - const toolCallParts = turn.responseParts.filter((p): p is ToolCallResponsePart => p.kind === ResponsePartKind.ToolCall); - assert.strictEqual(toolCallParts.length, 1); - const tc = toolCallParts[0].toolCall as ToolCallCompletedState; - assert.strictEqual(tc.status, ToolCallStatus.Completed); - assert.strictEqual(tc.toolCallId, 'tc-1'); - assert.strictEqual(tc.confirmed, ToolCallConfirmationReason.NotNeeded); - }); - - test('interleaves reasoning, markdown, and tool calls in stream order on resume', async () => { - service.registerProvider(copilotAgent); - const { session } = await copilotAgent.createSession(); - const sessions = await copilotAgent.listSessions(); - const sessionResource = sessions[0].session; - - copilotAgent.sessionMessages = [ - { type: 'message', session, role: 'user', messageId: 'u-1', content: 'Hello', toolRequests: [] }, - { type: 'message', session, role: 'assistant', messageId: 'a-1', content: 'Reply A', reasoningText: 'Thinking A', toolRequests: [{ toolCallId: 'tc-1', name: 'shell' }] }, - { type: 'tool_start', session, toolCallId: 'tc-1', toolName: 'shell', displayName: 'Shell', invocationMessage: 'Running...' }, - { type: 'tool_complete', session, toolCallId: 'tc-1', result: { success: true, pastTenseMessage: 'Ran', content: [{ type: ToolResultContentType.Text, text: 'ok' }] } }, - { type: 'message', session, role: 'assistant', messageId: 'a-2', content: 'Reply B', reasoningText: 'Thinking B', toolRequests: [] }, - ]; - - await service.restoreSession(sessionResource); - - const state = service.stateManager.getSessionState(sessionResource.toString()); - assert.ok(state); - const turn = state!.turns[0]; - const summary = turn.responseParts.map(p => { - if (p.kind === ResponsePartKind.Reasoning) { return ['reasoning', p.content]; } - if (p.kind === ResponsePartKind.Markdown) { return ['markdown', p.content]; } - if (p.kind === ResponsePartKind.ToolCall) { return ['toolCall', p.toolCall.toolCallId]; } - return ['other']; - }); - assert.deepStrictEqual(summary, [ - ['reasoning', 'Thinking A'], - ['markdown', 'Reply A'], - ['toolCall', 'tc-1'], - ['reasoning', 'Thinking B'], - ['markdown', 'Reply B'], - ]); - }); - - test('flushes interrupted turns', async () => { - service.registerProvider(copilotAgent); - const { session } = await copilotAgent.createSession(); - const sessions = await copilotAgent.listSessions(); - const sessionResource = sessions[0].session; - - copilotAgent.sessionMessages = [ - { type: 'message', session, role: 'user', messageId: 'msg-1', content: 'Interrupted', toolRequests: [] }, - { type: 'message', session, role: 'user', messageId: 'msg-2', content: 'Retried', toolRequests: [] }, - { type: 'message', session, role: 'assistant', messageId: 'msg-3', content: 'Answer', toolRequests: [] }, - ]; - - await service.restoreSession(sessionResource); - - const state = service.stateManager.getSessionState(sessionResource.toString()); - assert.ok(state); - assert.strictEqual(state!.turns.length, 2); - assert.strictEqual(state!.turns[0].state, TurnState.Cancelled); - assert.strictEqual(state!.turns[1].state, TurnState.Complete); - }); - - test('throws when session is not found on backend', async () => { - service.registerProvider(copilotAgent); - await assert.rejects( - () => service.restoreSession(AgentSession.uri('copilot', 'nonexistent')), - /Session not found on backend/, - ); - }); - - test('restores a session with subagent tool calls', async () => { - service.registerProvider(copilotAgent); - const { session } = await copilotAgent.createSession(); - const sessions = await copilotAgent.listSessions(); - const sessionResource = sessions[0].session; - - copilotAgent.sessionMessages = [ - { type: 'message', session, role: 'user', messageId: 'msg-1', content: 'Review this code', toolRequests: [] }, - { type: 'message', session, role: 'assistant', messageId: 'msg-2', content: '', toolRequests: [{ toolCallId: 'tc-sub', name: 'task' }] }, - { type: 'tool_start', session, toolCallId: 'tc-sub', toolName: 'task', displayName: 'Task', invocationMessage: 'Delegating...', toolKind: 'subagent' as const, subagentDescription: 'Find related files', subagentAgentName: 'explore' }, - { type: 'subagent_started', session, toolCallId: 'tc-sub', agentName: 'explore', agentDisplayName: 'Explore', agentDescription: 'Explores the codebase' }, - // Inner tool calls from the subagent (have parentToolCallId) - { type: 'tool_start', session, toolCallId: 'tc-inner-1', toolName: 'bash', displayName: 'Bash', invocationMessage: 'Running ls...', parentToolCallId: 'tc-sub' }, - { type: 'tool_complete', session, toolCallId: 'tc-inner-1', result: { success: true, pastTenseMessage: 'Ran ls', content: [{ type: ToolResultContentType.Text, text: 'file1.ts' }] }, parentToolCallId: 'tc-sub' }, - { type: 'tool_start', session, toolCallId: 'tc-inner-2', toolName: 'view', displayName: 'View File', invocationMessage: 'Reading file1.ts', parentToolCallId: 'tc-sub' }, - { type: 'tool_complete', session, toolCallId: 'tc-inner-2', result: { success: true, pastTenseMessage: 'Read file1.ts' }, parentToolCallId: 'tc-sub' }, - // Parent tool completes - { type: 'tool_complete', session, toolCallId: 'tc-sub', result: { success: true, pastTenseMessage: 'Delegated task', content: [{ type: ToolResultContentType.Text, text: 'Found 3 issues' }] } }, - { type: 'message', session, role: 'assistant', messageId: 'msg-3', content: 'The review found 3 issues.', toolRequests: [] }, - ]; - - await service.restoreSession(sessionResource); - - const state = service.stateManager.getSessionState(sessionResource.toString()); - assert.ok(state); - - // Should produce exactly one turn - assert.strictEqual(state!.turns.length, 1, `Expected 1 turn but got ${state!.turns.length}`); - - const turn = state!.turns[0]; - assert.strictEqual(turn.userMessage.text, 'Review this code'); - - // The parent turn should only have the parent tool call — inner - // tool calls are excluded from the parent and belong to the - // child subagent session instead. - const toolCallParts = turn.responseParts.filter((p): p is ToolCallResponsePart => p.kind === ResponsePartKind.ToolCall); - assert.strictEqual(toolCallParts.length, 1, `Expected 1 tool call (parent only) but got ${toolCallParts.length}`); - - // Parent subagent tool call - const parentTc = toolCallParts[0].toolCall as ToolCallCompletedState; - assert.strictEqual(parentTc.toolCallId, 'tc-sub'); - assert.strictEqual(parentTc.status, ToolCallStatus.Completed); - assert.strictEqual(parentTc._meta?.toolKind, 'subagent'); - assert.strictEqual(parentTc._meta?.subagentDescription, 'Find related files'); - assert.strictEqual(parentTc._meta?.subagentAgentName, 'explore'); - - // Parent tool should have subagent content entry - const content = parentTc.content ?? []; - const subagentEntry = content.find(c => hasKey(c, { type: true }) && c.type === ToolResultContentType.Subagent); - assert.ok(subagentEntry, 'Completed tool call should have subagent content entry'); - - // Subscribing to the child session should restore it with inner tool calls - const childSessionUri = buildSubagentSessionUri(sessionResource.toString(), 'tc-sub'); - const snapshot = await service.subscribe(URI.parse(childSessionUri), 'client-test'); - const childState = service.stateManager.getSessionState(childSessionUri); - assert.ok(snapshot?.state, 'Child session snapshot should exist'); - assert.ok(childState, 'Child session state should exist'); - assert.strictEqual(childState!.turns.length, 1, 'Child session should have 1 turn'); - const childToolParts = childState!.turns[0].responseParts.filter((p): p is ToolCallResponsePart => p.kind === ResponsePartKind.ToolCall); - assert.strictEqual(childToolParts.length, 2, `Child session should have 2 inner tool calls but got ${childToolParts.length}`); - assert.ok(childToolParts.some(p => p.toolCall.toolCallId === 'tc-inner-1'), 'Should have tc-inner-1'); - assert.ok(childToolParts.some(p => p.toolCall.toolCallId === 'tc-inner-2'), 'Should have tc-inner-2'); - - // The turn should also have the final markdown - const mdParts = turn.responseParts.filter((p): p is MarkdownResponsePart => p.kind === ResponsePartKind.Markdown); - assert.ok(mdParts.some(p => p.content.includes('3 issues')), 'Should have the final markdown response'); - }); - - test('inner assistant messages from subagent do not create extra turns (fixture)', async () => { - service.registerProvider(copilotAgent); - const { session } = await copilotAgent.createSession(); - const sessions = await copilotAgent.listSessions(); - const sessionResource = sessions[0].session; - - // Load real SDK events from fixture (sanitized from ~/.copilot/session-state/) - copilotAgent.sessionMessages = await loadFixtureMessages('subagent-session.jsonl', session); - - await service.restoreSession(sessionResource); - - const state = service.stateManager.getSessionState(sessionResource.toString()); - assert.ok(state); - assert.strictEqual(state!.turns.length, 1, `Expected 1 turn but got ${state!.turns.length}: ${state!.turns.map(t => `"${t.userMessage.text.substring(0, 40)}"`).join(', ')}`); - assert.strictEqual(state!.turns[0].userMessage.text, 'Run a sync subagent to do some searches, just testing subagent rendering'); - assert.strictEqual(state!.turns[0].state, TurnState.Complete); - - // Should have the parent subagent tool call with subagent content - const toolCallParts = state!.turns[0].responseParts.filter((p): p is ToolCallResponsePart => p.kind === ResponsePartKind.ToolCall); - const parentTc = toolCallParts.find(p => p.toolCall.toolName === 'task'); - assert.ok(parentTc, 'Should have a task tool call'); - assert.strictEqual(parentTc!.toolCall._meta?.toolKind, 'subagent'); - - // Inner tool calls should NOT be in the parent turn — they belong - // to the child subagent session. - const parentToolCallId = parentTc!.toolCall.toolCallId; - const nonParentTools = toolCallParts.filter(p => p.toolCall.toolCallId !== parentToolCallId); - assert.strictEqual(nonParentTools.length, 0, `Parent turn should only contain the task tool call, but found ${nonParentTools.length} extra tool calls`); - - // Subscribe to the child subagent session and verify inner tools - const childSessionUri = buildSubagentSessionUri(sessionResource.toString(), parentToolCallId); - const snapshot = await service.subscribe(URI.parse(childSessionUri), 'client-test'); - assert.ok(snapshot?.state, 'Child session snapshot should exist'); - const childState = service.stateManager.getSessionState(childSessionUri); - assert.ok(childState, 'Child session state should exist'); - assert.strictEqual(childState!.turns.length, 1, 'Child session should have 1 turn'); - const childToolParts = childState!.turns[0].responseParts.filter((p): p is ToolCallResponsePart => p.kind === ResponsePartKind.ToolCall); - assert.ok(childToolParts.length > 0, `Child session should have inner tool calls but got ${childToolParts.length}`); - - // Should have the final markdown - const mdParts = state!.turns[0].responseParts.filter((p): p is MarkdownResponsePart => p.kind === ResponsePartKind.Markdown); - assert.ok(mdParts.length > 0, 'Should have markdown content'); - }); - }); - - // ---- subscriber refcount + idle eviction ---------------------------- - - suite('subscriber refcount eviction', () => { - - test('an idle session created in this lifetime is evicted when subscribers drop', async () => { - service.registerProvider(copilotAgent); - const sessionResource = await service.createSession({ provider: 'copilot' }); - - service.addSubscriber(sessionResource, 'client-1'); - service.unsubscribe(sessionResource, 'client-1'); - - assert.strictEqual(service.stateManager.getSessionState(sessionResource.toString()), undefined, 'idle created session should be evicted; next subscribe will rehydrate from the agent'); - }); - - test('a session with an active turn is NOT evicted when its last subscriber drops', async () => { - service.registerProvider(copilotAgent); - const sessionResource = await service.createSession({ provider: 'copilot' }); - - service.addSubscriber(sessionResource, 'client-1'); - // Simulate an in-flight turn — eviction must skip this session even - // when the refcount reaches zero, otherwise we'd drop live state - // mid-response. - service.dispatchAction( - { type: ActionType.SessionTurnStarted, session: sessionResource.toString(), turnId: 'turn-1', userMessage: { text: 'hello' } }, - 'client-1', 1, - ); - - service.unsubscribe(sessionResource, 'client-1'); - - assert.ok(service.stateManager.getSessionState(sessionResource.toString()), 'active-turn session must not be evicted'); - }); - - test('a restored idle session is evicted when its last subscriber drops', async () => { - service.registerProvider(copilotAgent); - const { session } = await copilotAgent.createSession(); - const sessions = await copilotAgent.listSessions(); - const sessionResource = sessions[0].session; - - copilotAgent.sessionMessages = [ - { type: 'message', session, role: 'user', messageId: 'msg-1', content: 'Hello', toolRequests: [] }, - { type: 'message', session, role: 'assistant', messageId: 'msg-2', content: 'Hi', toolRequests: [] }, - ]; - await service.restoreSession(sessionResource); - service.addSubscriber(sessionResource, 'client-1'); - - service.unsubscribe(sessionResource, 'client-1'); - - assert.strictEqual(service.stateManager.getSessionState(sessionResource.toString()), undefined, 'restored idle session should be evicted'); - }); - - test('multiple subscribers keep a restored session alive until all drop', async () => { - service.registerProvider(copilotAgent); - const { session } = await copilotAgent.createSession(); - const sessions = await copilotAgent.listSessions(); - const sessionResource = sessions[0].session; - - copilotAgent.sessionMessages = [ - { type: 'message', session, role: 'user', messageId: 'msg-1', content: 'Hello', toolRequests: [] }, - { type: 'message', session, role: 'assistant', messageId: 'msg-2', content: 'Hi', toolRequests: [] }, - ]; - await service.restoreSession(sessionResource); - service.addSubscriber(sessionResource, 'client-1'); - service.addSubscriber(sessionResource, 'client-2'); - - service.unsubscribe(sessionResource, 'client-1'); - assert.ok(service.stateManager.getSessionState(sessionResource.toString()), 'still subscribed by client-2'); - - service.unsubscribe(sessionResource, 'client-2'); - assert.strictEqual(service.stateManager.getSessionState(sessionResource.toString()), undefined, 'evicted after last subscriber'); - }); - - test('subagent subscriber pins the parent session against eviction', async () => { - service.registerProvider(copilotAgent); - const { session } = await copilotAgent.createSession(); - const sessions = await copilotAgent.listSessions(); - const sessionResource = sessions[0].session; - - copilotAgent.sessionMessages = [ - { type: 'message', session, role: 'user', messageId: 'msg-1', content: 'Review', toolRequests: [] }, - { type: 'message', session, role: 'assistant', messageId: 'msg-2', content: '', toolRequests: [{ toolCallId: 'tc-sub', name: 'task' }] }, - { type: 'tool_start', session, toolCallId: 'tc-sub', toolName: 'task', displayName: 'Task', invocationMessage: 'Delegating', toolKind: 'subagent' as const, subagentDescription: 'Find files', subagentAgentName: 'explore' }, - { type: 'subagent_started', session, toolCallId: 'tc-sub', agentName: 'explore', agentDisplayName: 'Explore', agentDescription: 'Explores' }, - { type: 'tool_start', session, toolCallId: 'tc-inner', toolName: 'bash', displayName: 'Bash', invocationMessage: 'ls', parentToolCallId: 'tc-sub' }, - { type: 'tool_complete', session, toolCallId: 'tc-inner', result: { success: true, pastTenseMessage: 'ran', content: [{ type: ToolResultContentType.Text, text: 'a' }] }, parentToolCallId: 'tc-sub' }, - { type: 'tool_complete', session, toolCallId: 'tc-sub', result: { success: true, pastTenseMessage: 'done', content: [{ type: ToolResultContentType.Text, text: 'ok' }] } }, - { type: 'message', session, role: 'assistant', messageId: 'msg-3', content: 'Done', toolRequests: [] }, - ]; - await service.restoreSession(sessionResource); - const childUri = URI.parse(buildSubagentSessionUri(sessionResource.toString(), 'tc-sub')); - await service.subscribe(childUri, 'client-child'); - - service.addSubscriber(sessionResource, 'client-parent'); - - // Parent drops — child still subscribed, parent must not be evicted - service.unsubscribe(sessionResource, 'client-parent'); - assert.ok(service.stateManager.getSessionState(sessionResource.toString()), 'parent must stay while child is subscribed'); - assert.ok(service.stateManager.getSessionState(childUri.toString()), 'child still present'); - - // Child drops — both can now be evicted - service.unsubscribe(childUri, 'client-child'); - assert.strictEqual(service.stateManager.getSessionState(sessionResource.toString()), undefined, 'parent evicted after subagent drops'); - assert.strictEqual(service.stateManager.getSessionState(childUri.toString()), undefined, 'child also evicted with parent'); - }); - }); - - suite('session config persistence', () => { - - test('createSession persists initial config values to the session DB', async () => { - const sessionDb = disposables.add(await SessionDatabase.open(':memory:')); - const sessionDataService = createSessionDataService(sessionDb); - const localAgent = new MockAgent('copilot'); - disposables.add(toDisposable(() => localAgent.dispose())); - const localService = disposables.add(new AgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService())); - localService.registerProvider(localAgent); - - await localService.createSession({ provider: 'copilot', config: { autoApprove: 'autoApprove' } }); - - // Persistence is fire-and-forget; wait for it to flush - await new Promise(r => setTimeout(r, 50)); - - const persisted = await sessionDb.getMetadata('configValues'); - assert.ok(persisted, 'configValues should be persisted'); - assert.deepStrictEqual(JSON.parse(persisted!), { autoApprove: 'autoApprove' }); - }); - - test('createSession does not write configValues when there are no values', async () => { - const sessionDb = disposables.add(await SessionDatabase.open(':memory:')); - const sessionDataService = createSessionDataService(sessionDb); - const localAgent = new MockAgent('copilot'); - disposables.add(toDisposable(() => localAgent.dispose())); - const localService = disposables.add(new AgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService())); - localService.registerProvider(localAgent); - - await localService.createSession({ provider: 'copilot' }); - - await new Promise(r => setTimeout(r, 50)); - - const persisted = await sessionDb.getMetadata('configValues'); - assert.strictEqual(persisted, undefined); - }); - - test('restoreSession overlays persisted config values onto the resolved config', async () => { - const sessionDb = disposables.add(await SessionDatabase.open(':memory:')); - const sessionDataService = createSessionDataService(sessionDb); - const localAgent = new MockAgent('copilot'); - disposables.add(toDisposable(() => localAgent.dispose())); - const localService = disposables.add(new AgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService())); - localService.registerProvider(localAgent); - - // Create a session on the agent backend (no config) so listSessions can find it - const { session } = await localAgent.createSession(); - const sessions = await localAgent.listSessions(); - const sessionResource = sessions[0].session; - - // Pre-seed persisted config values - await sessionDb.setMetadata('configValues', JSON.stringify({ autoApprove: 'autoApprove' })); - - localAgent.sessionMessages = [ - { type: 'message', session, role: 'user', messageId: 'msg-1', content: 'Hello', toolRequests: [] }, - { type: 'message', session, role: 'assistant', messageId: 'msg-2', content: 'Hi', toolRequests: [] }, - ]; - - await localService.restoreSession(sessionResource); - - const state = localService.stateManager.getSessionState(sessionResource.toString()); - assert.ok(state); - // MockAgent.resolveSessionConfig echoes params.config back as values, so the - // persisted values are forwarded through and end up on state.config.values. - assert.deepStrictEqual(state!.config?.values, { autoApprove: 'autoApprove' }); - }); - - test('createSession + restoreSession round-trip restores initial config without any mid-session changes', async () => { - // Regression test: when a session is created with initial config but no - // mid-session SessionConfigChanged actions are dispatched, restoring it - // must still rehydrate the initial values. - const sessionDb = disposables.add(await SessionDatabase.open(':memory:')); - const sessionDataService = createSessionDataService(sessionDb); - const localAgent = new MockAgent('copilot'); - disposables.add(toDisposable(() => localAgent.dispose())); - const localService = disposables.add(new AgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService())); - localService.registerProvider(localAgent); - - const session = await localService.createSession({ provider: 'copilot', config: { autoApprove: 'autoApprove' } }); - - // Wait for the fire-and-forget persistence to flush - await new Promise(r => setTimeout(r, 50)); - - // Simulate a server restart: drop the in-memory state - localService.stateManager.removeSession(session.toString()); - - localAgent.sessionMessages = [ - { type: 'message', session, role: 'user', messageId: 'msg-1', content: 'Hello', toolRequests: [] }, - { type: 'message', session, role: 'assistant', messageId: 'msg-2', content: 'Hi', toolRequests: [] }, - ]; - await localService.restoreSession(session); - - const state = localService.stateManager.getSessionState(session.toString()); - assert.ok(state); - assert.deepStrictEqual(state!.config?.values, { autoApprove: 'autoApprove' }); - }); - - test('restoreSession ignores malformed persisted configValues', async () => { - const sessionDb = disposables.add(await SessionDatabase.open(':memory:')); - const sessionDataService = createSessionDataService(sessionDb); - const localAgent = new MockAgent('copilot'); - disposables.add(toDisposable(() => localAgent.dispose())); - const localService = disposables.add(new AgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService())); - localService.registerProvider(localAgent); - - const { session } = await localAgent.createSession(); - const sessions = await localAgent.listSessions(); - const sessionResource = sessions[0].session; - - await sessionDb.setMetadata('configValues', '{not json'); - - localAgent.sessionMessages = [ - { type: 'message', session, role: 'user', messageId: 'msg-1', content: 'Hello', toolRequests: [] }, - { type: 'message', session, role: 'assistant', messageId: 'msg-2', content: 'Hi', toolRequests: [] }, - ]; - - // Should not throw despite the malformed JSON - await localService.restoreSession(sessionResource); - - const state = localService.stateManager.getSessionState(sessionResource.toString()); - assert.ok(state); - // MockAgent has a workingDirectory? No — but the metadata supplies it as undefined. - // _resolveCreatedSessionConfig bails when both .config and .workingDirectory are - // missing, so state.config is undefined here. The key point is: no throw. - assert.strictEqual(state!.config, undefined); - }); - }); - - // ---- resourceList ------------------------------------------------ - - suite('resourceList', () => { - - test('throws when the directory does not exist', async () => { - await assert.rejects( - () => service.resourceList(URI.from({ scheme: Schemas.inMemory, path: '/nonexistent' })), - /Directory not found/, - ); - }); - - test('throws when the target is not a directory', async () => { - await assert.rejects( - () => service.resourceList(URI.from({ scheme: Schemas.inMemory, path: '/testDir/file.txt' })), - /Not a directory/, - ); - }); - }); - - // ---- worktree working directory ------------------------------------- - - suite('worktree working directory', () => { - - test('createSession uses agent-resolved working directory in state', async () => { - // Simulate an agent that resolves a worktree path different from the input - const worktreeDir = URI.file('/source/repo.worktrees/agents-xyz'); - copilotAgent.resolvedWorkingDirectory = worktreeDir; - service.registerProvider(copilotAgent); - - const sourceDir = URI.file('/source/repo'); - const session = await service.createSession({ provider: 'copilot', workingDirectory: sourceDir }); - - // The state manager should have the worktree path, not the source path - const state = service.stateManager.getSessionState(session.toString()); - assert.strictEqual(state?.summary.workingDirectory, worktreeDir.toString()); - }); - - test('createSession falls back to config working directory when agent does not resolve', async () => { - // Agent does not override the working directory (e.g. folder isolation) - copilotAgent.resolvedWorkingDirectory = undefined; - service.registerProvider(copilotAgent); - - const sourceDir = URI.file('/source/repo'); - const session = await service.createSession({ provider: 'copilot', workingDirectory: sourceDir }); - - const state = service.stateManager.getSessionState(session.toString()); - assert.strictEqual(state?.summary.workingDirectory, sourceDir.toString()); - }); - - test('restoreSession uses agent working directory in state', async () => { - // Agent returns the worktree path through listSessions - const worktreeDir = URI.file('/source/repo.worktrees/agents-xyz'); - copilotAgent.sessionMetadataOverrides = { workingDirectory: worktreeDir }; - service.registerProvider(copilotAgent); - - const session = await service.createSession({ provider: 'copilot' }); - - // Delete from state to simulate a server restart - service.stateManager.deleteSession(session.toString()); - assert.strictEqual(service.stateManager.getSessionState(session.toString()), undefined); - - // Restore the session (simulates a client subscribing after restart) - await service.restoreSession(session); - - const state = service.stateManager.getSessionState(session.toString()); - assert.strictEqual(state?.summary.workingDirectory, worktreeDir.toString()); - }); - }); -}); diff --git a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts deleted file mode 100644 index 5be9dfa9..00000000 --- a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts +++ /dev/null @@ -1,2572 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import assert from 'assert'; -import { VSBuffer } from '../../../../base/common/buffer.js'; -import { Event } from '../../../../base/common/event.js'; -import { DisposableStore, toDisposable } from '../../../../base/common/lifecycle.js'; -import { Schemas } from '../../../../base/common/network.js'; -import { observableValue } from '../../../../base/common/observable.js'; -import { hasKey } from '../../../../base/common/types.js'; -import { URI } from '../../../../base/common/uri.js'; -import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { FileService } from '../../../files/common/fileService.js'; -import { InMemoryFileSystemProvider } from '../../../files/common/inMemoryFilesystemProvider.js'; -import { InstantiationService } from '../../../instantiation/common/instantiationService.js'; -import { ServiceCollection } from '../../../instantiation/common/serviceCollection.js'; -import { ILogService, NullLogService } from '../../../log/common/log.js'; -import { AgentSession, IAgent } from '../../common/agentService.js'; -import { ISessionDataService } from '../../common/sessionDataService.js'; -import type { RootConfigChangedAction } from '../../common/state/protocol/actions.js'; -import { CustomizationStatus } from '../../common/state/protocol/state.js'; -import { ActionType, ActionEnvelope, SessionAction } from '../../common/state/sessionActions.js'; -import { AttachmentType, buildSubagentSessionUri, PendingMessageKind, ResponsePartKind, SessionStatus, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType } from '../../common/state/sessionState.js'; -import { IProductService } from '../../../product/common/productService.js'; -import { AgentConfigurationService, IAgentConfigurationService } from '../../node/agentConfigurationService.js'; -import { IAgentHostGitService } from '../../node/agentHostGitService.js'; -import { AgentService } from '../../node/agentService.js'; -import { AgentSideEffects, IAgentSideEffectsOptions } from '../../node/agentSideEffects.js'; -import { SessionDatabase } from '../../node/sessionDatabase.js'; -import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; -import { createNoopGitService, createNullSessionDataService, createSessionDataService } from '../common/sessionTestHelpers.js'; -import { MockAgent } from './mockAgent.js'; - -// ---- Tests ------------------------------------------------------------------ - -/** - * Constructs an {@link AgentSideEffects} with a minimal local instantiation - * scope that satisfies its {@link IAgentConfigurationService} / - * {@link ILogService} / {@link IAgentHostGitService} dependencies. - */ -function createTestSideEffects(disposables: DisposableStore, stateManager: AgentHostStateManager, options: IAgentSideEffectsOptions, gitService?: IAgentHostGitService): AgentSideEffects { - const logService = new NullLogService(); - const configService = disposables.add(new AgentConfigurationService(stateManager, logService)); - const instantiationService = disposables.add(new InstantiationService(new ServiceCollection( - [ILogService, logService], - [IAgentConfigurationService, configService], - [IAgentHostGitService, gitService ?? createNoopGitService()], - ), /*strict*/ true)); - return disposables.add(instantiationService.createInstance(AgentSideEffects, stateManager, options)); -} - -suite('AgentSideEffects', () => { - - const disposables = new DisposableStore(); - let fileService: FileService; - let stateManager: AgentHostStateManager; - let agent: MockAgent; - let sideEffects: AgentSideEffects; - let agentList: ReturnType>; - - const sessionUri = AgentSession.uri('mock', 'session-1'); - - function setupSession(workingDirectory?: string): void { - stateManager.createSession({ - resource: sessionUri.toString(), - provider: 'mock', - title: 'Test', - status: SessionStatus.Idle, - createdAt: Date.now(), - modifiedAt: Date.now(), - project: { uri: 'file:///test-project', displayName: 'Test Project' }, - workingDirectory, - }); - stateManager.dispatchServerAction({ type: ActionType.SessionReady, session: sessionUri.toString() }); - } - - function startTurn(turnId: string): void { - stateManager.dispatchClientAction( - { type: ActionType.SessionTurnStarted, session: sessionUri.toString(), turnId, userMessage: { text: 'hello' } }, - { clientId: 'test', clientSeq: 1 }, - ); - } - - setup(async () => { - fileService = disposables.add(new FileService(new NullLogService())); - const memFs = disposables.add(new InMemoryFileSystemProvider()); - disposables.add(fileService.registerProvider(Schemas.inMemory, memFs)); - - // Seed a file so the handleBrowseDirectory tests can distinguish files from dirs - const testDir = URI.from({ scheme: Schemas.inMemory, path: '/testDir' }); - await fileService.createFolder(testDir); - await fileService.writeFile(URI.from({ scheme: Schemas.inMemory, path: '/testDir/file.txt' }), VSBuffer.fromString('hello')); - - agent = new MockAgent(); - disposables.add(toDisposable(() => agent.dispose())); - stateManager = disposables.add(new AgentHostStateManager(new NullLogService())); - agentList = observableValue('agents', [agent]); - sideEffects = createTestSideEffects(disposables, stateManager, { - getAgent: () => agent, - agents: agentList, - sessionDataService: createNullSessionDataService(), - onTurnComplete: () => { }, - }); - }); - - teardown(() => { - disposables.clear(); - }); - ensureNoDisposablesAreLeakedInTestSuite(); - - // ---- handleAction: session/turnStarted ------------------------------ - - suite('handleAction — session/turnStarted', () => { - - test('calls sendMessage on the agent', async () => { - setupSession(); - const action: SessionAction = { - type: ActionType.SessionTurnStarted, - session: sessionUri.toString(), - turnId: 'turn-1', - userMessage: { text: 'hello world' }, - }; - sideEffects.handleAction(action); - - // sendMessage is async but fire-and-forget; wait a tick - await new Promise(r => setTimeout(r, 10)); - - assert.deepStrictEqual(agent.sendMessageCalls, [{ session: URI.parse(sessionUri.toString()), prompt: 'hello world', attachments: undefined }]); - }); - - test('parses protocol attachment URI strings before passing them to the agent', () => { - setupSession(); - const fileUri = URI.file('/workspace/test.ts'); - const action: SessionAction = { - type: ActionType.SessionTurnStarted, - session: sessionUri.toString(), - turnId: 'turn-1', - userMessage: { text: 'hello world', attachments: [{ type: AttachmentType.File, uri: fileUri.toString(), displayName: 'test.ts' }] }, - }; - - sideEffects.handleAction(action); - - assert.deepStrictEqual(agent.sendMessageCalls, [{ - session: URI.parse(sessionUri.toString()), - prompt: 'hello world', - attachments: [{ type: AttachmentType.File, uri: URI.parse(fileUri.toString()), displayName: 'test.ts' }], - }]); - }); - - test('dispatches session/error when no agent is found', async () => { - setupSession(); - const emptyAgents = observableValue('agents', []); - const noAgentSideEffects = createTestSideEffects(disposables, stateManager, { - getAgent: () => undefined, - agents: emptyAgents, - sessionDataService: {} as ISessionDataService, - onTurnComplete: () => { }, - }); - - const envelopes: ActionEnvelope[] = []; - disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e))); - - noAgentSideEffects.handleAction({ - type: ActionType.SessionTurnStarted, - session: sessionUri.toString(), - turnId: 'turn-1', - userMessage: { text: 'hello' }, - }); - - const errorAction = envelopes.find(e => e.action.type === ActionType.SessionError); - assert.ok(errorAction, 'should dispatch session/error'); - }); - }); - - // ---- immediate title on first turn ----------------------------------- - - suite('immediate title on first turn', () => { - - function setupDefaultSession(): void { - stateManager.createSession({ - resource: sessionUri.toString(), - provider: 'mock', - title: '', - status: SessionStatus.Idle, - createdAt: Date.now(), - modifiedAt: Date.now(), - project: { uri: 'file:///test-project', displayName: 'Test Project' }, - }); - stateManager.dispatchServerAction({ type: ActionType.SessionReady, session: sessionUri.toString() }); - } - - test('dispatches titleChanged with user message on first turn', () => { - setupDefaultSession(); - - const envelopes: ActionEnvelope[] = []; - disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e))); - - sideEffects.handleAction({ - type: ActionType.SessionTurnStarted, - session: sessionUri.toString(), - turnId: 'turn-1', - userMessage: { text: 'Fix the login bug' }, - }); - - const titleAction = envelopes.find(e => e.action.type === ActionType.SessionTitleChanged); - assert.ok(titleAction, 'should dispatch session/titleChanged'); - if (titleAction?.action.type === ActionType.SessionTitleChanged) { - assert.strictEqual(titleAction.action.title, 'Fix the login bug'); - } - }); - - test('does not dispatch titleChanged when message is whitespace', () => { - setupDefaultSession(); - - const envelopes: ActionEnvelope[] = []; - disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e))); - - sideEffects.handleAction({ - type: ActionType.SessionTurnStarted, - session: sessionUri.toString(), - turnId: 'turn-1', - userMessage: { text: ' ' }, - }); - - const titleAction = envelopes.find(e => e.action.type === ActionType.SessionTitleChanged); - assert.strictEqual(titleAction, undefined, 'should not dispatch titleChanged for empty message'); - }); - - test('normalizes whitespace and truncates long messages', () => { - setupDefaultSession(); - - const envelopes: ActionEnvelope[] = []; - disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e))); - - const longMessage = 'Fix the bug\nin the login\tpage please ' + 'a'.repeat(250); - sideEffects.handleAction({ - type: ActionType.SessionTurnStarted, - session: sessionUri.toString(), - turnId: 'turn-1', - userMessage: { text: longMessage }, - }); - - const titleAction = envelopes.find(e => e.action.type === ActionType.SessionTitleChanged); - assert.ok(titleAction, 'should dispatch session/titleChanged'); - if (titleAction?.action.type === ActionType.SessionTitleChanged) { - assert.ok(!titleAction.action.title.includes('\n'), 'should not contain newlines'); - assert.ok(!titleAction.action.title.includes('\t'), 'should not contain tabs'); - assert.ok(!titleAction.action.title.includes(' '), 'should not contain double spaces'); - assert.ok(titleAction.action.title.length <= 200, 'should be truncated to 200 chars'); - } - }); - - test('does not dispatch titleChanged on second turn', () => { - setupDefaultSession(); - startTurn('turn-1'); - - // Complete the first turn so turns.length becomes 1. - stateManager.dispatchServerAction({ - type: ActionType.SessionTurnComplete, - session: sessionUri.toString(), - turnId: 'turn-1', - }); - - const envelopes: ActionEnvelope[] = []; - disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e))); - - sideEffects.handleAction({ - type: ActionType.SessionTurnStarted, - session: sessionUri.toString(), - turnId: 'turn-2', - userMessage: { text: 'second message' }, - }); - - const titleAction = envelopes.find(e => e.action.type === ActionType.SessionTitleChanged); - assert.strictEqual(titleAction, undefined, 'should not dispatch titleChanged on second turn'); - }); - - test('does not dispatch titleChanged when title is already set', () => { - // Session has a non-empty title (e.g. user renamed before first message) - stateManager.createSession({ - resource: sessionUri.toString(), - provider: 'mock', - title: 'User Renamed', - status: SessionStatus.Idle, - createdAt: Date.now(), - modifiedAt: Date.now(), - project: { uri: 'file:///test-project', displayName: 'Test Project' }, - }); - stateManager.dispatchServerAction({ type: ActionType.SessionReady, session: sessionUri.toString() }); - - const envelopes: ActionEnvelope[] = []; - disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e))); - - sideEffects.handleAction({ - type: ActionType.SessionTurnStarted, - session: sessionUri.toString(), - turnId: 'turn-1', - userMessage: { text: 'hello' }, - }); - - const titleAction = envelopes.find(e => e.action.type === ActionType.SessionTitleChanged); - assert.strictEqual(titleAction, undefined, 'should not clobber existing title'); - }); - }); - - suite('handleAction — session/turnCancelled', () => { - - test('calls abortSession on the agent', async () => { - setupSession(); - sideEffects.handleAction({ - type: ActionType.SessionTurnCancelled, - session: sessionUri.toString(), - turnId: 'turn-1', - }); - - await new Promise(r => setTimeout(r, 10)); - - assert.deepStrictEqual(agent.abortSessionCalls, [URI.parse(sessionUri.toString())]); - }); - }); - - // ---- handleAction: session/modelChanged ----------------------------- - - suite('handleAction — session/modelChanged', () => { - - test('calls changeModel on the agent', async () => { - setupSession(); - sideEffects.handleAction({ - type: ActionType.SessionModelChanged, - session: sessionUri.toString(), - model: { id: 'gpt-5' }, - }); - - await new Promise(r => setTimeout(r, 10)); - - assert.deepStrictEqual(agent.changeModelCalls, [{ session: URI.parse(sessionUri.toString()), model: { id: 'gpt-5' } }]); - }); - }); - - // ---- registerProgressListener --------------------------------------- - - suite('registerProgressListener', () => { - - test('maps agent progress events to state actions', () => { - setupSession(); - startTurn('turn-1'); - - const envelopes: ActionEnvelope[] = []; - disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e))); - disposables.add(sideEffects.registerProgressListener(agent)); - - agent.fireProgress({ - kind: 'action', session: sessionUri, - action: { type: ActionType.SessionResponsePart, session: sessionUri.toString(), turnId: 'turn-1', part: { kind: ResponsePartKind.Markdown, id: 'msg-1', content: 'hi' } }, - }); - - // First delta creates a response part (not a delta action) - assert.ok(envelopes.some(e => e.action.type === ActionType.SessionResponsePart)); - }); - - test('returns a disposable that stops listening', () => { - setupSession(); - startTurn('turn-1'); - - const envelopes: ActionEnvelope[] = []; - disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e))); - const listener = sideEffects.registerProgressListener(agent); - - agent.fireProgress({ - kind: 'action', session: sessionUri, - action: { type: ActionType.SessionResponsePart, session: sessionUri.toString(), turnId: 'turn-1', part: { kind: ResponsePartKind.Markdown, id: 'msg-1', content: 'before' } }, - }); - assert.strictEqual(envelopes.filter(e => e.action.type === ActionType.SessionResponsePart).length, 1); - - listener.dispose(); - agent.fireProgress({ - kind: 'action', session: sessionUri, - action: { type: ActionType.SessionResponsePart, session: sessionUri.toString(), turnId: 'turn-1', part: { kind: ResponsePartKind.Markdown, id: 'msg-2', content: 'after' } }, - }); - assert.strictEqual(envelopes.filter(e => e.action.type === ActionType.SessionResponsePart).length, 1); - }); - }); - - // ---- agents observable -------------------------------------------------- - - suite('agents observable', () => { - - test('dispatches root/agentsChanged without fetching models when observable changes', async () => { - agentList.set([], undefined); - const envelope = Event.toPromise(Event.filter(stateManager.onDidEmitEnvelope, e => { - if (e.action.type !== ActionType.RootAgentsChanged) { - return false; - } - return e.action.agents.length === 1; - })); - agentList.set([agent], undefined); - const { action } = await envelope; - assert.strictEqual(action.type, ActionType.RootAgentsChanged); - - assert.deepStrictEqual(action.agents[0].models, []); - }); - - test('model observable update publishes models', async () => { - const envelopes: ActionEnvelope[] = []; - disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e))); - - const envelope = Event.toPromise(Event.filter(stateManager.onDidEmitEnvelope, e => { - if (e.action.type !== ActionType.RootAgentsChanged) { - return false; - } - return e.action.agents[0]?.models.length === 1; - })); - agent.setModels([{ provider: 'mock', id: 'mock-model', name: 'mock Model', maxContextWindow: 128000, supportsVision: false }]); - await envelope; - - const actions = envelopes.map(e => e.action).filter(action => action.type === ActionType.RootAgentsChanged); - const action = actions[actions.length - 1]; - assert.ok(action, 'should dispatch root/agentsChanged'); - assert.deepStrictEqual(action.agents[0].models, [{ - id: 'mock-model', - provider: 'mock', - name: 'mock Model', - maxContextWindow: 128000, - supportsVision: false, - policyState: undefined, - configSchema: undefined, - }]); - }); - - test('unchanged model observable update does not dispatch unchanged agent infos', async () => { - const envelopes: ActionEnvelope[] = []; - disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e))); - const models = [{ provider: 'mock' as const, id: 'mock-model', name: 'mock Model', maxContextWindow: 128000, supportsVision: false }]; - - const envelope = Event.toPromise(Event.filter(stateManager.onDidEmitEnvelope, e => { - if (e.action.type !== ActionType.RootAgentsChanged) { - return false; - } - return e.action.agents[0]?.models.length === 1; - })); - agent.setModels(models); - await envelope; - envelopes.length = 0; - agent.setModels([...models]); - await Promise.resolve(); - await Promise.resolve(); - - assert.strictEqual(envelopes.filter(e => e.action.type === ActionType.RootAgentsChanged).length, 0); - }); - }); - - // ---- Pending message sync ----------------------------------------------- - - suite('pending message sync', () => { - - test('syncs steering message to agent on SessionPendingMessageSet', () => { - setupSession(); - - const action = { - type: ActionType.SessionPendingMessageSet as const, - session: sessionUri.toString(), - kind: PendingMessageKind.Steering, - id: 'steer-1', - userMessage: { text: 'focus on tests' }, - }; - stateManager.dispatchClientAction(action, { clientId: 'test', clientSeq: 1 }); - sideEffects.handleAction(action); - - assert.strictEqual(agent.setPendingMessagesCalls.length, 1); - assert.deepStrictEqual(agent.setPendingMessagesCalls[0].steeringMessage, { id: 'steer-1', userMessage: { text: 'focus on tests' } }); - assert.deepStrictEqual(agent.setPendingMessagesCalls[0].queuedMessages, []); - }); - - test('syncs queued message to agent on SessionPendingMessageSet', () => { - setupSession(); - - const action = { - type: ActionType.SessionPendingMessageSet as const, - session: sessionUri.toString(), - kind: PendingMessageKind.Queued, - id: 'q-1', - userMessage: { text: 'queued message' }, - }; - stateManager.dispatchClientAction(action, { clientId: 'test', clientSeq: 1 }); - sideEffects.handleAction(action); - - // Queued messages are not forwarded to the agent; the server controls consumption - assert.strictEqual(agent.setPendingMessagesCalls.length, 1); - assert.strictEqual(agent.setPendingMessagesCalls[0].steeringMessage, undefined); - assert.deepStrictEqual(agent.setPendingMessagesCalls[0].queuedMessages, []); - - // Session was idle, so the queued message is consumed immediately - assert.strictEqual(agent.sendMessageCalls.length, 1); - assert.strictEqual(agent.sendMessageCalls[0].prompt, 'queued message'); - }); - - test('parses queued protocol attachment URI strings before passing them to the agent', () => { - setupSession(); - const fileUri = URI.file('/workspace/queued.ts'); - const action: SessionAction = { - type: ActionType.SessionPendingMessageSet as const, - session: sessionUri.toString(), - kind: PendingMessageKind.Queued, - id: 'q-uri', - userMessage: { text: 'queued message', attachments: [{ type: AttachmentType.File, uri: fileUri.toString(), displayName: 'queued.ts' }] }, - }; - - stateManager.dispatchClientAction(action, { clientId: 'test', clientSeq: 1 }); - sideEffects.handleAction(action); - - assert.deepStrictEqual(agent.sendMessageCalls, [{ - session: URI.parse(sessionUri.toString()), - prompt: 'queued message', - attachments: [{ type: AttachmentType.File, uri: URI.parse(fileUri.toString()), displayName: 'queued.ts' }], - }]); - }); - - test('syncs on SessionPendingMessageRemoved', () => { - setupSession(); - - // Add a queued message - const setAction = { - type: ActionType.SessionPendingMessageSet as const, - session: sessionUri.toString(), - kind: PendingMessageKind.Queued, - id: 'q-rm', - userMessage: { text: 'will be removed' }, - }; - stateManager.dispatchClientAction(setAction, { clientId: 'test', clientSeq: 1 }); - sideEffects.handleAction(setAction); - - agent.setPendingMessagesCalls.length = 0; - - // Remove - const removeAction = { - type: ActionType.SessionPendingMessageRemoved as const, - session: sessionUri.toString(), - kind: PendingMessageKind.Queued, - id: 'q-rm', - }; - stateManager.dispatchClientAction(removeAction, { clientId: 'test', clientSeq: 2 }); - sideEffects.handleAction(removeAction); - - assert.strictEqual(agent.setPendingMessagesCalls.length, 1); - assert.deepStrictEqual(agent.setPendingMessagesCalls[0].queuedMessages, []); - }); - - test('syncs on SessionQueuedMessagesReordered', () => { - setupSession(); - - // Add two queued messages - const setA = { type: ActionType.SessionPendingMessageSet as const, session: sessionUri.toString(), kind: PendingMessageKind.Queued, id: 'q-a', userMessage: { text: 'A' } }; - stateManager.dispatchClientAction(setA, { clientId: 'test', clientSeq: 1 }); - sideEffects.handleAction(setA); - - const setB = { type: ActionType.SessionPendingMessageSet as const, session: sessionUri.toString(), kind: PendingMessageKind.Queued, id: 'q-b', userMessage: { text: 'B' } }; - stateManager.dispatchClientAction(setB, { clientId: 'test', clientSeq: 2 }); - sideEffects.handleAction(setB); - - agent.setPendingMessagesCalls.length = 0; - - // Reorder - const reorderAction = { type: ActionType.SessionQueuedMessagesReordered as const, session: sessionUri.toString(), order: ['q-b', 'q-a'] }; - stateManager.dispatchClientAction(reorderAction, { clientId: 'test', clientSeq: 3 }); - sideEffects.handleAction(reorderAction); - - assert.strictEqual(agent.setPendingMessagesCalls.length, 1); - assert.deepStrictEqual(agent.setPendingMessagesCalls[0].queuedMessages, []); - }); - }); - - // ---- Queued message consumption ----------------------------------------- - - suite('queued message consumption', () => { - - test('auto-starts turn from queued message on idle', () => { - setupSession(); - disposables.add(sideEffects.registerProgressListener(agent)); - - // Queue a message while a turn is active - startTurn('turn-1'); - const setAction = { - type: ActionType.SessionPendingMessageSet as const, - session: sessionUri.toString(), - kind: PendingMessageKind.Queued, - id: 'q-auto', - userMessage: { text: 'auto queued' }, - }; - stateManager.dispatchClientAction(setAction, { clientId: 'test', clientSeq: 1 }); - sideEffects.handleAction(setAction); - - // Message should NOT be consumed yet (turn is active) - assert.strictEqual(agent.sendMessageCalls.length, 0); - - const envelopes: ActionEnvelope[] = []; - disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e))); - - // Fire idle → turn completes → queued message should be consumed - agent.fireProgress({ - kind: 'action', session: sessionUri, - action: { type: ActionType.SessionTurnComplete, session: sessionUri.toString(), turnId: 'turn-1' }, - }); - - const turnComplete = envelopes.find(e => e.action.type === ActionType.SessionTurnComplete); - assert.ok(turnComplete, 'should dispatch session/turnComplete'); - - const turnStarted = envelopes.find(e => e.action.type === ActionType.SessionTurnStarted); - assert.ok(turnStarted, 'should dispatch session/turnStarted for queued message'); - assert.strictEqual((turnStarted!.action as { queuedMessageId?: string }).queuedMessageId, 'q-auto'); - - assert.strictEqual(agent.sendMessageCalls.length, 1); - assert.strictEqual(agent.sendMessageCalls[0].prompt, 'auto queued'); - - // Queued message should be removed from state - const state = stateManager.getSessionState(sessionUri.toString()); - assert.strictEqual(state?.queuedMessages, undefined); - }); - - test('does not consume queued message while a turn is active', () => { - setupSession(); - startTurn('turn-1'); - - const envelopes: ActionEnvelope[] = []; - disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e))); - - const setAction = { - type: ActionType.SessionPendingMessageSet as const, - session: sessionUri.toString(), - kind: PendingMessageKind.Queued, - id: 'q-wait', - userMessage: { text: 'should wait' }, - }; - stateManager.dispatchClientAction(setAction, { clientId: 'test', clientSeq: 1 }); - sideEffects.handleAction(setAction); - - // No turn started for the queued message - const turnStarted = envelopes.find(e => e.action.type === ActionType.SessionTurnStarted); - assert.strictEqual(turnStarted, undefined, 'should not start a turn while one is active'); - assert.strictEqual(agent.sendMessageCalls.length, 0); - - // Queued message still in state - const state = stateManager.getSessionState(sessionUri.toString()); - assert.strictEqual(state?.queuedMessages?.length, 1); - assert.strictEqual(state?.queuedMessages?.[0].id, 'q-wait'); - }); - - test('dispatches SessionPendingMessageRemoved for steering messages on steering_consumed', () => { - setupSession(); - disposables.add(sideEffects.registerProgressListener(agent)); - - const envelopes: ActionEnvelope[] = []; - disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e))); - - const action = { - type: ActionType.SessionPendingMessageSet as const, - session: sessionUri.toString(), - kind: PendingMessageKind.Steering, - id: 'steer-rm', - userMessage: { text: 'steer me' }, - }; - stateManager.dispatchClientAction(action, { clientId: 'test', clientSeq: 1 }); - sideEffects.handleAction(action); - - // Removal is not dispatched synchronously; it waits for the agent - let removal = envelopes.find(e => - e.action.type === ActionType.SessionPendingMessageRemoved && - (e.action as { kind: PendingMessageKind }).kind === PendingMessageKind.Steering - ); - assert.strictEqual(removal, undefined, 'should not dispatch removal until steering_consumed'); - - // Simulate the agent consuming the steering message - agent.fireProgress({ - kind: 'steering_consumed', - session: sessionUri, - id: 'steer-rm', - }); - - removal = envelopes.find(e => - e.action.type === ActionType.SessionPendingMessageRemoved && - (e.action as { kind: PendingMessageKind }).kind === PendingMessageKind.Steering - ); - assert.ok(removal, 'should dispatch SessionPendingMessageRemoved for steering'); - assert.strictEqual((removal!.action as { id: string }).id, 'steer-rm'); - - // Steering message should be removed from state - const state = stateManager.getSessionState(sessionUri.toString()); - assert.strictEqual(state?.steeringMessage, undefined); - }); - }); - - // ---- handleAction: session/activeClientChanged ---------------------- - - suite('handleAction — session/activeClientChanged', () => { - - test('calls setClientCustomizations and dispatches customizationsChanged', async () => { - setupSession(); - agent.getSessionCustomizations = async () => [ - { - customization: { uri: 'file:///plugin-a', displayName: 'Plugin A' }, - enabled: true, - status: CustomizationStatus.Loaded, - }, - { - customization: { uri: 'file:///plugin-b', displayName: 'Plugin B' }, - enabled: true, - status: CustomizationStatus.Loaded, - }, - ]; - - const envelopes: ActionEnvelope[] = []; - disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e))); - - const action: SessionAction = { - type: ActionType.SessionActiveClientChanged, - session: sessionUri.toString(), - activeClient: { - clientId: 'test-client', - tools: [], - customizations: [ - { uri: 'file:///plugin-a', displayName: 'Plugin A' }, - { uri: 'file:///plugin-b', displayName: 'Plugin B' }, - ], - }, - }; - sideEffects.handleAction(action); - - // Wait for async setClientCustomizations - await new Promise(r => setTimeout(r, 50)); - - assert.deepStrictEqual(agent.setClientCustomizationsCalls, [{ - clientId: 'test-client', - customizations: [ - { uri: 'file:///plugin-a', displayName: 'Plugin A' }, - { uri: 'file:///plugin-b', displayName: 'Plugin B' }, - ], - }]); - - const customizationActions = envelopes - .filter(e => e.action.type === ActionType.SessionCustomizationsChanged); - assert.ok(customizationActions.length >= 1, 'should dispatch at least one customizationsChanged'); - }); - - test('clears client customizations when activeClient has no customizations', () => { - setupSession(); - - const envelopes: ActionEnvelope[] = []; - disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e))); - - const action: SessionAction = { - type: ActionType.SessionActiveClientChanged, - session: sessionUri.toString(), - activeClient: { - clientId: 'test-client', - tools: [], - }, - }; - sideEffects.handleAction(action); - - assert.deepStrictEqual(agent.setClientCustomizationsCalls, [{ - clientId: 'test-client', - customizations: [], - }]); - const customizationActions = envelopes - .filter(e => e.action.type === ActionType.SessionCustomizationsChanged); - assert.strictEqual(customizationActions.length, 0); - }); - - test('clears client customizations when activeClient is null', () => { - setupSession(); - - const action: SessionAction = { - type: ActionType.SessionActiveClientChanged, - session: sessionUri.toString(), - activeClient: null, - }; - sideEffects.handleAction(action); - - assert.deepStrictEqual(agent.setClientCustomizationsCalls, [{ - clientId: '', - customizations: [], - }]); - }); - }); - - // ---- handleAction: root/configChanged -------------------------------- - - suite('handleAction - root/configChanged', () => { - - test('republishes agent and session customizations for existing sessions', async () => { - setupSession('file:///workspace'); - const customization = { uri: 'file:///plugin-a', displayName: 'Plugin A' }; - agent.customizations = [customization]; - agent.getSessionCustomizations = async () => [{ - customization, - enabled: true, - status: CustomizationStatus.Loaded, - }]; - - const envelopes: ActionEnvelope[] = []; - disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e))); - - const action: RootConfigChangedAction = { - type: ActionType.RootConfigChanged, - config: { customizations: [customization] }, - }; - - stateManager.dispatchServerAction(action); - sideEffects.handleAction(action); - await new Promise(resolve => setTimeout(resolve, 10)); - - const agentInfoAction = envelopes.filter(e => e.action.type === ActionType.RootAgentsChanged).at(-1); - assert.ok(agentInfoAction && hasKey(agentInfoAction.action, { agents: true })); - assert.deepStrictEqual(agentInfoAction.action.agents[0]?.customizations, [customization]); - - const sessionCustomizationAction = envelopes.filter(e => e.action.type === ActionType.SessionCustomizationsChanged).at(-1); - assert.ok(sessionCustomizationAction && hasKey(sessionCustomizationAction.action, { customizations: true })); - assert.deepStrictEqual(sessionCustomizationAction.action.customizations, [{ - customization, - enabled: true, - status: CustomizationStatus.Loaded, - }]); - }); - }); - - // ---- onDidCustomizationsChange integration -------------------------- - - suite('onDidCustomizationsChange', () => { - - test('republishes agent info and session customizations when agent fires onDidCustomizationsChange', async () => { - disposables.add(sideEffects.registerProgressListener(agent)); - setupSession('file:///workspace'); - - const customization = { uri: 'file:///plugin-b', displayName: 'Plugin B' }; - agent.customizations = [customization]; - agent.getSessionCustomizations = async () => [{ - customization, - enabled: true, - status: CustomizationStatus.Loaded, - }]; - - const envelopes: ActionEnvelope[] = []; - disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e))); - - agent.fireCustomizationsChange(); - await new Promise(resolve => setTimeout(resolve, 10)); - - const agentInfoAction = envelopes.find(e => e.action.type === ActionType.RootAgentsChanged); - assert.ok(agentInfoAction && hasKey(agentInfoAction.action, { agents: true })); - assert.deepStrictEqual(agentInfoAction.action.agents[0]?.customizations, [customization]); - - const sessionCustomizationAction = envelopes.find(e => e.action.type === ActionType.SessionCustomizationsChanged); - assert.ok(sessionCustomizationAction && hasKey(sessionCustomizationAction.action, { customizations: true })); - assert.deepStrictEqual(sessionCustomizationAction.action.customizations, [{ - customization, - enabled: true, - status: CustomizationStatus.Loaded, - }]); - }); - - test('does not republish when registerProgressListener is disposed', async () => { - const listener = sideEffects.registerProgressListener(agent); - setupSession('file:///workspace'); - - agent.customizations = [{ uri: 'file:///plugin-c', displayName: 'Plugin C' }]; - - const envelopes: ActionEnvelope[] = []; - disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e))); - - listener.dispose(); - agent.fireCustomizationsChange(); - await new Promise(resolve => setTimeout(resolve, 10)); - - assert.strictEqual( - envelopes.filter(e => e.action.type === ActionType.SessionCustomizationsChanged).length, - 0, - 'should not republish session customizations after listener disposed', - ); - }); - }); - - // ---- handleAction: session/customizationToggled --------------------- - - suite('handleAction — session/customizationToggled', () => { - - test('calls setCustomizationEnabled on the agent', () => { - setupSession(); - - const action: SessionAction = { - type: ActionType.SessionCustomizationToggled, - session: sessionUri.toString(), - uri: 'file:///plugin-a', - enabled: false, - }; - sideEffects.handleAction(action); - - assert.deepStrictEqual(agent.setCustomizationEnabledCalls, [ - { uri: 'file:///plugin-a', enabled: false }, - ]); - }); - }); - - // ---- handleAction: session/toolCallConfirmed ------------------------ - - suite('handleAction — session/toolCallConfirmed', () => { - - test('routes confirmation to correct agent via _toolCallAgents', () => { - setupSession(); - startTurn('turn-1'); - disposables.add(sideEffects.registerProgressListener(agent)); - - // Fire tool_start to register the tool call - agent.fireProgress({ - kind: 'action', session: sessionUri, - action: { - type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1', - toolCallId: 'tc-conf-1', toolName: 'read', displayName: 'Read File', toolClientId: undefined, - _meta: { toolKind: undefined, language: undefined }, - }, - }); - agent.fireProgress({ - kind: 'action', session: sessionUri, - action: { - type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1', - toolCallId: 'tc-conf-1', invocationMessage: 'Reading file', toolInput: undefined, - confirmed: ToolCallConfirmationReason.NotNeeded, - }, - }); - - // Fire tool_ready asking for permission (non-write, so not auto-approved) - agent.fireProgress({ - kind: 'pending_confirmation', session: sessionUri, - state: { - status: ToolCallStatus.PendingConfirmation, - toolCallId: 'tc-conf-1', toolName: '', displayName: '', - invocationMessage: 'Read file.txt', toolInput: undefined, - confirmationTitle: 'Read file.txt', edits: undefined, - }, - permissionKind: undefined, permissionPath: undefined, - }); - - // Now confirm the tool call - sideEffects.handleAction({ - type: ActionType.SessionToolCallConfirmed, - session: sessionUri.toString(), - turnId: 'turn-1', - toolCallId: 'tc-conf-1', - approved: true, - confirmed: 'user-action' as const, - } as SessionAction); - - assert.deepStrictEqual(agent.respondToPermissionCalls, [ - { requestId: 'tc-conf-1', approved: true }, - ]); - }); - - test('handles denial of tool call', () => { - setupSession(); - startTurn('turn-1'); - disposables.add(sideEffects.registerProgressListener(agent)); - - agent.fireProgress({ - kind: 'action', session: sessionUri, - action: { - type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1', - toolCallId: 'tc-deny-1', toolName: 'shell', displayName: 'Shell', toolClientId: undefined, - _meta: { toolKind: undefined, language: undefined }, - }, - }); - agent.fireProgress({ - kind: 'action', session: sessionUri, - action: { - type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1', - toolCallId: 'tc-deny-1', invocationMessage: 'Running command', toolInput: undefined, - confirmed: ToolCallConfirmationReason.NotNeeded, - }, - }); - - sideEffects.handleAction({ - type: ActionType.SessionToolCallConfirmed, - session: sessionUri.toString(), - turnId: 'turn-1', - toolCallId: 'tc-deny-1', - approved: false, - reason: 'denied' as const, - } as SessionAction); - - assert.deepStrictEqual(agent.respondToPermissionCalls, [ - { requestId: 'tc-deny-1', approved: false }, - ]); - }); - }); - - // ---- tool_ready progress dispatch ----------------------------------- - - suite('tool_ready dispatches progress actions to advance tool call state', () => { - - test('tool_ready for a non-permission tool dispatches SessionToolCallReady and advances state from Streaming to Running', () => { - setupSession(); - startTurn('turn-1'); - disposables.add(sideEffects.registerProgressListener(agent)); - - // tool_start puts the tool call into Streaming state - agent.fireProgress({ - kind: 'action', session: sessionUri, - action: { - type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1', - toolCallId: 'tc-ready-1', toolName: 'runTask', displayName: 'Run Task', toolClientId: 'test-client', - _meta: { toolKind: undefined, language: undefined }, - }, - }); - - const stateAfterStart = stateManager.getSessionState(sessionUri.toString()); - const partAfterStart = stateAfterStart?.activeTurn?.responseParts[0]; - assert.strictEqual(partAfterStart?.kind, ResponsePartKind.ToolCall); - assert.strictEqual(partAfterStart?.kind === ResponsePartKind.ToolCall ? partAfterStart.toolCall.status : undefined, ToolCallStatus.Streaming); - - // tool_ready without confirmationTitle should dispatch the ready - // action and advance the tool call to Running - agent.fireProgress({ - kind: 'pending_confirmation', session: sessionUri, - state: { - status: ToolCallStatus.PendingConfirmation, - toolCallId: 'tc-ready-1', toolName: '', displayName: '', - invocationMessage: 'Run Task', toolInput: '{"task":"build"}', - confirmationTitle: undefined, edits: undefined, - }, - permissionKind: undefined, permissionPath: undefined, - }); - - const stateAfterReady = stateManager.getSessionState(sessionUri.toString()); - const partAfterReady = stateAfterReady?.activeTurn?.responseParts[0]; - assert.strictEqual(partAfterReady?.kind, ResponsePartKind.ToolCall); - assert.strictEqual(partAfterReady?.kind === ResponsePartKind.ToolCall ? partAfterReady.toolCall.status : undefined, ToolCallStatus.Running, - 'tool call should advance from Streaming to Running after tool_ready'); - }); - - test('tool_ready for a permission-gated tool dispatches SessionToolCallReady and advances state to PendingConfirmation', () => { - setupSession(); - startTurn('turn-1'); - disposables.add(sideEffects.registerProgressListener(agent)); - - agent.fireProgress({ - kind: 'action', session: sessionUri, - action: { - type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1', - toolCallId: 'tc-perm-1', toolName: 'write', displayName: 'Write File', toolClientId: 'test-client', - _meta: { toolKind: undefined, language: undefined }, - }, - }); - - // tool_ready with confirmationTitle should dispatch the ready - // action and advance the tool call to PendingConfirmation - agent.fireProgress({ - kind: 'pending_confirmation', session: sessionUri, - state: { - status: ToolCallStatus.PendingConfirmation, - toolCallId: 'tc-perm-1', toolName: '', displayName: '', - invocationMessage: 'Write .env', toolInput: '{"path":".env"}', - confirmationTitle: 'Write .env', edits: undefined, - }, - permissionKind: undefined, permissionPath: undefined, - }); - - const state = stateManager.getSessionState(sessionUri.toString()); - const part = state?.activeTurn?.responseParts[0]; - assert.strictEqual(part?.kind, ResponsePartKind.ToolCall); - assert.strictEqual(part?.kind === ResponsePartKind.ToolCall ? part.toolCall.status : undefined, ToolCallStatus.PendingConfirmation, - 'tool call should advance to PendingConfirmation for permission-gated tool_ready'); - }); - - test('pending_confirmation for a tool inside a subagent routes to the subagent session', () => { - // Regression: a `pending_confirmation` signal for a client tool - // inside a subagent must dispatch SessionToolCallReady against - // the subagent session, not the parent. Otherwise the parent - // session sees a stray `session/toolCallReady` with no - // preceding `session/toolCallStart`, which is illegal. - setupSession(); - startTurn('turn-1'); - disposables.add(sideEffects.registerProgressListener(agent)); - - // Parent tool that delegates to a subagent. - agent.fireProgress({ - kind: 'action', session: sessionUri, - action: { - type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1', - toolCallId: 'tc-parent', toolName: 'runSubagent', displayName: 'Run Subagent', toolClientId: undefined, - _meta: { toolKind: undefined, language: undefined }, - }, - }); - agent.fireProgress({ - kind: 'action', session: sessionUri, - action: { - type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1', - toolCallId: 'tc-parent', invocationMessage: 'Delegating...', toolInput: undefined, - confirmed: ToolCallConfirmationReason.NotNeeded, - }, - }); - agent.fireProgress({ kind: 'subagent_started', session: sessionUri, toolCallId: 'tc-parent', agentName: 'helper', agentDisplayName: 'Helper' }); - - // Inner client tool starts inside the subagent. - agent.fireProgress({ - kind: 'action', session: sessionUri, parentToolCallId: 'tc-parent', - action: { - type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1', - toolCallId: 'tc-inner', toolName: 'problems', displayName: 'Problems', toolClientId: 'client-tools', - _meta: { toolKind: undefined, language: undefined }, - }, - }); - - // Permission flow fires `pending_confirmation` for the inner - // client tool. The signal must be routed to the subagent - // session — not to the parent — even though the signal carries - // only the parent session URI. - agent.fireProgress({ - kind: 'pending_confirmation', session: sessionUri, parentToolCallId: 'tc-parent', - state: { - status: ToolCallStatus.PendingConfirmation, - toolCallId: 'tc-inner', toolName: 'problems', displayName: 'Problems', - invocationMessage: 'Get problems', toolInput: '{}', - confirmationTitle: undefined, edits: undefined, - }, - permissionKind: 'custom-tool', permissionPath: undefined, - }); - - // The subagent session must contain the SessionToolCallReady. - const subagentUri = buildSubagentSessionUri(sessionUri.toString(), 'tc-parent'); - const subState = stateManager.getSessionState(subagentUri); - const innerPart = subState?.activeTurn?.responseParts.find( - rp => rp.kind === ResponsePartKind.ToolCall && rp.toolCall.toolCallId === 'tc-inner' - ); - assert.ok(innerPart, 'inner client tool call should exist on subagent session'); - assert.strictEqual( - innerPart!.kind === ResponsePartKind.ToolCall ? innerPart.toolCall.status : undefined, - ToolCallStatus.Running, - 'inner client tool call should advance to Running after pending_confirmation' - ); - - // The parent session must NOT have a stray tool call for the - // inner toolCallId — that would be a SessionToolCallReady - // without a matching SessionToolCallStart. - const parentState = stateManager.getSessionState(sessionUri.toString()); - const parentInner = parentState?.activeTurn?.responseParts.find( - rp => rp.kind === ResponsePartKind.ToolCall && rp.toolCall.toolCallId === 'tc-inner' - ); - assert.strictEqual(parentInner, undefined, 'parent session must not contain the inner tool call'); - }); - }); - - // ---- Session-level auto-approve (config) ---------------------------- - - suite('session config auto-approve', () => { - - function setupSessionWithConfig(autoApproveLevel: string): void { - setupSession(URI.file('/workspace').toString()); - // Set config on the session state directly (as agentService.ts does) - const state = stateManager.getSessionState(sessionUri.toString()); - if (state) { - state.config = { - schema: { - type: 'object', - properties: { - autoApprove: { - type: 'string', - title: 'Approvals', - enum: ['default', 'autoApprove', 'autopilot'], - default: 'default', - sessionMutable: true, - }, - }, - }, - values: { autoApprove: autoApproveLevel }, - }; - } - } - - test('auto-approves all writes when autoApprove is set to bypass', () => { - setupSessionWithConfig('autoApprove'); - startTurn('turn-1'); - disposables.add(sideEffects.registerProgressListener(agent)); - - agent.fireProgress({ - kind: 'action', session: sessionUri, - action: { - type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1', - toolCallId: 'tc-bypass-1', toolName: 'write', displayName: 'Write', toolClientId: undefined, - _meta: { toolKind: undefined, language: undefined }, - }, - }); - agent.fireProgress({ - kind: 'action', session: sessionUri, - action: { - type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1', - toolCallId: 'tc-bypass-1', invocationMessage: 'Write .env', toolInput: undefined, - confirmed: ToolCallConfirmationReason.NotNeeded, - }, - }); - - agent.fireProgress({ - kind: 'pending_confirmation', session: sessionUri, - state: { - status: ToolCallStatus.PendingConfirmation, - toolCallId: 'tc-bypass-1', toolName: '', displayName: '', - invocationMessage: 'Write .env', toolInput: undefined, - confirmationTitle: undefined, edits: undefined, - }, - permissionKind: 'write', permissionPath: '/workspace/.env', - }); - - // .env would normally be blocked, but session-level auto-approve overrides - assert.deepStrictEqual(agent.respondToPermissionCalls, [ - { requestId: 'tc-bypass-1', approved: true }, - ]); - }); - - test('auto-approves shell commands when autoApprove is set to autopilot', () => { - setupSessionWithConfig('autopilot'); - startTurn('turn-1'); - disposables.add(sideEffects.registerProgressListener(agent)); - - agent.fireProgress({ - kind: 'action', session: sessionUri, - action: { - type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1', - toolCallId: 'tc-ap-shell-1', toolName: 'shell', displayName: 'Shell', toolClientId: undefined, - _meta: { toolKind: undefined, language: undefined }, - }, - }); - agent.fireProgress({ - kind: 'action', session: sessionUri, - action: { - type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1', - toolCallId: 'tc-ap-shell-1', invocationMessage: 'Run rm -rf /', toolInput: undefined, - confirmed: ToolCallConfirmationReason.NotNeeded, - }, - }); - - agent.fireProgress({ - kind: 'pending_confirmation', session: sessionUri, - state: { - status: ToolCallStatus.PendingConfirmation, - toolCallId: 'tc-ap-shell-1', toolName: '', displayName: '', - invocationMessage: 'Run rm -rf /', toolInput: 'rm -rf /', - confirmationTitle: undefined, edits: undefined, - }, - permissionKind: 'shell', permissionPath: undefined, - }); - - // Dangerous command would normally be blocked, but session-level auto-approve overrides - assert.deepStrictEqual(agent.respondToPermissionCalls, [ - { requestId: 'tc-ap-shell-1', approved: true }, - ]); - }); - - test('does NOT auto-approve when autoApprove is default', () => { - setupSessionWithConfig('default'); - startTurn('turn-1'); - disposables.add(sideEffects.registerProgressListener(agent)); - - agent.fireProgress({ - kind: 'action', session: sessionUri, - action: { - type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1', - toolCallId: 'tc-default-1', toolName: 'write', displayName: 'Write', toolClientId: undefined, - _meta: { toolKind: undefined, language: undefined }, - }, - }); - agent.fireProgress({ - kind: 'action', session: sessionUri, - action: { - type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1', - toolCallId: 'tc-default-1', invocationMessage: 'Write .env', toolInput: undefined, - confirmed: ToolCallConfirmationReason.NotNeeded, - }, - }); - - agent.fireProgress({ - kind: 'pending_confirmation', session: sessionUri, - state: { - status: ToolCallStatus.PendingConfirmation, - toolCallId: 'tc-default-1', toolName: '', displayName: '', - invocationMessage: 'Write .env', toolInput: undefined, - confirmationTitle: undefined, edits: undefined, - }, - permissionKind: 'write', permissionPath: '/workspace/.env', - }); - - // .env should still be blocked with default config - assert.strictEqual(agent.respondToPermissionCalls.length, 0); - }); - - test('respects mid-session config change via SessionConfigChanged', () => { - setupSessionWithConfig('default'); - startTurn('turn-1'); - disposables.add(sideEffects.registerProgressListener(agent)); - - // Change to bypass mid-session - stateManager.dispatchServerAction({ - type: ActionType.SessionConfigChanged, - session: sessionUri.toString(), - config: { autoApprove: 'autoApprove' }, - }); - - agent.fireProgress({ - kind: 'action', session: sessionUri, - action: { - type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1', - toolCallId: 'tc-mid-1', toolName: 'write', displayName: 'Write', toolClientId: undefined, - _meta: { toolKind: undefined, language: undefined }, - }, - }); - agent.fireProgress({ - kind: 'action', session: sessionUri, - action: { - type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1', - toolCallId: 'tc-mid-1', invocationMessage: 'Write .env', toolInput: undefined, - confirmed: ToolCallConfirmationReason.NotNeeded, - }, - }); - - agent.fireProgress({ - kind: 'pending_confirmation', session: sessionUri, - state: { - status: ToolCallStatus.PendingConfirmation, - toolCallId: 'tc-mid-1', toolName: '', displayName: '', - invocationMessage: 'Write .env', toolInput: undefined, - confirmationTitle: undefined, edits: undefined, - }, - permissionKind: 'write', permissionPath: '/workspace/.env', - }); - - // Should now be auto-approved after config change - assert.deepStrictEqual(agent.respondToPermissionCalls, [ - { requestId: 'tc-mid-1', approved: true }, - ]); - }); - }); - - // ---- Edit auto-approve ---------------------------------------------- - - suite('edit auto-approve', () => { - - test('auto-approves writes to regular source files', async () => { - setupSession(URI.file('/workspace').toString()); - startTurn('turn-1'); - disposables.add(sideEffects.registerProgressListener(agent)); - - agent.fireProgress({ - kind: 'action', session: sessionUri, - action: { - type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1', - toolCallId: 'tc-auto-1', toolName: 'write', displayName: 'Write', toolClientId: undefined, - _meta: { toolKind: undefined, language: undefined }, - }, - }); - agent.fireProgress({ - kind: 'action', session: sessionUri, - action: { - type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1', - toolCallId: 'tc-auto-1', invocationMessage: 'Write file', toolInput: undefined, - confirmed: ToolCallConfirmationReason.NotNeeded, - }, - }); - - agent.fireProgress({ - kind: 'pending_confirmation', session: sessionUri, - state: { - status: ToolCallStatus.PendingConfirmation, - toolCallId: 'tc-auto-1', toolName: '', displayName: '', - invocationMessage: 'Write src/app.ts', toolInput: undefined, - confirmationTitle: undefined, edits: undefined, - }, - permissionKind: 'write', permissionPath: '/workspace/src/app.ts', - }); - - // Auto-approved writes call respondToPermissionRequest directly - assert.deepStrictEqual(agent.respondToPermissionCalls, [ - { requestId: 'tc-auto-1', approved: true }, - ]); - }); - - test('blocks writes to .env files', () => { - setupSession(URI.file('/workspace').toString()); - startTurn('turn-1'); - disposables.add(sideEffects.registerProgressListener(agent)); - - const envelopes: ActionEnvelope[] = []; - disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e))); - - agent.fireProgress({ - kind: 'action', session: sessionUri, - action: { - type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1', - toolCallId: 'tc-env-1', toolName: 'write', displayName: 'Write', toolClientId: undefined, - _meta: { toolKind: undefined, language: undefined }, - }, - }); - agent.fireProgress({ - kind: 'action', session: sessionUri, - action: { - type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1', - toolCallId: 'tc-env-1', invocationMessage: 'Write .env', toolInput: undefined, - confirmed: ToolCallConfirmationReason.NotNeeded, - }, - }); - - agent.fireProgress({ - kind: 'pending_confirmation', session: sessionUri, - state: { - status: ToolCallStatus.PendingConfirmation, - toolCallId: 'tc-env-1', toolName: '', displayName: '', - invocationMessage: 'Write .env', toolInput: undefined, - confirmationTitle: 'Write .env', edits: undefined, - }, - permissionKind: 'write', permissionPath: '/workspace/.env', - }); - - // Should NOT auto-approve — .env is excluded - assert.strictEqual(agent.respondToPermissionCalls.length, 0); - - // Should dispatch a tool_ready action for the client to confirm - const readyAction = envelopes.find(e => e.action.type === ActionType.SessionToolCallReady); - assert.ok(readyAction, 'should dispatch tool_ready for blocked write'); - }); - - test('blocks writes to package.json', () => { - setupSession(URI.file('/workspace').toString()); - startTurn('turn-1'); - disposables.add(sideEffects.registerProgressListener(agent)); - - agent.fireProgress({ - kind: 'action', session: sessionUri, - action: { - type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1', - toolCallId: 'tc-pkg-1', toolName: 'write', displayName: 'Write', toolClientId: undefined, - _meta: { toolKind: undefined, language: undefined }, - }, - }); - agent.fireProgress({ - kind: 'action', session: sessionUri, - action: { - type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1', - toolCallId: 'tc-pkg-1', invocationMessage: 'Write package.json', toolInput: undefined, - confirmed: ToolCallConfirmationReason.NotNeeded, - }, - }); - - agent.fireProgress({ - kind: 'pending_confirmation', session: sessionUri, - state: { - status: ToolCallStatus.PendingConfirmation, - toolCallId: 'tc-pkg-1', toolName: '', displayName: '', - invocationMessage: 'Write package.json', toolInput: undefined, - confirmationTitle: 'Write package.json', edits: undefined, - }, - permissionKind: 'write', permissionPath: '/workspace/package.json', - }); - - assert.strictEqual(agent.respondToPermissionCalls.length, 0); - }); - - test('blocks writes to .lock files', () => { - setupSession(URI.file('/workspace').toString()); - startTurn('turn-1'); - disposables.add(sideEffects.registerProgressListener(agent)); - - agent.fireProgress({ - kind: 'action', session: sessionUri, - action: { - type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1', - toolCallId: 'tc-lock-1', toolName: 'write', displayName: 'Write', toolClientId: undefined, - _meta: { toolKind: undefined, language: undefined }, - }, - }); - agent.fireProgress({ - kind: 'action', session: sessionUri, - action: { - type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1', - toolCallId: 'tc-lock-1', invocationMessage: 'Write yarn.lock', toolInput: undefined, - confirmed: ToolCallConfirmationReason.NotNeeded, - }, - }); - - agent.fireProgress({ - kind: 'pending_confirmation', session: sessionUri, - state: { - status: ToolCallStatus.PendingConfirmation, - toolCallId: 'tc-lock-1', toolName: '', displayName: '', - invocationMessage: 'Write yarn.lock', toolInput: undefined, - confirmationTitle: 'Write yarn.lock', edits: undefined, - }, - permissionKind: 'write', permissionPath: '/workspace/yarn.lock', - }); - - assert.strictEqual(agent.respondToPermissionCalls.length, 0); - }); - - test('blocks writes to .git directory', () => { - setupSession(URI.file('/workspace').toString()); - startTurn('turn-1'); - disposables.add(sideEffects.registerProgressListener(agent)); - - agent.fireProgress({ - kind: 'action', session: sessionUri, - action: { - type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1', - toolCallId: 'tc-git-1', toolName: 'write', displayName: 'Write', toolClientId: undefined, - _meta: { toolKind: undefined, language: undefined }, - }, - }); - agent.fireProgress({ - kind: 'action', session: sessionUri, - action: { - type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1', - toolCallId: 'tc-git-1', invocationMessage: 'Write .git/config', toolInput: undefined, - confirmed: ToolCallConfirmationReason.NotNeeded, - }, - }); - - agent.fireProgress({ - kind: 'pending_confirmation', session: sessionUri, - state: { - status: ToolCallStatus.PendingConfirmation, - toolCallId: 'tc-git-1', toolName: '', displayName: '', - invocationMessage: 'Write .git/config', toolInput: undefined, - confirmationTitle: 'Write .git/config', edits: undefined, - }, - permissionKind: 'write', permissionPath: '/workspace/.git/config', - }); - - assert.strictEqual(agent.respondToPermissionCalls.length, 0); - }); - }); - - // ---- Read auto-approve ------------------------------------------------- - - suite('read auto-approve', () => { - - test('auto-approves reads inside working directory', () => { - setupSession(URI.file('/workspace').toString()); - startTurn('turn-1'); - disposables.add(sideEffects.registerProgressListener(agent)); - - agent.fireProgress({ - kind: 'action', session: sessionUri, - action: { - type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1', - toolCallId: 'tc-read-1', toolName: 'read', displayName: 'Read', toolClientId: undefined, - _meta: { toolKind: undefined, language: undefined }, - }, - }); - agent.fireProgress({ - kind: 'action', session: sessionUri, - action: { - type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1', - toolCallId: 'tc-read-1', invocationMessage: 'Read file', toolInput: undefined, - confirmed: ToolCallConfirmationReason.NotNeeded, - }, - }); - - agent.fireProgress({ - kind: 'pending_confirmation', session: sessionUri, - state: { - status: ToolCallStatus.PendingConfirmation, - toolCallId: 'tc-read-1', toolName: '', displayName: '', - invocationMessage: 'Read src/app.ts', toolInput: undefined, - confirmationTitle: undefined, edits: undefined, - }, - permissionKind: 'read', permissionPath: '/workspace/src/app.ts', - }); - - assert.deepStrictEqual(agent.respondToPermissionCalls, [ - { requestId: 'tc-read-1', approved: true }, - ]); - }); - - test('does not auto-approve reads outside working directory', () => { - setupSession(URI.file('/workspace').toString()); - startTurn('turn-1'); - disposables.add(sideEffects.registerProgressListener(agent)); - - const envelopes: ActionEnvelope[] = []; - disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e))); - - agent.fireProgress({ - kind: 'action', session: sessionUri, - action: { - type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1', - toolCallId: 'tc-read-2', toolName: 'read', displayName: 'Read', toolClientId: undefined, - _meta: { toolKind: undefined, language: undefined }, - }, - }); - agent.fireProgress({ - kind: 'action', session: sessionUri, - action: { - type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1', - toolCallId: 'tc-read-2', invocationMessage: 'Read file', toolInput: undefined, - confirmed: ToolCallConfirmationReason.NotNeeded, - }, - }); - - agent.fireProgress({ - kind: 'pending_confirmation', session: sessionUri, - state: { - status: ToolCallStatus.PendingConfirmation, - toolCallId: 'tc-read-2', toolName: '', displayName: '', - invocationMessage: 'Read /etc/passwd', toolInput: undefined, - confirmationTitle: undefined, edits: undefined, - }, - permissionKind: 'read', permissionPath: '/etc/passwd', - }); - - assert.strictEqual(agent.respondToPermissionCalls.length, 0); - - const readyAction = envelopes.find(e => e.action.type === ActionType.SessionToolCallReady); - assert.ok(readyAction, 'should dispatch tool_ready for read outside working directory'); - }); - }); - - // ---- Title persistence -------------------------------------------------- - - suite('title persistence', () => { - - let sessionDb: SessionDatabase; - - setup(async () => { - sessionDb = disposables.add(await SessionDatabase.open(':memory:')); - }); - - teardown(async () => { - await sessionDb.close(); - }); - - test('SessionTitleChanged persists to the database', async () => { - const sessionDataService = createSessionDataService(sessionDb); - const localStateManager = disposables.add(new AgentHostStateManager(new NullLogService())); - const localAgent = new MockAgent(); - disposables.add(toDisposable(() => localAgent.dispose())); - const localSideEffects = createTestSideEffects(disposables, localStateManager, { - getAgent: () => localAgent, - agents: observableValue('agents', [localAgent]), - sessionDataService, - onTurnComplete: () => { }, - }); - - localStateManager.createSession({ - resource: sessionUri.toString(), - provider: 'mock', - title: 'Initial', - status: SessionStatus.Idle, - createdAt: Date.now(), - modifiedAt: Date.now(), - project: { uri: 'file:///test-project', displayName: 'Test Project' }, - }); - - localSideEffects.handleAction({ - type: ActionType.SessionTitleChanged, - session: sessionUri.toString(), - title: 'Custom Title', - }); - - // Wait for the async persistence - await new Promise(r => setTimeout(r, 50)); - - assert.strictEqual(await sessionDb.getMetadata('customTitle'), 'Custom Title'); - }); - - test('handleListSessions returns persisted custom title', async () => { - const sessionDataService = createSessionDataService(sessionDb); - const localAgent = new MockAgent(); - disposables.add(toDisposable(() => localAgent.dispose())); - const localService = disposables.add(new AgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService())); - localService.registerProvider(localAgent); - - // Create a session on the agent backend - await localAgent.createSession(); - - // Persist a custom title in the DB - await sessionDb.setMetadata('customTitle', 'My Custom Title'); - - const sessions = await localService.listSessions(); - assert.strictEqual(sessions.length, 1); - // Custom title comes from the DB and is returned via the agent's listSessions - // The mock agent summary is used; the service doesn't read the DB for list - assert.ok(sessions[0].summary); - }); - - test('handleRestoreSession uses persisted custom title', async () => { - const sessionDataService = createSessionDataService(sessionDb); - const localAgent = new MockAgent(); - disposables.add(toDisposable(() => localAgent.dispose())); - const localService = disposables.add(new AgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService())); - localService.registerProvider(localAgent); - - // Create a session on the agent backend - const { session } = await localAgent.createSession(); - const sessions = await localAgent.listSessions(); - const sessionResource = sessions[0].session; - - // Persist a custom title in the DB - await sessionDb.setMetadata('customTitle', 'Restored Title'); - - // Set up minimal messages for restore - localAgent.sessionMessages = [ - { type: 'message', session, role: 'user', messageId: 'msg-1', content: 'Hello', toolRequests: [] }, - { type: 'message', session, role: 'assistant', messageId: 'msg-2', content: 'Hi', toolRequests: [] }, - ]; - - await localService.restoreSession(sessionResource); - - const state = localService.stateManager.getSessionState(sessionResource.toString()); - assert.ok(state); - assert.strictEqual(state!.summary.title, 'Restored Title'); - }); - - test('SessionConfigChanged persists merged config values to the database', async () => { - const sessionDataService = createSessionDataService(sessionDb); - const localStateManager = disposables.add(new AgentHostStateManager(new NullLogService())); - const localAgent = new MockAgent(); - disposables.add(toDisposable(() => localAgent.dispose())); - const localSideEffects = createTestSideEffects(disposables, localStateManager, { - getAgent: () => localAgent, - agents: observableValue('agents', [localAgent]), - sessionDataService, - onTurnComplete: () => { }, - }); - - const session = localStateManager.createSession({ - resource: sessionUri.toString(), - provider: 'mock', - title: 'Initial', - status: SessionStatus.Idle, - createdAt: Date.now(), - modifiedAt: Date.now(), - project: { uri: 'file:///test-project', displayName: 'Test Project' }, - }); - session.config = { schema: { type: 'object', properties: {} }, values: { autoApprove: 'default' } }; - - // Mid-session change merges new values into existing. - localStateManager.dispatchClientAction({ - type: ActionType.SessionConfigChanged, - session: sessionUri.toString(), - config: { autoApprove: 'autoApprove' }, - }, { clientId: 'test-client', clientSeq: 1 }); - localSideEffects.handleAction({ - type: ActionType.SessionConfigChanged, - session: sessionUri.toString(), - config: { autoApprove: 'autoApprove' }, - }); - - await new Promise(r => setTimeout(r, 50)); - - const persisted = await sessionDb.getMetadata('configValues'); - assert.ok(persisted); - assert.deepStrictEqual(JSON.parse(persisted!), { autoApprove: 'autoApprove' }); - }); - }); - - // ---- Subagent sessions ---------------------------------------------- - - suite('subagent sessions', () => { - - test('subagent_started creates a subagent session and dispatches content on parent tool call', () => { - setupSession(); - startTurn('turn-1'); - disposables.add(sideEffects.registerProgressListener(agent)); - - // Start a parent tool call - agent.fireProgress({ - kind: 'action', session: sessionUri, - action: { - type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1', - toolCallId: 'tc-1', toolName: 'runSubagent', displayName: 'Run Subagent', toolClientId: undefined, - _meta: { toolKind: undefined, language: undefined }, - }, - }); - agent.fireProgress({ - kind: 'action', session: sessionUri, - action: { - type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1', - toolCallId: 'tc-1', invocationMessage: 'Delegating task...', toolInput: undefined, - confirmed: ToolCallConfirmationReason.NotNeeded, - }, - }); - - // Fire subagent_started - agent.fireProgress({ - kind: 'subagent_started', session: sessionUri, - toolCallId: 'tc-1', - agentName: 'code-reviewer', - agentDisplayName: 'Code Reviewer', - agentDescription: 'Reviews code', - }); - - // Verify the subagent session was created - const subagentUri = `${sessionUri.toString()}/subagent/tc-1`; - const subState = stateManager.getSessionState(subagentUri); - assert.ok(subState, 'subagent session should exist'); - assert.strictEqual(subState!.summary.title, 'Code Reviewer'); - assert.ok(subState!.activeTurn, 'subagent should have an active turn'); - - // Verify content was dispatched on the parent tool call - const parentState = stateManager.getSessionState(sessionUri.toString()); - assert.ok(parentState?.activeTurn); - const parentToolCall = parentState!.activeTurn!.responseParts.find( - rp => rp.kind === ResponsePartKind.ToolCall && rp.toolCall.toolCallId === 'tc-1' - ); - assert.ok(parentToolCall); - if (parentToolCall?.kind === ResponsePartKind.ToolCall && parentToolCall.toolCall.status === ToolCallStatus.Running) { - assert.ok(parentToolCall.toolCall.content); - assert.strictEqual(parentToolCall.toolCall.content![0].type, ToolResultContentType.Subagent); - } - }); - - test('events with parentToolCallId route to subagent session', () => { - setupSession(); - startTurn('turn-1'); - disposables.add(sideEffects.registerProgressListener(agent)); - - // Start parent tool + subagent - agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1', toolCallId: 'tc-1', toolName: 'runSubagent', displayName: 'Run Subagent', toolClientId: undefined, _meta: { toolKind: undefined, language: undefined } } }); - agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1', toolCallId: 'tc-1', invocationMessage: 'Delegating...', toolInput: undefined, confirmed: ToolCallConfirmationReason.NotNeeded } }); - agent.fireProgress({ kind: 'subagent_started', session: sessionUri, toolCallId: 'tc-1', agentName: 'helper', agentDisplayName: 'Helper', agentDescription: 'Helps' }); - - // Fire an inner tool start with parentToolCallId - agent.fireProgress({ - kind: 'action', session: sessionUri, parentToolCallId: 'tc-1', - action: { - type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1', - toolCallId: 'inner-tc-1', toolName: 'readFile', displayName: 'Read File', toolClientId: undefined, - _meta: { toolKind: undefined, language: undefined }, - }, - }); - agent.fireProgress({ - kind: 'action', session: sessionUri, parentToolCallId: 'tc-1', - action: { - type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1', - toolCallId: 'inner-tc-1', invocationMessage: 'Reading file...', toolInput: undefined, - confirmed: ToolCallConfirmationReason.NotNeeded, - }, - }); - - // Verify the inner tool call is on the subagent session's turn, not the parent - const subagentUri = `${sessionUri.toString()}/subagent/tc-1`; - const subState = stateManager.getSessionState(subagentUri); - assert.ok(subState?.activeTurn); - const innerTool = subState!.activeTurn!.responseParts.find( - rp => rp.kind === ResponsePartKind.ToolCall && rp.toolCall.toolCallId === 'inner-tc-1' - ); - assert.ok(innerTool, 'inner tool call should be in subagent session'); - - // Verify the parent session does NOT have the inner tool call - const parentState = stateManager.getSessionState(sessionUri.toString()); - const parentInnerTool = parentState!.activeTurn!.responseParts.find( - rp => rp.kind === ResponsePartKind.ToolCall && rp.toolCall.toolCallId === 'inner-tc-1' - ); - assert.strictEqual(parentInnerTool, undefined, 'inner tool call should NOT be in parent session'); - }); - - test('completeSubagentSession clears pending buffered events when subagent never started', () => { - // Regression: if the parent tool completes (or fails) before any - // `subagent_started` arrives, buffered inner events would - // otherwise leak in `_pendingSubagentEvents` until session - // disposal. After completion, a late `subagent_started` for the - // same toolCallId must not replay stale events. - setupSession(); - startTurn('turn-1'); - disposables.add(sideEffects.registerProgressListener(agent)); - - agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1', toolCallId: 'tc-1', toolName: 'runSubagent', displayName: 'Run Subagent', toolClientId: undefined, _meta: { toolKind: undefined, language: undefined } } }); - agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1', toolCallId: 'tc-1', invocationMessage: 'Delegating...', toolInput: undefined, confirmed: ToolCallConfirmationReason.NotNeeded } }); - - // Inner event arrives but `subagent_started` never does. - agent.fireProgress({ - kind: 'action', session: sessionUri, parentToolCallId: 'tc-1', - action: { - type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1', - toolCallId: 'inner-1', toolName: 'read', displayName: 'Read', toolClientId: undefined, - _meta: { toolKind: undefined, language: undefined }, - }, - }); - agent.fireProgress({ - kind: 'action', session: sessionUri, parentToolCallId: 'tc-1', - action: { - type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1', - toolCallId: 'inner-1', invocationMessage: 'Reading...', toolInput: undefined, - confirmed: ToolCallConfirmationReason.NotNeeded, - }, - }); - - // Parent tool completes (e.g. it errored before delegating). - agent.fireProgress({ - kind: 'action', session: sessionUri, - action: { - type: ActionType.SessionToolCallComplete, session: sessionUri.toString(), turnId: 'turn-1', - toolCallId: 'tc-1', - result: { success: false, pastTenseMessage: 'Failed' }, - }, - }); - - // Now a late `subagent_started` for the same toolCallId arrives. - // This is unusual but possible after a reconnect/replay. The - // drain must NOT replay the (cleared) buffered inner tool call. - agent.fireProgress({ kind: 'subagent_started', session: sessionUri, toolCallId: 'tc-1', agentName: 'helper', agentDisplayName: 'Helper', agentDescription: 'Helps' }); - - const subagentUri = `${sessionUri.toString()}/subagent/tc-1`; - const subState = stateManager.getSessionState(subagentUri); - assert.ok(subState, 'subagent session should still be created'); - const innerTool = subState!.activeTurn?.responseParts.find( - rp => rp.kind === ResponsePartKind.ToolCall && rp.toolCall.toolCallId === 'inner-1' - ); - assert.strictEqual(innerTool, undefined, 'stale buffered inner tool call must not be replayed'); - }); - - test('completeSubagentSession completes the subagent turn when parent tool completes', () => { - setupSession(); - startTurn('turn-1'); - disposables.add(sideEffects.registerProgressListener(agent)); - - // Start parent tool + subagent - agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1', toolCallId: 'tc-1', toolName: 'runSubagent', displayName: 'Run Subagent', toolClientId: undefined, _meta: { toolKind: undefined, language: undefined } } }); - agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1', toolCallId: 'tc-1', invocationMessage: 'Delegating...', toolInput: undefined, confirmed: ToolCallConfirmationReason.NotNeeded } }); - agent.fireProgress({ kind: 'subagent_started', session: sessionUri, toolCallId: 'tc-1', agentName: 'helper', agentDisplayName: 'Helper', agentDescription: 'Helps' }); - - // Complete the parent tool call - agent.fireProgress({ - kind: 'action', session: sessionUri, - action: { - type: ActionType.SessionToolCallComplete, session: sessionUri.toString(), turnId: 'turn-1', - toolCallId: 'tc-1', - result: { success: true, pastTenseMessage: 'Done' }, - }, - }); - - // Verify the subagent session's turn was completed - const subagentUri = `${sessionUri.toString()}/subagent/tc-1`; - const subState = stateManager.getSessionState(subagentUri); - assert.ok(subState); - assert.strictEqual(subState!.activeTurn, undefined, 'subagent turn should be completed'); - assert.strictEqual(subState!.turns.length, 1); - }); - - test('cancelSubagentSessions cancels all subagent sessions', () => { - setupSession(); - startTurn('turn-1'); - disposables.add(sideEffects.registerProgressListener(agent)); - - // Start two parent tool calls with subagents - agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1', toolCallId: 'tc-1', toolName: 'runSubagent', displayName: 'Sub 1', toolClientId: undefined, _meta: { toolKind: undefined, language: undefined } } }); - agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1', toolCallId: 'tc-1', invocationMessage: 'Delegating 1...', toolInput: undefined, confirmed: ToolCallConfirmationReason.NotNeeded } }); - agent.fireProgress({ kind: 'subagent_started', session: sessionUri, toolCallId: 'tc-1', agentName: 'sub1', agentDisplayName: 'Sub 1', agentDescription: 'First' }); - - agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1', toolCallId: 'tc-2', toolName: 'runSubagent', displayName: 'Sub 2', toolClientId: undefined, _meta: { toolKind: undefined, language: undefined } } }); - agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1', toolCallId: 'tc-2', invocationMessage: 'Delegating 2...', toolInput: undefined, confirmed: ToolCallConfirmationReason.NotNeeded } }); - agent.fireProgress({ kind: 'subagent_started', session: sessionUri, toolCallId: 'tc-2', agentName: 'sub2', agentDisplayName: 'Sub 2', agentDescription: 'Second' }); - - // Cancel via parent turn cancellation - sideEffects.handleAction({ - type: ActionType.SessionTurnCancelled, - session: sessionUri.toString(), - turnId: 'turn-1', - }); - - // Both subagent sessions should have their turns completed (cancelled) - const sub1 = stateManager.getSessionState(`${sessionUri.toString()}/subagent/tc-1`); - const sub2 = stateManager.getSessionState(`${sessionUri.toString()}/subagent/tc-2`); - assert.strictEqual(sub1?.activeTurn, undefined, 'sub1 turn should be cancelled'); - assert.strictEqual(sub2?.activeTurn, undefined, 'sub2 turn should be cancelled'); - }); - - test('removeSubagentSessions removes all subagent sessions from state', () => { - setupSession(); - startTurn('turn-1'); - disposables.add(sideEffects.registerProgressListener(agent)); - - agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1', toolCallId: 'tc-1', toolName: 'runSubagent', displayName: 'Sub 1', toolClientId: undefined, _meta: { toolKind: undefined, language: undefined } } }); - agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1', toolCallId: 'tc-1', invocationMessage: 'Delegating...', toolInput: undefined, confirmed: ToolCallConfirmationReason.NotNeeded } }); - agent.fireProgress({ kind: 'subagent_started', session: sessionUri, toolCallId: 'tc-1', agentName: 'sub', agentDisplayName: 'Sub', agentDescription: 'Has subagent' }); - - const subagentUri = `${sessionUri.toString()}/subagent/tc-1`; - assert.ok(stateManager.getSessionState(subagentUri)); - - sideEffects.removeSubagentSessions(sessionUri.toString()); - - assert.strictEqual(stateManager.getSessionState(subagentUri), undefined, 'subagent session should be removed'); - }); - - test('deltas with parentToolCallId route to subagent session', () => { - setupSession(); - startTurn('turn-1'); - disposables.add(sideEffects.registerProgressListener(agent)); - - agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1', toolCallId: 'tc-1', toolName: 'runSubagent', displayName: 'Run Subagent', toolClientId: undefined, _meta: { toolKind: undefined, language: undefined } } }); - agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1', toolCallId: 'tc-1', invocationMessage: 'Delegating...', toolInput: undefined, confirmed: ToolCallConfirmationReason.NotNeeded } }); - agent.fireProgress({ kind: 'subagent_started', session: sessionUri, toolCallId: 'tc-1', agentName: 'helper', agentDisplayName: 'Helper', agentDescription: 'Helps' }); - - // Fire a delta with parentToolCallId - agent.fireProgress({ - kind: 'action', session: sessionUri, parentToolCallId: 'tc-1', - action: { type: ActionType.SessionResponsePart, session: sessionUri.toString(), turnId: 'turn-1', part: { kind: ResponsePartKind.Markdown, id: 'msg-sub', content: 'thinking...' } }, - }); - - // Verify the delta went to the subagent session - const subagentUri = `${sessionUri.toString()}/subagent/tc-1`; - const subState = stateManager.getSessionState(subagentUri); - assert.ok(subState?.activeTurn); - const markdownPart = subState!.activeTurn!.responseParts.find( - rp => rp.kind === ResponsePartKind.Markdown - ); - assert.ok(markdownPart, 'delta should create a markdown part in subagent session'); - }); - - test('tool_complete preserves subagent content in completed tool call', () => { - setupSession(); - startTurn('turn-1'); - disposables.add(sideEffects.registerProgressListener(agent)); - - agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1', toolCallId: 'tc-1', toolName: 'task', displayName: 'Task', toolClientId: undefined, _meta: { toolKind: undefined, language: undefined } } }); - agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1', toolCallId: 'tc-1', invocationMessage: 'Delegating...', toolInput: undefined, confirmed: ToolCallConfirmationReason.NotNeeded } }); - agent.fireProgress({ kind: 'subagent_started', session: sessionUri, toolCallId: 'tc-1', agentName: 'explore', agentDisplayName: 'Explore', agentDescription: 'Explores' }); - - // Verify subagent content is on the running tool - const runningState = stateManager.getSessionState(sessionUri.toString()); - const runningTool = runningState?.activeTurn?.responseParts.find( - rp => rp.kind === ResponsePartKind.ToolCall && rp.toolCall.toolCallId === 'tc-1' - ); - assert.ok(runningTool?.kind === ResponsePartKind.ToolCall); - assert.strictEqual(runningTool.toolCall.status, ToolCallStatus.Running); - - // Complete the tool — the SDK result has its own content - agent.fireProgress({ - kind: 'action', session: sessionUri, - action: { - type: ActionType.SessionToolCallComplete, session: sessionUri.toString(), turnId: 'turn-1', - toolCallId: 'tc-1', - result: { success: true, pastTenseMessage: 'Delegated', content: [{ type: ToolResultContentType.Text, text: 'Done' }] }, - }, - }); - - // Verify the completed tool still has the subagent content entry - const completedState = stateManager.getSessionState(sessionUri.toString()); - const completedTool = completedState?.activeTurn?.responseParts.find( - rp => rp.kind === ResponsePartKind.ToolCall && rp.toolCall.toolCallId === 'tc-1' - ); - assert.ok(completedTool?.kind === ResponsePartKind.ToolCall); - assert.strictEqual(completedTool.toolCall.status, ToolCallStatus.Completed); - const content = completedTool.toolCall.content ?? []; - const subagentEntry = content.find(c => hasKey(c, { type: true }) && c.type === ToolResultContentType.Subagent); - assert.ok(subagentEntry, 'Completed tool should preserve subagent content entry'); - const textEntry = content.find(c => hasKey(c, { type: true }) && c.type === ToolResultContentType.Text); - assert.ok(textEntry, 'Completed tool should also have the SDK result content'); - }); - - test('inner tool_start arriving BEFORE subagent_started routes to subagent (not parent)', () => { - // Reproduces the regression where inner subagent tool calls show up - // flat at the top level of the parent session because the SDK can - // emit `tool_start` (with parentToolCallId) before `subagent_started`. - setupSession(); - startTurn('turn-1'); - disposables.add(sideEffects.registerProgressListener(agent)); - - // 1. Parent tool starts (the `task` invocation). - agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1', toolCallId: 'tc-parent', toolName: 'task', displayName: 'Task', toolClientId: undefined, _meta: { toolKind: undefined, language: undefined } } }); - agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1', toolCallId: 'tc-parent', invocationMessage: 'Delegating...', toolInput: undefined, confirmed: ToolCallConfirmationReason.NotNeeded } }); - - // 2. Inner tool fires BEFORE subagent_started (race condition). - agent.fireProgress({ - kind: 'action', session: sessionUri, parentToolCallId: 'tc-parent', - action: { - type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1', - toolCallId: 'inner-tc-1', toolName: 'readFile', displayName: 'Read File', toolClientId: undefined, - _meta: { toolKind: undefined, language: undefined }, - }, - }); - agent.fireProgress({ - kind: 'action', session: sessionUri, parentToolCallId: 'tc-parent', - action: { - type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1', - toolCallId: 'inner-tc-1', invocationMessage: 'Reading file...', toolInput: undefined, - confirmed: ToolCallConfirmationReason.NotNeeded, - }, - }); - - // 3. subagent_started arrives later. - agent.fireProgress({ kind: 'subagent_started', session: sessionUri, toolCallId: 'tc-parent', agentName: 'helper', agentDisplayName: 'Helper', agentDescription: 'Helps' }); - - const subagentUri = buildSubagentSessionUri(sessionUri.toString(), 'tc-parent'); - const subState = stateManager.getSessionState(subagentUri); - assert.ok(subState?.activeTurn, 'subagent session should exist'); - - const innerTool = subState!.activeTurn!.responseParts.find( - rp => rp.kind === ResponsePartKind.ToolCall && rp.toolCall.toolCallId === 'inner-tc-1' - ); - assert.ok(innerTool, 'inner tool fired before subagent_started should still end up in the subagent session'); - - // Parent must NOT have the inner tool. - const parentState = stateManager.getSessionState(sessionUri.toString()); - const parentInnerTool = parentState!.activeTurn!.responseParts.find( - rp => rp.kind === ResponsePartKind.ToolCall && rp.toolCall.toolCallId === 'inner-tc-1' - ); - assert.strictEqual(parentInnerTool, undefined, 'inner tool must not leak into parent session'); - }); - - test('reads inside parent working directory are auto-approved for tools in subagent sessions', () => { - // Subagent sessions don't carry their own workingDirectory or - // autoApprove config. Without inheritance from the parent, every - // tool call inside a subagent (even a read in the workspace) would - // surface a confirmation dialog. - setupSession(URI.file('/workspace').toString()); - startTurn('turn-1'); - disposables.add(sideEffects.registerProgressListener(agent)); - - // Parent task tool spawns a subagent. - agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1', toolCallId: 'tc-parent', toolName: 'task', displayName: 'Task', toolClientId: undefined, _meta: { toolKind: undefined, language: undefined } } }); - agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1', toolCallId: 'tc-parent', invocationMessage: 'Delegating...', toolInput: undefined, confirmed: ToolCallConfirmationReason.NotNeeded } }); - agent.fireProgress({ kind: 'subagent_started', session: sessionUri, toolCallId: 'tc-parent', agentName: 'helper', agentDisplayName: 'Helper', agentDescription: 'Helps' }); - - // Inner tool inside the subagent requests permission to read a file - // inside the parent workspace. - agent.fireProgress({ - kind: 'action', session: sessionUri, parentToolCallId: 'tc-parent', - action: { - type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1', - toolCallId: 'inner-read-1', toolName: 'read', displayName: 'Read', toolClientId: undefined, - _meta: { toolKind: undefined, language: undefined }, - }, - }); - agent.fireProgress({ - kind: 'action', session: sessionUri, parentToolCallId: 'tc-parent', - action: { - type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1', - toolCallId: 'inner-read-1', invocationMessage: 'Read file', toolInput: undefined, - confirmed: ToolCallConfirmationReason.NotNeeded, - }, - }); - agent.fireProgress({ - kind: 'pending_confirmation', session: sessionUri, - state: { - status: ToolCallStatus.PendingConfirmation, - toolCallId: 'inner-read-1', toolName: '', displayName: '', - invocationMessage: 'Read src/app.ts', toolInput: undefined, - confirmationTitle: undefined, edits: undefined, - }, - permissionKind: 'read', permissionPath: '/workspace/src/app.ts', - }); - - assert.deepStrictEqual(agent.respondToPermissionCalls, [ - { requestId: 'inner-read-1', approved: true }, - ]); - }); - - test('session-level autoApprove on the parent is inherited by tools in subagent sessions', () => { - setupSession(URI.file('/workspace').toString()); - startTurn('turn-1'); - disposables.add(sideEffects.registerProgressListener(agent)); - - // Set the parent session to "Bypass Approvals" via session config. - const parentState = stateManager.getSessionState(sessionUri.toString()); - if (parentState) { - parentState.config = { - schema: { - type: 'object', - properties: { - autoApprove: { - type: 'string', - title: 'Approvals', - enum: ['default', 'autoApprove', 'autopilot'], - default: 'default', - sessionMutable: true, - }, - }, - }, - values: { autoApprove: 'autoApprove' }, - }; - } - - agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1', toolCallId: 'tc-parent', toolName: 'task', displayName: 'Task', toolClientId: undefined, _meta: { toolKind: undefined, language: undefined } } }); - agent.fireProgress({ kind: 'action', session: sessionUri, action: { type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1', toolCallId: 'tc-parent', invocationMessage: 'Delegating...', toolInput: undefined, confirmed: ToolCallConfirmationReason.NotNeeded } }); - agent.fireProgress({ kind: 'subagent_started', session: sessionUri, toolCallId: 'tc-parent', agentName: 'helper', agentDisplayName: 'Helper', agentDescription: 'Helps' }); - - // Inner write outside the workspace would normally NOT auto-approve, - // but session-level autoApprove on the parent must apply. - agent.fireProgress({ - kind: 'action', session: sessionUri, parentToolCallId: 'tc-parent', - action: { - type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1', - toolCallId: 'inner-write-1', toolName: 'write', displayName: 'Write', toolClientId: undefined, - _meta: { toolKind: undefined, language: undefined }, - }, - }); - agent.fireProgress({ - kind: 'action', session: sessionUri, parentToolCallId: 'tc-parent', - action: { - type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1', - toolCallId: 'inner-write-1', invocationMessage: 'Write file', toolInput: undefined, - confirmed: ToolCallConfirmationReason.NotNeeded, - }, - }); - agent.fireProgress({ - kind: 'pending_confirmation', session: sessionUri, - state: { - status: ToolCallStatus.PendingConfirmation, - toolCallId: 'inner-write-1', toolName: '', displayName: '', - invocationMessage: 'Write /tmp/foo', toolInput: undefined, - confirmationTitle: undefined, edits: undefined, - }, - permissionKind: 'write', permissionPath: '/tmp/foo', - }); - - assert.deepStrictEqual(agent.respondToPermissionCalls, [ - { requestId: 'inner-write-1', approved: true }, - ]); - }); - }); - - // ---- Session permissions ------------------------------------------------ - - suite('session permissions', () => { - - test('tool_ready action includes confirmation options when confirmation is needed', () => { - setupSession(); - startTurn('turn-1'); - disposables.add(sideEffects.registerProgressListener(agent)); - - agent.fireProgress({ - kind: 'action', session: sessionUri, - action: { - type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1', - toolCallId: 'tc-perm-1', toolName: 'CustomTool', displayName: 'Custom Tool', toolClientId: undefined, - _meta: { toolKind: undefined, language: undefined }, - }, - }); - agent.fireProgress({ - kind: 'action', session: sessionUri, - action: { - type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1', - toolCallId: 'tc-perm-1', invocationMessage: 'Running custom tool', toolInput: undefined, - confirmed: ToolCallConfirmationReason.NotNeeded, - }, - }); - - agent.fireProgress({ - kind: 'pending_confirmation', session: sessionUri, - state: { - status: ToolCallStatus.PendingConfirmation, - toolCallId: 'tc-perm-1', toolName: '', displayName: '', - invocationMessage: 'Run custom tool', toolInput: undefined, - confirmationTitle: 'Run custom tool', edits: undefined, - }, - permissionKind: 'custom-tool', permissionPath: undefined, - }); - - const state = stateManager.getSessionState(sessionUri.toString()); - const tc = state!.activeTurn!.responseParts.find( - rp => rp.kind === ResponsePartKind.ToolCall && rp.toolCall.toolCallId === 'tc-perm-1' - ); - assert.ok(tc && tc.kind === ResponsePartKind.ToolCall, 'tool call should exist'); - assert.strictEqual(tc.toolCall.status, ToolCallStatus.PendingConfirmation); - assert.ok(Array.isArray(tc.toolCall.options), 'options should be an array'); - assert.deepStrictEqual(tc.toolCall.options!.map(o => o.id), ['allow-session', 'allow-once', 'skip']); - }); - - test('SessionToolCallConfirmed with allow-session adds tool to session permissions', () => { - setupSession(); - const state = stateManager.getSessionState(sessionUri.toString()); - if (state) { - state.config = { - schema: { type: 'object', properties: {} }, - values: {}, - }; - } - startTurn('turn-1'); - disposables.add(sideEffects.registerProgressListener(agent)); - - agent.fireProgress({ - kind: 'action', session: sessionUri, - action: { - type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1', - toolCallId: 'tc-perm-2', toolName: 'CustomTool', displayName: 'Custom Tool', toolClientId: undefined, - _meta: { toolKind: undefined, language: undefined }, - }, - }); - agent.fireProgress({ - kind: 'action', session: sessionUri, - action: { - type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1', - toolCallId: 'tc-perm-2', invocationMessage: 'Running custom tool', toolInput: undefined, - confirmed: ToolCallConfirmationReason.NotNeeded, - }, - }); - - agent.fireProgress({ - kind: 'pending_confirmation', session: sessionUri, - state: { - status: ToolCallStatus.PendingConfirmation, - toolCallId: 'tc-perm-2', toolName: '', displayName: '', - invocationMessage: 'Run custom tool', toolInput: undefined, - confirmationTitle: 'Run custom tool', edits: undefined, - }, - permissionKind: 'custom-tool', permissionPath: undefined, - }); - - sideEffects.handleAction({ - type: ActionType.SessionToolCallConfirmed, - session: sessionUri.toString(), - turnId: 'turn-1', - toolCallId: 'tc-perm-2', - approved: true, - confirmed: 'user-action' as const, - selectedOptionId: 'allow-session', - } as SessionAction); - - const updatedState = stateManager.getSessionState(sessionUri.toString()); - assert.deepStrictEqual( - updatedState!.config!.values.permissions, - { allow: ['CustomTool'], deny: [] }, - ); - }); - - test('subsequent tool_ready for same tool is auto-approved after allow-session permission', () => { - setupSession(); - const state = stateManager.getSessionState(sessionUri.toString()); - if (state) { - state.config = { - schema: { type: 'object', properties: {} }, - values: { permissions: { allow: ['CustomTool'], deny: [] } }, - }; - } - startTurn('turn-1'); - disposables.add(sideEffects.registerProgressListener(agent)); - - agent.fireProgress({ - kind: 'action', session: sessionUri, - action: { - type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1', - toolCallId: 'tc-perm-3', toolName: 'CustomTool', displayName: 'Custom Tool', toolClientId: undefined, - _meta: { toolKind: undefined, language: undefined }, - }, - }); - agent.fireProgress({ - kind: 'action', session: sessionUri, - action: { - type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1', - toolCallId: 'tc-perm-3', invocationMessage: 'Running custom tool', toolInput: undefined, - confirmed: ToolCallConfirmationReason.NotNeeded, - }, - }); - - agent.fireProgress({ - kind: 'pending_confirmation', session: sessionUri, - state: { - status: ToolCallStatus.PendingConfirmation, - toolCallId: 'tc-perm-3', toolName: '', displayName: '', - invocationMessage: 'Run custom tool', toolInput: undefined, - confirmationTitle: 'Run custom tool', edits: undefined, - }, - permissionKind: 'custom-tool', permissionPath: undefined, - }); - - assert.deepStrictEqual(agent.respondToPermissionCalls, [ - { requestId: 'tc-perm-3', approved: true }, - ]); - }); - - test('subagent tool calls inherit parent session permissions', () => { - setupSession(); - const state = stateManager.getSessionState(sessionUri.toString()); - if (state) { - state.config = { - schema: { type: 'object', properties: {} }, - values: { permissions: { allow: ['CustomTool'], deny: [] } }, - }; - } - startTurn('turn-1'); - disposables.add(sideEffects.registerProgressListener(agent)); - - agent.fireProgress({ - kind: 'action', session: sessionUri, - action: { - type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1', - toolCallId: 'tc-parent', toolName: 'task', displayName: 'Task', toolClientId: undefined, - _meta: { toolKind: undefined, language: undefined }, - }, - }); - agent.fireProgress({ - kind: 'action', session: sessionUri, - action: { - type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1', - toolCallId: 'tc-parent', invocationMessage: 'Delegating...', toolInput: undefined, - confirmed: ToolCallConfirmationReason.NotNeeded, - }, - }); - agent.fireProgress({ - kind: 'subagent_started', session: sessionUri, - toolCallId: 'tc-parent', - agentName: 'helper', - agentDisplayName: 'Helper', - agentDescription: 'Helps', - }); - - agent.fireProgress({ - kind: 'action', session: sessionUri, parentToolCallId: 'tc-parent', - action: { - type: ActionType.SessionToolCallStart, session: sessionUri.toString(), turnId: 'turn-1', - toolCallId: 'inner-perm-1', toolName: 'CustomTool', displayName: 'Custom Tool', toolClientId: undefined, - _meta: { toolKind: undefined, language: undefined }, - }, - }); - agent.fireProgress({ - kind: 'action', session: sessionUri, parentToolCallId: 'tc-parent', - action: { - type: ActionType.SessionToolCallReady, session: sessionUri.toString(), turnId: 'turn-1', - toolCallId: 'inner-perm-1', invocationMessage: 'Running custom tool', toolInput: undefined, - confirmed: ToolCallConfirmationReason.NotNeeded, - }, - }); - - agent.fireProgress({ - kind: 'pending_confirmation', session: sessionUri, - state: { - status: ToolCallStatus.PendingConfirmation, - toolCallId: 'inner-perm-1', toolName: '', displayName: '', - invocationMessage: 'Run custom tool', toolInput: undefined, - confirmationTitle: 'Run custom tool', edits: undefined, - }, - permissionKind: 'custom-tool', permissionPath: undefined, - }); - - assert.deepStrictEqual(agent.respondToPermissionCalls, [ - { requestId: 'inner-perm-1', approved: true }, - ]); - }); - }); - - // ---- Session diff computation ---------------------------------------------- - - suite('session diff computation', () => { - - test('git-driven path is preferred when a git service is provided and the working dir is a git work tree', async () => { - const sessionDb = new SessionDatabase(':memory:'); - disposables.add(toDisposable(() => sessionDb.close())); - const sessionDataService = createSessionDataService(sessionDb); - const localStateManager = disposables.add(new AgentHostStateManager(new NullLogService())); - const localAgent = new MockAgent(); - disposables.add(toDisposable(() => localAgent.dispose())); - - const gitDiffs = [{ - after: { uri: 'file:///wd/new.ts', content: { uri: 'file:///wd/new.ts' } }, - diff: { added: 1, removed: 0 }, - }]; - const computeCalls: { workingDirectory: string; sessionUri: string; baseBranch: string | undefined }[] = []; - const stubGit = { - computeSessionFileDiffs: async (wd: URI, opts: { sessionUri: string; baseBranch?: string }) => { - computeCalls.push({ workingDirectory: wd.toString(), sessionUri: opts.sessionUri, baseBranch: opts.baseBranch }); - return gitDiffs; - }, - } as unknown as import('../../node/agentHostGitService.js').IAgentHostGitService; - - const localSideEffects = createTestSideEffects(disposables, localStateManager, { - getAgent: () => localAgent, - agents: observableValue('agents', [localAgent]), - sessionDataService, - onTurnComplete: () => { }, - }, stubGit); - - localStateManager.createSession({ - resource: sessionUri.toString(), - provider: 'mock', - title: 'Test', - status: SessionStatus.Idle, - createdAt: Date.now(), - modifiedAt: Date.now(), - workingDirectory: 'file:///wd', - }); - await sessionDb.setMetadata('agentHost.diffBaseBranch', 'main'); - disposables.add(localSideEffects.registerProgressListener(localAgent)); - - const envelopes: ActionEnvelope[] = []; - let resolveDiffs: (() => void) | undefined; - const diffsEmitted = new Promise(r => { resolveDiffs = r; }); - disposables.add(localStateManager.onDidEmitEnvelope(e => { - envelopes.push(e); - if (e.action.type === ActionType.SessionDiffsChanged) { - resolveDiffs?.(); - } - })); - - // Trigger a turn-complete (which fires the immediate diff path). - localSideEffects.handleAction({ - type: ActionType.SessionTurnStarted, - session: sessionUri.toString(), - turnId: 'turn-1', - userMessage: { text: 'hi' }, - }); - localAgent.fireProgress({ - kind: 'action', session: URI.parse(sessionUri.toString()), - action: { type: ActionType.SessionTurnComplete, session: sessionUri.toString(), turnId: 'turn-1' }, - }); - - // Wait deterministically for the SessionDiffsChanged envelope rather - // than sleeping a fixed amount. - await diffsEmitted; - - assert.deepStrictEqual(computeCalls, [{ workingDirectory: 'file:///wd', sessionUri: sessionUri.toString(), baseBranch: 'main' }]); - const diffsAction = envelopes.map(e => e.action).find(a => a.type === ActionType.SessionDiffsChanged); - assert.ok(diffsAction, 'expected a SessionDiffsChanged action'); - assert.deepStrictEqual((diffsAction as { diffs: unknown }).diffs, gitDiffs); - }); - - test('falls back to the edit-tracker aggregator when the git service returns undefined', async () => { - const sessionDb = new SessionDatabase(':memory:'); - disposables.add(toDisposable(() => sessionDb.close())); - const sessionDataService = createSessionDataService(sessionDb); - const localStateManager = disposables.add(new AgentHostStateManager(new NullLogService())); - const localAgent = new MockAgent(); - disposables.add(toDisposable(() => localAgent.dispose())); - - const stubGit = { - computeSessionFileDiffs: async () => undefined, - } as unknown as import('../../node/agentHostGitService.js').IAgentHostGitService; - - const localSideEffects = createTestSideEffects(disposables, localStateManager, { - getAgent: () => localAgent, - agents: observableValue('agents', [localAgent]), - sessionDataService, - onTurnComplete: () => { }, - }, stubGit); - - localStateManager.createSession({ - resource: sessionUri.toString(), - provider: 'mock', - title: 'Test', - status: SessionStatus.Idle, - createdAt: Date.now(), - modifiedAt: Date.now(), - workingDirectory: 'file:///wd', - }); - disposables.add(localSideEffects.registerProgressListener(localAgent)); - - const envelopes: ActionEnvelope[] = []; - let resolveDiffs: (() => void) | undefined; - const diffsEmitted = new Promise(r => { resolveDiffs = r; }); - disposables.add(localStateManager.onDidEmitEnvelope(e => { - envelopes.push(e); - if (e.action.type === ActionType.SessionDiffsChanged) { - resolveDiffs?.(); - } - })); - - localSideEffects.handleAction({ - type: ActionType.SessionTurnStarted, - session: sessionUri.toString(), - turnId: 'turn-1', - userMessage: { text: 'hi' }, - }); - localAgent.fireProgress({ - kind: 'action', session: URI.parse(sessionUri.toString()), - action: { type: ActionType.SessionTurnComplete, session: sessionUri.toString(), turnId: 'turn-1' }, - }); - - await diffsEmitted; - - // With no recorded edits, the edit-tracker aggregator returns an empty array — the - // important assertion is that we still produced a SessionDiffsChanged envelope, which - // proves the fallback path executed without throwing. - const diffsAction = envelopes.map(e => e.action).find(a => a.type === ActionType.SessionDiffsChanged); - assert.ok(diffsAction, 'expected a SessionDiffsChanged action from the fallback path'); - assert.deepStrictEqual((diffsAction as { diffs: unknown[] }).diffs, []); - }); - }); -}); diff --git a/src/vs/platform/agentHost/test/node/anthropicBetas.test.ts b/src/vs/platform/agentHost/test/node/anthropicBetas.test.ts deleted file mode 100644 index dae36029..00000000 --- a/src/vs/platform/agentHost/test/node/anthropicBetas.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import assert from 'assert'; -import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { filterSupportedBetas } from '../../node/claude/anthropicBetas.js'; - -suite('filterSupportedBetas', () => { - - ensureNoDisposablesAreLeakedInTestSuite(); - - test('allows exact match from supported list', () => { - assert.strictEqual(filterSupportedBetas('interleaved-thinking-2025-05-14'), 'interleaved-thinking-2025-05-14'); - }); - - test('allows prefix match for context-management', () => { - assert.strictEqual(filterSupportedBetas('context-management-2025-06-27'), 'context-management-2025-06-27'); - }); - - test('allows prefix match for advanced-tool-use', () => { - assert.strictEqual(filterSupportedBetas('advanced-tool-use-2025-11-20'), 'advanced-tool-use-2025-11-20'); - }); - - test('filters out unsupported betas', () => { - assert.strictEqual(filterSupportedBetas('unsupported-beta-123'), undefined); - }); - - test('filters a comma-separated list to only supported betas', () => { - assert.strictEqual( - filterSupportedBetas('interleaved-thinking-2025-05-14,unsupported-beta,context-management-2025-06-27'), - 'interleaved-thinking-2025-05-14,context-management-2025-06-27', - ); - }); - - test('handles whitespace around commas', () => { - assert.strictEqual( - filterSupportedBetas('interleaved-thinking-2025-05-14 , context-management-2025-06-27'), - 'interleaved-thinking-2025-05-14,context-management-2025-06-27', - ); - }); - - test('returns undefined when all betas are unsupported', () => { - assert.strictEqual(filterSupportedBetas('foo,bar,baz'), undefined); - }); - - test('returns undefined for empty string', () => { - assert.strictEqual(filterSupportedBetas(''), undefined); - }); - - test('rejects supported family without date suffix (date-suffix discipline)', () => { - assert.strictEqual(filterSupportedBetas('interleaved-thinking'), undefined); - }); -}); diff --git a/src/vs/platform/agentHost/test/node/claudeModelId.test.ts b/src/vs/platform/agentHost/test/node/claudeModelId.test.ts deleted file mode 100644 index 24633f00..00000000 --- a/src/vs/platform/agentHost/test/node/claudeModelId.test.ts +++ /dev/null @@ -1,261 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import assert from 'assert'; -import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { parseClaudeModelId, tryParseClaudeModelId } from '../../node/claude/claudeModelId.js'; - -suite('parseClaudeModelId', () => { - - ensureNoDisposablesAreLeakedInTestSuite(); - - suite('parsing SDK model IDs', () => { - test('parses claude-{name}-{major}-{minor}-{date}', () => { - const result = parseClaudeModelId('claude-opus-4-5-20251101'); - assert.deepStrictEqual( - { name: result.name, version: result.version, modifiers: result.modifiers }, - { name: 'opus', version: '4.5', modifiers: '20251101' }, - ); - }); - - test('parses claude-{major}-{minor}-{name}-{date} (old format)', () => { - const result = parseClaudeModelId('claude-3-5-sonnet-20241022'); - assert.deepStrictEqual( - { name: result.name, version: result.version, modifiers: result.modifiers }, - { name: 'sonnet', version: '3.5', modifiers: '20241022' }, - ); - }); - - test('parses claude-{name}-{major}-{date}', () => { - const result = parseClaudeModelId('claude-sonnet-4-20250514'); - assert.deepStrictEqual( - { name: result.name, version: result.version, modifiers: result.modifiers }, - { name: 'sonnet', version: '4', modifiers: '20250514' }, - ); - }); - - test('parses claude-{major}-{name}-{date} (old format)', () => { - const result = parseClaudeModelId('claude-3-opus-20240229'); - assert.deepStrictEqual( - { name: result.name, version: result.version, modifiers: result.modifiers }, - { name: 'opus', version: '3', modifiers: '20240229' }, - ); - }); - - test('parses SDK ID without date suffix', () => { - const result = parseClaudeModelId('claude-opus-4-5'); - assert.deepStrictEqual( - { name: result.name, version: result.version, modifiers: result.modifiers }, - { name: 'opus', version: '4.5', modifiers: '' }, - ); - }); - }); - - suite('parsing endpoint model IDs', () => { - test('parses claude-{name}-{major}.{minor}', () => { - const result = parseClaudeModelId('claude-opus-4.5'); - assert.deepStrictEqual( - { name: result.name, version: result.version, modifiers: result.modifiers }, - { name: 'opus', version: '4.5', modifiers: '' }, - ); - }); - - test('parses claude-{name}-{major}', () => { - const result = parseClaudeModelId('claude-sonnet-4'); - assert.deepStrictEqual( - { name: result.name, version: result.version, modifiers: result.modifiers }, - { name: 'sonnet', version: '4', modifiers: '' }, - ); - }); - - test('parses claude-haiku-3.5', () => { - const result = parseClaudeModelId('claude-haiku-3.5'); - assert.deepStrictEqual( - { name: result.name, version: result.version, modifiers: result.modifiers }, - { name: 'haiku', version: '3.5', modifiers: '' }, - ); - }); - }); - - suite('modifiers (non-date suffixes)', () => { - test('parses endpoint ID with 1m context variant (dot version)', () => { - const result = parseClaudeModelId('claude-opus-4.6-1m'); - assert.deepStrictEqual( - { name: result.name, version: result.version, modifiers: result.modifiers }, - { name: 'opus', version: '4.6', modifiers: '1m' }, - ); - }); - - test('parses SDK ID with 1m context variant (dash version)', () => { - const result = parseClaudeModelId('claude-opus-4-6-1m'); - assert.deepStrictEqual( - { name: result.name, version: result.version, modifiers: result.modifiers }, - { name: 'opus', version: '4.6', modifiers: '1m' }, - ); - }); - - test('parses SDK ID with both 1m modifier and date suffix', () => { - const result = parseClaudeModelId('claude-opus-4-6-1m-20251101'); - assert.deepStrictEqual( - { name: result.name, version: result.version, modifiers: result.modifiers }, - { name: 'opus', version: '4.6', modifiers: '1m-20251101' }, - ); - }); - - test('parses single-version ID with modifier', () => { - const result = parseClaudeModelId('claude-sonnet-4-1m'); - assert.deepStrictEqual( - { name: result.name, version: result.version, modifiers: result.modifiers }, - { name: 'sonnet', version: '4', modifiers: '1m' }, - ); - }); - - test('1m on opus converts to correct SDK model ID', () => { - assert.strictEqual(parseClaudeModelId('claude-opus-4.6-1m').toSdkModelId(), 'claude-opus-4-6-1m'); - }); - - test('1m on opus converts to correct endpoint model ID', () => { - assert.strictEqual(parseClaudeModelId('claude-opus-4-6-1m').toEndpointModelId(), 'claude-opus-4.6-1m'); - }); - - test('1m on non-opus model is not included in SDK model ID', () => { - assert.strictEqual(parseClaudeModelId('claude-sonnet-4-1m').toSdkModelId(), 'claude-sonnet-4'); - }); - - test('1m on non-opus model is not included in endpoint model ID', () => { - assert.strictEqual(parseClaudeModelId('claude-sonnet-4-1m').toEndpointModelId(), 'claude-sonnet-4'); - }); - - test('1m with date suffix on opus keeps only 1m in SDK model ID', () => { - assert.strictEqual(parseClaudeModelId('claude-opus-4-6-1m-20251101').toSdkModelId(), 'claude-opus-4-6-1m'); - }); - - test('1m with date suffix on opus keeps only 1m in endpoint model ID', () => { - assert.strictEqual(parseClaudeModelId('claude-opus-4-6-1m-20251101').toEndpointModelId(), 'claude-opus-4.6-1m'); - }); - }); - - suite('bare model names', () => { - test('parses a bare name with no version', () => { - const result = parseClaudeModelId('foo'); - assert.deepStrictEqual( - { name: result.name, version: result.version, modifiers: result.modifiers }, - { name: 'foo', version: '', modifiers: '' }, - ); - }); - - test('toSdkModelId returns the bare name', () => { - assert.strictEqual(parseClaudeModelId('foo').toSdkModelId(), 'foo'); - }); - - test('toEndpointModelId returns the bare name', () => { - assert.strictEqual(parseClaudeModelId('foo').toEndpointModelId(), 'foo'); - }); - - test('parses bare "claude" as a bare name', () => { - const result = parseClaudeModelId('claude'); - assert.deepStrictEqual( - { name: result.name, version: result.version, modifiers: result.modifiers }, - { name: 'claude', version: '', modifiers: '' }, - ); - }); - }); - - suite('unparseable inputs', () => { - test('throws for hyphenated non-Claude IDs', () => { - assert.throws(() => parseClaudeModelId('gpt-4o'), /Unable to parse Claude model ID: 'gpt-4o'/); - }); - - test('throws for garbage with hyphens', () => { - assert.throws(() => parseClaudeModelId('invalid-model-id')); - }); - }); - - suite('tryParseClaudeModelId', () => { - test('returns undefined for hyphenated non-Claude IDs', () => { - assert.strictEqual(tryParseClaudeModelId('gpt-4o'), undefined); - }); - - test('returns a result for bare names', () => { - const result = tryParseClaudeModelId('foo'); - assert.ok(result); - assert.deepStrictEqual({ name: result.name, version: result.version }, { name: 'foo', version: '' }); - }); - - test('returns a result for valid Claude IDs', () => { - const result = tryParseClaudeModelId('claude-sonnet-4'); - assert.ok(result); - assert.deepStrictEqual({ name: result.name, version: result.version }, { name: 'sonnet', version: '4' }); - }); - }); - - suite('case insensitivity', () => { - test('parses uppercase input', () => { - const result = parseClaudeModelId('CLAUDE-OPUS-4-5'); - assert.deepStrictEqual( - { name: result.name, version: result.version }, - { name: 'opus', version: '4.5' }, - ); - }); - - test('parses mixed case input', () => { - const result = parseClaudeModelId('Claude-Sonnet-4-20250514'); - assert.deepStrictEqual( - { name: result.name, version: result.version, modifiers: result.modifiers }, - { name: 'sonnet', version: '4', modifiers: '20250514' }, - ); - }); - }); - - suite('caching', () => { - test('returns the same object for repeated calls', () => { - const first = parseClaudeModelId('claude-opus-4-5-20251101'); - const second = parseClaudeModelId('claude-opus-4-5-20251101'); - assert.strictEqual(first, second); - }); - - test('returns the same object for different casing of the same ID', () => { - const lower = parseClaudeModelId('claude-haiku-3-5'); - const upper = parseClaudeModelId('CLAUDE-HAIKU-3-5'); - assert.strictEqual(lower, upper); - }); - }); - - suite('toSdkModelId', () => { - test('produces dash-separated version for major.minor', () => { - assert.strictEqual(parseClaudeModelId('claude-opus-4.5').toSdkModelId(), 'claude-opus-4-5'); - }); - - test('produces single-digit version when no minor', () => { - assert.strictEqual(parseClaudeModelId('claude-sonnet-4').toSdkModelId(), 'claude-sonnet-4'); - }); - - test('normalizes old-format SDK IDs to new format', () => { - assert.strictEqual(parseClaudeModelId('claude-3-5-sonnet-20241022').toSdkModelId(), 'claude-sonnet-3-5'); - }); - - test('strips date suffix from SDK IDs', () => { - assert.strictEqual(parseClaudeModelId('claude-opus-4-5-20251101').toSdkModelId(), 'claude-opus-4-5'); - }); - }); - - suite('toEndpointModelId', () => { - test('produces dot-separated version for major.minor', () => { - assert.strictEqual(parseClaudeModelId('claude-opus-4-5-20251101').toEndpointModelId(), 'claude-opus-4.5'); - }); - - test('produces single-digit version when no minor', () => { - assert.strictEqual(parseClaudeModelId('claude-sonnet-4-20250514').toEndpointModelId(), 'claude-sonnet-4'); - }); - - test('normalizes old-format SDK IDs', () => { - assert.strictEqual(parseClaudeModelId('claude-3-5-sonnet-20241022').toEndpointModelId(), 'claude-sonnet-3.5'); - }); - - test('is identity for endpoint-format IDs', () => { - assert.strictEqual(parseClaudeModelId('claude-haiku-4.5').toEndpointModelId(), 'claude-haiku-4.5'); - }); - }); -}); diff --git a/src/vs/platform/agentHost/test/node/claudeProxyAuth.test.ts b/src/vs/platform/agentHost/test/node/claudeProxyAuth.test.ts deleted file mode 100644 index e1cb0d61..00000000 --- a/src/vs/platform/agentHost/test/node/claudeProxyAuth.test.ts +++ /dev/null @@ -1,81 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import assert from 'assert'; -import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { parseProxyBearer } from '../../node/claude/claudeProxyAuth.js'; - -const NONCE = 'test-nonce-deadbeef'; - -suite('parseProxyBearer', () => { - - ensureNoDisposablesAreLeakedInTestSuite(); - - test('accepts Bearer . with non-empty sessionId', () => { - assert.deepStrictEqual( - parseProxyBearer({ 'authorization': `Bearer ${NONCE}.session-abc` }, NONCE), - { valid: true, sessionId: 'session-abc' }, - ); - }); - - test('preserves dots inside the sessionId portion', () => { - assert.deepStrictEqual( - parseProxyBearer({ 'authorization': `Bearer ${NONCE}.session.with.dots` }, NONCE), - { valid: true, sessionId: 'session.with.dots' }, - ); - }); - - test('rejects missing Authorization header', () => { - assert.deepStrictEqual( - parseProxyBearer({}, NONCE), - { valid: false, sessionId: undefined }, - ); - }); - - test('rejects non-Bearer Authorization scheme', () => { - assert.deepStrictEqual( - parseProxyBearer({ 'authorization': `Basic ${NONCE}.s` }, NONCE), - { valid: false, sessionId: undefined }, - ); - }); - - test('rejects Bearer with wrong nonce', () => { - assert.deepStrictEqual( - parseProxyBearer({ 'authorization': 'Bearer wrong-nonce.session-abc' }, NONCE), - { valid: false, sessionId: undefined }, - ); - }); - - test('rejects Bearer with no dot (legacy format not supported)', () => { - assert.deepStrictEqual( - parseProxyBearer({ 'authorization': `Bearer ${NONCE}` }, NONCE), - { valid: false, sessionId: undefined }, - ); - }); - - test('rejects Bearer . with empty sessionId', () => { - assert.deepStrictEqual( - parseProxyBearer({ 'authorization': `Bearer ${NONCE}.` }, NONCE), - { valid: false, sessionId: undefined }, - ); - }); - - test('ignores x-api-key when only x-api-key is present', () => { - assert.deepStrictEqual( - parseProxyBearer({ 'x-api-key': NONCE }, NONCE), - { valid: false, sessionId: undefined }, - ); - }); - - test('uses Authorization header when both x-api-key and Authorization are present', () => { - assert.deepStrictEqual( - parseProxyBearer({ - 'x-api-key': 'sk-ant-real-api-key', - 'authorization': `Bearer ${NONCE}.s`, - }, NONCE), - { valid: true, sessionId: 's' }, - ); - }); -}); diff --git a/src/vs/platform/agentHost/test/node/claudeProxyService.test.ts b/src/vs/platform/agentHost/test/node/claudeProxyService.test.ts deleted file mode 100644 index 0ab7b780..00000000 --- a/src/vs/platform/agentHost/test/node/claudeProxyService.test.ts +++ /dev/null @@ -1,1343 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import assert from 'assert'; -import type Anthropic from '@anthropic-ai/sdk'; -import type { CCAModel } from '@vscode/copilot-api'; -import type * as http from 'http'; -import * as net from 'net'; -import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { NullLogService } from '../../../log/common/log.js'; -import { - COPILOT_API_ERROR_STATUS_STREAMING, - CopilotApiError, - type ICopilotApiService, - type ICopilotApiServiceRequestOptions, -} from '../../node/shared/copilotApiService.js'; -import { ClaudeProxyService } from '../../node/claude/claudeProxyService.js'; - -// #region Test fakes - -interface IFakeCall { - githubToken: string; - body: Anthropic.MessageCreateParams; - options: ICopilotApiServiceRequestOptions | undefined; -} - -interface IModelsCall { - githubToken: string; - options: ICopilotApiServiceRequestOptions | undefined; -} - -type MessagesResult = - | { kind: 'message'; message: Anthropic.Message } - | { kind: 'stream'; events: Anthropic.MessageStreamEvent[]; midStreamError?: CopilotApiError | Error } - | { kind: 'error'; error: Error }; - -class FakeCopilotApiService implements ICopilotApiService { - declare readonly _serviceBrand: undefined; - - messagesResult: MessagesResult = { kind: 'error', error: new Error('not configured') }; - modelsResult: { kind: 'value'; value: CCAModel[] } | { kind: 'error'; error: Error } = { kind: 'value', value: [] }; - - readonly messagesCalls: IFakeCall[] = []; - readonly modelsCalls: IModelsCall[] = []; - - /** - * Resolved when the next streaming consumer reads its first event, - * useful for tests that need to assert on mid-stream behavior. - */ - onStreamFirstRead?: () => void; - - messages( - githubToken: string, - request: Anthropic.MessageCreateParamsStreaming, - options?: ICopilotApiServiceRequestOptions, - ): AsyncGenerator; - messages( - githubToken: string, - request: Anthropic.MessageCreateParamsNonStreaming, - options?: ICopilotApiServiceRequestOptions, - ): Promise; - messages( - githubToken: string, - request: Anthropic.MessageCreateParams, - options?: ICopilotApiServiceRequestOptions, - ): AsyncGenerator | Promise { - this.messagesCalls.push({ githubToken, body: request, options }); - const result = this.messagesResult; - if (request.stream) { - return this._streamGen(result, options); - } - if (result.kind === 'message') { - return Promise.resolve(result.message); - } - if (result.kind === 'error') { - return Promise.reject(result.error); - } - return Promise.reject(new Error(`stream result configured but non-streaming request received`)); - } - - private async *_streamGen( - result: MessagesResult, - options: ICopilotApiServiceRequestOptions | undefined, - ): AsyncGenerator { - if (result.kind === 'error') { - throw result.error; - } - if (result.kind !== 'stream') { - throw new Error(`non-stream result configured but streaming request received`); - } - let firstReadFired = false; - for (const ev of result.events) { - if (options?.signal?.aborted) { - const e = new Error('Aborted'); - (e as { name: string }).name = 'AbortError'; - throw e; - } - if (!firstReadFired) { - firstReadFired = true; - this.onStreamFirstRead?.(); - } - yield ev; - } - if (result.midStreamError) { - throw result.midStreamError; - } - } - - async countTokens(): Promise { - throw new Error('countTokens not supported'); - } - - async models(githubToken: string, options?: ICopilotApiServiceRequestOptions): Promise { - this.modelsCalls.push({ githubToken, options }); - if (this.modelsResult.kind === 'error') { - throw this.modelsResult.error; - } - return this.modelsResult.value; - } -} - -// #endregion - -// #region HTTP helpers - -let _httpModule: typeof http | undefined; -async function getHttp(): Promise { - if (!_httpModule) { - _httpModule = await import('http'); - } - return _httpModule; -} - -interface IFetchedJson { - status: number; - headers: http.IncomingHttpHeaders; - body: string; - parsed: unknown; -} - -function fetchJson(url: string, init?: { method?: string; headers?: Record; body?: string }): Promise { - return getHttp().then(httpMod => new Promise((resolve, reject) => { - const u = new URL(url); - const req = httpMod.request({ - hostname: u.hostname, - port: u.port, - path: u.pathname + u.search, - method: init?.method ?? 'GET', - headers: init?.headers, - }, res => { - const chunks: Buffer[] = []; - res.on('data', c => chunks.push(Buffer.isBuffer(c) ? c : Buffer.from(c))); - res.on('end', () => { - const body = Buffer.concat(chunks).toString('utf8'); - let parsed: unknown; - try { parsed = body ? JSON.parse(body) : undefined; } catch { parsed = undefined; } - resolve({ status: res.statusCode ?? 0, headers: res.headers, body, parsed }); - }); - res.on('error', reject); - }); - req.on('error', reject); - if (init?.body !== undefined) { - req.write(init.body); - } - req.end(); - })); -} - -interface ISseResult { - status: number; - headers: http.IncomingHttpHeaders; - rawBody: string; - events: { type: string; data: unknown }[]; -} - -function fetchSse( - url: string, - init: { method: string; headers?: Record; body?: string }, - onResponse?: (res: http.IncomingMessage, abort: () => void) => void, -): Promise { - return getHttp().then(httpMod => new Promise((resolve, reject) => { - const u = new URL(url); - const req = httpMod.request({ - hostname: u.hostname, - port: u.port, - path: u.pathname + u.search, - method: init.method, - headers: init.headers, - }, res => { - const chunks: Buffer[] = []; - res.on('data', c => chunks.push(Buffer.isBuffer(c) ? c : Buffer.from(c))); - res.on('end', () => { - const rawBody = Buffer.concat(chunks).toString('utf8'); - resolve({ - status: res.statusCode ?? 0, - headers: res.headers, - rawBody, - events: parseSseFrames(rawBody), - }); - }); - res.on('error', reject); - onResponse?.(res, () => req.destroy()); - }); - req.on('error', err => { - // Aborted requests reject naturally — surface as resolution - // with whatever we got rather than failing the test. - reject(err); - }); - if (init.body !== undefined) { - req.write(init.body); - } - req.end(); - })); -} - -function parseSseFrames(raw: string): { type: string; data: unknown }[] { - const out: { type: string; data: unknown }[] = []; - const blocks = raw.split('\n\n'); - for (const block of blocks) { - if (!block.trim()) { - continue; - } - let event = ''; - let data = ''; - for (const line of block.split('\n')) { - if (line.startsWith('event: ')) { - event = line.slice('event: '.length).trim(); - } else if (line.startsWith('data: ')) { - data = line.slice('data: '.length); - } - } - if (event && data) { - let parsed: unknown; - try { parsed = JSON.parse(data); } catch { parsed = data; } - out.push({ type: event, data: parsed }); - } - } - return out; -} - -// #endregion - -// #region Fixtures - -const ANTHROPIC_MODEL: CCAModel = { - id: 'claude-opus-4.6', - name: 'Claude Opus 4.6', - vendor: 'Anthropic', - supported_endpoints: ['/v1/messages'], - object: 'model', - version: '4.6', - is_chat_default: false, - is_chat_fallback: false, - model_picker_category: '', - model_picker_enabled: true, - preview: false, - billing: { is_premium: false } as unknown as CCAModel['billing'], - capabilities: {} as CCAModel['capabilities'], - policy: {} as CCAModel['policy'], -}; - -const NON_ANTHROPIC_MODEL: CCAModel = { - ...ANTHROPIC_MODEL, - id: 'gpt-5', - name: 'GPT-5', - vendor: 'OpenAI', - supported_endpoints: ['/v1/chat/completions'], -}; - -const NON_MESSAGES_ANTHROPIC: CCAModel = { - ...ANTHROPIC_MODEL, - id: 'claude-instant-tokenizer', - name: 'Anthropic Tokenizer', - supported_endpoints: ['/v1/tokenize'], -}; - -function makeMessage(model: string, text: string): Anthropic.Message { - return { - id: 'msg_test', - type: 'message', - role: 'assistant', - model, - content: [{ type: 'text', text, citations: null }], - stop_reason: 'end_turn', - stop_sequence: null, - usage: { - input_tokens: 1, - output_tokens: 1, - cache_creation_input_tokens: null, - cache_read_input_tokens: null, - server_tool_use: null, - service_tier: null, - }, - } as Anthropic.Message; -} - -function makeStreamEvents(model: string): Anthropic.MessageStreamEvent[] { - const message = makeMessage(model, ''); - return [ - { type: 'message_start', message }, - { type: 'content_block_start', index: 0, content_block: { type: 'text', text: '', citations: [] } } as Anthropic.MessageStreamEvent, - { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'hello' } } as Anthropic.MessageStreamEvent, - { type: 'content_block_stop', index: 0 }, - { type: 'message_delta', delta: { stop_reason: 'end_turn', stop_sequence: null }, usage: { input_tokens: 1, output_tokens: 1, cache_creation_input_tokens: null, cache_read_input_tokens: null, server_tool_use: null } as Anthropic.MessageDeltaUsage } as Anthropic.MessageStreamEvent, - { type: 'message_stop' }, - ]; -} - -// #endregion - -// #region Service builder - -function createProxyService(fakeApi: FakeCopilotApiService): ClaudeProxyService { - return new ClaudeProxyService(new NullLogService(), fakeApi); -} - -const TOKEN = 'gh-test-token'; - -// #endregion - -suite('ClaudeProxyService', () => { - - ensureNoDisposablesAreLeakedInTestSuite(); - - // #region Lifecycle - - suite('Lifecycle', () => { - - test('start() returns handle with baseUrl and 256-bit hex nonce', async () => { - const fake = new FakeCopilotApiService(); - const service = createProxyService(fake); - const handle = await service.start(TOKEN); - try { - assert.match(handle.baseUrl, /^http:\/\/127\.0\.0\.1:\d+$/); - assert.match(handle.nonce, /^[0-9a-f]{64}$/); - } finally { - handle.dispose(); - service.dispose(); - } - }); - - test('two concurrent handles share baseUrl and nonce', async () => { - const fake = new FakeCopilotApiService(); - const service = createProxyService(fake); - // Issue both starts before the first bind resolves; they - // must share the runtime — without this the second caller - // will bind a second server and orphan the first. - const [h1, h2] = await Promise.all([ - service.start(TOKEN), - service.start('gh-other'), - ]); - try { - assert.strictEqual(h1.baseUrl, h2.baseUrl); - assert.strictEqual(h1.nonce, h2.nonce); - } finally { - h1.dispose(); - h2.dispose(); - service.dispose(); - } - }); - - test('dispose() while start() is awaiting bind rejects the start', async () => { - const fake = new FakeCopilotApiService(); - const service = createProxyService(fake); - const startPromise = service.start(TOKEN); - service.dispose(); - await assert.rejects(() => startPromise, /disposed/); - // A subsequent start() must also reject — the service is - // disposed and no orphaned runtime should be reachable. - await assert.rejects(() => service.start(TOKEN), /disposed/); - }); - - test('disposing one handle while another is alive keeps server up', async () => { - const fake = new FakeCopilotApiService(); - fake.modelsResult = { kind: 'value', value: [ANTHROPIC_MODEL] }; - const service = createProxyService(fake); - const h1 = await service.start(TOKEN); - const h2 = await service.start(TOKEN); - h1.dispose(); - - const res = await fetchJson(`${h2.baseUrl}/v1/models`, { - headers: { 'Authorization': `Bearer ${h2.nonce}.s1` }, - }); - assert.strictEqual(res.status, 200); - - h2.dispose(); - service.dispose(); - }); - - test('start() after refcount-0 dispose binds a new port and fresh nonce', async () => { - const fake = new FakeCopilotApiService(); - const service = createProxyService(fake); - const h1 = await service.start(TOKEN); - const baseUrl1 = h1.baseUrl; - const nonce1 = h1.nonce; - h1.dispose(); - - const h2 = await service.start(TOKEN); - try { - assert.notStrictEqual(h2.nonce, nonce1); - // port may or may not be different depending on OS reuse, - // but baseUrl reflects whatever the new bind chose - assert.match(h2.baseUrl, /^http:\/\/127\.0\.0\.1:\d+$/); - // also: previous baseUrl is no longer reachable in tests - // — verified indirectly via fresh nonce above - void baseUrl1; - } finally { - h2.dispose(); - service.dispose(); - } - }); - - test('dispose() throws on subsequent start()', async () => { - const service = createProxyService(new FakeCopilotApiService()); - service.dispose(); - await assert.rejects(() => service.start(TOKEN), /disposed/); - }); - - test('start() updates token slot last-writer-wins', async () => { - const fake = new FakeCopilotApiService(); - fake.modelsResult = { kind: 'value', value: [] }; - const service = createProxyService(fake); - const h1 = await service.start('token-A'); - const h2 = await service.start('token-B'); - try { - await fetchJson(`${h2.baseUrl}/v1/models`, { - headers: { 'Authorization': `Bearer ${h2.nonce}.s1` }, - }); - assert.strictEqual(fake.modelsCalls.at(-1)?.githubToken, 'token-B'); - } finally { - h1.dispose(); - h2.dispose(); - service.dispose(); - } - }); - }); - - // #endregion - - // #region Bind safety - - suite('Bind safety', () => { - test('binds only on 127.0.0.1', async () => { - const fake = new FakeCopilotApiService(); - const service = createProxyService(fake); - const handle = await service.start(TOKEN); - try { - assert.ok(handle.baseUrl.startsWith('http://127.0.0.1:')); - } finally { - handle.dispose(); - service.dispose(); - } - }); - }); - - // #endregion - - // #region Auth - - suite('Auth', () => { - - async function withProxy(fn: (handle: { baseUrl: string; nonce: string }, fake: FakeCopilotApiService) => Promise): Promise { - const fake = new FakeCopilotApiService(); - fake.modelsResult = { kind: 'value', value: [ANTHROPIC_MODEL] }; - fake.messagesResult = { kind: 'message', message: makeMessage('claude-opus-4.6', 'hi') }; - const service = createProxyService(fake); - const handle = await service.start(TOKEN); - try { - return await fn(handle, fake); - } finally { - handle.dispose(); - service.dispose(); - } - } - - test('missing Authorization header → 401', async () => { - await withProxy(async handle => { - const res = await fetchJson(`${handle.baseUrl}/v1/models`); - assert.strictEqual(res.status, 401); - assert.deepStrictEqual(res.parsed, { - type: 'error', - error: { type: 'authentication_error', message: 'Invalid authentication' }, - request_id: null, - }); - }); - }); - - test('Bearer wrong-nonce.x → 401', async () => { - await withProxy(async handle => { - const res = await fetchJson(`${handle.baseUrl}/v1/models`, { - headers: { 'Authorization': 'Bearer wrong-nonce.session' }, - }); - assert.strictEqual(res.status, 401); - }); - }); - - test('Bearer (no dot) → 401', async () => { - await withProxy(async handle => { - const res = await fetchJson(`${handle.baseUrl}/v1/models`, { - headers: { 'Authorization': `Bearer ${handle.nonce}` }, - }); - assert.strictEqual(res.status, 401); - }); - }); - - test('Bearer . (empty sessionId) → 401', async () => { - await withProxy(async handle => { - const res = await fetchJson(`${handle.baseUrl}/v1/models`, { - headers: { 'Authorization': `Bearer ${handle.nonce}.` }, - }); - assert.strictEqual(res.status, 401); - }); - }); - - test('x-api-key alone → 401', async () => { - await withProxy(async handle => { - const res = await fetchJson(`${handle.baseUrl}/v1/models`, { - headers: { 'x-api-key': handle.nonce }, - }); - assert.strictEqual(res.status, 401); - }); - }); - - test('Bearer . → request proceeds', async () => { - await withProxy(async (handle, fake) => { - const res = await fetchJson(`${handle.baseUrl}/v1/models`, { - headers: { 'Authorization': `Bearer ${handle.nonce}.session-abc` }, - }); - assert.strictEqual(res.status, 200); - assert.strictEqual(fake.modelsCalls.length, 1); - }); - }); - - test('auth-first precedence: GET /v1/models with bad auth does not reach upstream', async () => { - await withProxy(async (handle, fake) => { - const res = await fetchJson(`${handle.baseUrl}/v1/models`, { - headers: { 'Authorization': 'Bearer wrong.s' }, - }); - assert.strictEqual(res.status, 401); - assert.strictEqual(fake.modelsCalls.length, 0); - }); - }); - - test('auth-first precedence: POST /v1/messages with bad auth does not reach upstream', async () => { - await withProxy(async (handle, fake) => { - const res = await fetchJson(`${handle.baseUrl}/v1/messages`, { - method: 'POST', - headers: { - 'Authorization': 'Bearer wrong.s', - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ model: 'claude-opus-4-6', messages: [], max_tokens: 8 }), - }); - assert.strictEqual(res.status, 401); - assert.strictEqual(fake.messagesCalls.length, 0); - }); - }); - - test('auth-first precedence: POST /v1/messages/count_tokens with bad auth → 401 (not 501)', async () => { - await withProxy(async handle => { - const res = await fetchJson(`${handle.baseUrl}/v1/messages/count_tokens`, { - method: 'POST', - headers: { 'Authorization': 'Bearer wrong.s' }, - body: '{}', - }); - assert.strictEqual(res.status, 401); - }); - }); - }); - - // #endregion - - // #region Routes - - suite('Routes', () => { - - test('GET / → 200 ok, no auth required', async () => { - const fake = new FakeCopilotApiService(); - const service = createProxyService(fake); - const handle = await service.start(TOKEN); - try { - const res = await fetchJson(`${handle.baseUrl}/`); - assert.strictEqual(res.status, 200); - assert.strictEqual(res.body, 'ok'); - } finally { - handle.dispose(); - service.dispose(); - } - }); - - test('POST /v1/messages/count_tokens → 501 api_error', async () => { - const fake = new FakeCopilotApiService(); - const service = createProxyService(fake); - const handle = await service.start(TOKEN); - try { - const res = await fetchJson(`${handle.baseUrl}/v1/messages/count_tokens`, { - method: 'POST', - headers: { 'Authorization': `Bearer ${handle.nonce}.s` }, - body: '{}', - }); - assert.strictEqual(res.status, 501); - assert.deepStrictEqual(res.parsed, { - type: 'error', - error: { type: 'api_error', message: 'count_tokens not supported by CAPI' }, - request_id: null, - }); - } finally { - handle.dispose(); - service.dispose(); - } - }); - - test('GET /something-else → 404 not_found_error', async () => { - const fake = new FakeCopilotApiService(); - const service = createProxyService(fake); - const handle = await service.start(TOKEN); - try { - const res = await fetchJson(`${handle.baseUrl}/v2/whatever`, { - headers: { 'Authorization': `Bearer ${handle.nonce}.s` }, - }); - assert.strictEqual(res.status, 404); - const env = res.parsed as Anthropic.ErrorResponse; - assert.strictEqual(env.type, 'error'); - assert.strictEqual(env.error.type, 'not_found_error'); - } finally { - handle.dispose(); - service.dispose(); - } - }); - }); - - // #endregion - - // #region Models route - - suite('GET /v1/models', () => { - - test('returns Page envelope with SDK-format IDs and filters by vendor + endpoint', async () => { - const fake = new FakeCopilotApiService(); - fake.modelsResult = { kind: 'value', value: [ANTHROPIC_MODEL, NON_ANTHROPIC_MODEL, NON_MESSAGES_ANTHROPIC] }; - const service = createProxyService(fake); - const handle = await service.start(TOKEN); - try { - const res = await fetchJson(`${handle.baseUrl}/v1/models`, { - headers: { 'Authorization': `Bearer ${handle.nonce}.s` }, - }); - assert.strictEqual(res.status, 200); - const body = res.parsed as { data: Anthropic.ModelInfo[]; has_more: boolean; first_id: string | null; last_id: string | null }; - assert.deepStrictEqual(body, { - data: [{ - id: 'claude-opus-4-6', - type: 'model', - display_name: 'Claude Opus 4.6', - created_at: '1970-01-01T00:00:00Z', - capabilities: null, - max_input_tokens: null, - max_tokens: null, - }], - has_more: false, - first_id: 'claude-opus-4-6', - last_id: 'claude-opus-4-6', - }); - } finally { - handle.dispose(); - service.dispose(); - } - }); - - test('upstream CopilotApiError is re-emitted verbatim with original status', async () => { - const fake = new FakeCopilotApiService(); - const envelope: Anthropic.ErrorResponse = { - type: 'error', - error: { type: 'rate_limit_error', message: 'slow down' }, - request_id: 'req_123', - }; - fake.modelsResult = { kind: 'error', error: new CopilotApiError(429, envelope) }; - const service = createProxyService(fake); - const handle = await service.start(TOKEN); - try { - const res = await fetchJson(`${handle.baseUrl}/v1/models`, { - headers: { 'Authorization': `Bearer ${handle.nonce}.s` }, - }); - assert.strictEqual(res.status, 429); - assert.deepStrictEqual(res.parsed, envelope); - } finally { - handle.dispose(); - service.dispose(); - } - }); - - test('non-CopilotApiError → 502 api_error', async () => { - const fake = new FakeCopilotApiService(); - fake.modelsResult = { kind: 'error', error: new Error('ECONNRESET') }; - const service = createProxyService(fake); - const handle = await service.start(TOKEN); - try { - const res = await fetchJson(`${handle.baseUrl}/v1/models`, { - headers: { 'Authorization': `Bearer ${handle.nonce}.s` }, - }); - assert.strictEqual(res.status, 502); - const env = res.parsed as Anthropic.ErrorResponse; - assert.strictEqual(env.error.type, 'api_error'); - assert.strictEqual(env.error.message, 'ECONNRESET'); - } finally { - handle.dispose(); - service.dispose(); - } - }); - }); - - // #endregion - - // #region Messages — model translation - - suite('POST /v1/messages model translation', () => { - - test('SDK ID inbound is translated to endpoint ID upstream', async () => { - const fake = new FakeCopilotApiService(); - fake.messagesResult = { kind: 'message', message: makeMessage('claude-opus-4.6', 'hi') }; - const service = createProxyService(fake); - const handle = await service.start(TOKEN); - try { - await fetchJson(`${handle.baseUrl}/v1/messages`, { - method: 'POST', - headers: { - 'Authorization': `Bearer ${handle.nonce}.s`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ model: 'claude-opus-4-6-20251101', messages: [], max_tokens: 8 }), - }); - assert.strictEqual(fake.messagesCalls.length, 1); - assert.strictEqual(fake.messagesCalls[0].body.model, 'claude-opus-4.6'); - } finally { - handle.dispose(); - service.dispose(); - } - }); - - test('endpoint ID inbound is also accepted', async () => { - const fake = new FakeCopilotApiService(); - fake.messagesResult = { kind: 'message', message: makeMessage('claude-opus-4.6', 'hi') }; - const service = createProxyService(fake); - const handle = await service.start(TOKEN); - try { - await fetchJson(`${handle.baseUrl}/v1/messages`, { - method: 'POST', - headers: { - 'Authorization': `Bearer ${handle.nonce}.s`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ model: 'claude-opus-4.6', messages: [], max_tokens: 8 }), - }); - assert.strictEqual(fake.messagesCalls[0].body.model, 'claude-opus-4.6'); - } finally { - handle.dispose(); - service.dispose(); - } - }); - - test('unparseable model → 404 with no upstream call', async () => { - const fake = new FakeCopilotApiService(); - const service = createProxyService(fake); - const handle = await service.start(TOKEN); - try { - const res = await fetchJson(`${handle.baseUrl}/v1/messages`, { - method: 'POST', - headers: { - 'Authorization': `Bearer ${handle.nonce}.s`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ model: 'gpt-4o', messages: [], max_tokens: 8 }), - }); - assert.strictEqual(res.status, 404); - const env = res.parsed as Anthropic.ErrorResponse; - assert.strictEqual(env.error.type, 'not_found_error'); - assert.strictEqual(fake.messagesCalls.length, 0); - } finally { - handle.dispose(); - service.dispose(); - } - }); - - test('non-streaming response model is rewritten to SDK format', async () => { - const fake = new FakeCopilotApiService(); - fake.messagesResult = { kind: 'message', message: makeMessage('claude-opus-4.6', 'hi') }; - const service = createProxyService(fake); - const handle = await service.start(TOKEN); - try { - const res = await fetchJson(`${handle.baseUrl}/v1/messages`, { - method: 'POST', - headers: { - 'Authorization': `Bearer ${handle.nonce}.s`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ model: 'claude-opus-4-6', messages: [], max_tokens: 8 }), - }); - assert.strictEqual(res.status, 200); - const msg = res.parsed as Anthropic.Message; - assert.strictEqual(msg.model, 'claude-opus-4-6'); - } finally { - handle.dispose(); - service.dispose(); - } - }); - }); - - // #endregion - - // #region Body validation - - suite('Body validation', () => { - - test('non-JSON body → 400', async () => { - const fake = new FakeCopilotApiService(); - const service = createProxyService(fake); - const handle = await service.start(TOKEN); - try { - const res = await fetchJson(`${handle.baseUrl}/v1/messages`, { - method: 'POST', - headers: { - 'Authorization': `Bearer ${handle.nonce}.s`, - 'Content-Type': 'application/json', - }, - body: 'not-json', - }); - assert.strictEqual(res.status, 400); - const env = res.parsed as Anthropic.ErrorResponse; - assert.strictEqual(env.error.type, 'invalid_request_error'); - } finally { - handle.dispose(); - service.dispose(); - } - }); - - test('missing model field → 400', async () => { - const fake = new FakeCopilotApiService(); - const service = createProxyService(fake); - const handle = await service.start(TOKEN); - try { - const res = await fetchJson(`${handle.baseUrl}/v1/messages`, { - method: 'POST', - headers: { - 'Authorization': `Bearer ${handle.nonce}.s`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ messages: [], max_tokens: 8 }), - }); - assert.strictEqual(res.status, 400); - } finally { - handle.dispose(); - service.dispose(); - } - }); - - test('missing messages field → 400', async () => { - const fake = new FakeCopilotApiService(); - const service = createProxyService(fake); - const handle = await service.start(TOKEN); - try { - const res = await fetchJson(`${handle.baseUrl}/v1/messages`, { - method: 'POST', - headers: { - 'Authorization': `Bearer ${handle.nonce}.s`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ model: 'claude-opus-4-6', max_tokens: 8 }), - }); - assert.strictEqual(res.status, 400); - } finally { - handle.dispose(); - service.dispose(); - } - }); - }); - - // #endregion - - // #region Header passthrough - - suite('Header passthrough', () => { - - async function postAndCaptureHeaders(beta: string | undefined, version: string | undefined): Promise | undefined> { - const fake = new FakeCopilotApiService(); - fake.messagesResult = { kind: 'message', message: makeMessage('claude-opus-4.6', 'hi') }; - const service = createProxyService(fake); - const handle = await service.start(TOKEN); - const headers: Record = { - 'Authorization': `Bearer ${handle.nonce}.s`, - 'Content-Type': 'application/json', - 'x-request-id': 'caller-rid-123', - 'x-custom-thing': 'should-drop', - }; - if (beta !== undefined) { - headers['anthropic-beta'] = beta; - } - if (version !== undefined) { - headers['anthropic-version'] = version; - } - try { - await fetchJson(`${handle.baseUrl}/v1/messages`, { - method: 'POST', - headers, - body: JSON.stringify({ model: 'claude-opus-4-6', messages: [], max_tokens: 8 }), - }); - return fake.messagesCalls[0].options?.headers as Record | undefined; - } finally { - handle.dispose(); - service.dispose(); - } - } - - test('forwards anthropic-version verbatim', async () => { - const headers = await postAndCaptureHeaders(undefined, '2023-06-01'); - assert.strictEqual(headers?.['anthropic-version'], '2023-06-01'); - }); - - test('forwards supported anthropic-beta', async () => { - const headers = await postAndCaptureHeaders('interleaved-thinking-2025-05-14', undefined); - assert.strictEqual(headers?.['anthropic-beta'], 'interleaved-thinking-2025-05-14'); - }); - - test('filters out unsupported betas', async () => { - const headers = await postAndCaptureHeaders('foo,bar,baz', undefined); - assert.strictEqual(headers?.['anthropic-beta'], undefined); - }); - - test('drops supported family without date suffix', async () => { - const headers = await postAndCaptureHeaders('interleaved-thinking', undefined); - assert.strictEqual(headers?.['anthropic-beta'], undefined); - }); - - test('mixed beta list keeps supported entries only', async () => { - const headers = await postAndCaptureHeaders('interleaved-thinking-2025-05-14,foo', undefined); - assert.strictEqual(headers?.['anthropic-beta'], 'interleaved-thinking-2025-05-14'); - }); - - test('drops x-request-id and arbitrary headers', async () => { - const headers = await postAndCaptureHeaders('interleaved-thinking-2025-05-14', '2023-06-01') ?? {}; - assert.deepStrictEqual(Object.keys(headers).sort(), ['anthropic-beta', 'anthropic-version']); - }); - }); - - // #endregion - - // #region Streaming - - suite('Streaming', () => { - - test('emits SSE frames in order with hand-rolled framing and rewrites message_start.message.model', async () => { - const fake = new FakeCopilotApiService(); - fake.messagesResult = { kind: 'stream', events: makeStreamEvents('claude-opus-4.6') }; - const service = createProxyService(fake); - const handle = await service.start(TOKEN); - try { - const res = await fetchSse(`${handle.baseUrl}/v1/messages`, { - method: 'POST', - headers: { - 'Authorization': `Bearer ${handle.nonce}.s`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ model: 'claude-opus-4-6', messages: [], max_tokens: 8, stream: true }), - }); - assert.strictEqual(res.status, 200); - assert.strictEqual(res.headers['content-type'], 'text/event-stream'); - const types = res.events.map(e => e.type); - assert.deepStrictEqual(types, [ - 'message_start', - 'content_block_start', - 'content_block_delta', - 'content_block_stop', - 'message_delta', - 'message_stop', - ]); - const start = res.events[0].data as { type: 'message_start'; message: { model: string } }; - assert.strictEqual(start.message.model, 'claude-opus-4-6'); - assert.ok(!res.rawBody.includes('[DONE]')); - } finally { - handle.dispose(); - service.dispose(); - } - }); - - test('mid-stream CopilotApiError → SSE error frame, then end, no message_stop after', async () => { - const fake = new FakeCopilotApiService(); - const events: Anthropic.MessageStreamEvent[] = [ - { type: 'message_start', message: makeMessage('claude-opus-4.6', '') }, - { type: 'content_block_start', index: 0, content_block: { type: 'text', text: '', citations: [] } } as Anthropic.MessageStreamEvent, - ]; - const upstreamEnvelope: Anthropic.ErrorResponse = { - type: 'error', - error: { type: 'rate_limit_error', message: 'slow down' }, - request_id: 'req_xyz', - }; - fake.messagesResult = { - kind: 'stream', - events, - midStreamError: new CopilotApiError(COPILOT_API_ERROR_STATUS_STREAMING, upstreamEnvelope), - }; - const service = createProxyService(fake); - const handle = await service.start(TOKEN); - try { - const res = await fetchSse(`${handle.baseUrl}/v1/messages`, { - method: 'POST', - headers: { - 'Authorization': `Bearer ${handle.nonce}.s`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ model: 'claude-opus-4-6', messages: [], max_tokens: 8, stream: true }), - }); - assert.strictEqual(res.status, 200); - const lastEvent = res.events.at(-1); - assert.ok(lastEvent); - assert.strictEqual(lastEvent.type, 'error'); - assert.deepStrictEqual(lastEvent.data, upstreamEnvelope); - const types = res.events.map(e => e.type); - assert.ok(!types.includes('message_stop'), 'no message_stop after error frame'); - } finally { - handle.dispose(); - service.dispose(); - } - }); - - test('pre-stream CopilotApiError → JSON error response with original status', async () => { - const fake = new FakeCopilotApiService(); - const envelope: Anthropic.ErrorResponse = { - type: 'error', - error: { type: 'authentication_error', message: 'token expired' }, - request_id: 'req_pre', - }; - fake.messagesResult = { kind: 'error', error: new CopilotApiError(401, envelope) }; - const service = createProxyService(fake); - const handle = await service.start(TOKEN); - try { - const res = await fetchJson(`${handle.baseUrl}/v1/messages`, { - method: 'POST', - headers: { - 'Authorization': `Bearer ${handle.nonce}.s`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ model: 'claude-opus-4-6', messages: [], max_tokens: 8, stream: true }), - }); - assert.strictEqual(res.status, 401); - assert.deepStrictEqual(res.parsed, envelope); - } finally { - handle.dispose(); - service.dispose(); - } - }); - - test('pre-stream CopilotApiError with streaming sentinel coerces to 502 but preserves envelope', async () => { - // The 520 sentinel is meaningless as an HTTP status pre- - // header; the proxy must coerce to 502 while keeping the - // upstream envelope verbatim. See plan §1.5. - const fake = new FakeCopilotApiService(); - const envelope: Anthropic.ErrorResponse = { - type: 'error', - error: { type: 'overloaded_error', message: 'capacity full' }, - request_id: 'req_sentinel', - }; - fake.messagesResult = { kind: 'error', error: new CopilotApiError(COPILOT_API_ERROR_STATUS_STREAMING, envelope) }; - const service = createProxyService(fake); - const handle = await service.start(TOKEN); - try { - const res = await fetchJson(`${handle.baseUrl}/v1/messages`, { - method: 'POST', - headers: { - 'Authorization': `Bearer ${handle.nonce}.s`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ model: 'claude-opus-4-6', messages: [], max_tokens: 8, stream: true }), - }); - assert.strictEqual(res.status, 502); - assert.deepStrictEqual(res.parsed, envelope); - } finally { - handle.dispose(); - service.dispose(); - } - }); - - test('mid-stream non-CopilotApiError → synthesized SSE error frame', async () => { - const fake = new FakeCopilotApiService(); - const events: Anthropic.MessageStreamEvent[] = [ - { type: 'message_start', message: makeMessage('claude-opus-4.6', '') }, - ]; - fake.messagesResult = { kind: 'stream', events, midStreamError: new Error('socket hang up') }; - const service = createProxyService(fake); - const handle = await service.start(TOKEN); - try { - const res = await fetchSse(`${handle.baseUrl}/v1/messages`, { - method: 'POST', - headers: { - 'Authorization': `Bearer ${handle.nonce}.s`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ model: 'claude-opus-4-6', messages: [], max_tokens: 8, stream: true }), - }); - const lastEvent = res.events.at(-1); - assert.strictEqual(lastEvent?.type, 'error'); - const env = lastEvent.data as Anthropic.ErrorResponse; - assert.strictEqual(env.error.type, 'api_error'); - assert.strictEqual(env.error.message, 'socket hang up'); - } finally { - handle.dispose(); - service.dispose(); - } - }); - - test('tool-use input_json_delta events pass through', async () => { - const fake = new FakeCopilotApiService(); - const events: Anthropic.MessageStreamEvent[] = [ - { type: 'message_start', message: makeMessage('claude-opus-4.6', '') }, - { type: 'content_block_start', index: 0, content_block: { type: 'tool_use', id: 'toolu_1', name: 'do_thing', input: {} } } as Anthropic.MessageStreamEvent, - { type: 'content_block_delta', index: 0, delta: { type: 'input_json_delta', partial_json: '{"a":' } } as Anthropic.MessageStreamEvent, - { type: 'content_block_delta', index: 0, delta: { type: 'input_json_delta', partial_json: '1}' } } as Anthropic.MessageStreamEvent, - { type: 'content_block_stop', index: 0 }, - { type: 'message_stop' }, - ]; - fake.messagesResult = { kind: 'stream', events }; - const service = createProxyService(fake); - const handle = await service.start(TOKEN); - try { - const res = await fetchSse(`${handle.baseUrl}/v1/messages`, { - method: 'POST', - headers: { - 'Authorization': `Bearer ${handle.nonce}.s`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ model: 'claude-opus-4-6', messages: [], max_tokens: 8, stream: true }), - }); - const deltas = res.events.filter(e => e.type === 'content_block_delta').map(e => e.data as { delta: { type: string; partial_json?: string } }); - assert.deepStrictEqual(deltas.map(d => d.delta.type), ['input_json_delta', 'input_json_delta']); - assert.deepStrictEqual(deltas.map(d => d.delta.partial_json), ['{"a":', '1}']); - } finally { - handle.dispose(); - service.dispose(); - } - }); - - test('thinking_delta events pass through', async () => { - const fake = new FakeCopilotApiService(); - const events: Anthropic.MessageStreamEvent[] = [ - { type: 'message_start', message: makeMessage('claude-opus-4.6', '') }, - { type: 'content_block_start', index: 0, content_block: { type: 'thinking', thinking: '' } } as Anthropic.MessageStreamEvent, - { type: 'content_block_delta', index: 0, delta: { type: 'thinking_delta', thinking: 'hmm' } } as Anthropic.MessageStreamEvent, - { type: 'content_block_delta', index: 0, delta: { type: 'thinking_delta', thinking: ' ok' } } as Anthropic.MessageStreamEvent, - { type: 'content_block_stop', index: 0 }, - { type: 'message_stop' }, - ]; - fake.messagesResult = { kind: 'stream', events }; - const service = createProxyService(fake); - const handle = await service.start(TOKEN); - try { - const res = await fetchSse(`${handle.baseUrl}/v1/messages`, { - method: 'POST', - headers: { - 'Authorization': `Bearer ${handle.nonce}.s`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ model: 'claude-opus-4-6', messages: [], max_tokens: 8, stream: true }), - }); - const deltas = res.events.filter(e => e.type === 'content_block_delta').map(e => e.data as { delta: { type: string; thinking?: string } }); - assert.deepStrictEqual(deltas.map(d => d.delta.type), ['thinking_delta', 'thinking_delta']); - assert.deepStrictEqual(deltas.map(d => d.delta.thinking), ['hmm', ' ok']); - } finally { - handle.dispose(); - service.dispose(); - } - }); - - test('socket.setNoDelay(true) is called on streaming responses', async () => { - const fake = new FakeCopilotApiService(); - fake.messagesResult = { kind: 'stream', events: makeStreamEvents('claude-opus-4.6') }; - const service = createProxyService(fake); - const handle = await service.start(TOKEN); - - // Patch net.Socket.prototype.setNoDelay to track calls during - // this test only. - const original = net.Socket.prototype.setNoDelay; - const calls: boolean[] = []; - net.Socket.prototype.setNoDelay = function (this: net.Socket, enable?: boolean): net.Socket { - calls.push(enable !== false); - return original.call(this, enable as boolean); - }; - try { - await fetchSse(`${handle.baseUrl}/v1/messages`, { - method: 'POST', - headers: { - 'Authorization': `Bearer ${handle.nonce}.s`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ model: 'claude-opus-4-6', messages: [], max_tokens: 8, stream: true }), - }); - assert.ok(calls.some(c => c === true), 'expected setNoDelay(true) to have been called at least once'); - } finally { - net.Socket.prototype.setNoDelay = original; - handle.dispose(); - service.dispose(); - } - }); - }); - - // #endregion - - // #region Abort - - suite('Abort', () => { - - test('client disconnect mid-stream propagates AbortSignal upstream and writes nothing else', async () => { - let signalSeen: AbortSignal | undefined; - let resolveAborted!: () => void; - const abortObserved = new Promise(resolve => { resolveAborted = resolve; }); - const wrapped: ICopilotApiService = { - _serviceBrand: undefined, - // Custom stream: yield message_start, then wait until the - // caller's AbortSignal fires (mimics a real long-running - // upstream stream waiting for tokens to arrive). The test - // client disconnects after receiving the first frame, and - // we assert that the abort propagated. - messages: ((_token: string, _body: Anthropic.MessageCreateParams, options?: ICopilotApiServiceRequestOptions) => { - signalSeen = options?.signal; - async function* gen(): AsyncGenerator { - yield { type: 'message_start', message: makeMessage('claude-opus-4.6', '') }; - await new Promise((_resolve, reject) => { - const onAbort = () => { - resolveAborted(); - const e = new Error('Aborted'); - (e as { name: string }).name = 'AbortError'; - reject(e); - }; - if (options?.signal?.aborted) { - onAbort(); - return; - } - options?.signal?.addEventListener('abort', onAbort); - }); - } - return gen(); - }) as ICopilotApiService['messages'], - countTokens: () => Promise.reject(new Error('not used')), - models: () => Promise.resolve([]), - }; - const service = new ClaudeProxyService(new NullLogService(), wrapped); - const handle = await service.start(TOKEN); - - try { - const u = new URL(`${handle.baseUrl}/v1/messages`); - const httpMod = await getHttp(); - const clientFinished = new Promise(resolve => { - const req = httpMod.request({ - hostname: u.hostname, - port: u.port, - path: u.pathname, - method: 'POST', - headers: { - 'Authorization': `Bearer ${handle.nonce}.s`, - 'Content-Type': 'application/json', - }, - }, res => { - let frames = 0; - res.on('data', () => { - frames++; - if (frames >= 1) { - req.destroy(); - resolve(); - } - }); - res.on('error', () => resolve()); - res.on('close', () => resolve()); - }); - req.on('error', () => resolve()); - req.write(JSON.stringify({ model: 'claude-opus-4-6', messages: [], max_tokens: 8, stream: true })); - req.end(); - }); - await clientFinished; - // Wait for the upstream generator to observe the abort. - await Promise.race([ - abortObserved, - new Promise((_resolve, reject) => setTimeout(() => reject(new Error('upstream did not observe abort within 2s')), 2000)), - ]); - - assert.ok(signalSeen, 'expected upstream signal'); - assert.ok(signalSeen.aborted, 'expected abort to fire on client disconnect'); - } finally { - handle.dispose(); - service.dispose(); - } - }); - - test('dispose() with in-flight non-streaming aborts the upstream call', async () => { - const fake = new FakeCopilotApiService(); - let signalSeen: AbortSignal | undefined; - let releaseUpstream: () => void = () => { }; - const upstream = new Promise((_resolve, reject) => { - releaseUpstream = () => reject(Object.assign(new Error('Aborted'), { name: 'AbortError' })); - }); - const wrapped: ICopilotApiService = { - _serviceBrand: undefined, - messages: ((token, body, options) => { - signalSeen = options?.signal; - if (body.stream) { - return fake.messages(token, body as Anthropic.MessageCreateParamsStreaming, options); - } - options?.signal?.addEventListener('abort', () => releaseUpstream()); - return upstream; - }) as ICopilotApiService['messages'], - countTokens: fake.countTokens.bind(fake), - models: fake.models.bind(fake), - }; - const service = new ClaudeProxyService(new NullLogService(), wrapped); - const handle = await service.start(TOKEN); - - const inflight = fetchJson(`${handle.baseUrl}/v1/messages`, { - method: 'POST', - headers: { - 'Authorization': `Bearer ${handle.nonce}.s`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ model: 'claude-opus-4-6', messages: [], max_tokens: 8 }), - }).catch(err => ({ aborted: true, err: err as Error })); - - // Wait until upstream has been called. - await new Promise(resolve => { - const i = setInterval(() => { - if (signalSeen) { clearInterval(i); resolve(); } - }, 10); - }); - - handle.dispose(); - service.dispose(); - - const result = await inflight; - assert.ok(signalSeen?.aborted, 'expected abort to fire on dispose'); - // connection should have been destroyed; result is either an - // http error or a partial response — just verify we didn't get - // a 200 with a body. - void result; - }); - }); - - // #endregion -}); diff --git a/src/vs/platform/agentHost/test/node/commandAutoApprover.test.ts b/src/vs/platform/agentHost/test/node/commandAutoApprover.test.ts deleted file mode 100644 index c890ca7a..00000000 --- a/src/vs/platform/agentHost/test/node/commandAutoApprover.test.ts +++ /dev/null @@ -1,141 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import assert from 'assert'; -import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { NullLogService } from '../../../log/common/log.js'; -import { CommandAutoApprover } from '../../node/commandAutoApprover.js'; - -suite('CommandAutoApprover', () => { - - const disposables = ensureNoDisposablesAreLeakedInTestSuite(); - - let approver: CommandAutoApprover; - - setup(() => { - approver = disposables.add(new CommandAutoApprover(new NullLogService())); - }); - - suite('shouldAutoApprove', () => { - - test('approves empty command', () => { - assert.strictEqual(approver.shouldAutoApprove(''), 'approved'); - assert.strictEqual(approver.shouldAutoApprove(' '), 'approved'); - }); - - // Safe readonly commands - test('approves allowed readonly commands', () => { - assert.strictEqual(approver.shouldAutoApprove('ls'), 'approved'); - assert.strictEqual(approver.shouldAutoApprove('ls -la'), 'approved'); - assert.strictEqual(approver.shouldAutoApprove('cat file.txt'), 'approved'); - assert.strictEqual(approver.shouldAutoApprove('head -n 10 file.txt'), 'approved'); - assert.strictEqual(approver.shouldAutoApprove('tail -f log.txt'), 'approved'); - assert.strictEqual(approver.shouldAutoApprove('pwd'), 'approved'); - assert.strictEqual(approver.shouldAutoApprove('echo hello'), 'approved'); - assert.strictEqual(approver.shouldAutoApprove('grep -r pattern .'), 'approved'); - assert.strictEqual(approver.shouldAutoApprove('wc -l file.txt'), 'approved'); - assert.strictEqual(approver.shouldAutoApprove('which node'), 'approved'); - }); - - // Dangerous commands - test('denies denied commands', () => { - assert.strictEqual(approver.shouldAutoApprove('rm file.txt'), 'denied'); - assert.strictEqual(approver.shouldAutoApprove('rm -rf /'), 'denied'); - assert.strictEqual(approver.shouldAutoApprove('rmdir folder'), 'denied'); - assert.strictEqual(approver.shouldAutoApprove('kill -9 1234'), 'denied'); - assert.strictEqual(approver.shouldAutoApprove('curl http://evil.com'), 'denied'); - assert.strictEqual(approver.shouldAutoApprove('wget http://evil.com'), 'denied'); - assert.strictEqual(approver.shouldAutoApprove('chmod 777 file'), 'denied'); - assert.strictEqual(approver.shouldAutoApprove('chown root file'), 'denied'); - assert.strictEqual(approver.shouldAutoApprove('eval "bad stuff"'), 'denied'); - assert.strictEqual(approver.shouldAutoApprove('xargs rm'), 'denied'); - assert.strictEqual(approver.shouldAutoApprove('dd if=/dev/zero of=/dev/sda'), 'denied'); - }); - - // Safe git sub-commands - test('approves allowed git sub-commands', () => { - assert.strictEqual(approver.shouldAutoApprove('git status'), 'approved'); - assert.strictEqual(approver.shouldAutoApprove('git log --oneline'), 'approved'); - assert.strictEqual(approver.shouldAutoApprove('git diff HEAD'), 'approved'); - assert.strictEqual(approver.shouldAutoApprove('git show HEAD'), 'approved'); - assert.strictEqual(approver.shouldAutoApprove('git ls-files'), 'approved'); - assert.strictEqual(approver.shouldAutoApprove('git branch'), 'approved'); - }); - - // Unsafe git sub-commands - test('denies denied git operations', () => { - assert.strictEqual(approver.shouldAutoApprove('git branch -D main'), 'denied'); - assert.strictEqual(approver.shouldAutoApprove('git branch --delete main'), 'denied'); - assert.strictEqual(approver.shouldAutoApprove('git log --output=/tmp/out'), 'denied'); - }); - - // Safe commands with dangerous arg blocking - test('handles find with blocked args', () => { - assert.strictEqual(approver.shouldAutoApprove('find . -name "*.ts"'), 'approved'); - assert.strictEqual(approver.shouldAutoApprove('find . -delete'), 'denied'); - // find -exec with ; is treated as a compound command, requiring confirmation - assert.strictEqual(approver.shouldAutoApprove('find . -exec rm {} ;'), 'noMatch'); - }); - - test('handles sed with blocked args', () => { - assert.strictEqual(approver.shouldAutoApprove('sed "s/foo/bar/g" file.txt'), 'approved'); - assert.strictEqual(approver.shouldAutoApprove('sed -e "s/foo/bar/"'), 'denied'); - assert.strictEqual(approver.shouldAutoApprove('sed --expression "s/foo/bar/"'), 'denied'); - }); - - // npm/package managers - test('approves allowed npm commands', () => { - assert.strictEqual(approver.shouldAutoApprove('npm ci'), 'approved'); - assert.strictEqual(approver.shouldAutoApprove('npm ls'), 'approved'); - assert.strictEqual(approver.shouldAutoApprove('npm audit'), 'approved'); - }); - - // Unknown commands get noMatch - test('returns noMatch for unknown commands', () => { - assert.strictEqual(approver.shouldAutoApprove('my-custom-script'), 'noMatch'); - assert.strictEqual(approver.shouldAutoApprove('python script.py'), 'noMatch'); - assert.strictEqual(approver.shouldAutoApprove('node index.js'), 'noMatch'); - }); - - // Transient env vars - test('denies transient environment variable assignments', () => { - assert.strictEqual(approver.shouldAutoApprove('FOO=bar some-command'), 'denied'); - assert.strictEqual(approver.shouldAutoApprove('PATH=/evil:$PATH ls'), 'denied'); - }); - - // PowerShell - test('approves allowed PowerShell commands', () => { - assert.strictEqual(approver.shouldAutoApprove('Get-ChildItem'), 'approved'); - assert.strictEqual(approver.shouldAutoApprove('Get-Content file.txt'), 'approved'); - assert.strictEqual(approver.shouldAutoApprove('Write-Host "hello"'), 'approved'); - assert.strictEqual(approver.shouldAutoApprove('Select-Object Name'), 'approved'); - }); - - test('PowerShell case-insensitive rules work', () => { - // Rules with /i flag (like Select-*, Measure-*, etc.) are case-insensitive - assert.strictEqual(approver.shouldAutoApprove('select-object Name'), 'approved'); - assert.strictEqual(approver.shouldAutoApprove('SELECT-OBJECT Name'), 'approved'); - assert.strictEqual(approver.shouldAutoApprove('Measure-Command'), 'approved'); - assert.strictEqual(approver.shouldAutoApprove('measure-command'), 'approved'); - }); - - test('denies denied PowerShell commands', () => { - assert.strictEqual(approver.shouldAutoApprove('Remove-Item file.txt'), 'denied'); - assert.strictEqual(approver.shouldAutoApprove('Invoke-Expression "bad"'), 'denied'); - assert.strictEqual(approver.shouldAutoApprove('Invoke-WebRequest http://evil.com'), 'denied'); - assert.strictEqual(approver.shouldAutoApprove('Stop-Process -Id 1234'), 'denied'); - }); - - // Compound commands containing denied sub-commands should never be auto-approved, - // regardless of whether tree-sitter is available (with tree-sitter they are - // 'denied', without they are 'noMatch' — both are safe). - test('compound commands with denied sub-commands are not auto-approved', () => { - assert.notStrictEqual(approver.shouldAutoApprove('echo ok && rm -rf /'), 'approved'); - assert.notStrictEqual(approver.shouldAutoApprove('ls || curl evil.com'), 'approved'); - assert.notStrictEqual(approver.shouldAutoApprove('cat file; rm file'), 'approved'); - assert.notStrictEqual(approver.shouldAutoApprove('echo $(whoami)'), 'approved'); - }); - }); -}); diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts deleted file mode 100644 index 30d5765c..00000000 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ /dev/null @@ -1,899 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import type { CopilotSession, SessionEventPayload, SessionEventType, TypedSessionEventHandler } from '@github/copilot-sdk'; -import assert from 'assert'; -import * as fs from 'fs/promises'; -import * as os from 'os'; -import { Disposable, type DisposableStore, type IDisposable, type IReference } from '../../../../base/common/lifecycle.js'; -import { URI } from '../../../../base/common/uri.js'; -import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { INativeEnvironmentService } from '../../../environment/common/environment.js'; -import { FileService } from '../../../files/common/fileService.js'; -import { IFileService } from '../../../files/common/files.js'; -import { IInstantiationService } from '../../../instantiation/common/instantiation.js'; -import { InstantiationService } from '../../../instantiation/common/instantiationService.js'; -import { ServiceCollection } from '../../../instantiation/common/serviceCollection.js'; -import { ILogService, NullLogService } from '../../../log/common/log.js'; -import { IAgentPluginManager, ISyncedCustomization } from '../../common/agentPluginManager.js'; -import { AgentSession, type AgentSignal, type IAgentActionSignal, type IAgentSessionMetadata } from '../../common/agentService.js'; -import { ISessionDataService } from '../../common/sessionDataService.js'; -import { AHP_AUTH_REQUIRED, ProtocolError } from '../../common/state/sessionProtocol.js'; -import { buildSubagentSessionUri, ResponsePartKind, SessionCustomization, TurnState, type CustomizationRef, type MarkdownResponsePart, type ToolCallResult, type Turn } from '../../common/state/sessionState.js'; -import { ActionType, type IDeltaAction } from '../../common/state/sessionActions.js'; - -import { AgentConfigurationService, IAgentConfigurationService } from '../../node/agentConfigurationService.js'; -import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; -import { IAgentHostGitService } from '../../node/agentHostGitService.js'; -import { IAgentHostTerminalManager } from '../../node/agentHostTerminalManager.js'; -import { CopilotAgent, getCopilotWorktreeBranchName, getCopilotWorktreeName, getCopilotWorktreesRoot, type ICopilotClient } from '../../node/copilot/copilotAgent.js'; -import { CopilotAgentSession, type SessionWrapperFactory } from '../../node/copilot/copilotAgentSession.js'; -import { CopilotSessionWrapper } from '../../node/copilot/copilotSessionWrapper.js'; -import { ShellManager } from '../../node/copilot/copilotShellTools.js'; -import { SessionDatabase } from '../../node/sessionDatabase.js'; -import { createNullSessionDataService } from '../common/sessionTestHelpers.js'; - -class TestAgentPluginManager implements IAgentPluginManager { - declare readonly _serviceBrand: undefined; - - async syncCustomizations(_clientId: string, _customizations: CustomizationRef[], _progress?: (status: SessionCustomization[]) => void): Promise { - return []; - } -} - -class TestAgentHostGitService implements IAgentHostGitService { - declare readonly _serviceBrand: undefined; - - repositoryRoot: URI | undefined = undefined; - addedWorktrees: { repositoryRoot: URI; worktree: URI; branchName: string; startPoint: string }[] = []; - addedExistingWorktrees: { repositoryRoot: URI; worktree: URI; branchName: string }[] = []; - removedWorktrees: { repositoryRoot: URI; worktree: URI }[] = []; - existingBranches = new Set(); - dirtyWorkingDirectories = new Set(); - - async isInsideWorkTree(): Promise { return false; } - async getCurrentBranch(): Promise { return undefined; } - async getDefaultBranch(): Promise { return undefined; } - async getBranches(): Promise { return []; } - async getRepositoryRoot(): Promise { return this.repositoryRoot; } - async getWorktreeRoots(): Promise { return []; } - async addWorktree(repositoryRoot: URI, worktree: URI, branchName: string, startPoint: string): Promise { - this.addedWorktrees.push({ repositoryRoot, worktree, branchName, startPoint }); - this.existingBranches.add(branchName); - } - async addExistingWorktree(repositoryRoot: URI, worktree: URI, branchName: string): Promise { - this.addedExistingWorktrees.push({ repositoryRoot, worktree, branchName }); - } - async removeWorktree(repositoryRoot: URI, worktree: URI): Promise { - this.removedWorktrees.push({ repositoryRoot, worktree }); - } - async branchExists(_repositoryRoot: URI, branchName: string): Promise { - return this.existingBranches.has(branchName); - } - async hasUncommittedChanges(workingDirectory: URI): Promise { - return this.dirtyWorkingDirectories.has(workingDirectory.fsPath); - } - async getSessionGitState(): Promise { return undefined; } - async computeSessionFileDiffs(): Promise { return undefined; } - async showBlob(): Promise { return undefined; } -} - -class TestAgentHostTerminalManager implements IAgentHostTerminalManager { - declare readonly _serviceBrand: undefined; - - async createTerminal(): Promise { } - writeInput(): void { } - onData(): IDisposable { return Disposable.None; } - onExit(): IDisposable { return Disposable.None; } - onClaimChanged(): IDisposable { return Disposable.None; } - onCommandFinished(): IDisposable { return Disposable.None; } - getContent(): string | undefined { return undefined; } - getClaim(): undefined { return undefined; } - hasTerminal(): boolean { return false; } - getExitCode(): number | undefined { return undefined; } - supportsCommandDetection(): boolean { return false; } - disposeTerminal(): void { } - getTerminalInfos(): [] { return []; } - getTerminalState(): undefined { return undefined; } -} - -class TestSessionDataService extends Disposable implements ISessionDataService { - declare readonly _serviceBrand: undefined; - - private readonly _databases = new Map(); - readonly openedSessions: string[] = []; - - getSessionDataDir(session: URI): URI { return URI.from({ scheme: 'test', path: `/session-data/${AgentSession.id(session)}` }); } - getSessionDataDirById(sessionId: string): URI { return URI.from({ scheme: 'test', path: `/session-data/${sessionId}` }); } - - openDatabase(session: URI): IReference { - const sessionId = AgentSession.id(session); - this.openedSessions.push(sessionId); - let db = this._databases.get(sessionId); - if (!db) { - db = this._register(new SessionDatabase(':memory:')); - this._databases.set(sessionId, db); - } - return { object: db, dispose: () => { } }; - } - - async tryOpenDatabase(session: URI): Promise | undefined> { - const db = this._databases.get(AgentSession.id(session)); - return db ? { object: db, dispose: () => { } } : undefined; - } - - deleteSessionData(): Promise { return Promise.resolve(); } - cleanupOrphanedData(): Promise { return Promise.resolve(); } - whenIdle(): Promise { return Promise.resolve(); } -} - -class TestCopilotClient implements ICopilotClient { - readonly rpc: ICopilotClient['rpc'] = { sessions: { fork: async () => ({ sessionId: 'forked-session' }) } }; - - constructor( - private readonly _sessions: Awaited>, - ) { } - - async start(): Promise { } - async stop(): ReturnType { return []; } - async listSessions(): ReturnType { return this._sessions; } - async listModels(): ReturnType { return []; } - async getSessionMetadata(): ReturnType { return undefined; } - createSession: ICopilotClient['createSession'] = async () => { throw new Error('not implemented'); }; - resumeSession: ICopilotClient['resumeSession'] = async () => { throw new Error('not implemented'); }; -} - -interface IFakeAgentSession { - send: (prompt: string, attachments?: unknown, turnId?: string, announcement?: string) => Promise; - getMessages: () => Promise; - dispose: () => void; -} - -class MockCopilotSession { - readonly sessionId = 'test-session-1'; - - on(_eventType: K, _handler: TypedSessionEventHandler): () => void { - return () => { }; - } - - async send(): Promise { return ''; } - async abort(): Promise { } - async setModel(): Promise { } - async getMessages(): Promise[]> { return []; } - async destroy(): Promise { } -} - -class TestableCopilotAgent extends CopilotAgent { - private readonly _fakeSessions = new Map(); - readonly resumeCalls: string[] = []; - - constructor( - private readonly _copilotClient: ICopilotClient, - @ILogService logService: ILogService, - @IInstantiationService instantiationService: IInstantiationService, - @IFileService fileService: IFileService, - @ISessionDataService sessionDataService: ISessionDataService, - @IAgentHostGitService gitService: IAgentHostGitService, - @IAgentHostTerminalManager terminalManager: IAgentHostTerminalManager, - @IAgentConfigurationService configurationService: IAgentConfigurationService, - ) { - super(logService, instantiationService, fileService, sessionDataService, gitService, terminalManager, configurationService); - this._enablePlanModeOnClient(this._copilotClient); - } - - protected override _createCopilotClient(): ICopilotClient { - return this._copilotClient; - } - - registerFakeSession(sessionId: string, fake: IFakeAgentSession): void { - this._fakeSessions.set(sessionId, fake); - } - - protected override async _resumeSession(sessionId: string): Promise { - this.resumeCalls.push(sessionId); - const fake = this._fakeSessions.get(sessionId); - if (!fake) { - throw new Error(`No fake session registered for '${sessionId}'`); - } - const sessionUri = AgentSession.uri('copilotcli', sessionId); - const emitter = (this as unknown as { _onDidSessionProgress: { fire(s: AgentSignal): void } })._onDidSessionProgress; - let turnId = ''; - // `_sessions` is a DisposableMap, so it will dispose() the entry on - // teardown. The fields below are the only ones touched by sendMessage - // and getSessionMessages in the code under test. - const stub = { - send: fake.send, - getMessages: fake.getMessages, - appliedSnapshot: undefined, - dispose: fake.dispose, - resetTurnState: (newTurnId: string) => { turnId = newTurnId; }, - emitInitialMarkdown: (content: string) => { - emitter.fire({ - kind: 'action', - session: sessionUri, - action: { - type: ActionType.SessionResponsePart, - session: sessionUri.toString(), - turnId, - part: { kind: ResponsePartKind.Markdown, id: `synth-${Date.now()}`, content }, - }, - }); - }, - } as unknown as CopilotAgentSession; - return stub; - } - - resolveWorktreeForTest(config: Parameters[0], sessionId: string): Promise { - return this._resolveSessionWorkingDirectory(config, sessionId); - } -} - -function createTestAgentContext(disposables: Pick, options?: { sessionDataService?: ISessionDataService; copilotClient?: ICopilotClient; gitService?: TestAgentHostGitService; environmentServiceRegistration?: 'native' | 'none'; pluginManager?: IAgentPluginManager }): { agent: CopilotAgent; instantiationService: IInstantiationService } { - const services = new ServiceCollection(); - const logService = new NullLogService(); - const fileService = disposables.add(new FileService(logService)); - const stateManager = disposables.add(new AgentHostStateManager(logService)); - const configService = disposables.add(new AgentConfigurationService(stateManager, logService)); - services.set(ILogService, logService); - services.set(IFileService, fileService); - services.set(IAgentConfigurationService, configService); - services.set(ISessionDataService, options?.sessionDataService ?? createNullSessionDataService()); - services.set(IAgentPluginManager, options?.pluginManager ?? new TestAgentPluginManager()); - services.set(IAgentHostGitService, options?.gitService ?? new TestAgentHostGitService()); - services.set(IAgentHostTerminalManager, new TestAgentHostTerminalManager()); - if (options?.environmentServiceRegistration !== 'none') { - const environmentService = { - _serviceBrand: undefined, - userHome: URI.file('/mock-home'), - } as INativeEnvironmentService; - services.set(INativeEnvironmentService, environmentService); - } - const instantiationService: IInstantiationService = disposables.add(new InstantiationService(services)); - services.set(IInstantiationService, instantiationService); - const agent = options?.copilotClient - ? instantiationService.createInstance(TestableCopilotAgent, options.copilotClient) - : instantiationService.createInstance(CopilotAgent); - return { agent, instantiationService }; -} - -function createTestAgent(disposables: Pick, options?: { sessionDataService?: ISessionDataService; copilotClient?: ICopilotClient; gitService?: TestAgentHostGitService; environmentServiceRegistration?: 'native' | 'none'; pluginManager?: IAgentPluginManager }): CopilotAgent { - return createTestAgentContext(disposables, options).agent; -} - -function createAgentSessionThroughAgent(agent: CopilotAgent, instantiationService: IInstantiationService): CopilotAgentSession { - const sessionUri = AgentSession.uri('copilotcli', 'test-session-1'); - const shellManager = instantiationService.createInstance(ShellManager, sessionUri, undefined); - const wrapperFactory: SessionWrapperFactory = async () => new CopilotSessionWrapper(new MockCopilotSession() as unknown as CopilotSession); - return (agent as unknown as { - _createAgentSession: (wrapperFactory: SessionWrapperFactory, sessionId: string, shellManager: ShellManager) => CopilotAgentSession; - })._createAgentSession(wrapperFactory, 'test-session-1', shellManager); -} - -function withoutUndefinedProperties(metadata: IAgentSessionMetadata): Record { - const result: Record = {}; - for (const [key, value] of Object.entries(metadata)) { - if (value !== undefined) { - result[key] = value; - } - } - return result; -} - -function sdkSession(sessionId: string, cwd?: string): Awaited>[number] { - return { - sessionId, - startTime: new Date(1000), - modifiedTime: new Date(2000), - summary: `SDK ${sessionId}`, - isRemote: false, - ...(cwd ? { context: { cwd } } : {}), - }; -} - -async function disposeAgent(agent: CopilotAgent): Promise { - await agent.shutdown(); - agent.dispose(); - // CopilotAgent.dispose calls super.dispose() from a promise continuation so - // async shutdown can stop SDK sessions before child disposables are released. - // Let that continuation run before the disposable leak tracker checks. - await Promise.resolve(); -} - -suite('CopilotAgent', () => { - const disposables = ensureNoDisposablesAreLeakedInTestSuite(); - - test('uses the Copilot CLI sibling worktrees root convention', () => { - assert.strictEqual( - getCopilotWorktreesRoot(URI.file('/Users/me/src/vscode')).fsPath, - URI.file('/Users/me/src/vscode.worktrees').fsPath, - ); - }); - - test('uses Agents-window Copilot CLI branch prefix', () => { - assert.strictEqual(getCopilotWorktreeBranchName('12345678-aaaa-bbbb-cccc-123456789abc', 'add-agent-host-config'), 'agents/add-agent-host-config-12345678'); - assert.strictEqual(getCopilotWorktreeBranchName('12345678-aaaa-bbbb-cccc-123456789abc', undefined), 'agents/12345678-aaaa-bbbb-cccc-123456789abc'); - }); - - test('uses Git extension branch-derived worktree folder names', () => { - assert.strictEqual(getCopilotWorktreeName('agents/add-agent-host-config-12345678'), 'agents-add-agent-host-config-12345678'); - }); - - test('keeps hinted branch names short', () => { - assert.strictEqual(getCopilotWorktreeBranchName('12345678-aaaa-bbbb-cccc-123456789abc', 'a'.repeat(48)).length, 'agents/'.length + 48 + '-12345678'.length); - }); - - test('returns empty models and throws AuthRequired for sessions before authentication', async () => { - const agent = createTestAgent(disposables); - try { - assert.deepStrictEqual(agent.models.get(), []); - await assert.rejects( - () => agent.listSessions(), - (error: Error) => error instanceof ProtocolError && error.code === AHP_AUTH_REQUIRED, - ); - } finally { - await disposeAgent(agent); - } - }); - - test('requires authentication before creating a session', async () => { - const agent = createTestAgent(disposables); - try { - await assert.rejects( - () => agent.createSession({ workingDirectory: URI.file('/workspace') }), - (error: Error) => error instanceof ProtocolError && error.code === AHP_AUTH_REQUIRED, - ); - } finally { - await disposeAgent(agent); - } - }); - - test('agent-created sessions can resolve session-state paths via INativeEnvironmentService', async () => { - const sessionDataService = disposables.add(new TestSessionDataService()); - const { agent, instantiationService } = createTestAgentContext(disposables, { - environmentServiceRegistration: 'native', - sessionDataService, - }); - const previousXdgStateHome = process.env['XDG_STATE_HOME']; - delete process.env['XDG_STATE_HOME']; - try { - const agentSession = disposables.add(createAgentSessionThroughAgent(agent, instantiationService)); - await agentSession.initializeSession(); - - const result = await agentSession.handlePermissionRequest({ - kind: 'read', - path: URI.file('/mock-home/.copilot/session-state/test-session-1/plan.md').fsPath, - toolCallId: 'tc-read-plan-agent-composition', - }); - - assert.strictEqual(result.kind, 'approve-once'); - } finally { - if (previousXdgStateHome === undefined) { - delete process.env['XDG_STATE_HOME']; - } else { - process.env['XDG_STATE_HOME'] = previousXdgStateHome; - } - await disposeAgent(agent); - } - }); - - test('listSessions only returns sessions with a database', async () => { - const sessionDataService = disposables.add(new TestSessionDataService()); - const ownedSession = AgentSession.uri('copilotcli', 'owned'); - const ownedDb = sessionDataService.openDatabase(ownedSession); - ownedDb.dispose(); - - const client = new TestCopilotClient([sdkSession('owned'), sdkSession('external')]); - const agent = createTestAgent(disposables, { sessionDataService, copilotClient: client }); - try { - await agent.authenticate('https://api.github.com', 'token'); - - assert.deepStrictEqual((await agent.listSessions()).map(s => AgentSession.id(s.session)), ['owned']); - } finally { - await disposeAgent(agent); - } - }); - - test('listSessions reads stored metadata from sessions with a database', async () => { - const sessionDataService = disposables.add(new TestSessionDataService()); - const legacySession = AgentSession.uri('copilotcli', 'legacy'); - const legacyDb = sessionDataService.openDatabase(legacySession); - await legacyDb.object.setMetadata('copilot.workingDirectory', URI.file('/workspace').toString()); - legacyDb.dispose(); - - const agent = createTestAgent(disposables, { sessionDataService, copilotClient: new TestCopilotClient([sdkSession('legacy')]) }); - try { - await agent.authenticate('https://api.github.com', 'token'); - - assert.deepStrictEqual((await agent.listSessions()).map(withoutUndefinedProperties), [{ - session: legacySession, - startTime: 1000, - modifiedTime: 2000, - summary: 'SDK legacy', - workingDirectory: URI.file('/workspace'), - }]); - } finally { - await disposeAgent(agent); - } - }); - - test('listSessions does not create databases for unowned SDK sessions', async () => { - const sessionDataService = disposables.add(new TestSessionDataService()); - const agent = createTestAgent(disposables, { sessionDataService, copilotClient: new TestCopilotClient([sdkSession('external', '/workspace')]) }); - try { - await agent.authenticate('https://api.github.com', 'token'); - - assert.deepStrictEqual(await agent.listSessions(), []); - assert.deepStrictEqual(sessionDataService.openedSessions, []); - } finally { - await disposeAgent(agent); - } - }); - - suite('createSession activeClient eager-claim', () => { - - class SpyingPluginManager extends TestAgentPluginManager { - public readonly calls: { clientId: string; customizations: CustomizationRef[] }[] = []; - - override async syncCustomizations(clientId: string, customizations: CustomizationRef[], _progress?: (status: SessionCustomization[]) => void): Promise { - this.calls.push({ clientId, customizations: [...customizations] }); - return []; - } - } - - test('createSession seeds activeClient tools and syncs customizations', async () => { - const sessionDataService = disposables.add(new TestSessionDataService()); - const client = new TestCopilotClient([]); - const pluginManager = new SpyingPluginManager(); - // Fail fast inside the SDK factory so we don't need to wire up a - // real raw session. The seeding of activeClient and the plugin - // sync both happen before `client.createSession` is invoked. - client.createSession = async () => { throw new Error('sentinel'); }; - - const agent = createTestAgent(disposables, { sessionDataService, copilotClient: client, pluginManager }); - try { - await agent.authenticate('https://api.github.com', 'token'); - - const customizations: CustomizationRef[] = [{ uri: 'file:///plugin-a', displayName: 'Plugin A' }]; - await assert.rejects( - agent.createSession({ - session: AgentSession.uri('copilotcli', 'test-session'), - workingDirectory: URI.file('/workspace'), - activeClient: { - clientId: 'client-1', - tools: [{ name: 't1', description: 'd', inputSchema: { type: 'object' } }], - customizations, - }, - }), - (err: Error) => /sentinel/.test(err.message), - ); - - assert.deepStrictEqual(pluginManager.calls, [{ clientId: 'client-1', customizations }]); - } finally { - await disposeAgent(agent); - } - }); - - test('createSession without activeClient does not sync customizations', async () => { - const sessionDataService = disposables.add(new TestSessionDataService()); - const client = new TestCopilotClient([]); - const pluginManager = new SpyingPluginManager(); - client.createSession = async () => { throw new Error('sentinel'); }; - - const agent = createTestAgent(disposables, { sessionDataService, copilotClient: client, pluginManager }); - try { - await agent.authenticate('https://api.github.com', 'token'); - - await assert.rejects( - agent.createSession({ - session: AgentSession.uri('copilotcli', 'test-session-2'), - workingDirectory: URI.file('/workspace'), - }), - (err: Error) => /sentinel/.test(err.message), - ); - - assert.deepStrictEqual(pluginManager.calls, []); - } finally { - await disposeAgent(agent); - } - }); - }); - - suite('onClientToolCallComplete', () => { - - /** - * Injects a stub session into the agent's `_sessions` map so we can - * observe how `onClientToolCallComplete` resolves URIs to session - * entries without standing up a full Copilot SDK session. - */ - function installStubSession(agent: CopilotAgent, sessionId: string): { calls: { toolCallId: string; result: ToolCallResult }[] } { - const calls: { toolCallId: string; result: ToolCallResult }[] = []; - const stub = { - handleClientToolCallComplete(toolCallId: string, result: ToolCallResult) { - calls.push({ toolCallId, result }); - }, - dispose() { }, - }; - const sessions = (agent as unknown as { _sessions: Map })._sessions; - sessions.set(sessionId, stub); - return { calls }; - } - - test('routes a top-level session URI to its session entry', async () => { - const agent = createTestAgent(disposables); - try { - const sessionUri = AgentSession.uri('copilotcli', 'session-top'); - const { calls } = installStubSession(agent, AgentSession.id(sessionUri)); - - const result: ToolCallResult = { success: true, pastTenseMessage: 'did it' }; - agent.onClientToolCallComplete(sessionUri, 'tc-top', result); - - assert.deepStrictEqual(calls, [{ toolCallId: 'tc-top', result }]); - } finally { - await disposeAgent(agent); - } - }); - - test('routes a subagent session URI to its parent session entry', async () => { - // Regression: client-tool completions for tools running inside a - // subagent are dispatched against the subagent session URI by - // the renderer. The agent must resolve that to the parent - // session entry — only the parent owns the SDK session and the - // pending deferred for the tool call. - const agent = createTestAgent(disposables); - try { - const parentUri = AgentSession.uri('copilotcli', 'session-parent'); - const { calls } = installStubSession(agent, AgentSession.id(parentUri)); - - const subagentUri = URI.parse(buildSubagentSessionUri(parentUri.toString(), 'tc-parent')); - const result: ToolCallResult = { success: true, pastTenseMessage: 'subagent tool done' }; - agent.onClientToolCallComplete(subagentUri, 'tc-inner', result); - - assert.deepStrictEqual(calls, [{ toolCallId: 'tc-inner', result }]); - } finally { - await disposeAgent(agent); - } - }); - - test('is a no-op when no session entry exists for the resolved id', async () => { - const agent = createTestAgent(disposables); - try { - const sessionUri = AgentSession.uri('copilotcli', 'session-missing'); - // No stub installed — the call should be silently ignored. - agent.onClientToolCallComplete(sessionUri, 'tc-x', { success: true, pastTenseMessage: 'noop' }); - } finally { - await disposeAgent(agent); - } - }); - }); - - suite('worktree announcement', () => { - // Drives a real session through worktree creation (calling the - // agent's _resolveSessionWorkingDirectory via a test seam so we don't - // need a full Copilot SDK), then exercises both the live path - // (sendMessage emits a synthetic delta) and the restore path - // (getSessionMessages prepends to the first assistant message). A - // stubbed CopilotAgentSession is injected via overriding _resumeSession - // because the real one requires a full SDK CopilotSession with ~30 - // event subscriptions. - - let tmpDir: string; - - setup(async () => { - tmpDir = await fs.mkdtemp(`${os.tmpdir()}/copilot-agent-worktree-test-`); - }); - - teardown(async () => { - await fs.rm(tmpDir, { recursive: true, force: true }); - }); - - test('emits announcement live as a delta on first sendMessage and persists it for restore via getSessionMessages', async () => { - const sessionId = 'wt-session'; - const session = AgentSession.uri('copilotcli', sessionId); - const repositoryRoot = URI.joinPath(URI.file(tmpDir), 'repo'); - await fs.mkdir(repositoryRoot.fsPath, { recursive: true }); - - const gitService = new TestAgentHostGitService(); - gitService.repositoryRoot = repositoryRoot; - - const sessionDataService = disposables.add(new TestSessionDataService()); - const agent = createTestAgent(disposables, { - sessionDataService, - copilotClient: new TestCopilotClient([]), - gitService, - }) as TestableCopilotAgent; - - const fakeMessages: Turn[] = [ - { id: 'u1', userMessage: { text: 'hi' }, responseParts: [{ kind: ResponsePartKind.Markdown, id: 'a1', content: 'hello back' }], usage: undefined, state: TurnState.Complete }, - ]; - let sendCalls = 0; - agent.registerFakeSession(sessionId, { - send: async () => { sendCalls++; }, - getMessages: async () => fakeMessages, - dispose: () => { }, - }); - - try { - await agent.authenticate('https://api.github.com', 'token'); - - // 1. Drive worktree resolution: this is what createSession does - // before constructing the SDK session. Verifies that the - // real production code path persists branch metadata and - // queues the live announcement. - const branchHint = 'add-feature'; - const expectedBranchName = getCopilotWorktreeBranchName(sessionId, branchHint); - const workingDir = await agent.resolveWorktreeForTest({ - workingDirectory: repositoryRoot, - config: { isolation: 'worktree', branch: 'main', branchNameHint: branchHint }, - }, sessionId); - assert.ok(workingDir, 'resolveWorktreeForTest must return a worktree URI'); - assert.deepStrictEqual(gitService.addedWorktrees.length, 1, 'addWorktree must be called once'); - assert.strictEqual(gitService.addedWorktrees[0].branchName, expectedBranchName); - - // 2. Live path: sendMessage must fire a synthetic markdown - // delta carrying the announcement before delegating to the - // SDK. The session is responsible for emitting the - // announcement after resetting partId tracking. - const signals: AgentSignal[] = []; - disposables.add(agent.onDidSessionProgress(s => { - signals.push(s); - })); - - await agent.sendMessage(session, 'hello'); - assert.strictEqual(sendCalls, 1, 'underlying SDK send must still be called'); - - const markdownSignals = signals.filter((s): s is IAgentActionSignal => - s.kind === 'action' && ( - (s.action.type === ActionType.SessionResponsePart && s.action.part.kind === ResponsePartKind.Markdown) || - s.action.type === ActionType.SessionDelta - ) - ); - assert.strictEqual(markdownSignals.length, 1, 'exactly one markdown announcement signal should be emitted for the worktree announcement'); - const announcement = markdownSignals[0]; - const announcementContent = announcement.action.type === ActionType.SessionResponsePart - ? (announcement.action.part as MarkdownResponsePart).content - : (announcement.action as IDeltaAction).content; - assert.ok(announcementContent.includes(expectedBranchName), `announcement should contain branch name '${expectedBranchName}', got '${announcementContent}'`); - - // 3. Live path is one-shot: a second sendMessage must not re-emit. - signals.length = 0; - await agent.sendMessage(session, 'follow-up'); - const reemittedMarkdown = signals.filter(s => - s.kind === 'action' && ( - (s.action.type === ActionType.SessionResponsePart && s.action.part.kind === ResponsePartKind.Markdown) || - s.action.type === ActionType.SessionDelta - ) - ); - assert.strictEqual(reemittedMarkdown.length, 0, 'announcement must not be re-emitted on subsequent sends'); - - // 4. Restore path: getSessionMessages must prepend the - // announcement to the first turn's first markdown part, - // using the persisted branch metadata. - const restored = await agent.getSessionMessages(session); - const md = restored[0]?.responseParts.find((p): p is MarkdownResponsePart => p.kind === ResponsePartKind.Markdown); - assert.ok(md, 'restored turns should include a markdown response part'); - assert.ok(md.content.includes(expectedBranchName), `restored markdown content should include the branch name, got '${md.content}'`); - assert.ok(md.content.endsWith('hello back'), `restored markdown content should still end with the original reply, got '${md.content}'`); - } finally { - await disposeAgent(agent); - } - }); - - test('does not announce or persist branch metadata when isolation is not worktree', async () => { - const sessionId = 'no-wt-session'; - const session = AgentSession.uri('copilotcli', sessionId); - const repositoryRoot = URI.joinPath(URI.file(tmpDir), 'repo'); - await fs.mkdir(repositoryRoot.fsPath, { recursive: true }); - - const gitService = new TestAgentHostGitService(); - gitService.repositoryRoot = repositoryRoot; - - const sessionDataService = disposables.add(new TestSessionDataService()); - const agent = createTestAgent(disposables, { - sessionDataService, - copilotClient: new TestCopilotClient([]), - gitService, - }) as TestableCopilotAgent; - - const fakeMessages: Turn[] = [ - { id: 'u1', userMessage: { text: 'hi' }, responseParts: [{ kind: ResponsePartKind.Markdown, id: 'a1', content: 'untouched reply' }], usage: undefined, state: TurnState.Complete }, - ]; - agent.registerFakeSession(sessionId, { - send: async () => { }, - getMessages: async () => fakeMessages, - dispose: () => { }, - }); - - try { - await agent.authenticate('https://api.github.com', 'token'); - - await agent.resolveWorktreeForTest({ workingDirectory: repositoryRoot }, sessionId); - assert.deepStrictEqual(gitService.addedWorktrees, [], 'addWorktree must not be called without worktree isolation'); - - const signals: AgentSignal[] = []; - disposables.add(agent.onDidSessionProgress(s => { - signals.push(s); - })); - await agent.sendMessage(session, 'hello'); - const markdownSignals = signals.filter(s => - s.kind === 'action' && ( - (s.action.type === ActionType.SessionResponsePart && s.action.part.kind === ResponsePartKind.Markdown) || - s.action.type === ActionType.SessionDelta - ) - ); - assert.deepStrictEqual(markdownSignals, [], 'no announcement should be emitted live'); - - const restored = await agent.getSessionMessages(session); - const md = restored[0]?.responseParts.find((p): p is MarkdownResponsePart => p.kind === ResponsePartKind.Markdown); - assert.strictEqual(md?.content, 'untouched reply', 'restored markdown content must not be modified'); - } finally { - await disposeAgent(agent); - } - }); - - test('onArchivedChanged removes the worktree on archive and recreates it on unarchive', async () => { - const sessionId = 'archive-cleanup-session'; - const session = AgentSession.uri('copilotcli', sessionId); - const repositoryRoot = URI.joinPath(URI.file(tmpDir), 'repo'); - await fs.mkdir(repositoryRoot.fsPath, { recursive: true }); - - const gitService = new TestAgentHostGitService(); - gitService.repositoryRoot = repositoryRoot; - - const agent = createTestAgent(disposables, { - sessionDataService: disposables.add(new TestSessionDataService()), - copilotClient: new TestCopilotClient([]), - gitService, - }) as TestableCopilotAgent; - - try { - await agent.authenticate('https://api.github.com', 'token'); - const workingDir = await agent.resolveWorktreeForTest({ - workingDirectory: repositoryRoot, - config: { isolation: 'worktree', branch: 'main', branchNameHint: 'feat' }, - }, sessionId); - assert.ok(workingDir, 'worktree must be created'); - // Simulate the worktree directory existing on disk so the archive - // path's existence-check passes; the test git service has no real repo. - await fs.mkdir(workingDir!.fsPath, { recursive: true }); - - await agent.onArchivedChanged(session, true); - assert.deepStrictEqual( - gitService.removedWorktrees.map(r => r.worktree.fsPath), - [workingDir!.fsPath], - 'archive must remove the worktree once it is clean and the branch is preserved', - ); - - // Simulate that the worktree directory is gone after removal. - await fs.rm(workingDir!.fsPath, { recursive: true, force: true }); - - await agent.onArchivedChanged(session, false); - assert.deepStrictEqual( - gitService.addedExistingWorktrees.map(r => ({ worktree: r.worktree.fsPath, branchName: r.branchName })), - [{ worktree: workingDir!.fsPath, branchName: gitService.addedWorktrees[0].branchName }], - 'unarchive must recreate the worktree using the preserved branch', - ); - } finally { - await disposeAgent(agent); - } - }); - - test('onArchivedChanged skips removal when worktree has uncommitted changes', async () => { - const sessionId = 'archive-skip-dirty-session'; - const session = AgentSession.uri('copilotcli', sessionId); - const repositoryRoot = URI.joinPath(URI.file(tmpDir), 'repo-dirty'); - await fs.mkdir(repositoryRoot.fsPath, { recursive: true }); - - const gitService = new TestAgentHostGitService(); - gitService.repositoryRoot = repositoryRoot; - - const agent = createTestAgent(disposables, { - sessionDataService: disposables.add(new TestSessionDataService()), - copilotClient: new TestCopilotClient([]), - gitService, - }) as TestableCopilotAgent; - - try { - await agent.authenticate('https://api.github.com', 'token'); - const workingDir = await agent.resolveWorktreeForTest({ - workingDirectory: repositoryRoot, - config: { isolation: 'worktree', branch: 'main', branchNameHint: 'feat' }, - }, sessionId); - await fs.mkdir(workingDir!.fsPath, { recursive: true }); - gitService.dirtyWorkingDirectories.add(workingDir!.fsPath); - - await agent.onArchivedChanged(session, true); - assert.deepStrictEqual(gitService.removedWorktrees, [], 'must not remove a dirty worktree'); - } finally { - await disposeAgent(agent); - } - }); - - test('onArchivedChanged skips removal when branch is missing', async () => { - const sessionId = 'archive-skip-no-branch-session'; - const session = AgentSession.uri('copilotcli', sessionId); - const repositoryRoot = URI.joinPath(URI.file(tmpDir), 'repo-nobranch'); - await fs.mkdir(repositoryRoot.fsPath, { recursive: true }); - - const gitService = new TestAgentHostGitService(); - gitService.repositoryRoot = repositoryRoot; - - const agent = createTestAgent(disposables, { - sessionDataService: disposables.add(new TestSessionDataService()), - copilotClient: new TestCopilotClient([]), - gitService, - }) as TestableCopilotAgent; - - try { - await agent.authenticate('https://api.github.com', 'token'); - const workingDir = await agent.resolveWorktreeForTest({ - workingDirectory: repositoryRoot, - config: { isolation: 'worktree', branch: 'main', branchNameHint: 'feat' }, - }, sessionId); - await fs.mkdir(workingDir!.fsPath, { recursive: true }); - // Drop the branch so cleanup must skip. - gitService.existingBranches.clear(); - - await agent.onArchivedChanged(session, true); - assert.deepStrictEqual(gitService.removedWorktrees, [], 'must not remove a worktree whose branch is missing'); - } finally { - await disposeAgent(agent); - } - }); - - test('onArchivedChanged is a no-op when no worktree metadata is persisted', async () => { - const sessionId = 'archive-no-meta-session'; - const session = AgentSession.uri('copilotcli', sessionId); - const gitService = new TestAgentHostGitService(); - const agent = createTestAgent(disposables, { - sessionDataService: disposables.add(new TestSessionDataService()), - copilotClient: new TestCopilotClient([]), - gitService, - }) as TestableCopilotAgent; - - try { - await agent.authenticate('https://api.github.com', 'token'); - await agent.onArchivedChanged(session, true); - await agent.onArchivedChanged(session, false); - assert.deepStrictEqual({ - removed: gitService.removedWorktrees, - addedExisting: gitService.addedExistingWorktrees, - }, { removed: [], addedExisting: [] }); - } finally { - await disposeAgent(agent); - } - }); - - test('onArchivedChanged unarchive skips when worktree directory already exists', async () => { - const sessionId = 'unarchive-existing-session'; - const session = AgentSession.uri('copilotcli', sessionId); - const repositoryRoot = URI.joinPath(URI.file(tmpDir), 'repo-exists'); - await fs.mkdir(repositoryRoot.fsPath, { recursive: true }); - - const gitService = new TestAgentHostGitService(); - gitService.repositoryRoot = repositoryRoot; - - const agent = createTestAgent(disposables, { - sessionDataService: disposables.add(new TestSessionDataService()), - copilotClient: new TestCopilotClient([]), - gitService, - }) as TestableCopilotAgent; - - try { - await agent.authenticate('https://api.github.com', 'token'); - const workingDir = await agent.resolveWorktreeForTest({ - workingDirectory: repositoryRoot, - config: { isolation: 'worktree', branch: 'main', branchNameHint: 'feat' }, - }, sessionId); - await fs.mkdir(workingDir!.fsPath, { recursive: true }); - - await agent.onArchivedChanged(session, false); - assert.deepStrictEqual(gitService.addedExistingWorktrees, [], 'must not recreate a worktree whose directory already exists'); - } finally { - await disposeAgent(agent); - } - }); - - }); -}); diff --git a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts deleted file mode 100644 index 52f14953..00000000 --- a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts +++ /dev/null @@ -1,1759 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import type { CopilotSession, SessionEvent, SessionEventPayload, SessionEventType, Tool, ToolResultObject, TypedSessionEventHandler } from '@github/copilot-sdk'; -import assert from 'assert'; -import { DeferredPromise } from '../../../../base/common/async.js'; -import { Emitter } from '../../../../base/common/event.js'; -import { DisposableStore } from '../../../../base/common/lifecycle.js'; -import { join, sep } from '../../../../base/common/path.js'; -import { URI } from '../../../../base/common/uri.js'; -import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { INativeEnvironmentService } from '../../../environment/common/environment.js'; -import { IFileService } from '../../../files/common/files.js'; -import { InstantiationService } from '../../../instantiation/common/instantiationService.js'; -import { ServiceCollection } from '../../../instantiation/common/serviceCollection.js'; -import { ILogService, NullLogService } from '../../../log/common/log.js'; -import { AgentSession, type AgentSignal, type IAgentActionSignal, type IAgentToolPendingConfirmationSignal } from '../../common/agentService.js'; -import { IDiffComputeService } from '../../common/diffComputeService.js'; -import { ISessionDataService } from '../../common/sessionDataService.js'; -import { ActionType, type SessionDeltaAction, type SessionErrorAction, type SessionInputRequestedAction, type SessionResponsePartAction, type SessionToolCallCompleteAction, type SessionToolCallReadyAction, type SessionToolCallStartAction } from '../../common/state/sessionActions.js'; -import { AttachmentType, ResponsePartKind, SessionInputAnswerState, SessionInputAnswerValueKind, SessionInputQuestionKind, SessionInputResponseKind, ToolResultContentType } from '../../common/state/sessionState.js'; -import { CopilotAgentSession, IActiveClientSnapshot, SessionWrapperFactory } from '../../node/copilot/copilotAgentSession.js'; -import { CopilotSessionWrapper } from '../../node/copilot/copilotSessionWrapper.js'; -import { IAgentConfigurationService } from '../../node/agentConfigurationService.js'; -import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; -import { createSessionDataService, createZeroDiffComputeService } from '../common/sessionTestHelpers.js'; - -// ---- Mock CopilotSession (SDK level) ---------------------------------------- - -/** - * Minimal mock of the SDK's {@link CopilotSession}. Implements `on()` to - * store typed handlers, and exposes `fire()` so tests can push events - * through the real {@link CopilotSessionWrapper} event pipeline. - */ -class MockCopilotSession { - readonly sessionId = 'test-session-1'; - readonly sendRequests: unknown[] = []; - readonly modeSetCalls: Array<{ mode: 'interactive' | 'plan' | 'autopilot' }> = []; - - private readonly _handlers = new Map void>>(); - planReadResult: { exists: boolean; content: string | null; path: string | null } = { exists: false, content: null, path: null }; - - on(eventType: K, handler: TypedSessionEventHandler): () => void { - let set = this._handlers.get(eventType); - if (!set) { - set = new Set(); - this._handlers.set(eventType, set); - } - set.add(handler as (event: SessionEvent) => void); - return () => { set.delete(handler as (event: SessionEvent) => void); }; - } - - /** Push an event through to all registered handlers of the given type. */ - fire(type: K, data: SessionEventPayload['data']): void { - const event = { type, data, id: 'evt-1', timestamp: new Date().toISOString(), parentId: null } as SessionEventPayload; - const set = this._handlers.get(type); - if (set) { - for (const handler of set) { - handler(event); - } - } - } - - // Stubs for methods the wrapper / session class calls - async send(request: unknown) { this.sendRequests.push(request); return ''; } - async abort() { } - async setModel() { } - async getMessages() { return []; } - async destroy() { } - - readonly rpc = { - mode: { - get: async () => ({ mode: 'interactive' as const }), - set: async (params: { mode: 'interactive' | 'plan' | 'autopilot' }) => { - this.modeSetCalls.push({ mode: params.mode }); - }, - }, - plan: { - read: async () => this.planReadResult, - update: async (_params: { content: string }) => { /* no-op */ }, - delete: async () => { /* no-op */ }, - }, - }; -} - -class CapturingLogService extends NullLogService { - readonly errors: Array<{ first: string | Error; args: unknown[] }> = []; - - override error(message: string | Error, ...args: unknown[]): void { - this.errors.push({ first: message, args }); - super.error(message, ...args); - } -} - -// ---- Helpers ---------------------------------------------------------------- - -/** - * Invokes a client-SDK tool's handler with the minimal fields the SDK - * contract requires, and narrows the `unknown` return type to - * {@link ToolResultObject} — which is what {@link CopilotAgentSession}'s - * handler implementation actually returns. - */ -function invokeClientToolHandler(tool: Pick, toolCallId: string, args: Record = {}): Promise { - return Promise.resolve(tool.handler(args, { - sessionId: 'test-session-1', - toolCallId, - toolName: tool.name, - arguments: args, - })) as Promise; -} - -type ISessionInternalsForTest = { - _onDidSessionProgress: { fire(event: AgentSignal): void }; - _editTracker: { - trackEditStart(path: string): Promise; - completeEdit(path: string): Promise; - }; - _pendingClientToolCalls: { - get(toolCallId: string): DeferredPromise | undefined; - set(toolCallId: string, value: DeferredPromise): Map>; - delete(toolCallId: string): boolean; - }; -}; - -function isAction(s: AgentSignal, type: ActionType): s is IAgentActionSignal { - return s.kind === 'action' && s.action.type === type; -} - -function getInputRequest(signal: AgentSignal): SessionInputRequestedAction['request'] { - assert.strictEqual(signal.kind, 'action'); - if (signal.kind !== 'action') { throw new Error('unreachable'); } - assert.strictEqual(signal.action.type, ActionType.SessionInputRequested); - return (signal.action as SessionInputRequestedAction).request; -} - -async function createAgentSession(disposables: DisposableStore, options?: { - clientSnapshot?: IActiveClientSnapshot; - environmentServiceRegistration?: 'native' | 'none'; - logService?: ILogService; - captureWrapperCallbacks?: { current?: Parameters[0] }; - workingDirectory?: URI; - /** Per-key effective config values returned by the fake configuration service. */ - configValues?: Record; -}): Promise<{ - session: CopilotAgentSession; - mockSession: MockCopilotSession; - signals: AgentSignal[]; - waitForSignal: (predicate: (signal: AgentSignal) => boolean) => Promise; - sessionConfigUpdates: ReadonlyArray<{ session: string; patch: Record }>; -}> { - const progressEmitter = disposables.add(new Emitter()); - const signals: AgentSignal[] = []; - const waiters: { predicate: (signal: AgentSignal) => boolean; deferred: DeferredPromise }[] = []; - - disposables.add(progressEmitter.event(signal => { - signals.push(signal); - for (let i = waiters.length - 1; i >= 0; i--) { - if (waiters[i].predicate(signal)) { - const { deferred } = waiters[i]; - waiters.splice(i, 1); - deferred.complete(signal); - } - } - })); - - const waitForSignal = (predicate: (signal: AgentSignal) => boolean): Promise => { - const existing = signals.find(predicate); - if (existing) { - return Promise.resolve(existing); - } - const deferred = new DeferredPromise(); - waiters.push({ predicate, deferred }); - return deferred.p; - }; - - const sessionUri = AgentSession.uri('copilot', 'test-session-1'); - const mockSession = new MockCopilotSession(); - - const factory: SessionWrapperFactory = async callbacks => { - if (options?.captureWrapperCallbacks) { - options.captureWrapperCallbacks.current = callbacks; - } - return new CopilotSessionWrapper(mockSession as unknown as CopilotSession); - }; - - const services = new ServiceCollection(); - services.set(ILogService, options?.logService ?? new NullLogService()); - services.set(IFileService, { _serviceBrand: undefined } as IFileService); - services.set(ISessionDataService, createSessionDataService()); - services.set(IDiffComputeService, createZeroDiffComputeService()); - const sessionConfigUpdates: Array<{ session: string; patch: Record }> = []; - const configValues = options?.configValues ?? {}; - const fakeConfigurationService: IAgentConfigurationService = { - _serviceBrand: undefined, - onDidRootConfigChange: new Emitter().event, - // Simple per-key map suffices for tests; the real service walks - // session → parent → host and validates against the schema, but - // neither matters here — we just need to surface a value the - // session class will read. - getEffectiveValue: ((_session: string, _schema: unknown, key: string) => configValues[key]) as IAgentConfigurationService['getEffectiveValue'], - getEffectiveWorkingDirectory: () => undefined, - updateSessionConfig: (session, patch) => { sessionConfigUpdates.push({ session, patch }); }, - getRootValue: () => undefined, - updateRootConfig: () => { /* no-op */ }, - persistRootConfig: () => { /* no-op */ }, - }; - services.set(IAgentConfigurationService, fakeConfigurationService); - const environmentService = { - _serviceBrand: undefined, - userHome: URI.file('/mock-home'), - } as INativeEnvironmentService; - if (options?.environmentServiceRegistration !== 'none') { - services.set(INativeEnvironmentService, environmentService); - } - const instantiationService = disposables.add(new InstantiationService(services)); - - const session = disposables.add(instantiationService.createInstance( - CopilotAgentSession, - { - sessionUri, - rawSessionId: 'test-session-1', - onDidSessionProgress: progressEmitter, - wrapperFactory: factory, - shellManager: undefined, - clientSnapshot: options?.clientSnapshot, - workingDirectory: options?.workingDirectory, - }, - )); - - await session.initializeSession(); - - return { session, mockSession, signals, waitForSignal, sessionConfigUpdates }; -} - -// ---- Tests ------------------------------------------------------------------ - -suite('CopilotAgentSession', () => { - - const disposables = new DisposableStore(); - - teardown(() => disposables.clear()); - ensureNoDisposablesAreLeakedInTestSuite(); - - test('maps internal attachment URIs to Copilot SDK path fields', async () => { - const { session, mockSession } = await createAgentSession(disposables); - const fileUri = URI.file('/workspace/file.ts'); - const selectionUri = URI.file('/workspace/selection.ts'); - - await session.send('hello', [ - { type: AttachmentType.File, uri: fileUri, displayName: 'file.ts' }, - { type: AttachmentType.Selection, uri: selectionUri, displayName: 'selection.ts' }, - ]); - - assert.deepStrictEqual(mockSession.sendRequests, [{ - prompt: 'hello', - attachments: [ - { type: 'file', path: fileUri.fsPath, displayName: 'file.ts' }, - { type: 'selection', filePath: selectionUri.fsPath, displayName: 'selection.ts', text: undefined, selection: undefined }, - ], - }]); - }); - - // ---- permission handling ---- - - suite('permission handling', () => { - - test('read permission fires tool_ready (deferred to side effects)', async () => { - const { session, signals, waitForSignal } = await createAgentSession(disposables); - const resultPromise = session.handlePermissionRequest({ - kind: 'read', - path: '/workspace/src/file.ts', - toolCallId: 'tc-1', - }); - - await waitForSignal(s => s.kind === 'pending_confirmation'); - assert.strictEqual(signals.length, 1); - - assert.ok(session.respondToPermissionRequest('tc-1', true)); - const result = await resultPromise; - assert.strictEqual(result.kind, 'approve-once'); - }); - - test('auto-approves read permission for session-state plan files', async () => { - const previousXdgStateHome = process.env['XDG_STATE_HOME']; - process.env['XDG_STATE_HOME'] = '/mock-state-home'; - try { - const { session, signals } = await createAgentSession(disposables); - const result = await session.handlePermissionRequest({ - kind: 'read', - path: join('/mock-state-home', '.copilot', 'session-state', 'test-session-1', 'plan.md'), - toolCallId: 'tc-read-plan', - }); - - assert.strictEqual(result.kind, 'approve-once'); - assert.strictEqual(signals.length, 0); - } finally { - if (previousXdgStateHome === undefined) { - delete process.env['XDG_STATE_HOME']; - } else { - process.env['XDG_STATE_HOME'] = previousXdgStateHome; - } - } - }); - - test('resolves native environment through INativeEnvironmentService registration', async () => { - const previousXdgStateHome = process.env['XDG_STATE_HOME']; - delete process.env['XDG_STATE_HOME']; - try { - const { session, signals } = await createAgentSession(disposables, { environmentServiceRegistration: 'native' }); - const result = await session.handlePermissionRequest({ - kind: 'read', - path: join('/mock-home', '.copilot', 'session-state', 'test-session-1', 'plan.md'), - toolCallId: 'tc-read-plan-native-env', - }); - - assert.strictEqual(result.kind, 'approve-once'); - assert.strictEqual(signals.length, 0); - } finally { - if (previousXdgStateHome === undefined) { - delete process.env['XDG_STATE_HOME']; - } else { - process.env['XDG_STATE_HOME'] = previousXdgStateHome; - } - } - }); - - test('logs and rethrows permission failures', async () => { - const previousXdgStateHome = process.env['XDG_STATE_HOME']; - delete process.env['XDG_STATE_HOME']; - const logService = new CapturingLogService(); - try { - const { session } = await createAgentSession(disposables, { - environmentServiceRegistration: 'none', - logService, - }); - - await assert.rejects( - session.handlePermissionRequest({ - kind: 'read', - path: join('/mock-home', '.copilot', 'session-state', 'test-session-1', 'plan.md'), - toolCallId: 'tc-read-plan-missing-env', - }), - ); - - assert.strictEqual(logService.errors.length, 1); - const [entry] = logService.errors; - assert.ok(entry.first instanceof TypeError); - assert.strictEqual(entry.args[0], '[Copilot:test-session-1] Failed to handle permission request: kind=read, toolCallId=tc-read-plan-missing-env'); - } finally { - if (previousXdgStateHome === undefined) { - delete process.env['XDG_STATE_HOME']; - } else { - process.env['XDG_STATE_HOME'] = previousXdgStateHome; - } - } - }); - - test('write permission fires tool_ready (deferred to side effects)', async () => { - const { session, signals, waitForSignal } = await createAgentSession(disposables); - const resultPromise = session.handlePermissionRequest({ - kind: 'write', - fileName: '/workspace/src/file.ts', - toolCallId: 'tc-1', - }); - - await waitForSignal(s => s.kind === 'pending_confirmation'); - assert.strictEqual(signals.length, 1); - - assert.ok(session.respondToPermissionRequest('tc-1', true)); - const result = await resultPromise; - assert.strictEqual(result.kind, 'approve-once'); - }); - - test('auto-approves write permission for session-state plan files', async () => { - const previousXdgStateHome = process.env['XDG_STATE_HOME']; - process.env['XDG_STATE_HOME'] = '/mock-state-home'; - try { - const { session, signals } = await createAgentSession(disposables); - const result = await session.handlePermissionRequest({ - kind: 'write', - fileName: join('/mock-state-home', '.copilot', 'session-state', 'test-session-1', 'plan.md'), - toolCallId: 'tc-write-plan', - }); - - assert.strictEqual(result.kind, 'approve-once'); - assert.strictEqual(signals.length, 0); - } finally { - if (previousXdgStateHome === undefined) { - delete process.env['XDG_STATE_HOME']; - } else { - process.env['XDG_STATE_HOME'] = previousXdgStateHome; - } - } - }); - - test('does not auto-approve session-state files from another session', async () => { - const previousXdgStateHome = process.env['XDG_STATE_HOME']; - process.env['XDG_STATE_HOME'] = '/mock-state-home'; - try { - const { session, signals, waitForSignal } = await createAgentSession(disposables); - const resultPromise = session.handlePermissionRequest({ - kind: 'write', - fileName: join('/mock-state-home', '.copilot', 'session-state', 'different-session', 'plan.md'), - toolCallId: 'tc-write-other-plan', - }); - - await waitForSignal(s => s.kind === 'pending_confirmation'); - assert.strictEqual(signals.length, 1); - - assert.ok(session.respondToPermissionRequest('tc-write-other-plan', true)); - const result = await resultPromise; - assert.strictEqual(result.kind, 'approve-once'); - } finally { - if (previousXdgStateHome === undefined) { - delete process.env['XDG_STATE_HOME']; - } else { - process.env['XDG_STATE_HOME'] = previousXdgStateHome; - } - } - }); - - test('does not auto-approve traversal paths that escape the session-state directory', async () => { - const previousXdgStateHome = process.env['XDG_STATE_HOME']; - process.env['XDG_STATE_HOME'] = '/mock-state-home'; - try { - const { session, signals, waitForSignal } = await createAgentSession(disposables); - const sessionDir = join('/mock-state-home', '.copilot', 'session-state', 'test-session-1'); - const resultPromise = session.handlePermissionRequest({ - kind: 'write', - fileName: `${sessionDir}${sep}..${sep}outside.md`, - toolCallId: 'tc-write-traversal', - }); - - await waitForSignal(s => s.kind === 'pending_confirmation'); - assert.strictEqual(signals.length, 1); - - assert.ok(session.respondToPermissionRequest('tc-write-traversal', true)); - const result = await resultPromise; - assert.strictEqual(result.kind, 'approve-once'); - } finally { - if (previousXdgStateHome === undefined) { - delete process.env['XDG_STATE_HOME']; - } else { - process.env['XDG_STATE_HOME'] = previousXdgStateHome; - } - } - }); - - test('write permission outside working directory fires tool_ready', async () => { - const { session, signals, waitForSignal } = await createAgentSession(disposables); - - const resultPromise = session.handlePermissionRequest({ - kind: 'write', - fileName: '/other/file.ts', - toolCallId: 'tc-write-outside', - }); - - await waitForSignal(s => s.kind === 'pending_confirmation'); - assert.strictEqual(signals.length, 1); - - assert.ok(session.respondToPermissionRequest('tc-write-outside', true)); - const result = await resultPromise; - assert.strictEqual(result.kind, 'approve-once'); - }); - - test('read permission outside working directory fires tool_ready', async () => { - const { session, signals, waitForSignal } = await createAgentSession(disposables); - - // Kick off permission request but don't await — it will block - const resultPromise = session.handlePermissionRequest({ - kind: 'read', - path: '/other/file.ts', - toolCallId: 'tc-2', - }); - - // Should have fired a pending_confirmation signal - await waitForSignal(s => s.kind === 'pending_confirmation'); - assert.strictEqual(signals.length, 1); - - // Respond to it - assert.ok(session.respondToPermissionRequest('tc-2', true)); - const result = await resultPromise; - assert.strictEqual(result.kind, 'approve-once'); - }); - - test('denies permission when no toolCallId', async () => { - const { session } = await createAgentSession(disposables); - const result = await session.handlePermissionRequest({ kind: 'write' }); - assert.strictEqual(result.kind, 'reject'); - }); - - test('denied-interactively when user denies', async () => { - const { session, signals, waitForSignal } = await createAgentSession(disposables); - const resultPromise = session.handlePermissionRequest({ - kind: 'shell', - toolCallId: 'tc-3', - }); - - await waitForSignal(s => s.kind === 'pending_confirmation'); - assert.strictEqual(signals.length, 1); - session.respondToPermissionRequest('tc-3', false); - const result = await resultPromise; - assert.strictEqual(result.kind, 'reject'); - }); - - test('pending permissions are denied on dispose', async () => { - const { session } = await createAgentSession(disposables); - const resultPromise = session.handlePermissionRequest({ - kind: 'write', - toolCallId: 'tc-4', - }); - - session.dispose(); - const result = await resultPromise; - assert.strictEqual(result.kind, 'reject'); - }); - - test('pending permissions are denied on abort', async () => { - const { session } = await createAgentSession(disposables); - const resultPromise = session.handlePermissionRequest({ - kind: 'write', - toolCallId: 'tc-5', - }); - - await session.abort(); - const result = await resultPromise; - assert.strictEqual(result.kind, 'reject'); - }); - - test('respondToPermissionRequest returns false for unknown id', async () => { - const { session } = await createAgentSession(disposables); - assert.strictEqual(session.respondToPermissionRequest('unknown-id', true), false); - }); - }); - - // ---- sendSteering ---- - - suite('sendSteering', () => { - - test('fires steering_consumed after send resolves', async () => { - const { session, signals } = await createAgentSession(disposables); - - await session.sendSteering({ id: 'steer-1', userMessage: { text: 'focus on tests' } }); - - const consumed = signals.find(s => s.kind === 'steering_consumed'); - assert.ok(consumed, 'should fire steering_consumed signal'); - assert.strictEqual((consumed as { id: string }).id, 'steer-1'); - }); - - test('does not fire steering_consumed when send fails', async () => { - const { session, mockSession, signals } = await createAgentSession(disposables); - - mockSession.send = async () => { throw new Error('send failed'); }; - - await session.sendSteering({ id: 'steer-fail', userMessage: { text: 'will fail' } }); - - const consumed = signals.find(s => s.kind === 'steering_consumed'); - assert.strictEqual(consumed, undefined, 'should not fire steering_consumed on failure'); - }); - }); - - // ---- event mapping ---- - - suite('event mapping', () => { - - test('tool_start event is mapped for non-hidden tools', async () => { - const { mockSession, signals } = await createAgentSession(disposables); - mockSession.fire('tool.execution_start', { - toolCallId: 'tc-10', - toolName: 'bash', - arguments: { command: 'echo hello' }, - } as SessionEventPayload<'tool.execution_start'>['data']); - - assert.strictEqual(signals.length, 2); - const toolStart = signals[0]; - assert.ok(isAction(toolStart, ActionType.SessionToolCallStart)); - if (isAction(toolStart, ActionType.SessionToolCallStart)) { - const action = toolStart.action as SessionToolCallStartAction; - assert.strictEqual(action.toolCallId, 'tc-10'); - assert.strictEqual(action.toolName, 'bash'); - } - }); - - test('live tool_start strips redundant cd prefix matching workingDirectory', async () => { - const wd = URI.file('/repo/project'); - const { mockSession, signals } = await createAgentSession(disposables, { workingDirectory: wd }); - mockSession.fire('tool.execution_start', { - toolCallId: 'tc-cd', - toolName: 'bash', - arguments: { command: 'cd /repo/project && npm test' }, - } as SessionEventPayload<'tool.execution_start'>['data']); - - assert.strictEqual(signals.length, 2); - // toolInput on the auto-ready signal (signals[1]) - const readySignal = signals[1]; - assert.ok(isAction(readySignal, ActionType.SessionToolCallReady)); - if (isAction(readySignal, ActionType.SessionToolCallReady)) { - const action = readySignal.action as SessionToolCallReadyAction; - assert.strictEqual(action.toolInput, 'npm test'); - } - // toolArguments in _meta on the tool_start signal (signals[0]) - const startSignal = signals[0]; - assert.ok(isAction(startSignal, ActionType.SessionToolCallStart)); - if (isAction(startSignal, ActionType.SessionToolCallStart)) { - const meta = (startSignal.action as SessionToolCallStartAction)._meta; - const toolArgs = meta?.['toolArguments'] as string | undefined; - assert.ok(toolArgs && toolArgs.includes('"npm test"'), `toolArguments should contain rewritten command, was: ${toolArgs}`); - assert.ok(!toolArgs?.includes('cd /repo/project'), 'toolArguments should not contain stripped prefix'); - } - }); - - test('live tool_complete past-tense message reflects the rewritten command', async () => { - const wd = URI.file('/repo/project'); - const { mockSession, signals } = await createAgentSession(disposables, { workingDirectory: wd }); - - mockSession.fire('tool.execution_start', { - toolCallId: 'tc-cd-complete', - toolName: 'bash', - arguments: { command: 'cd /repo/project && npm test' }, - } as SessionEventPayload<'tool.execution_start'>['data']); - - mockSession.fire('tool.execution_complete', { - toolCallId: 'tc-cd-complete', - success: true, - result: { content: 'all tests passed' }, - } as SessionEventPayload<'tool.execution_complete'>['data']); - - assert.strictEqual(signals.length, 3); - const completeSignal = signals[2]; - assert.ok(isAction(completeSignal, ActionType.SessionToolCallComplete)); - if (isAction(completeSignal, ActionType.SessionToolCallComplete)) { - const action = completeSignal.action as SessionToolCallCompleteAction; - const past = action.result.pastTenseMessage; - const pastStr = typeof past === 'string' ? past : (past?.markdown ?? ''); - assert.ok(!pastStr.includes('cd /repo/project'), `past-tense message should not contain stripped prefix, got: ${pastStr}`); - assert.ok(pastStr.includes('npm test'), `past-tense message should contain the rewritten command, got: ${pastStr}`); - } - }); - - test('live tool_start does not rewrite when cd target differs from workingDirectory', async () => { - const wd = URI.file('/repo/project'); - const { mockSession, signals } = await createAgentSession(disposables, { workingDirectory: wd }); - mockSession.fire('tool.execution_start', { - toolCallId: 'tc-cd-other', - toolName: 'bash', - arguments: { command: 'cd /tmp && ls' }, - } as SessionEventPayload<'tool.execution_start'>['data']); - - assert.strictEqual(signals.length, 2); - const readySignal = signals[1]; - assert.ok(isAction(readySignal, ActionType.SessionToolCallReady)); - if (isAction(readySignal, ActionType.SessionToolCallReady)) { - assert.strictEqual((readySignal.action as SessionToolCallReadyAction).toolInput, 'cd /tmp && ls'); - } - }); - - test('live tool_start without workingDirectory passes command through', async () => { - const { mockSession, signals } = await createAgentSession(disposables); - mockSession.fire('tool.execution_start', { - toolCallId: 'tc-cd-nowd', - toolName: 'bash', - arguments: { command: 'cd /repo/project && npm test' }, - } as SessionEventPayload<'tool.execution_start'>['data']); - - assert.strictEqual(signals.length, 2); - const readySignal = signals[1]; - assert.ok(isAction(readySignal, ActionType.SessionToolCallReady)); - if (isAction(readySignal, ActionType.SessionToolCallReady)) { - assert.strictEqual((readySignal.action as SessionToolCallReadyAction).toolInput, 'cd /repo/project && npm test'); - } - }); - - test('hidden tools are not emitted as tool_start', async () => { - const { mockSession, signals } = await createAgentSession(disposables); - mockSession.fire('tool.execution_start', { - toolCallId: 'tc-11', - toolName: 'report_intent', - } as SessionEventPayload<'tool.execution_start'>['data']); - - assert.strictEqual(signals.length, 0); - }); - - test('report_intent surfaces as session activity', async () => { - const { mockSession, signals } = await createAgentSession(disposables); - mockSession.fire('tool.execution_start', { - toolCallId: 'tc-intent-1', - toolName: 'report_intent', - arguments: { intent: 'Reading repo docs' }, - } as SessionEventPayload<'tool.execution_start'>['data']); - - assert.strictEqual(signals.length, 1); - const signal = signals[0]; - assert.ok(isAction(signal, ActionType.SessionActivityChanged)); - if (isAction(signal, ActionType.SessionActivityChanged)) { - assert.strictEqual((signal.action as { activity: string | undefined }).activity, 'Reading repo docs'); - } - - // Going idle clears the activity. - mockSession.fire('session.idle', {} as SessionEventPayload<'session.idle'>['data']); - const clearSignal = signals.find((s, i) => i > 0 && isAction(s, ActionType.SessionActivityChanged)); - assert.ok(clearSignal, 'expected activity to be cleared on idle'); - if (clearSignal && isAction(clearSignal, ActionType.SessionActivityChanged)) { - assert.strictEqual((clearSignal.action as { activity: string | undefined }).activity, undefined); - } - }); - - test('tool_complete event produces past-tense message', async () => { - const { mockSession, signals } = await createAgentSession(disposables); - - // First fire tool_start so it's tracked - mockSession.fire('tool.execution_start', { - toolCallId: 'tc-12', - toolName: 'bash', - arguments: { command: 'ls' }, - } as SessionEventPayload<'tool.execution_start'>['data']); - - // Then fire complete - mockSession.fire('tool.execution_complete', { - toolCallId: 'tc-12', - success: true, - result: { content: 'file1.ts\nfile2.ts' }, - } as SessionEventPayload<'tool.execution_complete'>['data']); - - assert.strictEqual(signals.length, 3); - const completeSignal = signals[2]; - assert.ok(isAction(completeSignal, ActionType.SessionToolCallComplete)); - if (isAction(completeSignal, ActionType.SessionToolCallComplete)) { - const action = completeSignal.action as SessionToolCallCompleteAction; - assert.strictEqual(action.toolCallId, 'tc-12'); - assert.ok(action.result.success); - assert.ok(action.result.pastTenseMessage); - } - }); - - test('tool_complete for untracked tool is ignored', async () => { - const { mockSession, signals } = await createAgentSession(disposables); - mockSession.fire('tool.execution_complete', { - toolCallId: 'tc-untracked', - success: true, - } as SessionEventPayload<'tool.execution_complete'>['data']); - - assert.strictEqual(signals.length, 0); - }); - - test('idle event is forwarded', async () => { - const { mockSession, signals } = await createAgentSession(disposables); - mockSession.fire('session.idle', {} as SessionEventPayload<'session.idle'>['data']); - - assert.strictEqual(signals.length, 1); - assert.ok(isAction(signals[0], ActionType.SessionTurnComplete)); - }); - - test('error event is forwarded', async () => { - const { mockSession, signals } = await createAgentSession(disposables); - mockSession.fire('session.error', { - errorType: 'TestError', - message: 'something went wrong', - stack: 'Error: something went wrong', - } as SessionEventPayload<'session.error'>['data']); - - assert.strictEqual(signals.length, 1); - assert.ok(isAction(signals[0], ActionType.SessionError)); - if (isAction(signals[0], ActionType.SessionError)) { - const action = signals[0].action as SessionErrorAction; - assert.strictEqual(action.error.errorType, 'TestError'); - assert.strictEqual(action.error.message, 'something went wrong'); - } - }); - - test('message delta is forwarded', async () => { - const { mockSession, signals } = await createAgentSession(disposables); - mockSession.fire('assistant.message_delta', { - messageId: 'msg-1', - deltaContent: 'Hello ', - } as SessionEventPayload<'assistant.message_delta'>['data']); - - assert.ok(signals.length >= 1); - const hasDelta = signals.some(s => { - if (s.kind !== 'action') { return false; } - if (s.action.type === ActionType.SessionResponsePart) { - const part = (s.action as SessionResponsePartAction).part; - return part.kind === ResponsePartKind.Markdown && part.content === 'Hello '; - } - if (s.action.type === ActionType.SessionDelta) { - return (s.action as SessionDeltaAction).content === 'Hello '; - } - return false; - }); - assert.ok(hasDelta, 'should have forwarded the delta content'); - }); - - test('complete assistant message without preceding deltas surfaces a markdown response part', async () => { - const { mockSession, signals } = await createAgentSession(disposables); - mockSession.fire('assistant.message', { - messageId: 'msg-2', - content: 'Let me help you.', - toolRequests: [{ - toolCallId: 'tc-20', - name: 'bash', - arguments: { command: 'ls' }, - type: 'function', - }], - } as SessionEventPayload<'assistant.message'>['data']); - - // The session emits a fresh markdown response part for the - // content. Tool calls fire their own events, so - // `toolRequests` on the assistant message are not forwarded - // during live streaming. - assert.ok(signals.length >= 1); - const hasPart = signals.some(s => { - if (s.kind !== 'action') { return false; } - if (s.action.type === ActionType.SessionResponsePart) { - const part = (s.action as SessionResponsePartAction).part; - return part.kind === ResponsePartKind.Markdown && part.content === 'Let me help you.'; - } - if (s.action.type === ActionType.SessionDelta) { - return (s.action as SessionDeltaAction).content === 'Let me help you.'; - } - return false; - }); - assert.ok(hasPart, 'should have surfaced the message content'); - }); - - test('reasoning delta after tool_start starts a new reasoning response part', async () => { - const { mockSession, signals } = await createAgentSession(disposables); - - // First reasoning delta — allocates a fresh reasoning response part. - mockSession.fire('assistant.reasoning_delta', { - deltaContent: 'thinking step 1', - } as SessionEventPayload<'assistant.reasoning_delta'>['data']); - - // A tool call interleaves between reasoning rounds. - mockSession.fire('tool.execution_start', { - toolCallId: 'tc-r-1', - toolName: 'bash', - arguments: { command: 'echo hi' }, - } as SessionEventPayload<'tool.execution_start'>['data']); - mockSession.fire('tool.execution_complete', { - toolCallId: 'tc-r-1', - success: true, - result: { content: 'hi' }, - } as SessionEventPayload<'tool.execution_complete'>['data']); - - // Second round of reasoning, after the tool call. This must - // land in a NEW reasoning response part — otherwise the - // renderer / state-tree would merge it into the pre-tool-call - // block and the visual ordering would be wrong on restore. - mockSession.fire('assistant.reasoning_delta', { - deltaContent: 'thinking step 2', - } as SessionEventPayload<'assistant.reasoning_delta'>['data']); - - // Pull the protocol-level reasoning response parts. Both - // `SessionResponsePart{Reasoning}` (allocates a new part) and - // `SessionReasoning` (appends to an existing part) translate to - // the legacy `'reasoning'` view, so we have to inspect raw - // signals to tell them apart. - const reasoningResponseParts = signals.flatMap(s => { - if (s.kind !== 'action' || s.action.type !== ActionType.SessionResponsePart) { - return []; - } - return s.action.part.kind === ResponsePartKind.Reasoning ? [s.action.part] : []; - }); - assert.strictEqual(reasoningResponseParts.length, 2, - 'reasoning after a tool call should allocate a new response part, not append to the part from before the tool call'); - assert.notStrictEqual(reasoningResponseParts[0].id, reasoningResponseParts[1].id, - 'second reasoning round should have a distinct part id'); - assert.strictEqual(reasoningResponseParts[0].content, 'thinking step 1'); - assert.strictEqual(reasoningResponseParts[1].content, 'thinking step 2'); - }); - }); - - // ---- user input handling ---- - - suite('user input handling', () => { - - test('handleUserInputRequest fires user_input_request progress event', async () => { - const { session, signals } = await createAgentSession(disposables); - - // Start the request (don't await — it blocks waiting for response) - const resultPromise = session.handleUserInputRequest( - { question: 'What is your name?' }, - { sessionId: 'test-session-1' } - ); - - // Verify signal was fired - assert.strictEqual(signals.length, 1); - const request = getInputRequest(signals[0]); - const requestId = request.id; - assert.ok(request.questions); - assert.strictEqual(request.questions[0].message, 'What is your name?'); - const questionId = request.questions[0].id; - - // Respond to unblock the promise - session.respondToUserInputRequest(requestId, SessionInputResponseKind.Accept, { - [questionId]: { - state: SessionInputAnswerState.Submitted, - value: { kind: SessionInputAnswerValueKind.Text, value: 'Alice' } - } - }); - - const result = await resultPromise; - assert.strictEqual(result.answer, 'Alice'); - assert.strictEqual(result.wasFreeform, true); - }); - - test('handleUserInputRequest with choices generates SingleSelect question', async () => { - const { session, signals } = await createAgentSession(disposables); - - const resultPromise = session.handleUserInputRequest( - { question: 'Pick a color', choices: ['red', 'blue', 'green'] }, - { sessionId: 'test-session-1' } - ); - - assert.strictEqual(signals.length, 1); - const request = getInputRequest(signals[0]); - assert.ok(request.questions); - assert.strictEqual(request.questions.length, 1); - assert.strictEqual(request.questions[0].kind, SessionInputQuestionKind.SingleSelect); - if (request.questions[0].kind === SessionInputQuestionKind.SingleSelect) { - assert.strictEqual(request.questions[0].options.length, 3); - assert.strictEqual(request.questions[0].options[0].label, 'red'); - } - - // Respond with a selected choice - const questions = request.questions; - session.respondToUserInputRequest(request.id, SessionInputResponseKind.Accept, { - [questions[0].id]: { - state: SessionInputAnswerState.Submitted, - value: { kind: SessionInputAnswerValueKind.Selected, value: 'blue' } - } - }); - - const result = await resultPromise; - assert.strictEqual(result.answer, 'blue'); - assert.strictEqual(result.wasFreeform, false); - }); - - test('handleUserInputRequest returns empty answer on cancel', async () => { - const { session, signals } = await createAgentSession(disposables); - - const resultPromise = session.handleUserInputRequest( - { question: 'Cancel me' }, - { sessionId: 'test-session-1' } - ); - - const request = getInputRequest(signals[0]); - session.respondToUserInputRequest(request.id, SessionInputResponseKind.Cancel); - - const result = await resultPromise; - assert.strictEqual(result.answer, ''); - assert.strictEqual(result.wasFreeform, true); - }); - - test('respondToUserInputRequest returns false for unknown id', async () => { - const { session } = await createAgentSession(disposables); - assert.strictEqual(session.respondToUserInputRequest('unknown-id', SessionInputResponseKind.Accept), false); - }); - - test('handleUserInputRequest returns empty answer on skipped question', async () => { - const { session, signals } = await createAgentSession(disposables); - - const resultPromise = session.handleUserInputRequest( - { question: 'Skip me' }, - { sessionId: 'test-session-1' } - ); - - const request = getInputRequest(signals[0]); - const questionId = request.questions![0].id; - session.respondToUserInputRequest(request.id, SessionInputResponseKind.Accept, { - [questionId]: { - state: SessionInputAnswerState.Skipped, - } - }); - - const result = await resultPromise; - assert.strictEqual(result.answer, ''); - assert.strictEqual(result.wasFreeform, true); - }); - - test('pending user inputs are cancelled on dispose', async () => { - const { session } = await createAgentSession(disposables); - - const resultPromise = session.handleUserInputRequest( - { question: 'Will be cancelled' }, - { sessionId: 'test-session-1' } - ); - - session.dispose(); - const result = await resultPromise; - assert.strictEqual(result.answer, ''); - assert.strictEqual(result.wasFreeform, true); - }); - - test('autopilot auto-answers a free-form question without firing a progress event', async () => { - const { session, signals } = await createAgentSession(disposables, { - configValues: { [SessionConfigKey.AutoApprove]: 'autopilot' }, - }); - - const result = await session.handleUserInputRequest( - { question: 'Pick a color', choices: ['red', 'blue', 'green'] }, - { sessionId: 'test-session-1' } - ); - - // `wasFreeform: false` because we picked one of the SDK's - // offered choices — the SDK uses this hint to record whether - // the user typed something custom. - assert.strictEqual(result.answer, 'The user is not available to answer your question. Choose a pragmatic option best aligned with the context of the request.'); - assert.strictEqual(result.wasFreeform, true); - assert.strictEqual(signals.length, 0); - }); - - test('autopilot does not auto-answer when autoApprove is not "autopilot"', async () => { - // Sanity check: with autoApprove=default the question must - // still be surfaced as a progress event (the existing behavior). - const { session, signals } = await createAgentSession(disposables, { - configValues: { [SessionConfigKey.AutoApprove]: 'default' }, - }); - - session.handleUserInputRequest( - { question: 'Need user input' }, - { sessionId: 'test-session-1' } - ); - - // Microtask flush so the handler can run far enough to either - // short-circuit or emit a progress event. - await Promise.resolve(); - assert.strictEqual(signals.length, 1); - assert.ok(isAction(signals[0], ActionType.SessionInputRequested)); - }); - }); - - suite('SDK callback logging', () => { - - test('logs and rethrows user input callback failures', async () => { - const logService = new CapturingLogService(); - const { session } = await createAgentSession(disposables, { logService }); - const sessionInternals = session as unknown as ISessionInternalsForTest; - sessionInternals._onDidSessionProgress.fire = () => { - throw new Error('user input boom'); - }; - - await assert.rejects( - session.handleUserInputRequest( - { question: 'Need input' }, - { sessionId: 'test-session-1' }, - ), - /user input boom/, - ); - - assert.strictEqual(logService.errors.length, 1); - const [entry] = logService.errors; - assert.ok(entry.first instanceof Error); - assert.strictEqual((entry.first as Error).message, 'user input boom'); - assert.strictEqual(entry.args[0], '[Copilot:test-session-1] Failed to handle user input request: question="Need input"'); - }); - - test('logs and rethrows onPreToolUse failures', async () => { - const logService = new CapturingLogService(); - const capturedCallbacks: { current?: Parameters[0] } = {}; - const { session } = await createAgentSession(disposables, { logService, captureWrapperCallbacks: capturedCallbacks }); - const sessionInternals = session as unknown as ISessionInternalsForTest; - sessionInternals._editTracker.trackEditStart = async () => { - throw new Error('pre tool boom'); - }; - - await assert.rejects( - capturedCallbacks.current!.hooks.onPreToolUse({ - timestamp: 0, - cwd: '/tmp', - toolName: 'edit', - toolArgs: { path: '/tmp/file.ts' }, - }), - /pre tool boom/, - ); - - assert.strictEqual(logService.errors.length, 1); - const [entry] = logService.errors; - assert.ok(entry.first instanceof Error); - assert.strictEqual((entry.first as Error).message, 'pre tool boom'); - assert.strictEqual(entry.args[0], '[Copilot:test-session-1] Failed in onPreToolUse: tool=edit'); - }); - - test('logs and rethrows onPostToolUse failures', async () => { - const logService = new CapturingLogService(); - const capturedCallbacks: { current?: Parameters[0] } = {}; - const { session } = await createAgentSession(disposables, { logService, captureWrapperCallbacks: capturedCallbacks }); - const sessionInternals = session as unknown as ISessionInternalsForTest; - sessionInternals._editTracker.completeEdit = async () => { - throw new Error('post tool boom'); - }; - - await assert.rejects( - capturedCallbacks.current!.hooks.onPostToolUse({ - timestamp: 0, - cwd: '/tmp', - toolName: 'edit', - toolArgs: { path: '/tmp/file.ts' }, - toolResult: { textResultForLlm: '', resultType: 'success' }, - }), - /post tool boom/, - ); - - assert.strictEqual(logService.errors.length, 1); - const [entry] = logService.errors; - assert.ok(entry.first instanceof Error); - assert.strictEqual((entry.first as Error).message, 'post tool boom'); - assert.strictEqual(entry.args[0], '[Copilot:test-session-1] Failed in onPostToolUse: tool=edit'); - }); - }); - - // ---- client tool calls ---- - - suite('client tool calls', () => { - - const snapshot: IActiveClientSnapshot = { - clientId: 'test-client', - tools: [{ - name: 'my_tool', - description: 'A test tool', - inputSchema: { type: 'object', properties: {} }, - }], - plugins: [], - }; - - test('client tool handler waits for completion without emitting tool_ready', async () => { - const { session, mockSession, signals } = await createAgentSession(disposables, { clientSnapshot: snapshot }); - - // SDK emits tool.execution_start — tool_start fires immediately - mockSession.fire('tool.execution_start', { - toolCallId: 'tc-client-1', - toolName: 'my_tool', - arguments: {}, - } as SessionEventPayload<'tool.execution_start'>['data']); - - // tool_start fires immediately (client tools don't auto-ready) - assert.strictEqual(signals.filter(s => isAction(s, ActionType.SessionToolCallStart)).length, 1); - const startSignal = signals.find(s => isAction(s, ActionType.SessionToolCallStart)); - assert.ok(startSignal && isAction(startSignal, ActionType.SessionToolCallStart)); - if (isAction(startSignal!, ActionType.SessionToolCallStart)) { - assert.strictEqual((startSignal.action as SessionToolCallStartAction).toolClientId, 'test-client'); - } - - // SDK invokes the handler — it creates a deferred and waits, - // but does NOT fire tool_ready (that comes from the permission flow). - const tools = session.createClientSdkTools(); - const handlerPromise = invokeClientToolHandler(tools[0], 'tc-client-1', { file: 'test.ts' }); - - // No pending_confirmation or tool_ready should have been emitted by the handler - assert.strictEqual(signals.filter(s => s.kind === 'pending_confirmation' || isAction(s, ActionType.SessionToolCallReady)).length, 0); - - // Complete the tool call - session.handleClientToolCallComplete('tc-client-1', { - success: true, - pastTenseMessage: 'did it', - content: [{ type: ToolResultContentType.Text, text: 'result text' }], - }); - - const result = await handlerPromise; - assert.strictEqual(result.resultType, 'success'); - assert.strictEqual(result.textResultForLlm, 'result text'); - }); - - test('client tool handler does not emit tool_ready (permission flow owns it)', async () => { - const { session, mockSession, signals, waitForSignal } = await createAgentSession(disposables, { clientSnapshot: snapshot }); - - // SDK emits tool.execution_start — tool_start fires immediately - mockSession.fire('tool.execution_start', { - toolCallId: 'tc-client-perm', - toolName: 'my_tool', - arguments: {}, - } as SessionEventPayload<'tool.execution_start'>['data']); - - // tool_start fired, no pending_confirmation yet - assert.strictEqual(signals.filter(s => isAction(s, ActionType.SessionToolCallStart)).length, 1); - assert.strictEqual(signals.filter(s => s.kind === 'pending_confirmation').length, 0); - - // Permission request fires — pending_confirmation from permission flow. - const resultPromise = session.handlePermissionRequest({ - kind: 'custom-tool', - toolCallId: 'tc-client-perm', - toolName: 'my_tool', - }); - - // pending_confirmation from permission flow should have fired (with confirmationTitle) - await waitForSignal(s => s.kind === 'pending_confirmation'); - const permSignals = signals.filter((s): s is IAgentToolPendingConfirmationSignal => s.kind === 'pending_confirmation'); - assert.strictEqual(permSignals.length, 1); - assert.strictEqual(permSignals[0].state.toolCallId, 'tc-client-perm'); - assert.ok(permSignals[0].state.confirmationTitle); - - const tools = session.createClientSdkTools(); - const handlerPromise = invokeClientToolHandler(tools[0], 'tc-client-perm'); - - // The handler should NOT emit its own pending_confirmation — only the - // permission flow fires pending_confirmation for client tools. - assert.strictEqual(signals.filter(s => s.kind === 'pending_confirmation').length, 1, 'handler should not emit a second pending_confirmation'); - - // Approve and clean up - session.respondToPermissionRequest('tc-client-perm', true); - const permResult = await resultPromise; - assert.strictEqual(permResult.kind, 'approve-once'); - session.handleClientToolCallComplete('tc-client-perm', { - success: true, - pastTenseMessage: 'did it', - }); - await handlerPromise; - }); - - test('pending_confirmation forwards parentToolCallId for tools inside subagents', async () => { - // Regression: when a client tool runs inside a subagent the - // permission-flow `pending_confirmation` must carry the - // parentToolCallId from the originating tool_start. Without it - // the host has no way to route the resulting - // SessionToolCallReady to the subagent session and emits a - // stray ready against the parent session (no preceding - // SessionToolCallStart). - const { session, mockSession, signals, waitForSignal } = await createAgentSession(disposables, { clientSnapshot: snapshot }); - - mockSession.fire('tool.execution_start', { - toolCallId: 'tc-sub-client', - toolName: 'my_tool', - arguments: {}, - parentToolCallId: 'tc-parent-subagent', - } as SessionEventPayload<'tool.execution_start'>['data']); - - const resultPromise = session.handlePermissionRequest({ - kind: 'custom-tool', - toolCallId: 'tc-sub-client', - toolName: 'my_tool', - }); - - await waitForSignal(s => s.kind === 'pending_confirmation'); - const permSignals = signals.filter((s): s is IAgentToolPendingConfirmationSignal => s.kind === 'pending_confirmation'); - assert.strictEqual(permSignals.length, 1); - assert.strictEqual(permSignals[0].parentToolCallId, 'tc-parent-subagent'); - - session.respondToPermissionRequest('tc-sub-client', false); - await resultPromise; - }); - - test('handleClientToolCallComplete pre-completes when no handler is waiting yet', async () => { - const { session } = await createAgentSession(disposables, { clientSnapshot: snapshot }); - - // Completion arrives before handler — pre-creates deferred - session.handleClientToolCallComplete('tc-unknown', { - success: true, - pastTenseMessage: 'done', - }); - - // Handler picks up the pre-completed result - const tools = session.createClientSdkTools(); - const result = await invokeClientToolHandler(tools[0], 'tc-unknown'); - assert.strictEqual(result.resultType, 'success'); - }); - - test('handleClientToolCallComplete with failure result', async () => { - const { session } = await createAgentSession(disposables, { clientSnapshot: snapshot }); - - const tools = session.createClientSdkTools(); - const handlerPromise = invokeClientToolHandler(tools[0], 'tc-client-3'); - - session.handleClientToolCallComplete('tc-client-3', { - success: false, - pastTenseMessage: 'failed', - error: { message: 'something broke' }, - }); - - const result = await handlerPromise; - assert.strictEqual(result.resultType, 'failure'); - assert.strictEqual(result.error, 'something broke'); - }); - - test('pending client tool calls are cancelled on dispose', async () => { - const { session } = await createAgentSession(disposables, { clientSnapshot: snapshot }); - - const tools = session.createClientSdkTools(); - const handlerPromise = invokeClientToolHandler(tools[0], 'tc-client-4'); - - session.dispose(); - const result = await handlerPromise; - assert.strictEqual(result.resultType, 'failure'); - assert.ok(result.error); - }); - - test('multiple concurrent client tool calls resolve independently', async () => { - const { session } = await createAgentSession(disposables, { clientSnapshot: snapshot }); - - const tools = session.createClientSdkTools(); - const promise1 = invokeClientToolHandler(tools[0], 'tc-multi-1'); - const promise2 = invokeClientToolHandler(tools[0], 'tc-multi-2'); - - // Complete in reverse order - session.handleClientToolCallComplete('tc-multi-2', { - success: true, - pastTenseMessage: 'second done', - content: [{ type: ToolResultContentType.Text, text: 'result-2' }], - }); - session.handleClientToolCallComplete('tc-multi-1', { - success: true, - pastTenseMessage: 'first done', - content: [{ type: ToolResultContentType.Text, text: 'result-1' }], - }); - - const [result1, result2] = await Promise.all([promise1, promise2]); - assert.strictEqual(result1.textResultForLlm, 'result-1'); - assert.strictEqual(result2.textResultForLlm, 'result-2'); - }); - - test('handler cleans up deferred after consuming result', async () => { - const { session } = await createAgentSession(disposables, { clientSnapshot: snapshot }); - - const tools = session.createClientSdkTools(); - const handlerPromise = invokeClientToolHandler(tools[0], 'tc-cleanup'); - - session.handleClientToolCallComplete('tc-cleanup', { - success: true, - pastTenseMessage: 'done', - content: [{ type: ToolResultContentType.Text, text: 'ok' }], - }); - - await handlerPromise; - - // A second complete for the same toolCallId should create a new - // deferred (not fail). This tests the cleanup path. - session.handleClientToolCallComplete('tc-cleanup', { - success: true, - pastTenseMessage: 'done again', - }); - }); - - test('client tool handler logs and rethrows failures', async () => { - const logService = new CapturingLogService(); - const { session } = await createAgentSession(disposables, { clientSnapshot: snapshot, logService }); - const tools = session.createClientSdkTools(); - const sessionInternals = session as unknown as ISessionInternalsForTest; - sessionInternals._pendingClientToolCalls.get = () => { - throw new Error('client tool boom'); - }; - - await assert.rejects( - invokeClientToolHandler(tools[0], 'tc-client-error'), - /client tool boom/, - ); - - assert.strictEqual(logService.errors.length, 1); - const [entry] = logService.errors; - assert.ok(entry.first instanceof Error); - assert.strictEqual((entry.first as Error).message, 'client tool boom'); - assert.strictEqual(entry.args[0], '[Copilot:test-session-1] Failed in client tool handler: tool=my_tool, toolCallId=tc-client-error'); - }); - - test('permission request before client tool handler emits only confirmation ready', async () => { - const { session, mockSession, signals, waitForSignal } = await createAgentSession(disposables, { clientSnapshot: snapshot }); - - mockSession.fire('tool.execution_start', { - toolCallId: 'tc-ready-data', - toolName: 'my_tool', - arguments: { file: 'test.ts' }, - } as SessionEventPayload<'tool.execution_start'>['data']); - - // tool_start should have fired - assert.strictEqual(signals.filter(s => isAction(s, ActionType.SessionToolCallStart)).length, 1); - - // Permission before the handler should produce only the confirmation - // pending_confirmation, not a synthetic auto-ready. - const resultPromise = session.handlePermissionRequest({ - kind: 'custom-tool', - toolCallId: 'tc-ready-data', - toolName: 'my_tool', - }); - - await waitForSignal(s => s.kind === 'pending_confirmation'); - const permSignals = signals.filter((s): s is IAgentToolPendingConfirmationSignal => s.kind === 'pending_confirmation'); - assert.strictEqual(permSignals.length, 1); - assert.ok(permSignals[0].state.confirmationTitle); - - session.respondToPermissionRequest('tc-ready-data', true); - await resultPromise; - }); - - test('handleClientToolCallComplete with content containing embedded resources', async () => { - const { session } = await createAgentSession(disposables, { clientSnapshot: snapshot }); - - const tools = session.createClientSdkTools(); - const handlerPromise = invokeClientToolHandler(tools[0], 'tc-embedded'); - - session.handleClientToolCallComplete('tc-embedded', { - success: true, - pastTenseMessage: 'done', - content: [ - { type: ToolResultContentType.Text, text: 'text part' }, - { type: ToolResultContentType.EmbeddedResource, data: 'base64data', contentType: 'image/png' }, - ], - }); - - const result = await handlerPromise; - assert.strictEqual(result.resultType, 'success'); - // Text content should be extracted - assert.strictEqual(result.textResultForLlm, 'text part'); - }); - }); - - // ---- Plan mode ---------------------------------------------------------- - - suite('plan mode', () => { - - const planRequestParams = (overrides?: Partial<{ actions: string[]; recommendedAction: string; summary: string }>) => ({ - sessionId: 'test-session-1', - summary: overrides?.summary ?? '## Plan summary', - planContent: '## Plan', - actions: overrides?.actions ?? ['autopilot', 'interactive', 'exit_only'], - recommendedAction: overrides?.recommendedAction ?? 'autopilot', - }); - - test('applyMode pushes the mode to the SDK only when it changes', async () => { - const { session, mockSession } = await createAgentSession(disposables); - - await session.applyMode('plan'); - await session.applyMode('plan'); - await session.applyMode('autopilot'); - await session.applyMode(undefined); - await session.applyMode('autopilot'); - - assert.deepStrictEqual(mockSession.modeSetCalls, [ - { mode: 'plan' }, - { mode: 'autopilot' }, - ]); - }); - - test('send applies mode before forwarding to the SDK', async () => { - const { session, mockSession } = await createAgentSession(disposables); - - await session.send('hi', undefined, 'turn-1', 'plan'); - - assert.deepStrictEqual(mockSession.modeSetCalls, [{ mode: 'plan' }]); - assert.strictEqual(mockSession.sendRequests.length, 1); - }); - - test('handleExitPlanModeRequest produces a single-select input request with options and recommended', async () => { - const { session, mockSession, signals, waitForSignal } = await createAgentSession(disposables); - - mockSession.planReadResult = { exists: true, content: '## Plan', path: '/sessions/abc/plan.md' }; - - const responsePromise = session.handleExitPlanModeRequest(planRequestParams()); - - const signal = await waitForSignal(s => isAction(s, ActionType.SessionInputRequested)); - const request = getInputRequest(signal); - - // The plan summary and "View full plan" link are emitted as a - // markdown response part before the input request, so the - // client renders them inline above the question. - const deltaContent = signals.flatMap(s => { - if (s.kind !== 'action') { return []; } - if (s.action.type === ActionType.SessionResponsePart) { - const part = (s.action as SessionResponsePartAction).part; - return part.kind === ResponsePartKind.Markdown ? [part.content] : []; - } - if (s.action.type === ActionType.SessionDelta) { - return [(s.action as SessionDeltaAction).content]; - } - return []; - }).join(''); - assert.ok(deltaContent.includes('Plan summary'), `expected delta to include plan summary; got: ${deltaContent}`); - assert.ok(deltaContent.includes('plan.md'), 'delta should include a link to the plan file'); - - const question = request.questions?.[0]; - assert.strictEqual(question?.kind, SessionInputQuestionKind.SingleSelect); - if (question?.kind === SessionInputQuestionKind.SingleSelect) { - assert.deepStrictEqual(question.options.map(o => o.id), ['autopilot', 'interactive', 'exit_only']); - const recommended = question.options.find(o => o.recommended); - assert.strictEqual(recommended?.id, 'autopilot'); - assert.strictEqual(question.allowFreeformInput, true); - } - - // Resolve the request so the deferred completes and the test can clean up. - session.respondToUserInputRequest(request.id, SessionInputResponseKind.Decline); - await responsePromise; - }); - - test('completing the input request with autopilot resolves with approved + autopilot + autoApproveEdits', async () => { - const { session, waitForSignal } = await createAgentSession(disposables); - - const responsePromise = session.handleExitPlanModeRequest(planRequestParams({ actions: ['autopilot', 'interactive'], recommendedAction: 'autopilot' })); - const signal = await waitForSignal(s => isAction(s, ActionType.SessionInputRequested)); - const request = getInputRequest(signal); - const requestId = request.id; - const questionId = request.questions![0].id; - - session.respondToUserInputRequest(requestId, SessionInputResponseKind.Accept, { - [questionId]: { - state: SessionInputAnswerState.Submitted, - value: { kind: SessionInputAnswerValueKind.Selected, value: 'autopilot' }, - }, - }); - - assert.deepStrictEqual(await responsePromise, { approved: true, selectedAction: 'autopilot', autoApproveEdits: true }); - }); - - test('completing the input request with interactive resolves with approved + interactive (no autoApprove)', async () => { - const { session, waitForSignal } = await createAgentSession(disposables); - - const responsePromise = session.handleExitPlanModeRequest(planRequestParams({ actions: ['autopilot', 'interactive'], recommendedAction: 'interactive' })); - const signal = await waitForSignal(s => isAction(s, ActionType.SessionInputRequested)); - const request = getInputRequest(signal); - const requestId = request.id; - const questionId = request.questions![0].id; - - session.respondToUserInputRequest(requestId, SessionInputResponseKind.Accept, { - [questionId]: { - state: SessionInputAnswerState.Submitted, - value: { kind: SessionInputAnswerValueKind.Selected, value: 'interactive' }, - }, - }); - - assert.deepStrictEqual(await responsePromise, { approved: true, selectedAction: 'interactive' }); - }); - - test('declining the input request resolves with approved=false', async () => { - const { session, waitForSignal } = await createAgentSession(disposables); - - const responsePromise = session.handleExitPlanModeRequest(planRequestParams()); - const signal = await waitForSignal(s => isAction(s, ActionType.SessionInputRequested)); - - session.respondToUserInputRequest(getInputRequest(signal).id, SessionInputResponseKind.Decline); - - assert.deepStrictEqual(await responsePromise, { approved: false }); - }); - - test('exit_only resolves as approved + interactive without autoApproveEdits', async () => { - const { session, waitForSignal } = await createAgentSession(disposables); - - const responsePromise = session.handleExitPlanModeRequest(planRequestParams({ actions: ['autopilot', 'interactive', 'exit_only'], recommendedAction: 'exit_only' })); - const signal = await waitForSignal(s => isAction(s, ActionType.SessionInputRequested)); - const request = getInputRequest(signal); - const requestId = request.id; - const questionId = request.questions![0].id; - - session.respondToUserInputRequest(requestId, SessionInputResponseKind.Accept, { - [questionId]: { - state: SessionInputAnswerState.Submitted, - value: { kind: SessionInputAnswerValueKind.Selected, value: 'exit_only' }, - }, - }); - - assert.deepStrictEqual(await responsePromise, { approved: true, selectedAction: 'exit_only' }); - }); - - test('freeform feedback alongside a selected action becomes a revision request', async () => { - const { session, waitForSignal } = await createAgentSession(disposables); - - const responsePromise = session.handleExitPlanModeRequest(planRequestParams({ actions: ['autopilot', 'interactive'], recommendedAction: 'interactive' })); - const signal = await waitForSignal(s => isAction(s, ActionType.SessionInputRequested)); - const request = getInputRequest(signal); - const requestId = request.id; - const questionId = request.questions![0].id; - - session.respondToUserInputRequest(requestId, SessionInputResponseKind.Accept, { - [questionId]: { - state: SessionInputAnswerState.Submitted, - value: { - kind: SessionInputAnswerValueKind.Selected, - value: 'interactive', - freeformValues: ['Please use Python instead of Node.js'], - }, - }, - }); - - assert.deepStrictEqual(await responsePromise, { - approved: false, - feedback: 'Please use Python instead of Node.js', - selectedAction: 'interactive', - }); - }); - - test('selectedAction not in offered actions falls back to recommendedAction', async () => { - const { session, waitForSignal } = await createAgentSession(disposables); - - const responsePromise = session.handleExitPlanModeRequest(planRequestParams({ actions: ['interactive', 'exit_only'], recommendedAction: 'interactive' })); - const signal = await waitForSignal(s => isAction(s, ActionType.SessionInputRequested)); - const request = getInputRequest(signal); - const requestId = request.id; - const questionId = request.questions![0].id; - - // SDK only offered `interactive` and `exit_only`; the client - // somehow sent `autopilot` (e.g. stale UI state). The agent - // host clamps to `recommendedAction` so the SDK never sees a - // value it didn't offer. - session.respondToUserInputRequest(requestId, SessionInputResponseKind.Accept, { - [questionId]: { - state: SessionInputAnswerState.Submitted, - value: { kind: SessionInputAnswerValueKind.Selected, value: 'autopilot' }, - }, - }); - - assert.deepStrictEqual(await responsePromise, { approved: true, selectedAction: 'interactive' }); - }); - - test('selectedAction not in offered actions and no fallback resolves to approved=false', async () => { - const { session, waitForSignal } = await createAgentSession(disposables); - - // SDK offered `exit_only` only and recommended a value not in - // the offered set. The client picked something invalid. With - // no usable selectedAction and no feedback, decline. - const responsePromise = session.handleExitPlanModeRequest(planRequestParams({ actions: ['exit_only'], recommendedAction: 'autopilot' })); - const signal = await waitForSignal(s => isAction(s, ActionType.SessionInputRequested)); - const request = getInputRequest(signal); - const requestId = request.id; - const questionId = request.questions![0].id; - - session.respondToUserInputRequest(requestId, SessionInputResponseKind.Accept, { - [questionId]: { - state: SessionInputAnswerState.Submitted, - value: { kind: SessionInputAnswerValueKind.Selected, value: 'interactive' }, - }, - }); - - assert.deepStrictEqual(await responsePromise, { approved: false }); - }); - - test('text answer with feedback becomes a revision request without selectedAction', async () => { - const { session, waitForSignal } = await createAgentSession(disposables); - - const responsePromise = session.handleExitPlanModeRequest(planRequestParams({ actions: ['autopilot', 'interactive'], recommendedAction: 'interactive' })); - const signal = await waitForSignal(s => isAction(s, ActionType.SessionInputRequested)); - const request = getInputRequest(signal); - const requestId = request.id; - const questionId = request.questions![0].id; - - // The single-select question normally produces a Selected - // value, but a defensive Text response should still be - // translated to a revision request when the answer is - // non-empty (selectedAction falls back to recommendedAction). - session.respondToUserInputRequest(requestId, SessionInputResponseKind.Accept, { - [questionId]: { - state: SessionInputAnswerState.Submitted, - value: { kind: SessionInputAnswerValueKind.Text, value: 'Add tests for edge cases' }, - }, - }); - - assert.deepStrictEqual(await responsePromise, { - approved: false, - feedback: 'Add tests for edge cases', - selectedAction: 'interactive', - }); - }); - - test('whitespace-only freeform feedback is ignored', async () => { - const { session, waitForSignal } = await createAgentSession(disposables); - - const responsePromise = session.handleExitPlanModeRequest(planRequestParams({ actions: ['autopilot', 'interactive'], recommendedAction: 'interactive' })); - const signal = await waitForSignal(s => isAction(s, ActionType.SessionInputRequested)); - const request = getInputRequest(signal); - const requestId = request.id; - const questionId = request.questions![0].id; - - session.respondToUserInputRequest(requestId, SessionInputResponseKind.Accept, { - [questionId]: { - state: SessionInputAnswerState.Submitted, - value: { - kind: SessionInputAnswerValueKind.Selected, - value: 'interactive', - freeformValues: [' ', ''], - }, - }, - }); - - assert.deepStrictEqual(await responsePromise, { approved: true, selectedAction: 'interactive' }); - }); - - test('session.mode_changed → plan updates the AHP session config', async () => { - const { mockSession, sessionConfigUpdates } = await createAgentSession(disposables); - - mockSession.fire('session.mode_changed', { previousMode: 'interactive', newMode: 'plan' } as SessionEventPayload<'session.mode_changed'>['data']); - - assert.deepStrictEqual(sessionConfigUpdates, [ - { session: 'copilot:/test-session-1', patch: { mode: 'plan' } }, - ]); - }); - - test('session.mode_changed → interactive updates the AHP session config', async () => { - const { mockSession, sessionConfigUpdates } = await createAgentSession(disposables); - - mockSession.fire('session.mode_changed', { previousMode: 'plan', newMode: 'interactive' } as SessionEventPayload<'session.mode_changed'>['data']); - - assert.deepStrictEqual(sessionConfigUpdates, [ - { session: 'copilot:/test-session-1', patch: { mode: 'interactive' } }, - ]); - }); - - test('session.mode_changed → autopilot translates to mode=interactive + autoApprove=autopilot', async () => { - // The SDK has a first-class `autopilot` mode but AHP exposes it - // as the `autopilot` value on the orthogonal `autoApprove` axis. - // The translation is contained in the Copilot agent. - const { mockSession, sessionConfigUpdates } = await createAgentSession(disposables); - - mockSession.fire('session.mode_changed', { previousMode: 'plan', newMode: 'autopilot' } as SessionEventPayload<'session.mode_changed'>['data']); - - assert.deepStrictEqual(sessionConfigUpdates, [ - { session: 'copilot:/test-session-1', patch: { mode: 'interactive', autoApprove: 'autopilot' } }, - ]); - }); - - test('session.mode_changed for unsupported mode is ignored', async () => { - const { mockSession, sessionConfigUpdates } = await createAgentSession(disposables); - - mockSession.fire('session.mode_changed', { previousMode: 'interactive', newMode: 'shell' } as SessionEventPayload<'session.mode_changed'>['data']); - - assert.strictEqual(sessionConfigUpdates.length, 0); - }); - - // ---- autopilot fast-path ------------------------------------------- - - test('handleExitPlanModeRequest auto-accepts when autoApprove=autopilot (recommended action)', async () => { - const { session, signals } = await createAgentSession(disposables, { - configValues: { [SessionConfigKey.AutoApprove]: 'autopilot' }, - }); - - const response = await session.handleExitPlanModeRequest(planRequestParams({ - actions: ['autopilot', 'interactive', 'exit_only'], - recommendedAction: 'autopilot', - })); - - assert.deepStrictEqual(response, { approved: true, selectedAction: 'autopilot', autoApproveEdits: true }); - // User-input request should NOT be surfaced to the client. - assert.strictEqual(signals.filter(s => isAction(s, ActionType.SessionInputRequested)).length, 0); - }); - - test('handleExitPlanModeRequest auto-accepts with priority order when no recommended action available', async () => { - const { session } = await createAgentSession(disposables, { - configValues: { [SessionConfigKey.AutoApprove]: 'autopilot' }, - }); - - // SDK proposes a recommended action that's NOT in the offered set — - // fall back to the priority order (autopilot > autopilot_fleet > - // interactive > exit_only). - const response = await session.handleExitPlanModeRequest(planRequestParams({ - actions: ['interactive', 'exit_only'], - recommendedAction: 'autopilot_fleet', - })); - - assert.deepStrictEqual(response, { approved: true, selectedAction: 'interactive' }); - }); - - test('handleExitPlanModeRequest does NOT auto-accept when autoApprove=default', async () => { - const { session, waitForSignal } = await createAgentSession(disposables, { - configValues: { [SessionConfigKey.AutoApprove]: 'default' }, - }); - - const responsePromise = session.handleExitPlanModeRequest(planRequestParams()); - - // The user-input request fires — the user must respond. - const signal = await waitForSignal(s => isAction(s, ActionType.SessionInputRequested)); - session.respondToUserInputRequest(getInputRequest(signal).id, SessionInputResponseKind.Decline); - await responsePromise; - }); - }); -}); diff --git a/src/vs/platform/agentHost/test/node/copilotGitProject.test.ts b/src/vs/platform/agentHost/test/node/copilotGitProject.test.ts deleted file mode 100644 index be344064..00000000 --- a/src/vs/platform/agentHost/test/node/copilotGitProject.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import assert from 'assert'; -import { URI } from '../../../../base/common/uri.js'; -import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import type { IAgentHostGitService } from '../../node/agentHostGitService.js'; -import { projectFromCopilotContext, projectFromRepository, resolveGitProject } from '../../node/copilot/copilotGitProject.js'; - -class TestAgentHostGitService implements IAgentHostGitService { - declare readonly _serviceBrand: undefined; - - insideWorkTree = true; - repositoryRoot: URI | undefined; - worktreeRoots: URI[] = []; - - async isInsideWorkTree(): Promise { return this.insideWorkTree; } - async getCurrentBranch(): Promise { return undefined; } - async getDefaultBranch(): Promise { return undefined; } - async getBranches(): Promise { return []; } - async getRepositoryRoot(): Promise { return this.repositoryRoot; } - async getWorktreeRoots(): Promise { return this.worktreeRoots; } - async addWorktree(): Promise { } - async addExistingWorktree(): Promise { } - async removeWorktree(): Promise { } - async branchExists(): Promise { return false; } - async hasUncommittedChanges(): Promise { return false; } - async getSessionGitState(): Promise { return undefined; } - async computeSessionFileDiffs(): Promise { return undefined; } - async showBlob(): Promise { return undefined; } -} - -suite('Copilot Git Project', () => { - ensureNoDisposablesAreLeakedInTestSuite(); - - let gitService: TestAgentHostGitService; - - setup(() => { - gitService = new TestAgentHostGitService(); - }); - - test('resolves a repository project from a worktree working directory', async () => { - gitService.worktreeRoots = [URI.file('/workspace/source-repo')]; - - const project = await resolveGitProject(URI.file('/workspace/worktree-checkout'), gitService); - - assert.deepStrictEqual({ - uri: project?.uri.toString(), - displayName: project?.displayName, - }, { - uri: URI.file('/workspace/source-repo').toString(), - displayName: 'source-repo', - }); - }); - - test('resolves the repository itself for a normal git working directory', async () => { - gitService.repositoryRoot = URI.file('/workspace/normal-repo'); - - const project = await resolveGitProject(URI.file('/workspace/normal-repo'), gitService); - - assert.deepStrictEqual({ - uri: project?.uri.toString(), - displayName: project?.displayName, - }, { - uri: URI.file('/workspace/normal-repo').toString(), - displayName: 'normal-repo', - }); - }); - - test('returns undefined outside a git working tree', async () => { - gitService.insideWorkTree = false; - - assert.strictEqual(await resolveGitProject(URI.file('/workspace/plain-folder'), gitService), undefined); - }); - - test('falls back to repository context when no git project is available', async () => { - gitService.insideWorkTree = false; - - const project = await projectFromCopilotContext({ repository: 'microsoft/vscode' }, gitService); - - assert.deepStrictEqual({ - uri: project?.uri.toString(), - displayName: project?.displayName, - }, { - uri: 'https://github.com/microsoft/vscode', - displayName: 'vscode', - }); - }); - - test('parses repository URLs', () => { - const project = projectFromRepository('https://github.com/microsoft/vscode.git'); - - assert.deepStrictEqual({ - uri: project?.uri.toString(), - displayName: project?.displayName, - }, { - uri: 'https://github.com/microsoft/vscode.git', - displayName: 'vscode', - }); - }); -}); diff --git a/src/vs/platform/agentHost/test/node/copilotPluginConverters.test.ts b/src/vs/platform/agentHost/test/node/copilotPluginConverters.test.ts deleted file mode 100644 index 990a15bb..00000000 --- a/src/vs/platform/agentHost/test/node/copilotPluginConverters.test.ts +++ /dev/null @@ -1,349 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import assert from 'assert'; -import { writeFileSync, unlinkSync } from 'fs'; -import { fileURLToPath } from 'url'; -import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { DisposableStore } from '../../../../base/common/lifecycle.js'; -import { Schemas } from '../../../../base/common/network.js'; -import { URI } from '../../../../base/common/uri.js'; -import { VSBuffer } from '../../../../base/common/buffer.js'; -import { FileService } from '../../../files/common/fileService.js'; -import { InMemoryFileSystemProvider } from '../../../files/common/inMemoryFilesystemProvider.js'; -import { NullLogService } from '../../../log/common/log.js'; -import { McpServerType } from '../../../mcp/common/mcpPlatformTypes.js'; -import { toSdkMcpServers, toSdkCustomAgents, toSdkSkillDirectories, parsedPluginsEqual, toSdkHooks } from '../../node/copilot/copilotPluginConverters.js'; -import type { IMcpServerDefinition, INamedPluginResource, IParsedHookGroup, IParsedPlugin } from '../../../agentPlugins/common/pluginParsers.js'; - -suite('copilotPluginConverters', () => { - - const disposables = new DisposableStore(); - let fileService: FileService; - - setup(() => { - fileService = disposables.add(new FileService(new NullLogService())); - disposables.add(fileService.registerProvider(Schemas.inMemory, disposables.add(new InMemoryFileSystemProvider()))); - }); - - teardown(() => disposables.clear()); - ensureNoDisposablesAreLeakedInTestSuite(); - - // ---- toSdkMcpServers ------------------------------------------------ - - suite('toSdkMcpServers', () => { - - test('converts local server definitions', () => { - const defs: IMcpServerDefinition[] = [{ - name: 'test-server', - uri: URI.file('/plugin'), - configuration: { - type: McpServerType.LOCAL, - command: 'node', - args: ['server.js', '--port', '3000'], - env: { NODE_ENV: 'production', PORT: 3000 as unknown as string }, - cwd: '/workspace', - }, - }]; - - const result = toSdkMcpServers(defs); - assert.deepStrictEqual(result, { - 'test-server': { - type: 'local', - command: 'node', - args: ['server.js', '--port', '3000'], - tools: ['*'], - env: { NODE_ENV: 'production', PORT: '3000' }, - cwd: '/workspace', - }, - }); - }); - - test('converts remote/http server definitions', () => { - const defs: IMcpServerDefinition[] = [{ - name: 'remote-server', - uri: URI.file('/plugin'), - configuration: { - type: McpServerType.REMOTE, - url: 'https://example.com/mcp', - headers: { 'Authorization': 'Bearer token' }, - }, - }]; - - const result = toSdkMcpServers(defs); - assert.deepStrictEqual(result, { - 'remote-server': { - type: 'http', - url: 'https://example.com/mcp', - tools: ['*'], - headers: { 'Authorization': 'Bearer token' }, - }, - }); - }); - - test('handles empty definitions', () => { - const result = toSdkMcpServers([]); - assert.deepStrictEqual(result, {}); - }); - - test('omits optional fields when undefined', () => { - const defs: IMcpServerDefinition[] = [{ - name: 'minimal', - uri: URI.file('/plugin'), - configuration: { - type: McpServerType.LOCAL, - command: 'echo', - }, - }]; - - const result = toSdkMcpServers(defs); - assert.strictEqual(result['minimal'].type, 'local'); - assert.deepStrictEqual((result['minimal'] as { args?: string[] }).args, []); - assert.strictEqual(Object.hasOwn(result['minimal'], 'env'), false); - assert.strictEqual(Object.hasOwn(result['minimal'], 'cwd'), false); - }); - - test('filters null values from env', () => { - const defs: IMcpServerDefinition[] = [{ - name: 'with-null-env', - uri: URI.file('/plugin'), - configuration: { - type: McpServerType.LOCAL, - command: 'test', - env: { KEEP: 'value', DROP: null as unknown as string }, - }, - }]; - - const result = toSdkMcpServers(defs); - const env = (result['with-null-env'] as { env?: Record }).env; - assert.deepStrictEqual(env, { KEEP: 'value' }); - }); - }); - - // ---- toSdkCustomAgents ---------------------------------------------- - - suite('toSdkCustomAgents', () => { - - test('reads agent files and creates configs', async () => { - const agentUri = URI.from({ scheme: Schemas.inMemory, path: '/agents/helper.md' }); - await fileService.writeFile(agentUri, VSBuffer.fromString('You are a helpful assistant')); - - const agents: INamedPluginResource[] = [{ uri: agentUri, name: 'helper' }]; - const result = await toSdkCustomAgents(agents, fileService); - - assert.deepStrictEqual(result, [{ - name: 'helper', - prompt: 'You are a helpful assistant', - }]); - }); - - test('skips agents whose files cannot be read', async () => { - const agents: INamedPluginResource[] = [ - { uri: URI.from({ scheme: Schemas.inMemory, path: '/nonexistent/agent.md' }), name: 'missing' }, - ]; - const result = await toSdkCustomAgents(agents, fileService); - assert.deepStrictEqual(result, []); - }); - - test('processes multiple agents, skipping failures', async () => { - const goodUri = URI.from({ scheme: Schemas.inMemory, path: '/agents/good.md' }); - await fileService.writeFile(goodUri, VSBuffer.fromString('Good agent')); - - const agents: INamedPluginResource[] = [ - { uri: goodUri, name: 'good' }, - { uri: URI.from({ scheme: Schemas.inMemory, path: '/agents/bad.md' }), name: 'bad' }, - ]; - const result = await toSdkCustomAgents(agents, fileService); - assert.strictEqual(result.length, 1); - assert.strictEqual(result[0].name, 'good'); - }); - }); - - // ---- toSdkSkillDirectories ------------------------------------------ - - suite('toSdkSkillDirectories', () => { - - test('extracts parent directories of skill URIs', () => { - const skills: INamedPluginResource[] = [ - { uri: URI.file('/plugins/skill-a/SKILL.md'), name: 'skill-a' }, - { uri: URI.file('/plugins/skill-b/SKILL.md'), name: 'skill-b' }, - ]; - const result = toSdkSkillDirectories(skills); - assert.strictEqual(result.length, 2); - }); - - test('deduplicates directories', () => { - const skills: INamedPluginResource[] = [ - { uri: URI.file('/plugins/shared/SKILL.md'), name: 'skill-a' }, - { uri: URI.file('/plugins/shared/SKILL.md'), name: 'skill-b' }, - ]; - const result = toSdkSkillDirectories(skills); - assert.strictEqual(result.length, 1); - }); - - test('handles empty input', () => { - const result = toSdkSkillDirectories([]); - assert.deepStrictEqual(result, []); - }); - }); - - // ---- toSdkHooks ------------------------------------------------------- - - suite('toSdkHooks', () => { - - function makeHookGroup(type: string, command: string): IParsedHookGroup { - return { - type, - commands: [{ command }], - uri: URI.file('/plugin/hooks.json'), - originalId: type, - }; - } - - /** - * Writes a temp JS script that outputs JSON to stdout and returns - * a `node ` command. Works on both bash (/bin/sh -c) and - * cmd.exe without any shell-quoting issues. - * The script is written alongside the compiled test file which is - * guaranteed to exist, be writable, and have no spaces in CI. - */ - function echoJsonCmd(value: object): { command: string; cleanup: () => void } { - const json = JSON.stringify(value); - // fileURLToPath(new URL('.', import.meta.url)) is the Node ESM equivalent - // of __dirname and works on Node 12+, unlike import.meta.dirname (Node 21.2+). - const dir = fileURLToPath(new URL('.', import.meta.url)).replace(/[\\/]$/, ''); - const filePath = `${dir}/vscode-test-hook-${Date.now()}.js`; - writeFileSync(filePath, `process.stdout.write(${JSON.stringify(json)});\n`); - // Do NOT quote the path: cmd.exe /c "node path" strips the outer quotes, - // leaving "node path" without inner quoting which cmd.exe handles cleanly. - const command = `node ${filePath}`; - return { command, cleanup: () => { try { unlinkSync(filePath); } catch { /* ignore */ } } }; - } - - test('onPostToolUse returns parsed JSON output as hook result', async () => { - const expectedOutput = { additionalContext: 'Before presenting the plan, run review-plan skill' }; - const { command, cleanup } = echoJsonCmd(expectedOutput); - try { - const hookGroup = makeHookGroup('PostToolUse', command); - const hooks = toSdkHooks([hookGroup]); - const toolResult = { textResultForLlm: 'ok', resultType: 'success' as const }; - const result = await hooks.onPostToolUse!({ toolName: 'memory', toolArgs: {}, toolResult, timestamp: 0, cwd: '/' }, { sessionId: 'test' }); - assert.deepStrictEqual(result, expectedOutput); - } finally { - cleanup(); - } - }); - - test('onPostToolUse returns undefined when output is non-JSON', async () => { - // Use a script file so there are no cmd.exe quoting issues on Windows. - const dir = fileURLToPath(new URL('.', import.meta.url)).replace(/[\\/]$/, ''); - const filePath = `${dir}/vscode-test-hook-nonjson-${Date.now()}.js`; - writeFileSync(filePath, `process.stdout.write('not-json');\n`); - try { - const hookGroup = makeHookGroup('PostToolUse', `node ${filePath}`); - const hooks = toSdkHooks([hookGroup]); - const toolResult = { textResultForLlm: 'ok', resultType: 'success' as const }; - const result = await hooks.onPostToolUse!({ toolName: 'memory', toolArgs: {}, toolResult, timestamp: 0, cwd: '/' }, { sessionId: 'test' }); - assert.strictEqual(result, undefined); - } finally { - try { unlinkSync(filePath); } catch { /* ignore */ } - } - }); - - test('onPostToolUse returns undefined when command fails', async () => { - const dir = fileURLToPath(new URL('.', import.meta.url)).replace(/[\\/]$/, ''); - const filePath = `${dir}/vscode-test-hook-fail-${Date.now()}.js`; - writeFileSync(filePath, `process.exit(1);\n`); - try { - const hookGroup = makeHookGroup('PostToolUse', `node ${filePath}`); - const hooks = toSdkHooks([hookGroup]); - const toolResult = { textResultForLlm: 'ok', resultType: 'success' as const }; - const result = await hooks.onPostToolUse!({ toolName: 'memory', toolArgs: {}, toolResult, timestamp: 0, cwd: '/' }, { sessionId: 'test' }); - assert.strictEqual(result, undefined); - } finally { - try { unlinkSync(filePath); } catch { /* ignore */ } - } - }); - - test('onPostToolUse returns undefined when no commands', async () => { - const hooks = toSdkHooks([]); - assert.strictEqual(hooks.onPostToolUse, undefined); - }); - - test('onPostToolUse calls editTrackingHooks and returns command output', async () => { - const expectedOutput = { additionalContext: 'context from hook' }; - const { command, cleanup } = echoJsonCmd(expectedOutput); - try { - const hookGroup = makeHookGroup('PostToolUse', command); - let trackingInput: unknown; - const editTrackingHooks = { - onPreToolUse: async () => { }, - onPostToolUse: async (input: unknown) => { trackingInput = input; }, - }; - const hooks = toSdkHooks([hookGroup], editTrackingHooks); - const toolResult = { textResultForLlm: 'ok', resultType: 'success' as const }; - const callInput = { toolName: 'memory', toolArgs: {}, toolResult, timestamp: 0, cwd: '/' }; - const result = await hooks.onPostToolUse!(callInput, { sessionId: 'test' }); - assert.deepStrictEqual(result, expectedOutput); - assert.deepStrictEqual(trackingInput, callInput); - } finally { - cleanup(); - } - }); - }); - - // ---- parsedPluginsEqual --------------------------------------------- - - suite('parsedPluginsEqual', () => { - - function makePlugin(overrides?: Partial): IParsedPlugin { - return { - hooks: [], - mcpServers: [], - skills: [], - agents: [], - ...overrides, - }; - } - - test('returns true for identical empty plugins', () => { - assert.strictEqual(parsedPluginsEqual([makePlugin()], [makePlugin()]), true); - }); - - test('returns true for same content', () => { - const a = makePlugin({ - skills: [{ uri: URI.file('/a/SKILL.md'), name: 'a' }], - mcpServers: [{ - name: 'server', - uri: URI.file('/mcp'), - configuration: { type: McpServerType.LOCAL, command: 'node' }, - }], - }); - const b = makePlugin({ - skills: [{ uri: URI.file('/a/SKILL.md'), name: 'a' }], - mcpServers: [{ - name: 'server', - uri: URI.file('/mcp'), - configuration: { type: McpServerType.LOCAL, command: 'node' }, - }], - }); - assert.strictEqual(parsedPluginsEqual([a], [b]), true); - }); - - test('returns false for different content', () => { - const a = makePlugin({ skills: [{ uri: URI.file('/a/SKILL.md'), name: 'a' }] }); - const b = makePlugin({ skills: [{ uri: URI.file('/b/SKILL.md'), name: 'b' }] }); - assert.strictEqual(parsedPluginsEqual([a], [b]), false); - }); - - test('returns false for different lengths', () => { - assert.strictEqual(parsedPluginsEqual([makePlugin()], [makePlugin(), makePlugin()]), false); - }); - - test('returns true for empty arrays', () => { - assert.strictEqual(parsedPluginsEqual([], []), true); - }); - }); -}); diff --git a/src/vs/platform/agentHost/test/node/copilotShellTools.test.ts b/src/vs/platform/agentHost/test/node/copilotShellTools.test.ts deleted file mode 100644 index 698ab70d..00000000 --- a/src/vs/platform/agentHost/test/node/copilotShellTools.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import assert from 'assert'; -import { URI } from '../../../../base/common/uri.js'; -import { Disposable, DisposableStore, type IDisposable } from '../../../../base/common/lifecycle.js'; -import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { IInstantiationService } from '../../../instantiation/common/instantiation.js'; -import { InstantiationService } from '../../../instantiation/common/instantiationService.js'; -import { ServiceCollection } from '../../../instantiation/common/serviceCollection.js'; -import { ILogService, NullLogService } from '../../../log/common/log.js'; -import type { CreateTerminalParams } from '../../common/state/protocol/commands.js'; -import type { TerminalClaim, TerminalInfo } from '../../common/state/protocol/state.js'; -import { IAgentHostTerminalManager } from '../../node/agentHostTerminalManager.js'; -import { ShellManager, prefixForHistorySuppression } from '../../node/copilot/copilotShellTools.js'; - -class TestAgentHostTerminalManager implements IAgentHostTerminalManager { - declare readonly _serviceBrand: undefined; - - readonly created: { params: CreateTerminalParams; options?: { shell?: string; preventShellHistory?: boolean; nonInteractive?: boolean } }[] = []; - - async createTerminal(params: CreateTerminalParams, options?: { shell?: string; preventShellHistory?: boolean; nonInteractive?: boolean }): Promise { - this.created.push({ params, options }); - } - writeInput(): void { } - onData(): IDisposable { return Disposable.None; } - onExit(): IDisposable { return Disposable.None; } - onClaimChanged(): IDisposable { return Disposable.None; } - onCommandFinished(): IDisposable { return Disposable.None; } - getContent(): string | undefined { return undefined; } - getClaim(): TerminalClaim | undefined { return undefined; } - hasTerminal(): boolean { return false; } - getExitCode(): number | undefined { return undefined; } - supportsCommandDetection(): boolean { return false; } - disposeTerminal(): void { } - getTerminalInfos(): TerminalInfo[] { return []; } - getTerminalState(): undefined { return undefined; } -} - -suite('CopilotShellTools', () => { - - const disposables = new DisposableStore(); - - teardown(() => disposables.clear()); - ensureNoDisposablesAreLeakedInTestSuite(); - - test('uses session working directory for created shells', async () => { - const terminalManager = new TestAgentHostTerminalManager(); - const services = new ServiceCollection(); - services.set(ILogService, new NullLogService()); - services.set(IAgentHostTerminalManager, terminalManager); - const instantiationService: IInstantiationService = disposables.add(new InstantiationService(services)); - services.set(IInstantiationService, instantiationService); - const worktreePath = URI.file('/workspace/worktree').fsPath; - const explicitCwd = URI.file('/explicit/cwd').fsPath; - const shellManager = disposables.add(instantiationService.createInstance(ShellManager, URI.parse('copilot:/session-1'), URI.file(worktreePath))); - - await shellManager.getOrCreateShell('bash', 'turn-1', 'tool-1'); - await shellManager.getOrCreateShell('bash', 'turn-2', 'tool-2', explicitCwd); - - assert.deepStrictEqual(terminalManager.created.map(c => c.params.cwd), [ - worktreePath, - explicitCwd, - ]); - }); - - test('opts every managed shell into shell-history suppression and non-interactive mode', async () => { - const terminalManager = new TestAgentHostTerminalManager(); - const services = new ServiceCollection(); - services.set(ILogService, new NullLogService()); - services.set(IAgentHostTerminalManager, terminalManager); - const instantiationService: IInstantiationService = disposables.add(new InstantiationService(services)); - services.set(IInstantiationService, instantiationService); - const shellManager = disposables.add(instantiationService.createInstance(ShellManager, URI.parse('copilot:/session-1'), undefined)); - - await shellManager.getOrCreateShell('bash', 'turn-1', 'tool-1'); - - assert.strictEqual(terminalManager.created.length, 1); - assert.strictEqual(terminalManager.created[0].options?.preventShellHistory, true); - assert.strictEqual(terminalManager.created[0].options?.nonInteractive, true); - }); - - test('prefixForHistorySuppression prepends a space for POSIX shells, no-op for PowerShell', () => { - assert.strictEqual(prefixForHistorySuppression('bash'), ' '); - assert.strictEqual(prefixForHistorySuppression('powershell'), ''); - }); -}); diff --git a/src/vs/platform/agentHost/test/node/copilotToolDisplay.test.ts b/src/vs/platform/agentHost/test/node/copilotToolDisplay.test.ts deleted file mode 100644 index 4cc8b678..00000000 --- a/src/vs/platform/agentHost/test/node/copilotToolDisplay.test.ts +++ /dev/null @@ -1,333 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import assert from 'assert'; -import { URI } from '../../../../base/common/uri.js'; -import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { getInvocationMessage, getPastTenseMessage, getPermissionDisplay, getShellLanguage, getToolInputString, getToolKind, isHiddenTool, synthesizeSkillToolCall, type ITypedPermissionRequest } from '../../node/copilot/copilotToolDisplay.js'; - -suite('getPermissionDisplay — cd-prefix stripping', () => { - - ensureNoDisposablesAreLeakedInTestSuite(); - - const wd = URI.file('/repo/project'); - - test('strips redundant cd from shell permission request fullCommandText', () => { - const request: ITypedPermissionRequest = { - kind: 'shell', - fullCommandText: 'cd /repo/project && npm test', - } as ITypedPermissionRequest; - const display = getPermissionDisplay(request, wd); - assert.strictEqual(display.toolInput, 'npm test'); - assert.strictEqual(display.permissionKind, 'shell'); - }); - - test('leaves shell command alone when cd target differs from working directory', () => { - const request: ITypedPermissionRequest = { - kind: 'shell', - fullCommandText: 'cd /tmp && ls', - } as ITypedPermissionRequest; - const display = getPermissionDisplay(request, wd); - assert.strictEqual(display.toolInput, 'cd /tmp && ls'); - }); - - test('leaves shell command alone when no working directory provided', () => { - const request: ITypedPermissionRequest = { - kind: 'shell', - fullCommandText: 'cd /repo/project && npm test', - } as ITypedPermissionRequest; - const display = getPermissionDisplay(request, undefined); - assert.strictEqual(display.toolInput, 'cd /repo/project && npm test'); - }); - - test('strips redundant cd from custom-tool shell permission request', () => { - const request: ITypedPermissionRequest = { - kind: 'custom-tool', - toolName: 'bash', - args: { command: 'cd /repo/project && echo hi' }, - } as ITypedPermissionRequest; - const display = getPermissionDisplay(request, wd); - assert.strictEqual(display.toolInput, 'echo hi'); - assert.strictEqual(display.permissionKind, 'shell'); - }); - - test('does not affect non-shell custom-tool requests', () => { - const request: ITypedPermissionRequest = { - kind: 'custom-tool', - toolName: 'some_other_tool', - args: { command: 'cd /repo/project && echo hi' }, - } as ITypedPermissionRequest; - const display = getPermissionDisplay(request, wd); - // Falls through to the generic branch — toolInput is the JSON-stringified args. - assert.ok(display.toolInput?.includes('cd /repo/project'), `expected unrewritten args, got: ${display.toolInput}`); - assert.strictEqual(display.permissionKind, 'custom-tool'); - }); - - test('handles powershell custom-tool with semicolon separator', () => { - const request: ITypedPermissionRequest = { - kind: 'custom-tool', - toolName: 'powershell', - args: { command: 'cd /repo/project; dir' }, - } as ITypedPermissionRequest; - const display = getPermissionDisplay(request, wd); - assert.strictEqual(display.toolInput, 'dir'); - }); -}); - -suite('view tool — view_range display', () => { - - ensureNoDisposablesAreLeakedInTestSuite(); - - function invocation(parameters: Record | undefined): string { - const result = getInvocationMessage('view', 'View File', parameters); - return typeof result === 'string' ? result : result.markdown; - } - - function pastTense(parameters: Record | undefined): string { - const result = getPastTenseMessage('view', 'View File', parameters, true); - return typeof result === 'string' ? result : result.markdown; - } - - test('renders path-only when view_range is absent', () => { - assert.ok(invocation({ path: '/repo/file.ts' }).startsWith('Reading [')); - assert.ok(pastTense({ path: '/repo/file.ts' }).startsWith('Read [')); - }); - - test('renders "lines X to Y" for a valid two-element range', () => { - assert.ok(invocation({ path: '/repo/file.ts', view_range: [10, 20] }).endsWith(', lines 10 to 20')); - assert.ok(pastTense({ path: '/repo/file.ts', view_range: [10, 20] }).endsWith(', lines 10 to 20')); - }); - - test('renders "line X" when start === end', () => { - assert.ok(invocation({ path: '/repo/file.ts', view_range: [10, 10] }).endsWith(', line 10')); - assert.ok(pastTense({ path: '/repo/file.ts', view_range: [10, 10] }).endsWith(', line 10')); - }); - - test('renders "line X to the end" for the -1 EOF sentinel', () => { - assert.ok(invocation({ path: '/repo/file.ts', view_range: [10, -1] }).endsWith(', line 10 to the end')); - assert.ok(pastTense({ path: '/repo/file.ts', view_range: [10, -1] }).endsWith(', line 10 to the end')); - }); - - test('falls back to path-only for invalid ranges', () => { - // end < start (and not -1) - assert.ok(!invocation({ path: '/repo/file.ts', view_range: [20, 10] }).includes(',')); - // negative start - assert.ok(!invocation({ path: '/repo/file.ts', view_range: [-5, 10] }).includes(',')); - // non-integer - assert.ok(!invocation({ path: '/repo/file.ts', view_range: [1.5, 10] }).includes(',')); - // wrong arity - assert.ok(!invocation({ path: '/repo/file.ts', view_range: [10] }).includes(',')); - assert.ok(!invocation({ path: '/repo/file.ts', view_range: [10, 20, 30] }).includes(',')); - // non-array - assert.ok(!invocation({ path: '/repo/file.ts', view_range: 'whatever' }).includes(',')); - }); -}); - -// ---- write_/read_ shell tool display --------------------------------------- -// -// Coverage for the secondary shell helpers (write_bash, read_bash, and their -// powershell siblings). These never appear in a permission dialog (they're -// registered with `skipPermission: true` — see copilotShellTools.ts), but they -// still flow through the tool-execution display pipeline. - -suite('copilotToolDisplay — write_/read_ shell tools', () => { - - ensureNoDisposablesAreLeakedInTestSuite(); - - suite('getToolKind', () => { - - test('returns terminal for bash', () => { - assert.strictEqual(getToolKind('bash'), 'terminal'); - }); - - test('returns terminal for powershell', () => { - assert.strictEqual(getToolKind('powershell'), 'terminal'); - }); - - test('returns undefined for write_bash (sending input to a running program, not launching a terminal)', () => { - assert.strictEqual(getToolKind('write_bash'), undefined); - }); - - test('returns undefined for write_powershell', () => { - assert.strictEqual(getToolKind('write_powershell'), undefined); - }); - - test('returns undefined for read_bash (reading output, not launching a terminal)', () => { - assert.strictEqual(getToolKind('read_bash'), undefined); - }); - - test('returns undefined for read_powershell', () => { - assert.strictEqual(getToolKind('read_powershell'), undefined); - }); - - test('returns subagent for task', () => { - assert.strictEqual(getToolKind('task'), 'subagent'); - }); - - test('returns undefined for view', () => { - assert.strictEqual(getToolKind('view'), undefined); - }); - }); - - suite('getShellLanguage', () => { - - test('bash returns shellscript', () => { - assert.strictEqual(getShellLanguage('bash'), 'shellscript'); - }); - - test('powershell returns powershell', () => { - assert.strictEqual(getShellLanguage('powershell'), 'powershell'); - }); - - test('write_bash returns shellscript', () => { - assert.strictEqual(getShellLanguage('write_bash'), 'shellscript'); - }); - - test('write_powershell returns powershell', () => { - assert.strictEqual(getShellLanguage('write_powershell'), 'powershell'); - }); - - test('read_bash returns shellscript', () => { - assert.strictEqual(getShellLanguage('read_bash'), 'shellscript'); - }); - - test('read_powershell returns powershell', () => { - assert.strictEqual(getShellLanguage('read_powershell'), 'powershell'); - }); - }); - - suite('getInvocationMessage', () => { - - function getText(msg: ReturnType): string { - return typeof msg === 'string' ? msg : msg.markdown; - } - - test('write_bash with command includes the command text', () => { - const msg = getInvocationMessage('write_bash', 'Write Shell Input', { command: 'echo hello' }); - assert.ok(getText(msg).includes('echo hello'), `expected 'echo hello' in: ${getText(msg)}`); - }); - - test('write_bash without command returns a non-empty fallback message', () => { - const msg = getInvocationMessage('write_bash', 'Write Shell Input', undefined); - assert.ok(getText(msg).length > 0); - assert.ok(!getText(msg).includes('undefined')); - }); - - test('write_powershell with command includes the command text', () => { - const msg = getInvocationMessage('write_powershell', 'Write Shell Input', { command: 'Get-Date' }); - assert.ok(getText(msg).includes('Get-Date'), `expected 'Get-Date' in: ${getText(msg)}`); - }); - - test('read_bash returns a non-empty message', () => { - const msg = getInvocationMessage('read_bash', 'Read Shell Output', undefined); - assert.ok(getText(msg).length > 0); - }); - - test('read_powershell returns a non-empty message', () => { - const msg = getInvocationMessage('read_powershell', 'Read Shell Output', undefined); - assert.ok(getText(msg).length > 0); - }); - - test('write_bash message differs from bash message (distinct wording)', () => { - const writeBashMsg = getText(getInvocationMessage('write_bash', 'Write Shell Input', { command: 'echo hi' })); - const bashMsg = getText(getInvocationMessage('bash', 'Bash', { command: 'echo hi' })); - // Both include the command, but the surrounding text should differ - assert.notStrictEqual(writeBashMsg, bashMsg); - }); - }); - - suite('getPastTenseMessage', () => { - - function getText(msg: ReturnType): string { - return typeof msg === 'string' ? msg : msg.markdown; - } - - test('write_bash with command includes the command text', () => { - const msg = getPastTenseMessage('write_bash', 'Write Shell Input', { command: 'echo hello' }, true); - assert.ok(getText(msg).includes('echo hello'), `expected 'echo hello' in: ${getText(msg)}`); - }); - - test('write_bash without command returns a non-empty fallback message', () => { - const msg = getPastTenseMessage('write_bash', 'Write Shell Input', undefined, true); - assert.ok(getText(msg).length > 0); - }); - - test('write_powershell with command includes the command text', () => { - const msg = getPastTenseMessage('write_powershell', 'Write Shell Input', { command: 'Get-Date' }, true); - assert.ok(getText(msg).includes('Get-Date'), `expected 'Get-Date' in: ${getText(msg)}`); - }); - - test('read_bash success returns a non-empty message', () => { - const msg = getPastTenseMessage('read_bash', 'Read Shell Output', undefined, true); - assert.ok(getText(msg).length > 0); - }); - - test('write_bash failure returns a non-empty error message', () => { - const msg = getPastTenseMessage('write_bash', 'Write Shell Input', { command: 'echo hello' }, false); - assert.ok(getText(msg).length > 0); - }); - }); - - suite('getToolInputString', () => { - - test('write_bash extracts command field', () => { - assert.strictEqual(getToolInputString('write_bash', { command: 'echo hello' }, undefined), 'echo hello'); - }); - - test('write_powershell extracts command field', () => { - assert.strictEqual(getToolInputString('write_powershell', { command: 'Get-Date' }, undefined), 'Get-Date'); - }); - - test('write_bash falls back to rawArguments when no command field', () => { - assert.strictEqual(getToolInputString('write_bash', {}, '{"command":"echo hello"}'), '{"command":"echo hello"}'); - }); - - test('write_bash returns undefined when both parameters and rawArguments are absent', () => { - assert.strictEqual(getToolInputString('write_bash', undefined, undefined), undefined); - }); - - test('read_bash with no parameters returns undefined', () => { - assert.strictEqual(getToolInputString('read_bash', undefined, undefined), undefined); - }); - }); -}); - -suite('skill events', () => { - - ensureNoDisposablesAreLeakedInTestSuite(); - - test('hides the raw `skill` tool call and synthesizes a tool-start/complete pair from `skill.invoked`', () => { - const withPath = synthesizeSkillToolCall( - { name: 'plan', path: '/abs/repo/skills/plan/SKILL.md' }, - 'evt-123', - ); - const noPath = synthesizeSkillToolCall( - { name: 'plan' }, - undefined, - ); - - assert.deepStrictEqual({ - skillIsHidden: isHiddenTool('skill'), - withPathToolCallId: withPath.toolCallId, - withPathToolName: withPath.toolName, - withPathDisplayName: withPath.displayName, - withPathInvocation: withPath.invocationMessage, - withPathPastTense: withPath.pastTenseMessage, - noPathToolCallId: noPath.toolCallId, - noPathInvocation: noPath.invocationMessage, - noPathPastTense: noPath.pastTenseMessage, - }, { - skillIsHidden: true, - withPathToolCallId: 'synth-skill-evt-123', - withPathToolName: 'skill', - withPathDisplayName: 'Read Skill', - withPathInvocation: { markdown: 'Reading skill [plan](file:///abs/repo/skills/plan/SKILL.md)' }, - withPathPastTense: { markdown: 'Read skill [plan](file:///abs/repo/skills/plan/SKILL.md)' }, - noPathToolCallId: 'synth-skill-2108d652', - noPathInvocation: 'Reading skill plan', - noPathPastTense: 'Read skill plan', - }); - }); -}); diff --git a/src/vs/platform/agentHost/test/node/fileEditTracker.test.ts b/src/vs/platform/agentHost/test/node/fileEditTracker.test.ts deleted file mode 100644 index d17dfff9..00000000 --- a/src/vs/platform/agentHost/test/node/fileEditTracker.test.ts +++ /dev/null @@ -1,179 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import assert from 'assert'; -import { VSBuffer } from '../../../../base/common/buffer.js'; -import { DisposableStore } from '../../../../base/common/lifecycle.js'; -import { URI } from '../../../../base/common/uri.js'; -import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { FileService } from '../../../files/common/fileService.js'; -import { IFileService } from '../../../files/common/files.js'; -import { InMemoryFileSystemProvider } from '../../../files/common/inMemoryFilesystemProvider.js'; -import { ILogService, NullLogService } from '../../../log/common/log.js'; -import { IInstantiationService } from '../../../instantiation/common/instantiation.js'; -import { InstantiationService } from '../../../instantiation/common/instantiationService.js'; -import { ServiceCollection } from '../../../instantiation/common/serviceCollection.js'; -import { IDiffComputeService } from '../../common/diffComputeService.js'; -import { ToolResultContentType } from '../../common/state/sessionState.js'; -import { createZeroDiffComputeService } from '../common/sessionTestHelpers.js'; -import { SessionDatabase } from '../../node/sessionDatabase.js'; -import { FileEditTracker, buildSessionDbUri, parseSessionDbUri } from '../../node/copilot/fileEditTracker.js'; - -suite('FileEditTracker', () => { - - const disposables = new DisposableStore(); - let fileService: FileService; - let db: SessionDatabase; - let tracker: FileEditTracker; - - setup(async () => { - fileService = disposables.add(new FileService(new NullLogService())); - const sourceFs = disposables.add(new InMemoryFileSystemProvider()); - disposables.add(fileService.registerProvider('file', sourceFs)); - - db = disposables.add(await SessionDatabase.open(':memory:')); - await db.createTurn('turn-1'); - - const services = new ServiceCollection(); - services.set(ILogService, new NullLogService()); - services.set(IFileService, fileService); - services.set(IDiffComputeService, createZeroDiffComputeService()); - const instantiationService: IInstantiationService = disposables.add(new InstantiationService(services)); - tracker = instantiationService.createInstance(FileEditTracker, 'copilot:/test-session', db); - }); - - teardown(async () => { - disposables.clear(); - await db.close(); - }); - ensureNoDisposablesAreLeakedInTestSuite(); - - test('tracks edit start and complete for existing file', async () => { - await fileService.writeFile(URI.file('/workspace/test.txt'), VSBuffer.fromString('original content\nline 2')); - - await tracker.trackEditStart('/workspace/test.txt'); - await fileService.writeFile(URI.file('/workspace/test.txt'), VSBuffer.fromString('modified content\nline 2\nline 3')); - await tracker.completeEdit('/workspace/test.txt'); - - const fileEdit = await tracker.takeCompletedEdit('turn-1', 'tc-1', '/workspace/test.txt'); - assert.ok(fileEdit); - assert.strictEqual(fileEdit.type, ToolResultContentType.FileEdit); - - // URIs are parseable session-db: URIs - const beforeFields = parseSessionDbUri(fileEdit.before!.content.uri); - assert.ok(beforeFields); - assert.strictEqual(beforeFields.sessionUri, 'copilot:/test-session'); - assert.strictEqual(beforeFields.toolCallId, 'tc-1'); - assert.strictEqual(beforeFields.filePath, '/workspace/test.txt'); - assert.strictEqual(beforeFields.part, 'before'); - - const afterFields = parseSessionDbUri(fileEdit.after!.content.uri); - assert.ok(afterFields); - assert.strictEqual(afterFields.part, 'after'); - - // Content is persisted in the database (wait for fire-and-forget write) - await new Promise(r => setTimeout(r, 50)); - - const content = await db.readFileEditContent('tc-1', '/workspace/test.txt'); - assert.ok(content); - assert.strictEqual(new TextDecoder().decode(content.beforeContent), 'original content\nline 2'); - assert.strictEqual(new TextDecoder().decode(content.afterContent), 'modified content\nline 2\nline 3'); - }); - - test('tracks edit for newly created file (no before content)', async () => { - await tracker.trackEditStart('/workspace/new-file.txt'); - await fileService.writeFile(URI.file('/workspace/new-file.txt'), VSBuffer.fromString('new file\ncontent')); - await tracker.completeEdit('/workspace/new-file.txt'); - - const fileEdit = await tracker.takeCompletedEdit('turn-1', 'tc-2', '/workspace/new-file.txt'); - assert.ok(fileEdit); - - // Wait for the fire-and-forget DB write to complete - await new Promise(r => setTimeout(r, 50)); - - const content = await db.readFileEditContent('tc-2', '/workspace/new-file.txt'); - assert.ok(content); - assert.strictEqual(new TextDecoder().decode(content.beforeContent), ''); - assert.strictEqual(new TextDecoder().decode(content.afterContent), 'new file\ncontent'); - }); - - test('takeCompletedEdit returns undefined for unknown file path', async () => { - const result = await tracker.takeCompletedEdit('turn-1', 'tc-x', '/nonexistent'); - assert.strictEqual(result, undefined); - }); - - test('before and after content can be read from database', async () => { - await fileService.writeFile(URI.file('/workspace/file.ts'), VSBuffer.fromString('original')); - - await tracker.trackEditStart('/workspace/file.ts'); - await fileService.writeFile(URI.file('/workspace/file.ts'), VSBuffer.fromString('modified')); - await tracker.completeEdit('/workspace/file.ts'); - - await tracker.takeCompletedEdit('turn-1', 'tc-3', '/workspace/file.ts'); - - // Wait for the fire-and-forget DB write to complete - await new Promise(r => setTimeout(r, 50)); - - const content = await db.readFileEditContent('tc-3', '/workspace/file.ts'); - assert.ok(content); - assert.strictEqual(new TextDecoder().decode(content.beforeContent), 'original'); - assert.strictEqual(new TextDecoder().decode(content.afterContent), 'modified'); - }); -}); - -suite('buildSessionDbUri / parseSessionDbUri', () => { - - ensureNoDisposablesAreLeakedInTestSuite(); - - test('round-trips a simple URI', () => { - const uri = buildSessionDbUri('copilot:/abc-123', 'tc-1', '/workspace/file.ts', 'before'); - const parsed = parseSessionDbUri(uri); - assert.ok(parsed); - assert.deepStrictEqual(parsed, { - sessionUri: 'copilot:/abc-123', - toolCallId: 'tc-1', - filePath: '/workspace/file.ts', - part: 'before', - }); - }); - - test('round-trips with special characters in filePath', () => { - const uri = buildSessionDbUri('copilot:/s1', 'tc-2', '/work space/file (1).ts', 'after'); - const parsed = parseSessionDbUri(uri); - assert.ok(parsed); - assert.strictEqual(parsed.filePath, '/work space/file (1).ts'); - assert.strictEqual(parsed.part, 'after'); - }); - - test('round-trips with special characters in toolCallId', () => { - const uri = buildSessionDbUri('copilot:/s1', 'call_abc=123&x', '/file.ts', 'before'); - const parsed = parseSessionDbUri(uri); - assert.ok(parsed); - assert.strictEqual(parsed.toolCallId, 'call_abc=123&x'); - }); - - test('parseSessionDbUri returns undefined for non-session-db URIs', () => { - assert.strictEqual(parseSessionDbUri('file:///foo/bar'), undefined); - assert.strictEqual(parseSessionDbUri('https://example.com'), undefined); - }); - - test('parseSessionDbUri returns undefined for malformed session-db URIs', () => { - assert.strictEqual(parseSessionDbUri('session-db:copilot:/s1'), undefined); - assert.strictEqual(parseSessionDbUri('session-db:copilot:/s1?toolCallId=tc-1'), undefined); - assert.strictEqual(parseSessionDbUri('session-db:copilot:/s1?toolCallId=tc-1&filePath=/f&part=middle'), undefined); - }); - - test('URI path ends with the basename of the file', () => { - const uri = buildSessionDbUri('copilot:/s1', 'tc-1', '/workspace/src/index.ts', 'before'); - const parsed = URI.parse(uri); - assert.ok(parsed.path.endsWith('/index.ts')); - }); - - test('URI path ends with basename for files with spaces and special chars', () => { - const uri = buildSessionDbUri('copilot:/s1', 'tc-1', '/work space/file (1).ts', 'after'); - const parsed = URI.parse(uri); - assert.ok(parsed.path.endsWith('/file (1).ts')); - }); -}); diff --git a/src/vs/platform/agentHost/test/node/gitDiffContent.test.ts b/src/vs/platform/agentHost/test/node/gitDiffContent.test.ts deleted file mode 100644 index c4011758..00000000 --- a/src/vs/platform/agentHost/test/node/gitDiffContent.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import assert from 'assert'; -import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { buildGitBlobUri, parseGitBlobUri } from '../../node/gitDiffContent.js'; - -suite('gitDiffContent', () => { - ensureNoDisposablesAreLeakedInTestSuite(); - - test('round-trips simple inputs', () => { - const sessionUri = 'copilot:/abc-123'; - const sha = 'deadbeef0123456789abcdef0123456789abcdef'; - const path = 'src/foo/bar.ts'; - const built = buildGitBlobUri(sessionUri, sha, path); - const parsed = parseGitBlobUri(built); - assert.deepStrictEqual(parsed, { sessionUri, sha, repoRelativePath: path }); - }); - - test('round-trips paths with spaces, unicode and slashes', () => { - const sessionUri = 'copilot:/sess/with space?q=1'; - const sha = '1234567890abcdef1234567890abcdef12345678'; - const path = 'a folder/файл.txt'; - const parsed = parseGitBlobUri(buildGitBlobUri(sessionUri, sha, path)); - assert.deepStrictEqual(parsed, { sessionUri, sha, repoRelativePath: path }); - }); - - test('returns undefined for non git-blob URIs', () => { - assert.strictEqual(parseGitBlobUri('file:///foo/bar.ts'), undefined); - assert.strictEqual(parseGitBlobUri('session-db://abc/def/before/x'), undefined); - assert.strictEqual(parseGitBlobUri('not a uri at all'), undefined); - }); -}); diff --git a/src/vs/platform/agentHost/test/node/historyRecordFixtures.test.ts b/src/vs/platform/agentHost/test/node/historyRecordFixtures.test.ts deleted file mode 100644 index 29b5ed9d..00000000 --- a/src/vs/platform/agentHost/test/node/historyRecordFixtures.test.ts +++ /dev/null @@ -1,402 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import assert from 'assert'; -import { DisposableStore } from '../../../../base/common/lifecycle.js'; -import { URI } from '../../../../base/common/uri.js'; -import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { AgentSession } from '../../common/agentService.js'; -import { FileEditKind, ToolResultContentType } from '../../common/state/sessionState.js'; -import { SessionDatabase } from '../../node/sessionDatabase.js'; -import { parseSessionDbUri } from '../../node/copilot/fileEditTracker.js'; -import { mapSessionEventsToHistoryRecords } from './historyRecordFixtures.js'; -import { type ISessionEvent } from '../../node/copilot/mapSessionEvents.js'; - -suite('mapSessionEventsToHistoryRecords', () => { - - const disposables = new DisposableStore(); - let db: SessionDatabase | undefined; - const session = AgentSession.uri('copilot', 'test-session'); - - teardown(async () => { - disposables.clear(); - await db?.close(); - }); - ensureNoDisposablesAreLeakedInTestSuite(); - - // ---- Basic event mapping -------------------------------------------- - - test('maps user and assistant messages', async () => { - const events: ISessionEvent[] = [ - { type: 'user.message', data: { messageId: 'msg-1', content: 'hello' } }, - { type: 'assistant.message', data: { messageId: 'msg-2', content: 'world' } }, - ]; - - const result = await mapSessionEventsToHistoryRecords(session, undefined, events); - assert.strictEqual(result.length, 2); - assert.deepStrictEqual(result[0], { - session, - type: 'message', - role: 'user', - messageId: 'msg-1', - content: 'hello', - toolRequests: undefined, - reasoningOpaque: undefined, - reasoningText: undefined, - encryptedContent: undefined, - parentToolCallId: undefined, - }); - assert.strictEqual(result[1].type, 'message'); - assert.strictEqual((result[1] as { role: string }).role, 'assistant'); - }); - - test('maps tool start and complete events', async () => { - const events: ISessionEvent[] = [ - { - type: 'tool.execution_start', - data: { toolCallId: 'tc-1', toolName: 'shell', arguments: { command: 'echo hi' } }, - }, - { - type: 'tool.execution_complete', - data: { toolCallId: 'tc-1', success: true, result: { content: 'hi\n' } }, - }, - ]; - - const result = await mapSessionEventsToHistoryRecords(session, undefined, events); - assert.strictEqual(result.length, 2); - assert.strictEqual(result[0].type, 'tool_start'); - assert.strictEqual(result[1].type, 'tool_complete'); - - const complete = result[1] as { result: { content?: readonly { type: string; text?: string }[] } }; - assert.ok(complete.result.content); - assert.strictEqual(complete.result.content[0].type, ToolResultContentType.Text); - }); - - test('skips tool_complete without matching tool_start', async () => { - const events: ISessionEvent[] = [ - { type: 'tool.execution_complete', data: { toolCallId: 'orphan', success: true } }, - ]; - - const result = await mapSessionEventsToHistoryRecords(session, undefined, events); - assert.strictEqual(result.length, 0); - }); - - test('ignores unknown event types', async () => { - const events: ISessionEvent[] = [ - { type: 'some.unknown.event', data: {} }, - { type: 'user.message', data: { messageId: 'msg-1', content: 'test' } }, - ]; - - const result = await mapSessionEventsToHistoryRecords(session, undefined, events); - assert.strictEqual(result.length, 1); - }); - - // ---- File edit restoration ------------------------------------------ - - suite('file edit restoration', () => { - - test('restores file edits from database for edit tools', async () => { - db = disposables.add(await SessionDatabase.open(':memory:')); - await db.createTurn('turn-1'); - await db.storeFileEdit({ - turnId: 'turn-1', - toolCallId: 'tc-edit', - filePath: '/workspace/file.ts', - kind: FileEditKind.Edit, - beforeContent: new TextEncoder().encode('before'), - afterContent: new TextEncoder().encode('after'), - addedLines: 3, - removedLines: 1, - }); - - const events: ISessionEvent[] = [ - { - type: 'tool.execution_start', - data: { toolCallId: 'tc-edit', toolName: 'edit', arguments: { filePath: '/workspace/file.ts' } }, - }, - { - type: 'tool.execution_complete', - data: { toolCallId: 'tc-edit', success: true, result: { content: 'Edited file.ts' } }, - }, - ]; - - const result = await mapSessionEventsToHistoryRecords(session, db, events); - const complete = result[1]; - assert.strictEqual(complete.type, 'tool_complete'); - - const content = (complete as { result: { content?: readonly Record[] } }).result.content; - assert.ok(content); - // Should have text content + file edit - assert.strictEqual(content.length, 2); - assert.strictEqual(content[0].type, ToolResultContentType.Text); - assert.strictEqual(content[1].type, ToolResultContentType.FileEdit); - - // File edit URIs should be parseable - const fileEdit = content[1] as { before: { uri: any; content: { uri: any } }; after: { uri: any; content: { uri: any } }; diff?: { added?: number; removed?: number } }; - const beforeFields = parseSessionDbUri(fileEdit.before.content.uri); - assert.ok(beforeFields); - assert.strictEqual(beforeFields.toolCallId, 'tc-edit'); - assert.strictEqual(beforeFields.filePath, '/workspace/file.ts'); - assert.strictEqual(beforeFields.part, 'before'); - assert.deepStrictEqual(fileEdit.diff, { added: 3, removed: 1 }); - }); - - test('handles multiple file edits for one tool call', async () => { - db = disposables.add(await SessionDatabase.open(':memory:')); - await db.createTurn('turn-1'); - await db.storeFileEdit({ - turnId: 'turn-1', - toolCallId: 'tc-multi', - filePath: '/workspace/a.ts', - kind: FileEditKind.Edit, - beforeContent: new Uint8Array(0), - afterContent: new TextEncoder().encode('a'), - addedLines: undefined, - removedLines: undefined, - }); - await db.storeFileEdit({ - turnId: 'turn-1', - toolCallId: 'tc-multi', - filePath: '/workspace/b.ts', - kind: FileEditKind.Edit, - beforeContent: new Uint8Array(0), - afterContent: new TextEncoder().encode('b'), - addedLines: undefined, - removedLines: undefined, - }); - - const events: ISessionEvent[] = [ - { - type: 'tool.execution_start', - data: { toolCallId: 'tc-multi', toolName: 'edit' }, - }, - { - type: 'tool.execution_complete', - data: { toolCallId: 'tc-multi', success: true }, - }, - ]; - - const result = await mapSessionEventsToHistoryRecords(session, db, events); - const content = (result[1] as { result: { content?: readonly Record[] } }).result.content; - assert.ok(content); - // Two file edits (no text since result had no content) - const fileEdits = content.filter(c => c.type === ToolResultContentType.FileEdit); - assert.strictEqual(fileEdits.length, 2); - }); - - test('works without database (no file edits restored)', async () => { - const events: ISessionEvent[] = [ - { - type: 'tool.execution_start', - data: { toolCallId: 'tc-1', toolName: 'edit', arguments: { filePath: '/workspace/file.ts' } }, - }, - { - type: 'tool.execution_complete', - data: { toolCallId: 'tc-1', success: true, result: { content: 'done' } }, - }, - ]; - - const result = await mapSessionEventsToHistoryRecords(session, undefined, events); - const content = (result[1] as { result: { content?: readonly Record[] } }).result.content; - assert.ok(content); - // Only text content, no file edits - assert.strictEqual(content.length, 1); - assert.strictEqual(content[0].type, ToolResultContentType.Text); - }); - - test('non-edit tools do not get file edits even if db has data', async () => { - db = disposables.add(await SessionDatabase.open(':memory:')); - - const events: ISessionEvent[] = [ - { - type: 'tool.execution_start', - data: { toolCallId: 'tc-1', toolName: 'shell', arguments: { command: 'ls' } }, - }, - { - type: 'tool.execution_complete', - data: { toolCallId: 'tc-1', success: true, result: { content: 'files' } }, - }, - ]; - - const result = await mapSessionEventsToHistoryRecords(session, db, events); - const content = (result[1] as { result: { content?: readonly Record[] } }).result.content; - assert.ok(content); - assert.strictEqual(content.length, 1); - assert.strictEqual(content[0].type, ToolResultContentType.Text); - }); - }); - - // ---- Subagent events ------------------------------------------------ - - suite('subagent events', () => { - - test('maps subagent.started event to subagent_started progress event', async () => { - const events: ISessionEvent[] = [ - { - type: 'subagent.started', - data: { - toolCallId: 'tc-1', - agentName: 'code-reviewer', - agentDisplayName: 'Code Reviewer', - agentDescription: 'Reviews code', - }, - }, - ]; - - const result = await mapSessionEventsToHistoryRecords(session, undefined, events); - assert.strictEqual(result.length, 1); - assert.strictEqual(result[0].type, 'subagent_started'); - const event = result[0] as { type: string; toolCallId: string; agentName: string; agentDisplayName: string }; - assert.strictEqual(event.toolCallId, 'tc-1'); - assert.strictEqual(event.agentName, 'code-reviewer'); - assert.strictEqual(event.agentDisplayName, 'Code Reviewer'); - }); - }); - - // ---- Skill events --------------------------------------------------- - - suite('skill events', () => { - - test('synthesizes tool start/complete from skill.invoked and filters synthetic skill-injected user messages', async () => { - const events: ISessionEvent[] = [ - { - type: 'tool.execution_start', - data: { toolCallId: 'tc-skill', toolName: 'skill', arguments: { skill: 'plan' } }, - }, - { - type: 'tool.execution_complete', - data: { toolCallId: 'tc-skill', success: true }, - }, - { - type: 'skill.invoked', - id: 'evt-42', - data: { name: 'plan', path: '/abs/repo/skills/plan/SKILL.md' }, - }, - { - type: 'user.message', - data: { messageId: 'msg-skill', content: '', source: 'skill-plan' }, - }, - { - type: 'assistant.message', - data: { messageId: 'msg-1', content: 'ok' }, - }, - ]; - - const result = await mapSessionEventsToHistoryRecords(session, undefined, events); - - assert.deepStrictEqual({ - count: result.length, - types: result.map(r => r.type), - skillStart: result[0], - skillComplete: result[1], - assistantRole: (result[2] as { role: string }).role, - }, { - count: 3, - types: ['tool_start', 'tool_complete', 'message'], - skillStart: { - session, - type: 'tool_start', - toolCallId: 'synth-skill-evt-42', - toolName: 'skill', - displayName: 'Read Skill', - invocationMessage: { markdown: 'Reading skill [plan](file:///abs/repo/skills/plan/SKILL.md)' }, - }, - skillComplete: { - session, - type: 'tool_complete', - toolCallId: 'synth-skill-evt-42', - result: { - success: true, - pastTenseMessage: { markdown: 'Read skill [plan](file:///abs/repo/skills/plan/SKILL.md)' }, - }, - }, - assistantRole: 'assistant', - }); - }); - }); - - // ---- cd-prefix rewriting -------------------------------------------- - - suite('cd-prefix rewriting', () => { - - const cwd = URI.file('/workspace/proj'); - - function makeBashEvent(command: string, toolCallId = 'tc-1'): ISessionEvent { - return { - type: 'tool.execution_start', - data: { toolCallId, toolName: 'bash', arguments: { command } }, - }; - } - - function getStart(events: ReturnType extends Promise ? R : never) { - return events[0] as { toolInput: string; toolArguments?: string }; - } - - test('strips redundant bash cd prefix matching workingDirectory', async () => { - const result = await mapSessionEventsToHistoryRecords(session, undefined, [ - makeBashEvent('cd /workspace/proj && ls -la'), - ], cwd); - const start = getStart(result); - assert.strictEqual(start.toolInput, 'ls -la'); - assert.deepStrictEqual(JSON.parse(start.toolArguments!), { command: 'ls -la' }); - }); - - test('leaves command unchanged when cd dir does not match', async () => { - const result = await mapSessionEventsToHistoryRecords(session, undefined, [ - makeBashEvent('cd /other && ls'), - ], cwd); - const start = getStart(result); - assert.strictEqual(start.toolInput, 'cd /other && ls'); - }); - - test('leaves command unchanged when no workingDirectory provided', async () => { - const result = await mapSessionEventsToHistoryRecords(session, undefined, [ - makeBashEvent('cd /workspace/proj && ls'), - ]); - const start = getStart(result); - assert.strictEqual(start.toolInput, 'cd /workspace/proj && ls'); - }); - - test('non-shell tools are not rewritten even with matching command field', async () => { - const result = await mapSessionEventsToHistoryRecords(session, undefined, [ - { - type: 'tool.execution_start', - data: { toolCallId: 'tc-1', toolName: 'edit', arguments: { command: 'cd /workspace/proj && ls' } }, - }, - ], cwd); - const start = getStart(result); - // edit tool's toolInput is derived from filePath, not command — but toolArguments preserves original - assert.deepStrictEqual(JSON.parse(start.toolArguments!), { command: 'cd /workspace/proj && ls' }); - }); - - test('handles trailing slash on workingDirectory', async () => { - const result = await mapSessionEventsToHistoryRecords(session, undefined, [ - makeBashEvent('cd /workspace/proj && ls'), - ], URI.file('/workspace/proj/')); - const start = getStart(result); - assert.strictEqual(start.toolInput, 'ls'); - }); - - test('handles quoted directory in cd prefix', async () => { - const cwdWithSpaces = URI.file('/workspace/my proj'); - const result = await mapSessionEventsToHistoryRecords(session, undefined, [ - makeBashEvent('cd "/workspace/my proj" && ls'), - ], cwdWithSpaces); - const start = getStart(result); - assert.strictEqual(start.toolInput, 'ls'); - }); - - test('rewrites powershell commands too', async () => { - const result = await mapSessionEventsToHistoryRecords(session, undefined, [ - { - type: 'tool.execution_start', - data: { toolCallId: 'tc-1', toolName: 'powershell', arguments: { command: 'cd /workspace/proj; dir' } }, - }, - ], cwd); - const start = getStart(result); - assert.strictEqual(start.toolInput, 'dir'); - }); - }); -}); diff --git a/src/vs/platform/agentHost/test/node/historyRecordFixtures.ts b/src/vs/platform/agentHost/test/node/historyRecordFixtures.ts deleted file mode 100644 index b4bee42d..00000000 --- a/src/vs/platform/agentHost/test/node/historyRecordFixtures.ts +++ /dev/null @@ -1,552 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { URI } from '../../../../base/common/uri.js'; -import { generateUuid } from '../../../../base/common/uuid.js'; -import { stripRedundantCdPrefix } from '../../common/commandLineHelpers.js'; -import { IFileEditRecord, ISessionDatabase } from '../../common/sessionDataService.js'; -import { ResponsePartKind, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, TurnState, buildSubagentSessionUri, type ResponsePart, type StringOrMarkdown, type ToolCallCompletedState, type ToolResultContent, type Turn } from '../../common/state/sessionState.js'; -import { getInvocationMessage, getPastTenseMessage, getShellLanguage, getSubagentMetadata, getToolDisplayName, getToolInputString, getToolKind, isEditTool, isHiddenTool, synthesizeSkillToolCall } from '../../node/copilot/copilotToolDisplay.js'; -import { buildSessionDbUri } from '../../node/copilot/fileEditTracker.js'; -import type { ISessionEvent, ISessionEventMessage, ISessionEventSkillInvoked, ISessionEventSubagentStarted, ISessionEventToolComplete, ISessionEventToolStart } from '../../node/copilot/mapSessionEvents.js'; - -// ============================================================================= -// History-record test fixtures -// -// Flat, declarative DSL used by mock agents and unit tests to build session -// history without manually constructing `Turn[]`. Records mirror the wire -// shape of an SDK event stream — `message`, `tool_start`, `tool_complete`, -// `subagent_started` — so transcripts read like the protocol they're -// emulating. -// -// Production code does NOT depend on this module. The real -// SDK-events-to-Turn[] pipeline in `node/copilot/mapSessionEvents.ts` runs -// in a single pass without producing the intermediate record shape. -// ============================================================================= - -interface IHistoryRecordBase { - readonly session: URI; -} - -interface IHistoryMessageRecord extends IHistoryRecordBase { - readonly type: 'message'; - readonly role: 'user' | 'assistant'; - readonly messageId: string; - readonly content: string; - readonly toolRequests?: readonly { - readonly toolCallId: string; - readonly name: string; - readonly arguments?: string; - readonly type?: 'function' | 'custom'; - }[]; - readonly reasoningOpaque?: string; - readonly reasoningText?: string; - readonly encryptedContent?: string; - readonly parentToolCallId?: string; -} - -export interface IHistoryToolStartRecord extends IHistoryRecordBase { - readonly type: 'tool_start'; - readonly toolCallId: string; - readonly toolName: string; - readonly displayName: string; - readonly invocationMessage: StringOrMarkdown; - readonly toolInput?: string; - readonly toolKind?: 'terminal' | 'subagent'; - readonly language?: string; - readonly toolArguments?: string; - readonly subagentAgentName?: string; - readonly subagentDescription?: string; - readonly mcpServerName?: string; - readonly mcpToolName?: string; - readonly parentToolCallId?: string; -} - -interface IHistoryToolCompleteRecord extends IHistoryRecordBase { - readonly type: 'tool_complete'; - readonly toolCallId: string; - readonly result: { - readonly success: boolean; - readonly pastTenseMessage: StringOrMarkdown; - readonly content?: ToolResultContent[]; - readonly error?: { readonly message: string; readonly code?: string }; - }; - readonly isUserRequested?: boolean; - readonly toolTelemetry?: string; - readonly parentToolCallId?: string; -} - -interface IHistorySubagentStartedRecord extends IHistoryRecordBase { - readonly type: 'subagent_started'; - readonly toolCallId: string; - readonly agentName: string; - readonly agentDisplayName: string; - readonly agentDescription?: string; -} - -/** Test fixture record. Hand-constructed by tests to seed mock session histories. */ -export type IHistoryRecord = - | IHistoryMessageRecord - | IHistoryToolStartRecord - | IHistoryToolCompleteRecord - | IHistorySubagentStartedRecord; - -function extractSubagentMeta(start: IHistoryToolStartRecord | undefined): { subagentDescription?: string; subagentAgentName?: string } { - if (!start) { - return {}; - } - return { - subagentDescription: start.subagentDescription, - subagentAgentName: start.subagentAgentName, - }; -} - -/** - * Builds a parent session's {@link Turn}s from a flat list of history - * records. - * - * Each `user` message starts a new turn. Inner subagent records (those - * carrying `parentToolCallId`) are skipped — see - * {@link buildSubagentTurnsFromHistory}. - */ -export function buildTurnsFromHistory(messages: readonly IHistoryRecord[]): Turn[] { - const turns: Turn[] = []; - const subagentsByToolCallId = new Map(); - let currentTurn: { - id: string; - userMessage: { text: string }; - responseParts: ResponsePart[]; - pendingTools: Map; - } | undefined; - - const finalizeTurn = (turn: NonNullable, state: TurnState): void => { - turns.push({ - id: turn.id, - userMessage: turn.userMessage, - responseParts: turn.responseParts, - usage: undefined, - state, - }); - }; - - const startTurn = (id: string, text: string): NonNullable => ({ - id, - userMessage: { text }, - responseParts: [], - pendingTools: new Map(), - }); - - for (const msg of messages) { - if (msg.type === 'message' && msg.role === 'user') { - if (currentTurn) { - finalizeTurn(currentTurn, TurnState.Cancelled); - } - currentTurn = startTurn(msg.messageId, msg.content); - } else if (msg.type === 'message' && msg.role === 'assistant') { - if (msg.parentToolCallId) { - continue; - } - if (!currentTurn) { - currentTurn = startTurn(msg.messageId, ''); - } - if (msg.reasoningText) { - currentTurn.responseParts.push({ - kind: ResponsePartKind.Reasoning, - id: generateUuid(), - content: msg.reasoningText, - }); - } - if (msg.content) { - currentTurn.responseParts.push({ - kind: ResponsePartKind.Markdown, - id: generateUuid(), - content: msg.content, - }); - } - if (!msg.toolRequests || msg.toolRequests.length === 0) { - finalizeTurn(currentTurn, TurnState.Complete); - currentTurn = undefined; - } - } else if (msg.type === 'subagent_started') { - subagentsByToolCallId.set(msg.toolCallId, msg); - } else if (msg.type === 'tool_start') { - if (msg.parentToolCallId) { - continue; - } - currentTurn?.pendingTools.set(msg.toolCallId, msg); - } else if (msg.type === 'tool_complete') { - if (msg.parentToolCallId) { - continue; - } - if (currentTurn) { - const start = currentTurn.pendingTools.get(msg.toolCallId); - currentTurn.pendingTools.delete(msg.toolCallId); - - const subagentEvent = subagentsByToolCallId.get(msg.toolCallId); - const contentWithSubagent = msg.result.content ? [...msg.result.content] : []; - if (subagentEvent) { - const parentSessionStr = msg.session.toString(); - contentWithSubagent.push({ - type: ToolResultContentType.Subagent, - resource: buildSubagentSessionUri(parentSessionStr, msg.toolCallId), - title: subagentEvent.agentDisplayName, - agentName: subagentEvent.agentName, - description: subagentEvent.agentDescription, - }); - } - - const tc: ToolCallCompletedState = { - status: ToolCallStatus.Completed, - toolCallId: msg.toolCallId, - toolName: start?.toolName ?? 'unknown', - displayName: start?.displayName ?? 'Unknown Tool', - invocationMessage: start?.invocationMessage ?? 'Unknown tool', - toolInput: start?.toolInput, - success: msg.result.success, - pastTenseMessage: msg.result.pastTenseMessage, - content: contentWithSubagent.length > 0 ? contentWithSubagent : undefined, - error: msg.result.error, - confirmed: ToolCallConfirmationReason.NotNeeded, - _meta: { - toolKind: start?.toolKind, - language: start?.language, - ...extractSubagentMeta(start), - }, - }; - currentTurn.responseParts.push({ - kind: ResponsePartKind.ToolCall, - toolCall: tc, - }); - } - } - } - - if (currentTurn) { - finalizeTurn(currentTurn, TurnState.Cancelled); - } - - return turns; -} - -/** - * Builds the {@link Turn}s for a subagent child session by filtering the - * parent's history for records carrying the matching `parentToolCallId`. - * Returns a single turn containing all inner tool calls and assistant - * messages. - */ -export function buildSubagentTurnsFromHistory( - parentMessages: readonly IHistoryRecord[], - parentToolCallId: string, - childSessionUri: string, -): Turn[] { - const innerToolCallIds = new Set(); - for (const msg of parentMessages) { - if ((msg.type === 'tool_start' || msg.type === 'tool_complete') && msg.parentToolCallId === parentToolCallId) { - innerToolCallIds.add(msg.toolCallId); - } - } - - const subagentsByToolCallId = new Map(); - for (const msg of parentMessages) { - if (msg.type === 'subagent_started' && innerToolCallIds.has(msg.toolCallId)) { - subagentsByToolCallId.set(msg.toolCallId, msg); - } - } - - const innerMessages = parentMessages.filter(msg => { - if (msg.type === 'tool_start' || msg.type === 'tool_complete') { - return msg.parentToolCallId === parentToolCallId; - } - if (msg.type === 'message') { - return msg.parentToolCallId === parentToolCallId; - } - return false; - }); - - if (innerMessages.length === 0) { - return []; - } - - const responseParts: ResponsePart[] = []; - const pendingTools = new Map(); - - for (const msg of innerMessages) { - if (msg.type === 'tool_start') { - pendingTools.set(msg.toolCallId, msg); - } else if (msg.type === 'tool_complete') { - const start = pendingTools.get(msg.toolCallId); - pendingTools.delete(msg.toolCallId); - - const subagentEvent = subagentsByToolCallId.get(msg.toolCallId); - const contentWithSubagent = msg.result.content ? [...msg.result.content] : []; - if (subagentEvent) { - contentWithSubagent.push({ - type: ToolResultContentType.Subagent, - resource: buildSubagentSessionUri(childSessionUri, msg.toolCallId), - title: subagentEvent.agentDisplayName, - agentName: subagentEvent.agentName, - description: subagentEvent.agentDescription, - }); - } - - const tc: ToolCallCompletedState = { - status: ToolCallStatus.Completed, - toolCallId: msg.toolCallId, - toolName: start?.toolName ?? 'unknown', - displayName: start?.displayName ?? 'Unknown Tool', - invocationMessage: start?.invocationMessage ?? 'Unknown tool', - toolInput: start?.toolInput, - success: msg.result.success, - pastTenseMessage: msg.result.pastTenseMessage, - content: contentWithSubagent.length > 0 ? contentWithSubagent : undefined, - error: msg.result.error, - confirmed: ToolCallConfirmationReason.NotNeeded, - _meta: { - toolKind: start?.toolKind, - language: start?.language, - ...extractSubagentMeta(start), - }, - }; - responseParts.push({ - kind: ResponsePartKind.ToolCall, - toolCall: tc, - }); - } else if (msg.type === 'message' && msg.role === 'assistant') { - if (msg.reasoningText) { - responseParts.push({ - kind: ResponsePartKind.Reasoning, - id: generateUuid(), - content: msg.reasoningText, - }); - } - if (msg.content) { - responseParts.push({ - kind: ResponsePartKind.Markdown, - id: generateUuid(), - content: msg.content, - }); - } - } - } - - if (responseParts.length === 0) { - return []; - } - - return [{ - id: generateUuid(), - userMessage: { text: '' }, - responseParts, - usage: undefined, - state: TurnState.Complete, - }]; -} - -// ============================================================================= -// SDK-events-to-history-records (test fixture loader) -// -// Translates raw Copilot SDK session events into a flat IHistoryRecord -// stream. This is the test-side equivalent of the production single-pass -// `mapSessionEvents` (which goes directly to Turn[]). It exists so JSONL -// fixtures captured from real `~/.copilot/session-state/` files can be -// loaded into the test DSL without forcing tests to also adopt Turn[]. -// ============================================================================= - -function tryStringify(value: unknown): string | undefined { - try { - return JSON.stringify(value); - } catch { - return undefined; - } -} - -function isSyntheticUserMessage(event: ISessionEvent): boolean { - if (event.type !== 'user.message') { - return false; - } - const source = (event as ISessionEventMessage).data?.source; - return !!source && source.toLowerCase() !== 'user'; -} - -/** - * Maps raw SDK session events into a flat list of {@link IHistoryRecord}s, - * restoring stored file-edit metadata from the session database when - * available. Test-fixture-only. - */ -export async function mapSessionEventsToHistoryRecords( - session: URI, - db: ISessionDatabase | undefined, - events: readonly ISessionEvent[], - workingDirectory?: URI, -): Promise { - const result: IHistoryRecord[] = []; - const toolInfoByCallId = new Map | undefined; rewrittenArgs?: string }>(); - const editToolCallIds: string[] = []; - - for (const e of events) { - if (e.type === 'tool.execution_start') { - const d = (e as ISessionEventToolStart).data; - if (isHiddenTool(d.toolName)) { - continue; - } - const toolArgs = d.arguments !== undefined ? tryStringify(d.arguments) : undefined; - let parameters: Record | undefined; - if (toolArgs) { - try { parameters = JSON.parse(toolArgs) as Record; } catch { /* ignore */ } - } - const rewrittenArgs = stripRedundantCdPrefix(d.toolName, parameters, workingDirectory) ? tryStringify(parameters) : undefined; - toolInfoByCallId.set(d.toolCallId, { toolName: d.toolName, parameters, rewrittenArgs }); - if (isEditTool(d.toolName)) { - editToolCallIds.push(d.toolCallId); - } - } - } - - let storedEdits: Map | undefined; - if (db && editToolCallIds.length > 0) { - try { - const records = await db.getFileEdits(editToolCallIds); - if (records.length > 0) { - storedEdits = new Map(); - for (const r of records) { - let list = storedEdits.get(r.toolCallId); - if (!list) { - list = []; - storedEdits.set(r.toolCallId, list); - } - list.push(r); - } - } - } catch { - // Database may not exist yet — that's fine. - } - } - - const sessionUriStr = session.toString(); - - for (const e of events) { - if (e.type === 'assistant.message' || e.type === 'user.message') { - if (isSyntheticUserMessage(e)) { - continue; - } - const d = (e as ISessionEventMessage).data; - result.push({ - session, - type: 'message', - role: e.type === 'user.message' ? 'user' : 'assistant', - messageId: d?.messageId ?? d?.interactionId ?? '', - content: d?.content ?? '', - toolRequests: d?.toolRequests?.map(tr => ({ - toolCallId: tr.toolCallId, - name: tr.name, - arguments: tr.arguments !== undefined ? tryStringify(tr.arguments) : undefined, - type: tr.type, - })), - reasoningOpaque: d?.reasoningOpaque, - reasoningText: d?.reasoningText, - encryptedContent: d?.encryptedContent, - parentToolCallId: d?.parentToolCallId, - }); - } else if (e.type === 'tool.execution_start') { - const d = (e as ISessionEventToolStart).data; - if (isHiddenTool(d.toolName)) { - continue; - } - const info = toolInfoByCallId.get(d.toolCallId); - const displayName = getToolDisplayName(d.toolName); - const toolKind = getToolKind(d.toolName); - const toolArgs = info?.rewrittenArgs ?? (d.arguments !== undefined ? tryStringify(d.arguments) : undefined); - const subagentMeta = toolKind === 'subagent' ? getSubagentMetadata(info?.parameters) : undefined; - result.push({ - session, - type: 'tool_start', - toolCallId: d.toolCallId, - toolName: d.toolName, - displayName, - invocationMessage: getInvocationMessage(d.toolName, displayName, info?.parameters), - toolInput: getToolInputString(d.toolName, info?.parameters, toolArgs), - toolKind, - language: toolKind === 'terminal' ? getShellLanguage(d.toolName) : undefined, - toolArguments: toolArgs, - subagentAgentName: subagentMeta?.agentName, - subagentDescription: subagentMeta?.description, - mcpServerName: d.mcpServerName, - mcpToolName: d.mcpToolName, - parentToolCallId: d.parentToolCallId, - }); - } else if (e.type === 'tool.execution_complete') { - const d = (e as ISessionEventToolComplete).data; - const info = toolInfoByCallId.get(d.toolCallId); - if (!info) { - continue; - } - toolInfoByCallId.delete(d.toolCallId); - const displayName = getToolDisplayName(info.toolName); - const toolOutput = d.error?.message ?? d.result?.content; - const content: ToolResultContent[] = []; - if (toolOutput !== undefined) { - content.push({ type: ToolResultContentType.Text, text: toolOutput }); - } - const edits = storedEdits?.get(d.toolCallId); - if (edits) { - for (const edit of edits) { - const beforeUri = edit.kind === 'rename' && edit.originalPath - ? URI.file(edit.originalPath).toString() - : URI.file(edit.filePath).toString(); - const afterUri = URI.file(edit.filePath).toString(); - const hasBefore = edit.kind !== 'create'; - const hasAfter = edit.kind !== 'delete'; - content.push({ - type: ToolResultContentType.FileEdit, - before: hasBefore ? { - uri: beforeUri, - content: { uri: buildSessionDbUri(sessionUriStr, edit.toolCallId, edit.filePath, 'before') }, - } : undefined, - after: hasAfter ? { - uri: afterUri, - content: { uri: buildSessionDbUri(sessionUriStr, edit.toolCallId, edit.filePath, 'after') }, - } : undefined, - diff: (edit.addedLines !== undefined || edit.removedLines !== undefined) - ? { added: edit.addedLines, removed: edit.removedLines } - : undefined, - }); - } - } - result.push({ - session, - type: 'tool_complete', - toolCallId: d.toolCallId, - result: { - success: d.success, - pastTenseMessage: getPastTenseMessage(info.toolName, displayName, info.parameters, d.success), - content: content.length > 0 ? content : undefined, - error: d.error, - }, - isUserRequested: d.isUserRequested, - toolTelemetry: d.toolTelemetry !== undefined ? tryStringify(d.toolTelemetry) : undefined, - parentToolCallId: d.parentToolCallId, - }); - } else if (e.type === 'subagent.started') { - const d = (e as ISessionEventSubagentStarted).data; - result.push({ - session, - type: 'subagent_started', - toolCallId: d.toolCallId, - agentName: d.agentName, - agentDisplayName: d.agentDisplayName, - agentDescription: d.agentDescription, - }); - } else if (e.type === 'skill.invoked') { - const skillEvent = e as ISessionEventSkillInvoked; - const synth = synthesizeSkillToolCall(skillEvent.data, skillEvent.id); - result.push( - { session, type: 'tool_start', toolCallId: synth.toolCallId, toolName: synth.toolName, displayName: synth.displayName, invocationMessage: synth.invocationMessage }, - { session, type: 'tool_complete', toolCallId: synth.toolCallId, result: { success: true, pastTenseMessage: synth.pastTenseMessage } }, - ); - } - } - - return result; -} diff --git a/src/vs/platform/agentHost/test/node/mockAgent.ts b/src/vs/platform/agentHost/test/node/mockAgent.ts deleted file mode 100644 index 8daf1a9a..00000000 --- a/src/vs/platform/agentHost/test/node/mockAgent.ts +++ /dev/null @@ -1,866 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { timeout } from '../../../../base/common/async.js'; -import { Emitter } from '../../../../base/common/event.js'; -import { observableValue } from '../../../../base/common/observable.js'; -import type { IAuthorizationProtectedResourceMetadata } from '../../../../base/common/oauth.js'; -import { URI } from '../../../../base/common/uri.js'; -import { type ISyncedCustomization } from '../../common/agentPluginManager.js'; -import { AgentSession, type AgentProvider, type AgentSignal, type IAgent, type IAgentActionSignal, type IAgentAttachment, type IAgentCreateSessionConfig, type IAgentCreateSessionResult, type IAgentDescriptor, type IAgentModelInfo, type IAgentResolveSessionConfigParams, type IAgentSessionConfigCompletionsParams, type IAgentSessionMetadata, type IAgentToolPendingConfirmationSignal } from '../../common/agentService.js'; -import { buildSubagentTurnsFromHistory, buildTurnsFromHistory, type IHistoryRecord } from './historyRecordFixtures.js'; -import { ProtectedResourceMetadata, type ModelSelection } from '../../common/state/protocol/state.js'; -import type { ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../../common/state/protocol/commands.js'; -import { ActionType } from '../../common/state/sessionActions.js'; -import { CustomizationStatus, ResponsePartKind, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, parseSubagentSessionUri, type CustomizationRef, type PendingMessage, type SessionCustomization, type StringOrMarkdown, type ToolCallResult, type Turn } from '../../common/state/sessionState.js'; -import { hasKey } from '../../../../base/common/types.js'; - -/** Well-known auto-generated title used by the 'with-title' prompt. */ -export const MOCK_AUTO_TITLE = 'Automatically generated title'; - -function uriKey(session: URI): string { - // Build a stable key from raw URI fields without invoking `toString()`, - // which would mutate the URI's `_formatted` cache and break - // `assert.deepStrictEqual` comparisons in tests that capture the URI - // before it is observed elsewhere. - return `${session.scheme}://${session.authority}${session.path}${session.query ? '?' + session.query : ''}${session.fragment ? '#' + session.fragment : ''}`; -} - -function mockProject(provider: AgentProvider) { - return { uri: URI.from({ scheme: 'mock-project', path: `/${provider}` }), displayName: `Agent ${provider}` }; -} - -/** - * General-purpose mock agent for unit tests. Tracks all method calls - * for assertion and exposes {@link fireProgress} to inject progress events. - */ -export class MockAgent implements IAgent { - private readonly _onDidSessionProgress = new Emitter(); - readonly onDidSessionProgress = this._onDidSessionProgress.event; - private readonly _models = observableValue(this, []); - readonly models = this._models; - - private readonly _sessions = new Map(); - private _nextId = 1; - /** Active turn IDs per session, captured from sendMessage(). */ - private readonly _activeTurnIds = new Map(); - - - readonly sendMessageCalls: { session: URI; prompt: string; attachments?: readonly IAgentAttachment[] }[] = []; - readonly setPendingMessagesCalls: { session: URI; steeringMessage: PendingMessage | undefined; queuedMessages: readonly PendingMessage[] }[] = []; - readonly disposeSessionCalls: URI[] = []; - readonly abortSessionCalls: URI[] = []; - readonly respondToPermissionCalls: { requestId: string; approved: boolean }[] = []; - readonly changeModelCalls: { session: URI; model: ModelSelection }[] = []; - readonly authenticateCalls: { resource: string; token: string }[] = []; - readonly setClientCustomizationsCalls: { clientId: string; customizations: CustomizationRef[] }[] = []; - readonly setCustomizationEnabledCalls: { uri: string; enabled: boolean }[] = []; - /** Configurable return value for getCustomizations. */ - customizations: CustomizationRef[] = []; - private readonly _onDidCustomizationsChange = new Emitter(); - readonly onDidCustomizationsChange = this._onDidCustomizationsChange.event; - getSessionCustomizations?: (session: URI) => Promise; - - /** - * Configurable session history. Tests construct {@link IHistoryRecord} - * entries (the agent-internal intermediate shape) and the mock converts - * them to {@link Turn}s on demand. Subagent URIs are routed to filtered - * subagent turns via {@link buildSubagentTurnsFromHistory}. - */ - sessionMessages: IHistoryRecord[] = []; - - /** Optional overrides applied to session metadata from listSessions. */ - sessionMetadataOverrides: Partial> = {}; - - constructor(readonly id: AgentProvider = 'mock') { } - - getDescriptor(): IAgentDescriptor { - return { provider: this.id, displayName: `Agent ${this.id}`, description: `Test ${this.id} agent` }; - } - - getProtectedResources(): ProtectedResourceMetadata[] { - if (this.id === 'copilot') { - return [{ resource: 'https://api.github.com', authorization_servers: ['https://github.com/login/oauth'], required: true }]; - } - return []; - } - - setModels(models: readonly IAgentModelInfo[]): void { - this._models.set(models, undefined); - } - - async listSessions(): Promise { - return [...this._sessions.values()].map(s => ({ session: s, startTime: Date.now(), modifiedTime: Date.now(), project: mockProject(this.id), ...this.sessionMetadataOverrides })); - } - - /** Optional override for the working directory returned by createSession. */ - resolvedWorkingDirectory: URI | undefined; - - async createSession(config?: IAgentCreateSessionConfig): Promise { - const session = config?.session ?? AgentSession.uri(this.id, `${this.id}-session-${this._nextId++}`); - const rawId = AgentSession.id(session); - this._sessions.set(rawId, session); - return { session, project: mockProject(this.id), workingDirectory: this.resolvedWorkingDirectory }; - } - - async resolveSessionConfig(params: IAgentResolveSessionConfigParams): Promise { - return { schema: { type: 'object', properties: {} }, values: params.config ?? {} }; - } - - async sessionConfigCompletions(_params: IAgentSessionConfigCompletionsParams): Promise { - return { items: [] }; - } - - async sendMessage(session: URI, prompt: string, attachments?: IAgentAttachment[], turnId?: string): Promise { - this.sendMessageCalls.push({ session, prompt, attachments }); - if (turnId) { - this._activeTurnIds.set(uriKey(session), turnId); - } - } - - setPendingMessages(session: URI, steeringMessage: PendingMessage | undefined, queuedMessages: readonly PendingMessage[]): void { - this.setPendingMessagesCalls.push({ session, steeringMessage, queuedMessages }); - } - - async getSessionMessages(session: URI): Promise { - const subagentInfo = parseSubagentSessionUri(session.toString()); - if (subagentInfo) { - return buildSubagentTurnsFromHistory(this.sessionMessages, subagentInfo.toolCallId, session.toString()); - } - return buildTurnsFromHistory(this.sessionMessages); - } - - async disposeSession(session: URI): Promise { - this.disposeSessionCalls.push(session); - this._sessions.delete(AgentSession.id(session)); - } - - async abortSession(session: URI): Promise { - this.abortSessionCalls.push(session); - } - - respondToPermissionRequest(requestId: string, approved: boolean): void { - this.respondToPermissionCalls.push({ requestId, approved }); - } - - respondToUserInputRequest(): void { - // no-op for tests - } - - async changeModel(session: URI, model: ModelSelection): Promise { - this.changeModelCalls.push({ session, model }); - } - - async authenticate(resource: string, token: string): Promise { - this.authenticateCalls.push({ resource, token }); - return true; - } - - getCustomizations(): CustomizationRef[] { - return this.customizations; - } - - async setClientCustomizations(clientId: string, customizations: CustomizationRef[], progress?: (results: ISyncedCustomization[]) => void): Promise { - this.setClientCustomizationsCalls.push({ clientId, customizations }); - const results: ISyncedCustomization[] = customizations.map(c => ({ - customization: { - customization: c, - enabled: true, - status: CustomizationStatus.Loaded, - }, - })); - progress?.(results); - return results; - } - - setCustomizationEnabled(uri: string, enabled: boolean): void { - this.setCustomizationEnabledCalls.push({ uri, enabled }); - } - - setClientTools(): void { } - - onClientToolCallComplete(): void { } - - async shutdown(): Promise { } - - /** - * Fires an {@link AgentSignal} on this agent. - */ - fireProgress(signal: AgentSignal): void { - this._onDidSessionProgress.fire(signal); - } - - /** - * Looks up the active turn id captured from the most recent - * {@link sendMessage} call for a given session. Returns `undefined` if - * the session has no active turn yet (e.g. tests that fire progress - * without first calling sendMessage). - */ - getActiveTurnId(session: URI): string | undefined { - return this._activeTurnIds.get(uriKey(session)); - } - - fireCustomizationsChange(): void { - this._onDidCustomizationsChange.fire(); - } - - dispose(): void { - this._onDidSessionProgress.dispose(); - this._onDidCustomizationsChange.dispose(); - } -} - -/** - * Well-known URI of a pre-existing session seeded in {@link ScriptedMockAgent}. - * This session appears in `listSessions()` and has message history via - * `getSessionMessages()`, but was never created through the server's - * `handleCreateSession`. It simulates a session from a previous server - * lifetime for testing the restore-on-subscribe path. - */ -export const PRE_EXISTING_SESSION_URI = AgentSession.uri('mock', 'pre-existing-session'); - -export class ScriptedMockAgent implements IAgent { - readonly id: AgentProvider = 'mock'; - - private readonly _onDidSessionProgress = new Emitter(); - readonly onDidSessionProgress = this._onDidSessionProgress.event; - private readonly _models = observableValue(this, [{ provider: 'mock', id: 'mock-model', name: 'Mock Model', maxContextWindow: 128000, supportsVision: false }]); - readonly models = this._models; - - private readonly _sessions = new Map(); - private _nextId = 1; - - /** - * Message history for the pre-existing session: a single user→assistant - * turn with a tool call. - */ - private readonly _preExistingMessages: IHistoryRecord[] = [ - { type: 'message', role: 'user', session: PRE_EXISTING_SESSION_URI, messageId: 'h-msg-1', content: 'What files are here?' }, - { type: 'tool_start', session: PRE_EXISTING_SESSION_URI, toolCallId: 'h-tc-1', toolName: 'list_files', displayName: 'List Files', invocationMessage: 'Listing files...' }, - { type: 'tool_complete', session: PRE_EXISTING_SESSION_URI, toolCallId: 'h-tc-1', result: { pastTenseMessage: 'Listed files', content: [{ type: ToolResultContentType.Text, text: 'file1.ts\nfile2.ts' }], success: true } satisfies ToolCallResult }, - { type: 'message', role: 'assistant', session: PRE_EXISTING_SESSION_URI, messageId: 'h-msg-2', content: 'Here are the files: file1.ts and file2.ts' }, - ]; - - // Track pending permission requests - private readonly _pendingPermissions = new Map void>(); - // Track the active turn ID per session, captured from sendMessage(). - private readonly _activeTurnIds = new Map(); - // Track pending abort callbacks for slow responses - private readonly _pendingAborts = new Map void>(); - - constructor() { - // Seed the pre-existing session so it appears in listSessions() - this._sessions.set(AgentSession.id(PRE_EXISTING_SESSION_URI), PRE_EXISTING_SESSION_URI); - - // Allow integration tests to seed additional pre-existing sessions across - // server restarts via env var. The value is a comma-separated list of - // session URIs (e.g. `mock://pre-1,mock://pre-2`). - const seeded = process.env['VSCODE_AGENT_HOST_MOCK_SEED_SESSIONS']; - if (seeded) { - for (const raw of seeded.split(',')) { - const trimmed = raw.trim(); - if (!trimmed) { - continue; - } - const uri = URI.parse(trimmed); - this._sessions.set(AgentSession.id(uri), uri); - } - } - } - - getDescriptor(): IAgentDescriptor { - return { provider: 'mock', displayName: 'Mock Agent', description: 'Scripted test agent' }; - } - - getProtectedResources(): IAuthorizationProtectedResourceMetadata[] { - return []; - } - - async listSessions(): Promise { - return [...this._sessions.values()].map(s => ({ - session: s, - startTime: Date.now(), - modifiedTime: Date.now(), - project: mockProject(this.id), - summary: s.toString() === PRE_EXISTING_SESSION_URI.toString() ? 'Pre-existing session' : undefined, - })); - } - - async createSession(config?: IAgentCreateSessionConfig): Promise { - const session = config?.session ?? AgentSession.uri('mock', `mock-session-${this._nextId++}`); - const rawId = AgentSession.id(session); - this._sessions.set(rawId, session); - return { session, project: mockProject(this.id) }; - } - - async resolveSessionConfig(params: IAgentResolveSessionConfigParams): Promise { - const isolation = params.config?.isolation === 'folder' || params.config?.isolation === 'worktree' ? params.config.isolation : 'worktree'; - const branch = isolation === 'worktree' && typeof params.config?.branch === 'string' ? params.config.branch : 'main'; - return { - schema: { - type: 'object', - properties: { - isolation: { - type: 'string', - title: 'Isolation', - description: 'Where the mock agent should make changes', - enum: ['folder', 'worktree'], - enumLabels: ['Folder', 'Worktree'], - default: 'worktree', - }, - branch: { - type: 'string', - title: 'Branch', - description: 'Base branch to work from', - enum: ['main'], - enumLabels: ['main'], - default: 'main', - enumDynamic: isolation === 'worktree', - readOnly: isolation === 'folder', - }, - }, - }, - values: { isolation, branch }, - }; - } - - async sessionConfigCompletions(params: IAgentSessionConfigCompletionsParams): Promise { - if (params.property !== 'branch') { - return { items: [] }; - } - const query = params.query?.toLowerCase() ?? ''; - const branches = ['main', 'feature/config', 'release'].filter(branch => branch.toLowerCase().includes(query)); - return { items: branches.map(branch => ({ value: branch, label: branch })) }; - } - - async sendMessage(session: URI, prompt: string, _attachments?: IAgentAttachment[], turnId?: string): Promise { - if (turnId) { - this._activeTurnIds.set(uriKey(session), turnId); - } - const { sessionStr, turnId: tid } = this._ctx(session); - switch (prompt) { - case 'hello': - this._fireSequence([ - _markdown(session, sessionStr, tid, 'Hello, world!'), - _idle(session, sessionStr, tid), - ]); - break; - - case 'use-tool': - this._fireSequence([ - ..._toolStart(session, sessionStr, tid, 'tc-1', 'echo_tool', 'Echo Tool', 'Running echo tool...'), - _toolComplete(session, sessionStr, tid, 'tc-1', { pastTenseMessage: 'Ran echo tool', content: [{ type: ToolResultContentType.Text, text: 'echoed' }], success: true }), - _markdown(session, sessionStr, tid, 'Tool done.'), - _idle(session, sessionStr, tid), - ]); - break; - - case 'error': - this._fireSequence([ - _error(session, sessionStr, tid, 'test_error', 'Something went wrong'), - ]); - break; - - case 'permission': { - // Fire tool_start to create the tool, then pending_confirmation to request confirmation - (async () => { - await timeout(10); - for (const s of _toolStart(session, sessionStr, tid, 'tc-perm-1', 'shell', 'Shell', 'Run a test command')) { - this._onDidSessionProgress.fire(s); - } - await timeout(5); - this._onDidSessionProgress.fire(_pendingConfirmation(session, 'tc-perm-1', 'Run a test command', { toolInput: 'echo test', confirmationTitle: 'Run a test command' })); - })(); - this._pendingPermissions.set('tc-perm-1', (approved) => { - if (approved) { - this._fireSequence([ - _markdown(session, sessionStr, tid, 'Allowed.'), - _idle(session, sessionStr, tid), - ]); - } - }); - break; - } - - case 'write-file': { - // Fire tool_start + pending_confirmation with write permission for a regular file (should be auto-approved) - (async () => { - await timeout(10); - for (const s of _toolStart(session, sessionStr, tid, 'tc-write-1', 'create', 'Create File', 'Create file')) { - this._onDidSessionProgress.fire(s); - } - await timeout(5); - this._onDidSessionProgress.fire(_pendingConfirmation(session, 'tc-write-1', 'Write src/app.ts', { permissionKind: 'write', permissionPath: '/workspace/src/app.ts' })); - // Auto-approved writes resolve immediately — complete the tool and turn - await timeout(10); - this._fireSequence([ - _toolComplete(session, sessionStr, tid, 'tc-write-1', { pastTenseMessage: 'Wrote file', content: [{ type: ToolResultContentType.Text, text: 'ok' }], success: true }), - _idle(session, sessionStr, tid), - ]); - })(); - break; - } - - case 'write-env': { - // Fire tool_start + pending_confirmation with write permission for .env (should be blocked) - (async () => { - await timeout(10); - for (const s of _toolStart(session, sessionStr, tid, 'tc-write-env-1', 'create', 'Create File', 'Create file')) { - this._onDidSessionProgress.fire(s); - } - await timeout(5); - this._onDidSessionProgress.fire(_pendingConfirmation(session, 'tc-write-env-1', 'Write .env', { permissionKind: 'write', permissionPath: '/workspace/.env', confirmationTitle: 'Write .env' })); - })(); - this._pendingPermissions.set('tc-write-env-1', (approved) => { - if (approved) { - this._fireSequence([ - _toolComplete(session, sessionStr, tid, 'tc-write-env-1', { pastTenseMessage: 'Wrote .env', content: [{ type: ToolResultContentType.Text, text: 'ok' }], success: true }), - _idle(session, sessionStr, tid), - ]); - } - }); - break; - } - - case 'run-safe-command': { - // Fire tool_start + pending_confirmation with shell permission for an allowed command (should be auto-approved) - (async () => { - await timeout(10); - for (const s of _toolStart(session, sessionStr, tid, 'tc-shell-1', 'bash', 'Run Command', 'Run command')) { - this._onDidSessionProgress.fire(s); - } - await timeout(5); - this._onDidSessionProgress.fire(_pendingConfirmation(session, 'tc-shell-1', 'ls -la', { permissionKind: 'shell', toolInput: 'ls -la' })); - // Auto-approved shell commands resolve immediately - await timeout(10); - this._fireSequence([ - _toolComplete(session, sessionStr, tid, 'tc-shell-1', { pastTenseMessage: 'Ran command', content: [{ type: ToolResultContentType.Text, text: 'file1.ts\nfile2.ts' }], success: true }), - _idle(session, sessionStr, tid), - ]); - })(); - break; - } - - case 'run-dangerous-command': { - // Fire tool_start + pending_confirmation with shell permission for a denied command (should require confirmation) - (async () => { - await timeout(10); - for (const s of _toolStart(session, sessionStr, tid, 'tc-shell-deny-1', 'bash', 'Run Command', 'Run command')) { - this._onDidSessionProgress.fire(s); - } - await timeout(5); - this._onDidSessionProgress.fire(_pendingConfirmation(session, 'tc-shell-deny-1', 'rm -rf /', { permissionKind: 'shell', toolInput: 'rm -rf /', confirmationTitle: 'Run in terminal' })); - })(); - this._pendingPermissions.set('tc-shell-deny-1', (approved) => { - if (approved) { - this._fireSequence([ - _toolComplete(session, sessionStr, tid, 'tc-shell-deny-1', { pastTenseMessage: 'Ran command', content: [{ type: ToolResultContentType.Text, text: '' }], success: true }), - _idle(session, sessionStr, tid), - ]); - } - }); - break; - } - - case 'with-usage': - this._fireSequence([ - _markdown(session, sessionStr, tid, 'Usage response.'), - _usage(session, sessionStr, tid, { inputTokens: 100, outputTokens: 50, model: 'mock-model' }), - _idle(session, sessionStr, tid), - ]); - break; - - case 'with-reasoning': { - const initialReasoning = _reasoning(session, sessionStr, tid, 'Let me think'); - const partId = initialReasoning.action.type === ActionType.SessionResponsePart - && hasKey(initialReasoning.action.part, { id: true }) - ? initialReasoning.action.part.id - : ''; - this._fireSequence([ - initialReasoning, - _action(session, { - type: ActionType.SessionReasoning, - session: sessionStr, - turnId: tid, - partId, - content: ' about this...', - }), - _markdown(session, sessionStr, tid, 'Reasoned response.'), - _idle(session, sessionStr, tid), - ]); - break; - } - - case 'with-title': - this._fireSequence([ - _markdown(session, sessionStr, tid, 'Title response.'), - _titleChanged(session, sessionStr, MOCK_AUTO_TITLE), - _idle(session, sessionStr, tid), - ]); - break; - - case 'slow': { - // Slow response for cancel testing — fires delta after a long delay - const timer = setTimeout(() => { - const ctx = this._ctx(session); - this._fireSequence([ - _markdown(session, ctx.sessionStr, ctx.turnId, 'Slow response.'), - _idle(session, ctx.sessionStr, ctx.turnId), - ]); - }, 5000); - this._pendingAborts.set(session.toString(), () => clearTimeout(timer)); - break; - } - - case 'client-tool': { - // Fires tool_start with toolClientId followed by pending_confirmation - // (without confirmationTitle) to simulate a client-provided tool - // that is ready for execution. The real SDK handler fires - // tool_ready once its deferred is in place. - (async () => { - await timeout(10); - // Client tools don't get auto-ready — toolStart with toolClientId only emits tool_start - this._onDidSessionProgress.fire(_action(session, { - type: ActionType.SessionToolCallStart, - session: sessionStr, - turnId: tid, - toolCallId: 'tc-client-1', - toolName: 'runTests', - displayName: 'Run Tests', - toolClientId: 'test-client-tool', - })); - await timeout(5); - this._onDidSessionProgress.fire(_pendingConfirmation(session, 'tc-client-1', 'Running tests...', { toolInput: '{}' })); - })(); - // The tool stays pending — the client is responsible for dispatching toolCallComplete. - // Once complete, fire a response delta and idle. - this._pendingPermissions.set('tc-client-1', () => { - this._fireSequence([ - _markdown(session, sessionStr, tid, 'Client tool done.'), - _idle(session, sessionStr, tid), - ]); - }); - break; - } - - case 'client-tool-with-permission': { - // Fires tool_start with toolClientId followed by a permission request. - (async () => { - await timeout(10); - this._onDidSessionProgress.fire(_action(session, { - type: ActionType.SessionToolCallStart, - session: sessionStr, - turnId: tid, - toolCallId: 'tc-client-perm-1', - toolName: 'runTests', - displayName: 'Run Tests', - toolClientId: 'test-client-tool', - })); - await timeout(5); - this._onDidSessionProgress.fire(_pendingConfirmation(session, 'tc-client-perm-1', 'Run tests on project', { confirmationTitle: 'Allow Run Tests?' })); - })(); - this._pendingPermissions.set('tc-client-perm-1', (approved) => { - if (approved) { - this._fireSequence([ - _toolComplete(session, sessionStr, tid, 'tc-client-perm-1', { pastTenseMessage: 'Ran tests', content: [{ type: ToolResultContentType.Text, text: 'all passed' }], success: true }), - _markdown(session, sessionStr, tid, 'Permission granted, tool done.'), - _idle(session, sessionStr, tid), - ]); - } - }); - break; - } - - case 'subagent': { - // Spawns a subagent: parent `task` tool starts (emits start + - // auto-ready as a pair), then `subagent_started` creates the - // child session, then an inner tool runs in the child session - // (routed via `parentToolCallId`). - this._fireSequence([ - ..._toolStart(session, sessionStr, tid, 'tc-task-1', 'task', 'Task', 'Spawning subagent', { toolKind: 'subagent', subagentAgentName: 'explore', subagentDescription: 'Explore' }), - { kind: 'subagent_started', session, toolCallId: 'tc-task-1', agentName: 'explore', agentDisplayName: 'Explore', agentDescription: 'Exploration helper' }, - ..._toolStart(session, sessionStr, tid, 'tc-inner-1', 'echo_tool', 'Echo Tool', 'Inner tool running...', { parentToolCallId: 'tc-task-1' }), - _toolComplete(session, sessionStr, tid, 'tc-inner-1', { pastTenseMessage: 'Ran inner tool', content: [{ type: ToolResultContentType.Text, text: 'inner-ok' }], success: true }, 'tc-task-1'), - _toolComplete(session, sessionStr, tid, 'tc-task-1', { pastTenseMessage: 'Subagent done', content: [{ type: ToolResultContentType.Text, text: 'task-ok' }], success: true }), - _markdown(session, sessionStr, tid, 'Subagent finished.'), - _idle(session, sessionStr, tid), - ]); - break; - } - - default: - if (prompt.startsWith('terminal-edit:')) { - // Test prompt: simulate a terminal command that edits a file on disk - // without emitting any ToolResultFileEditContent. The test relies on the - // git-driven diff path to pick this up. Format: `terminal-edit:`. - const filePath = prompt.slice('terminal-edit:'.length); - void (async () => { - for (const s of _toolStart(session, sessionStr, tid, 'tc-term-edit-1', 'bash', 'Run Command', 'Edit file via shell')) { - this._onDidSessionProgress.fire(s); - } - const fs = await import('fs/promises'); - await fs.writeFile(filePath, 'edited-from-terminal\n'); - this._fireSequence([ - _toolComplete(session, sessionStr, tid, 'tc-term-edit-1', { pastTenseMessage: 'Edited file', content: [{ type: ToolResultContentType.Text, text: 'ok' }], success: true }), - _idle(session, sessionStr, tid), - ]); - })().catch(err => { - // Surface failures deterministically — an unhandled rejection - // would make the test suite flaky. - this._fireSequence([ - _markdown(session, sessionStr, tid, 'terminal-edit failed: ' + (err instanceof Error ? err.message : String(err))), - _idle(session, sessionStr, tid), - ]); - }); - break; - } - this._fireSequence([ - _markdown(session, sessionStr, tid, 'Unknown prompt: ' + prompt), - _idle(session, sessionStr, tid), - ]); - break; - } - } - - setPendingMessages(session: URI, steeringMessage: PendingMessage | undefined, _queuedMessages: readonly PendingMessage[]): void { - // When steering is set, consume it on the next tick - if (steeringMessage) { - timeout(20).then(() => { - this._onDidSessionProgress.fire({ kind: 'steering_consumed', session, id: steeringMessage.id }); - }); - } - } - - async setClientCustomizations() { - return []; - } - - setCustomizationEnabled() { - - } - - setClientTools(): void { } - - private didCompleteToolCalls = new Set(); - - onClientToolCallComplete(session: URI, toolCallId: string, result: ToolCallResult): void { - const key = `${session.toString()}:${toolCallId}`; - if (this.didCompleteToolCalls.has(key)) { - return; - } - this.didCompleteToolCalls.add(key); - // Fire tool_complete action signal and resolve any pending callback. - const { sessionStr, turnId } = this._ctx(session); - this._onDidSessionProgress.fire(_toolComplete(session, sessionStr, turnId, toolCallId, result)); - const callback = this._pendingPermissions.get(toolCallId); - if (callback) { - this._pendingPermissions.delete(toolCallId); - callback(true); - } - } - - async getSessionMessages(session: URI): Promise { - const subagentInfo = parseSubagentSessionUri(session.toString()); - if (subagentInfo) { - return buildSubagentTurnsFromHistory(this._preExistingMessages, subagentInfo.toolCallId, session.toString()); - } - if (session.toString() === PRE_EXISTING_SESSION_URI.toString()) { - return buildTurnsFromHistory(this._preExistingMessages); - } - return []; - } - - async disposeSession(session: URI): Promise { - this._sessions.delete(AgentSession.id(session)); - } - - async abortSession(session: URI): Promise { - const callback = this._pendingAborts.get(session.toString()); - if (callback) { - this._pendingAborts.delete(session.toString()); - callback(); - } - } - - async changeModel(_session: URI, _model: ModelSelection): Promise { - // Mock agent doesn't track model state - } - - async truncateSession(_session: URI, _turnId?: string): Promise { - // Mock agent accepts truncation without side effects - } - - respondToPermissionRequest(toolCallId: string, approved: boolean): void { - const callback = this._pendingPermissions.get(toolCallId); - if (callback) { - this._pendingPermissions.delete(toolCallId); - callback(approved); - } - } - - respondToUserInputRequest(): void { - // no-op for tests - } - - async authenticate(_resource: string, _token: string): Promise { - return true; - } - - async shutdown(): Promise { } - - dispose(): void { - this._onDidSessionProgress.dispose(); - } - - /** - * Fires a sequence of {@link AgentSignal}s with staggered 10 ms delays - * so the state manager processes them in order. - */ - private _fireSequence(signals: AgentSignal[]): void { - let delay = 0; - for (const signal of signals) { - delay += 10; - setTimeout(() => this._onDidSessionProgress.fire(signal), delay); - } - } - - /** Builds the session-string + turnId context for signal construction. */ - private _ctx(session: URI): { sessionStr: string; turnId: string } { - return { - sessionStr: session.toString(), - turnId: this._activeTurnIds.get(uriKey(session)) ?? 'mock-turn', - }; - } -} - -// ============================================================================= -// Test-event helpers -// ============================================================================= - -// ============================================================================= -// Signal factory helpers -// ============================================================================= - -let _mockPartIdCounter = 0; - -/** Wraps a session action into an {@link IAgentActionSignal}. */ -function _action(session: URI, action: import('../../common/state/sessionActions.js').SessionAction, parentToolCallId?: string): IAgentActionSignal { - return { kind: 'action', session, action, parentToolCallId }; -} - -/** Creates a markdown {@link ResponsePartKind.Markdown} response part signal. */ -function _markdown(session: URI, sessionStr: string, turnId: string, content: string, parentToolCallId?: string): IAgentActionSignal { - return _action(session, { - type: ActionType.SessionResponsePart, - session: sessionStr, - turnId, - part: { kind: ResponsePartKind.Markdown, id: `mock-md-${++_mockPartIdCounter}`, content }, - }, parentToolCallId); -} - -/** Creates a reasoning {@link ResponsePartKind.Reasoning} response part signal. */ -function _reasoning(session: URI, sessionStr: string, turnId: string, content: string): IAgentActionSignal { - return _action(session, { - type: ActionType.SessionResponsePart, - session: sessionStr, - turnId, - part: { kind: ResponsePartKind.Reasoning, id: `mock-rs-${++_mockPartIdCounter}`, content }, - }); -} - -/** Creates a {@link ActionType.SessionTurnComplete} signal. */ -function _idle(session: URI, sessionStr: string, turnId: string): IAgentActionSignal { - return _action(session, { type: ActionType.SessionTurnComplete, session: sessionStr, turnId }); -} - -/** Creates a {@link ActionType.SessionError} signal. */ -function _error(session: URI, sessionStr: string, turnId: string, errorType: string, message: string, stack?: string): IAgentActionSignal { - return _action(session, { type: ActionType.SessionError, session: sessionStr, turnId, error: { errorType, message, stack } }); -} - -/** Creates a {@link ActionType.SessionTitleChanged} signal. */ -function _titleChanged(session: URI, sessionStr: string, title: string): IAgentActionSignal { - return _action(session, { type: ActionType.SessionTitleChanged, session: sessionStr, title }); -} - -/** Creates a {@link ActionType.SessionUsage} signal. */ -function _usage(session: URI, sessionStr: string, turnId: string, usage: { inputTokens?: number; outputTokens?: number; model?: string; cacheReadTokens?: number }): IAgentActionSignal { - return _action(session, { type: ActionType.SessionUsage, session: sessionStr, turnId, usage }); -} - -/** - * Creates tool-start signals: a {@link ActionType.SessionToolCallStart} and, - * for non-client tools, an auto-ready {@link ActionType.SessionToolCallReady}. - */ -function _toolStart(session: URI, sessionStr: string, turnId: string, toolCallId: string, toolName: string, displayName: string, invocationMessage: StringOrMarkdown, opts?: { - toolInput?: string; - toolKind?: string; - toolClientId?: string; - subagentAgentName?: string; - subagentDescription?: string; - parentToolCallId?: string; -}): IAgentActionSignal[] { - const meta: Record = {}; - if (opts?.toolKind) { - meta.toolKind = opts.toolKind; - } - if (opts?.subagentAgentName) { - meta.subagentAgentName = opts.subagentAgentName; - } - if (opts?.subagentDescription) { - meta.subagentDescription = opts.subagentDescription; - } - const signals: IAgentActionSignal[] = [_action(session, { - type: ActionType.SessionToolCallStart, - session: sessionStr, - turnId, - toolCallId, - toolName, - displayName, - toolClientId: opts?.toolClientId, - _meta: Object.keys(meta).length ? meta : undefined, - }, opts?.parentToolCallId)]; - if (!opts?.toolClientId) { - signals.push(_action(session, { - type: ActionType.SessionToolCallReady, - session: sessionStr, - turnId, - toolCallId, - invocationMessage, - toolInput: opts?.toolInput, - confirmed: ToolCallConfirmationReason.NotNeeded, - }, opts?.parentToolCallId)); - } - return signals; -} - -/** Creates a {@link ActionType.SessionToolCallComplete} signal. */ -function _toolComplete(session: URI, sessionStr: string, turnId: string, toolCallId: string, result: ToolCallResult, parentToolCallId?: string): IAgentActionSignal { - return _action(session, { type: ActionType.SessionToolCallComplete, session: sessionStr, turnId, toolCallId, result }, parentToolCallId); -} - -/** Creates a {@link IAgentToolPendingConfirmationSignal}. */ -function _pendingConfirmation(session: URI, toolCallId: string, invocationMessage: StringOrMarkdown, opts?: { - toolInput?: string; - confirmationTitle?: StringOrMarkdown; - permissionKind?: IAgentToolPendingConfirmationSignal['permissionKind']; - permissionPath?: IAgentToolPendingConfirmationSignal['permissionPath']; -}): IAgentToolPendingConfirmationSignal { - return { - kind: 'pending_confirmation', - session, - state: { - status: ToolCallStatus.PendingConfirmation, - toolCallId, - toolName: '', - displayName: '', - invocationMessage, - toolInput: opts?.toolInput, - confirmationTitle: opts?.confirmationTitle, - }, - permissionKind: opts?.permissionKind, - permissionPath: opts?.permissionPath, - }; -} diff --git a/src/vs/platform/agentHost/test/node/osc633Parser.test.ts b/src/vs/platform/agentHost/test/node/osc633Parser.test.ts deleted file mode 100644 index 7a90ad9b..00000000 --- a/src/vs/platform/agentHost/test/node/osc633Parser.test.ts +++ /dev/null @@ -1,298 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import assert from 'assert'; -import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { Osc633EventType, Osc633Parser } from '../../node/osc633Parser.js'; - -suite('Osc633Parser', () => { - - ensureNoDisposablesAreLeakedInTestSuite(); - - let parser: Osc633Parser; - - setup(() => { - parser = new Osc633Parser(); - }); - - // -- Helper to build OSC 633 sequences -------------------------------- - - function osc633(payload: string, terminator: 'bel' | 'st' = 'bel'): string { - const term = terminator === 'bel' ? '\x07' : '\x1b\\'; - return `\x1b]633;${payload}${term}`; - } - - // -- Basic sequence extraction ---------------------------------------- - - test('no sequences returns data unchanged with no events', () => { - const result = parser.parse('hello world'); - assert.deepStrictEqual(result, { - cleanedData: 'hello world', - events: [], - }); - }); - - test('PromptStart (A) with BEL terminator', () => { - const result = parser.parse(`before${osc633('A')}after`); - assert.deepStrictEqual(result, { - cleanedData: 'beforeafter', - events: [{ type: Osc633EventType.PromptStart }], - }); - }); - - test('CommandStart (B)', () => { - const result = parser.parse(osc633('B')); - assert.deepStrictEqual(result, { - cleanedData: '', - events: [{ type: Osc633EventType.CommandStart }], - }); - }); - - test('CommandExecuted (C)', () => { - const result = parser.parse(osc633('C')); - assert.deepStrictEqual(result, { - cleanedData: '', - events: [{ type: Osc633EventType.CommandExecuted }], - }); - }); - - test('CommandFinished (D) without exit code', () => { - const result = parser.parse(osc633('D')); - assert.deepStrictEqual(result, { - cleanedData: '', - events: [{ type: Osc633EventType.CommandFinished, exitCode: undefined }], - }); - }); - - test('CommandFinished (D) with exit code 0', () => { - const result = parser.parse(osc633('D;0')); - assert.deepStrictEqual(result, { - cleanedData: '', - events: [{ type: Osc633EventType.CommandFinished, exitCode: 0 }], - }); - }); - - test('CommandFinished (D) with non-zero exit code', () => { - const result = parser.parse(osc633('D;127')); - assert.deepStrictEqual(result, { - cleanedData: '', - events: [{ type: Osc633EventType.CommandFinished, exitCode: 127 }], - }); - }); - - test('CommandLine (E) with command and nonce', () => { - const result = parser.parse(osc633('E;echo\\x20hello;my-nonce')); - assert.deepStrictEqual(result, { - cleanedData: '', - events: [{ - type: Osc633EventType.CommandLine, - commandLine: 'echo hello', - nonce: 'my-nonce', - }], - }); - }); - - test('CommandLine (E) without nonce', () => { - const result = parser.parse(osc633('E;ls\\x20-la')); - assert.deepStrictEqual(result, { - cleanedData: '', - events: [{ - type: Osc633EventType.CommandLine, - commandLine: 'ls -la', - nonce: undefined, - }], - }); - }); - - test('CommandLine (E) with escaped backslash', () => { - const result = parser.parse(osc633('E;echo\\x20\\\\hello')); - assert.deepStrictEqual(result, { - cleanedData: '', - events: [{ - type: Osc633EventType.CommandLine, - commandLine: 'echo \\hello', - nonce: undefined, - }], - }); - }); - - test('Property (P) Cwd', () => { - const result = parser.parse(osc633('P;Cwd=/home/user')); - assert.deepStrictEqual(result, { - cleanedData: '', - events: [{ - type: Osc633EventType.Property, - key: 'Cwd', - value: '/home/user', - }], - }); - }); - - test('Property (P) without value is ignored', () => { - const result = parser.parse(osc633('P;NoEquals')); - assert.deepStrictEqual(result, { - cleanedData: '', - events: [], - }); - }); - - // -- ST terminator ---------------------------------------------------- - - test('PromptStart (A) with ST terminator', () => { - const result = parser.parse(`before${osc633('A', 'st')}after`); - assert.deepStrictEqual(result, { - cleanedData: 'beforeafter', - events: [{ type: Osc633EventType.PromptStart }], - }); - }); - - // -- Multiple sequences in one chunk ---------------------------------- - - test('multiple sequences in one chunk', () => { - const data = `prompt${osc633('A')}$ ${osc633('B')}${osc633('E;ls;nonce1')}${osc633('C')}file1\nfile2\n${osc633('D;0')}`; - const result = parser.parse(data); - assert.deepStrictEqual(result, { - cleanedData: 'prompt$ file1\nfile2\n', - events: [ - { type: Osc633EventType.PromptStart }, - { type: Osc633EventType.CommandStart }, - { type: Osc633EventType.CommandLine, commandLine: 'ls', nonce: 'nonce1' }, - { type: Osc633EventType.CommandExecuted }, - { type: Osc633EventType.CommandFinished, exitCode: 0 }, - ], - }); - }); - - // -- Non-633 OSC sequences are preserved ------------------------------ - - test('non-633 OSC sequences are preserved in output', () => { - const nonOsc = '\x1b]0;window title\x07'; - const result = parser.parse(`before${nonOsc}after`); - assert.deepStrictEqual(result, { - cleanedData: `before${nonOsc}after`, - events: [], - }); - }); - - test('non-633 OSC sequences preserve ST terminator in output', () => { - const nonOsc = '\x1b]0;window title\x1b\\'; - const result = parser.parse(`before${nonOsc}after`); - assert.deepStrictEqual(result, { - cleanedData: `before${nonOsc}after`, - events: [], - }); - }); - - // -- Partial sequences across chunks ---------------------------------- - - test('sequence split across two chunks (split in payload)', () => { - const r1 = parser.parse('before\x1b]633;'); - assert.strictEqual(r1.cleanedData, 'before'); - assert.deepStrictEqual(r1.events, []); - - const r2 = parser.parse('A\x07after'); - assert.strictEqual(r2.cleanedData, 'after'); - assert.deepStrictEqual(r2.events, [{ type: Osc633EventType.PromptStart }]); - }); - - test('sequence split across two chunks (split at ESC of ST terminator)', () => { - // First chunk ends with ESC (potential start of ST) - const r1 = parser.parse('data\x1b]633;D;42\x1b'); - assert.strictEqual(r1.cleanedData, 'data'); - assert.deepStrictEqual(r1.events, []); - - // Second chunk starts with \ (completing ST) - const r2 = parser.parse('\\more'); - assert.strictEqual(r2.cleanedData, 'more'); - assert.deepStrictEqual(r2.events, [{ type: Osc633EventType.CommandFinished, exitCode: 42 }]); - }); - - test('non-633 OSC sequence split at ESC of ST terminator preserves ST', () => { - const r1 = parser.parse('before\x1b]0;window title\x1b'); - assert.strictEqual(r1.cleanedData, 'before'); - assert.deepStrictEqual(r1.events, []); - - const r2 = parser.parse('\\after'); - assert.strictEqual(r2.cleanedData, '\x1b]0;window title\x1b\\after'); - assert.deepStrictEqual(r2.events, []); - }); - - test('sequence split across three chunks', () => { - const r1 = parser.parse('\x1b]63'); - assert.strictEqual(r1.cleanedData, ''); - assert.deepStrictEqual(r1.events, []); - - const r2 = parser.parse('3;C'); - assert.strictEqual(r2.cleanedData, ''); - assert.deepStrictEqual(r2.events, []); - - const r3 = parser.parse('\x07output'); - assert.strictEqual(r3.cleanedData, 'output'); - assert.deepStrictEqual(r3.events, [{ type: Osc633EventType.CommandExecuted }]); - }); - - // -- Full command lifecycle ------------------------------------------- - - test('full command lifecycle with interleaved data', () => { - const allEvents: typeof result.events = []; - let allCleaned = ''; - - // Prompt appears - let result = parser.parse(`${osc633('A')}user@host:~ $ ${osc633('B')}`); - allEvents.push(...result.events); - allCleaned += result.cleanedData; - - // User types command, shell reports it and executes - result = parser.parse(`${osc633('E;echo\\x20hi;nonce1')}${osc633('C')}`); - allEvents.push(...result.events); - allCleaned += result.cleanedData; - - // Command output - result = parser.parse('hi\r\n'); - allEvents.push(...result.events); - allCleaned += result.cleanedData; - - // Command finishes - result = parser.parse(`${osc633('D;0')}${osc633('A')}`); - allEvents.push(...result.events); - allCleaned += result.cleanedData; - - assert.strictEqual(allCleaned, 'user@host:~ $ hi\r\n'); - assert.deepStrictEqual(allEvents, [ - { type: Osc633EventType.PromptStart }, - { type: Osc633EventType.CommandStart }, - { type: Osc633EventType.CommandLine, commandLine: 'echo hi', nonce: 'nonce1' }, - { type: Osc633EventType.CommandExecuted }, - { type: Osc633EventType.CommandFinished, exitCode: 0 }, - { type: Osc633EventType.PromptStart }, - ]); - }); - - // -- Edge cases ------------------------------------------------------- - - test('empty string', () => { - const result = parser.parse(''); - assert.deepStrictEqual(result, { cleanedData: '', events: [] }); - }); - - test('data with regular ANSI escape sequences (non-OSC) passes through', () => { - const ansi = '\x1b[31mred\x1b[0m'; - const result = parser.parse(ansi); - assert.deepStrictEqual(result, { cleanedData: ansi, events: [] }); - }); - - test('CommandLine (E) with semicolons in command', () => { - // Semicolons in the command line should be escaped as \x3b - const result = parser.parse(osc633('E;echo\\x3b\\x20hello;my-nonce')); - assert.deepStrictEqual(result, { - cleanedData: '', - events: [{ - type: Osc633EventType.CommandLine, - commandLine: 'echo; hello', - nonce: 'my-nonce', - }], - }); - }); -}); diff --git a/src/vs/platform/agentHost/test/node/pluginStorage.test.ts b/src/vs/platform/agentHost/test/node/pluginStorage.test.ts deleted file mode 100644 index 02fedc68..00000000 --- a/src/vs/platform/agentHost/test/node/pluginStorage.test.ts +++ /dev/null @@ -1,7 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -// Plugin storage logic has been folded into AgentPluginManager. -// See agentPluginManager.test.ts for tests. diff --git a/src/vs/platform/agentHost/test/node/protocol/agentHostServer.integrationTest.ts b/src/vs/platform/agentHost/test/node/protocol/agentHostServer.integrationTest.ts deleted file mode 100644 index f7521463..00000000 --- a/src/vs/platform/agentHost/test/node/protocol/agentHostServer.integrationTest.ts +++ /dev/null @@ -1,33 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { PROTOCOL_VERSION } from '../../../common/state/sessionCapabilities.js'; -import { IServerHandle, startServer, TestProtocolClient } from './testHelpers.js'; - -suite('Agent Host Server', function () { - - let server: IServerHandle; - - suiteSetup(async function () { - this.timeout(15_000); - server = await startServer({ quiet: false }); - }); - - suiteTeardown(function () { - server.process.kill(); - }); - - test('starts with production agent services registered', async function () { - this.timeout(10_000); - - const client = new TestProtocolClient(server.port); - try { - await client.connect(); - await client.call('initialize', { protocolVersion: PROTOCOL_VERSION, clientId: 'test-agent-host-server-services' }); - } finally { - client.close(); - } - }); -}); diff --git a/src/vs/platform/agentHost/test/node/protocol/clientTools.integrationTest.ts b/src/vs/platform/agentHost/test/node/protocol/clientTools.integrationTest.ts deleted file mode 100644 index 2849e35f..00000000 --- a/src/vs/platform/agentHost/test/node/protocol/clientTools.integrationTest.ts +++ /dev/null @@ -1,190 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -/** - * Integration tests for client-provided tool handling through the protocol layer. - * - * These tests verify that: - * - tool_start with toolClientId emits only a toolCallStart (no auto-ready) - * - tool_ready without confirmationTitle transitions to Running (auto-confirmed) - * - tool_ready with confirmationTitle transitions to PendingConfirmation - * - toolCallComplete dispatched by the client flows through to the agent - * - * Run with: - * ./scripts/test-integration.sh --run src/vs/platform/agentHost/test/node/protocol/clientTools.integrationTest.ts - */ - -import assert from 'assert'; -import { ToolResultContentType } from '../../../common/state/sessionState.js'; -import { - createAndSubscribeSession, - dispatchTurnStarted, - getActionEnvelope, - IServerHandle, - isActionNotification, - startServer, - TestProtocolClient, -} from './testHelpers.js'; - -suite('Protocol WebSocket — Client Tools', function () { - - let server: IServerHandle; - let client: TestProtocolClient; - - suiteSetup(async function () { - this.timeout(15_000); - server = await startServer(); - }); - - suiteTeardown(function () { - server.process.kill(); - }); - - setup(async function () { - this.timeout(10_000); - client = new TestProtocolClient(server.port); - await client.connect(); - }); - - teardown(function () { - client.close(); - }); - - // ---- Client tool: tool_start with toolClientId -------------------------- - - test('client tool_start emits toolCallStart then toolCallReady (auto-confirmed)', async function () { - this.timeout(10_000); - - const sessionUri = await createAndSubscribeSession(client, 'test-client-tool'); - dispatchTurnStarted(client, sessionUri, 'turn-ct', 'client-tool', 1); - - // Wait for toolCallStart - const [toolStartNotif, toolReadyNotif] = await Promise.all([ - client.waitForNotification(n => isActionNotification(n, 'session/toolCallStart')), - client.waitForNotification(n => isActionNotification(n, 'session/toolCallReady')), - ]); - const toolStartAction = getActionEnvelope(toolStartNotif).action as { - toolCallId: string; - toolClientId?: string; - }; - assert.strictEqual(toolStartAction.toolCallId, 'tc-client-1'); - assert.strictEqual(toolStartAction.toolClientId, 'test-client-tool'); - - const toolReadyAction = getActionEnvelope(toolReadyNotif).action as { - toolCallId: string; - confirmed?: string; - }; - assert.strictEqual(toolReadyAction.toolCallId, 'tc-client-1'); - assert.strictEqual(toolReadyAction.confirmed, 'not-needed'); - - // Complete the client tool call - client.notify('dispatchAction', { - clientSeq: 2, - action: { - type: 'session/toolCallComplete', - session: sessionUri, - turnId: 'turn-ct', - toolCallId: 'tc-client-1', - result: { - success: true, - pastTenseMessage: 'Ran tests', - content: [{ type: ToolResultContentType.Text, text: 'all passed' }], - }, - }, - }); - - // Wait for turn completion - await client.waitForNotification( - n => isActionNotification(n, 'session/turnComplete'), - ); - }); - - // ---- Client tool with permission request -------------------------------- - - test('client tool with permission fires toolCallReady with confirmationTitle', async function () { - this.timeout(10_000); - - const sessionUri = await createAndSubscribeSession(client, 'test-client-perm'); - dispatchTurnStarted(client, sessionUri, 'turn-cp', 'client-tool-with-permission', 1); - - // Wait for toolCallStart (should have toolClientId) - const toolStartNotif = await client.waitForNotification( - n => isActionNotification(n, 'session/toolCallStart'), - ); - const toolStartAction = getActionEnvelope(toolStartNotif).action as { - toolCallId: string; - toolClientId?: string; - }; - assert.strictEqual(toolStartAction.toolCallId, 'tc-client-perm-1'); - assert.strictEqual(toolStartAction.toolClientId, 'test-client-tool'); - - // Wait for toolCallReady with confirmationTitle (permission flow) - const toolReadyNotif = await client.waitForNotification( - n => isActionNotification(n, 'session/toolCallReady'), - ); - const toolReadyAction = getActionEnvelope(toolReadyNotif).action as { - toolCallId: string; - confirmationTitle?: string; - confirmed?: string; - }; - assert.strictEqual(toolReadyAction.toolCallId, 'tc-client-perm-1'); - assert.strictEqual(toolReadyAction.confirmationTitle, 'Allow Run Tests?'); - // Permission flow should NOT have auto-confirmed - assert.strictEqual(toolReadyAction.confirmed, undefined); - - // Approve the permission - client.notify('dispatchAction', { - clientSeq: 2, - action: { - type: 'session/toolCallConfirmed', - session: sessionUri, - turnId: 'turn-cp', - toolCallId: 'tc-client-perm-1', - approved: true, - }, - }); - - // Wait for turn completion - await client.waitForNotification( - n => isActionNotification(n, 'session/turnComplete'), - ); - }); - - // ---- tool_ready auto-confirm (non-permission client tools) --------------- - - test('tool_ready without confirmationTitle auto-confirms with NotNeeded', async function () { - this.timeout(10_000); - - const sessionUri = await createAndSubscribeSession(client, 'test-ready-auto'); - dispatchTurnStarted(client, sessionUri, 'turn-ra', 'client-tool', 1); - - // Wait for toolCallStart - await client.waitForNotification( - n => isActionNotification(n, 'session/toolCallStart'), - ); - - // Dispatch a synthetic tool_ready without confirmationTitle via - // completing the tool — the server-side reducer will process the - // tool_ready that was generated by the event mapper. - client.notify('dispatchAction', { - clientSeq: 2, - action: { - type: 'session/toolCallComplete', - session: sessionUri, - turnId: 'turn-ra', - toolCallId: 'tc-client-1', - result: { - success: true, - pastTenseMessage: 'Done', - content: [{ type: ToolResultContentType.Text, text: 'ok' }], - }, - }, - }); - - await client.waitForNotification( - n => isActionNotification(n, 'session/turnComplete'), - ); - }); -}); diff --git a/src/vs/platform/agentHost/test/node/protocol/handshake.integrationTest.ts b/src/vs/platform/agentHost/test/node/protocol/handshake.integrationTest.ts deleted file mode 100644 index 05314e2e..00000000 --- a/src/vs/platform/agentHost/test/node/protocol/handshake.integrationTest.ts +++ /dev/null @@ -1,91 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import assert from 'assert'; -import { URI } from '../../../../../base/common/uri.js'; -import { PROTOCOL_VERSION } from '../../../common/state/sessionCapabilities.js'; -import { - JSON_RPC_PARSE_ERROR, - type InitializeResult, - type JsonRpcErrorResponse, -} from '../../../common/state/sessionProtocol.js'; -import { IServerHandle, nextSessionUri, startServer, TestProtocolClient } from './testHelpers.js'; - -suite('Protocol WebSocket — Handshake & Errors', function () { - - let server: IServerHandle; - let client: TestProtocolClient; - - suiteSetup(async function () { - this.timeout(15_000); - server = await startServer(); - }); - - suiteTeardown(function () { - server.process.kill(); - }); - - setup(async function () { - this.timeout(10_000); - client = new TestProtocolClient(server.port); - await client.connect(); - }); - - teardown(function () { - client.close(); - }); - - test('handshake returns initialize response with protocol version', async function () { - this.timeout(5_000); - - const result = await client.call('initialize', { - protocolVersion: PROTOCOL_VERSION, - clientId: 'test-handshake', - initialSubscriptions: [URI.from({ scheme: 'agenthost', path: '/root' }).toString()], - }); - - assert.strictEqual(result.protocolVersion, PROTOCOL_VERSION); - assert.ok(result.serverSeq >= 0); - assert.ok(result.snapshots.length >= 1, 'should have root state snapshot'); - }); - - test('malformed JSON message returns parse error', async function () { - this.timeout(10_000); - - const raw = new TestProtocolClient(server.port); - await raw.connect(); - - const responsePromise = raw.waitForRawMessage(); - raw.sendRaw('this is not valid json{{{'); - - const response = await responsePromise as JsonRpcErrorResponse; - assert.strictEqual(response.jsonrpc, '2.0'); - assert.strictEqual(response.id, null); - assert.strictEqual(response.error.code, JSON_RPC_PARSE_ERROR); - - raw.close(); - }); - - test('createSession with invalid provider does not crash server', async function () { - this.timeout(10_000); - - await client.call('initialize', { protocolVersion: PROTOCOL_VERSION, clientId: 'test-invalid-create' }); - - let gotError = false; - try { - await client.call('createSession', { session: nextSessionUri(), provider: 'nonexistent' }); - } catch { - gotError = true; - } - assert.ok(gotError, 'should have received an error for invalid provider'); - - // Server should still be functional - await client.call('createSession', { session: nextSessionUri(), provider: 'mock' }); - const notif = await client.waitForNotification(n => - n.method === 'notification' && (n.params as { notification: { type: string } }).notification.type === 'notify/sessionAdded' - ); - assert.ok(notif); - }); -}); diff --git a/src/vs/platform/agentHost/test/node/protocol/multiClient.integrationTest.ts b/src/vs/platform/agentHost/test/node/protocol/multiClient.integrationTest.ts deleted file mode 100644 index d2170523..00000000 --- a/src/vs/platform/agentHost/test/node/protocol/multiClient.integrationTest.ts +++ /dev/null @@ -1,327 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import assert from 'assert'; -import { SubscribeResult } from '../../../common/state/protocol/commands.js'; -import type { SessionAddedNotification, SessionRemovedNotification } from '../../../common/state/sessionActions.js'; -import { PROTOCOL_VERSION } from '../../../common/state/sessionCapabilities.js'; -import type { INotificationBroadcastParams, ReconnectResult } from '../../../common/state/sessionProtocol.js'; -import type { SessionState } from '../../../common/state/sessionState.js'; -import { - createAndSubscribeSession, - dispatchTurnStarted, - getActionEnvelope, - IServerHandle, - isActionNotification, - nextSessionUri, - startServer, - TestProtocolClient, -} from './testHelpers.js'; - -suite('Protocol WebSocket — Multi-Client', function () { - - let server: IServerHandle; - let client: TestProtocolClient; - - suiteSetup(async function () { - this.timeout(15_000); - server = await startServer(); - }); - - suiteTeardown(function () { - server.process.kill(); - }); - - setup(async function () { - this.timeout(10_000); - client = new TestProtocolClient(server.port); - await client.connect(); - }); - - teardown(function () { - client.close(); - }); - - test('sessionAdded notification is broadcast to all connected clients', async function () { - this.timeout(10_000); - - await client.call('initialize', { protocolVersion: PROTOCOL_VERSION, clientId: 'test-broadcast-add-1' }); - - const client2 = new TestProtocolClient(server.port); - await client2.connect(); - await client2.call('initialize', { protocolVersion: PROTOCOL_VERSION, clientId: 'test-broadcast-add-2' }); - - client.clearReceived(); - client2.clearReceived(); - - await client.call('createSession', { session: nextSessionUri(), provider: 'mock' }); - - const n1 = await client.waitForNotification(n => - n.method === 'notification' && (n.params as INotificationBroadcastParams).notification.type === 'notify/sessionAdded' - ); - const n2 = await client2.waitForNotification(n => - n.method === 'notification' && (n.params as INotificationBroadcastParams).notification.type === 'notify/sessionAdded' - ); - assert.ok(n1, 'client 1 should receive sessionAdded'); - assert.ok(n2, 'client 2 should receive sessionAdded'); - - const uri1 = ((n1.params as INotificationBroadcastParams).notification as SessionAddedNotification).summary.resource; - const uri2 = ((n2.params as INotificationBroadcastParams).notification as SessionAddedNotification).summary.resource; - assert.strictEqual(uri1, uri2, 'both clients should see the same session URI'); - - client2.close(); - }); - - test('sessionRemoved notification is broadcast to all connected clients', async function () { - this.timeout(10_000); - - const sessionUri = await createAndSubscribeSession(client, 'test-broadcast-remove-1'); - - const client2 = new TestProtocolClient(server.port); - await client2.connect(); - await client2.call('initialize', { protocolVersion: PROTOCOL_VERSION, clientId: 'test-broadcast-remove-2' }); - client2.clearReceived(); - - await client.call('disposeSession', { session: sessionUri }); - - const n1 = await client.waitForNotification(n => - n.method === 'notification' && (n.params as INotificationBroadcastParams).notification.type === 'notify/sessionRemoved' - ); - const n2 = await client2.waitForNotification(n => - n.method === 'notification' && (n.params as INotificationBroadcastParams).notification.type === 'notify/sessionRemoved' - ); - assert.ok(n1, 'client 1 should receive sessionRemoved'); - assert.ok(n2, 'client 2 should receive sessionRemoved even without subscribing'); - - const removed1 = (n1.params as INotificationBroadcastParams).notification as SessionRemovedNotification; - const removed2 = (n2.params as INotificationBroadcastParams).notification as SessionRemovedNotification; - assert.strictEqual(removed1.session.toString(), sessionUri.toString()); - assert.strictEqual(removed2.session.toString(), sessionUri.toString()); - - client2.close(); - }); - - test('two clients on same session both see actions', async function () { - this.timeout(10_000); - - const sessionUri = await createAndSubscribeSession(client, 'test-multi-client-1'); - - const client2 = new TestProtocolClient(server.port); - await client2.connect(); - await client2.call('initialize', { protocolVersion: PROTOCOL_VERSION, clientId: 'test-multi-client-2' }); - await client2.call('subscribe', { resource: sessionUri }); - client2.clearReceived(); - - dispatchTurnStarted(client, sessionUri, 'turn-mc', 'hello', 1); - - const d1 = await client.waitForNotification(n => isActionNotification(n, 'session/responsePart')); - const d2 = await client2.waitForNotification(n => isActionNotification(n, 'session/responsePart')); - assert.ok(d1); - assert.ok(d2); - - await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); - await client2.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); - - client2.close(); - }); - - test('client B sends message on session created by client A', async function () { - this.timeout(10_000); - - const sessionUri = await createAndSubscribeSession(client, 'test-cross-msg-1'); - - const client2 = new TestProtocolClient(server.port); - await client2.connect(); - await client2.call('initialize', { protocolVersion: PROTOCOL_VERSION, clientId: 'test-cross-msg-2' }); - await client2.call('subscribe', { resource: sessionUri }); - client.clearReceived(); - client2.clearReceived(); - - // Client B dispatches the turn - dispatchTurnStarted(client2, sessionUri, 'turn-cross', 'hello', 1); - - const r1 = await client.waitForNotification(n => isActionNotification(n, 'session/responsePart')); - const r2 = await client2.waitForNotification(n => isActionNotification(n, 'session/responsePart')); - assert.ok(r1, 'client A should see responsePart from client B turn'); - assert.ok(r2, 'client B should see its own responsePart'); - - await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); - await client2.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); - - client2.close(); - }); - - test('both clients receive full tool progress updates', async function () { - this.timeout(10_000); - - const sessionUri = await createAndSubscribeSession(client, 'test-tool-progress-1'); - - const client2 = new TestProtocolClient(server.port); - await client2.connect(); - await client2.call('initialize', { protocolVersion: PROTOCOL_VERSION, clientId: 'test-tool-progress-2' }); - await client2.call('subscribe', { resource: sessionUri }); - client.clearReceived(); - client2.clearReceived(); - - dispatchTurnStarted(client, sessionUri, 'turn-tool-mc', 'use-tool', 1); - - // Both clients should see the full tool lifecycle - for (const c of [client, client2]) { - await c.waitForNotification(n => isActionNotification(n, 'session/toolCallStart')); - await c.waitForNotification(n => isActionNotification(n, 'session/toolCallReady')); - await c.waitForNotification(n => isActionNotification(n, 'session/toolCallComplete')); - await c.waitForNotification(n => isActionNotification(n, 'session/responsePart')); - await c.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); - } - - client2.close(); - }); - - test('unsubscribe stops receiving session actions', async function () { - this.timeout(10_000); - - const sessionUri = await createAndSubscribeSession(client, 'test-unsubscribe'); - client.notify('unsubscribe', { resource: sessionUri }); - await new Promise(resolve => setTimeout(resolve, 100)); - client.clearReceived(); - - const client2 = new TestProtocolClient(server.port); - await client2.connect(); - await client2.call('initialize', { protocolVersion: PROTOCOL_VERSION, clientId: 'test-unsub-helper' }); - await client2.call('subscribe', { resource: sessionUri }); - - dispatchTurnStarted(client2, sessionUri, 'turn-unsub', 'hello', 1); - await client2.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); - - await new Promise(resolve => setTimeout(resolve, 300)); - const sessionActions = client.receivedNotifications(n => isActionNotification(n, 'session/')); - assert.strictEqual(sessionActions.length, 0, 'unsubscribed client should not receive session actions'); - - client2.close(); - }); - - test('unsubscribed client receives no actions but still gets notifications', async function () { - this.timeout(10_000); - - const sessionUri = await createAndSubscribeSession(client, 'test-scoping-1'); - - const client2 = new TestProtocolClient(server.port); - await client2.connect(); - await client2.call('initialize', { protocolVersion: PROTOCOL_VERSION, clientId: 'test-scoping-2' }); - // Client 2 does NOT subscribe to the session - client2.clearReceived(); - - dispatchTurnStarted(client, sessionUri, 'turn-scoped', 'hello', 1); - await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); - - // Give some time for any stray actions to arrive - await new Promise(resolve => setTimeout(resolve, 300)); - const sessionActions = client2.receivedNotifications(n => n.method === 'action'); - assert.strictEqual(sessionActions.length, 0, 'unsubscribed client should receive no session actions'); - - // But disposing the session should still broadcast a notification - client2.clearReceived(); - await client.call('disposeSession', { session: sessionUri }); - - const removed = await client2.waitForNotification(n => - n.method === 'notification' && (n.params as INotificationBroadcastParams).notification.type === 'notify/sessionRemoved' - ); - assert.ok(removed, 'unsubscribed client should still receive sessionRemoved notification'); - - client2.close(); - }); - - test('late subscriber gets current state via snapshot', async function () { - this.timeout(15_000); - - const sessionUri = await createAndSubscribeSession(client, 'test-late-sub'); - dispatchTurnStarted(client, sessionUri, 'turn-late', 'hello', 1); - await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); - - // Client 2 joins after the turn has completed - const client2 = new TestProtocolClient(server.port); - await client2.connect(); - await client2.call('initialize', { protocolVersion: PROTOCOL_VERSION, clientId: 'test-late-sub-2' }); - - const result = await client2.call('subscribe', { resource: sessionUri }); - const state = result.snapshot.state as SessionState; - assert.ok(state.turns.length >= 1, `late subscriber should see completed turn, got ${state.turns.length}`); - assert.strictEqual(state.turns[0].id, 'turn-late'); - assert.strictEqual(state.turns[0].state, 'complete'); - - client2.close(); - }); - - test('permission flow: client B confirms tool started by client A', async function () { - this.timeout(10_000); - - const sessionUri = await createAndSubscribeSession(client, 'test-cross-perm-1'); - - const client2 = new TestProtocolClient(server.port); - await client2.connect(); - await client2.call('initialize', { protocolVersion: PROTOCOL_VERSION, clientId: 'test-cross-perm-2' }); - await client2.call('subscribe', { resource: sessionUri }); - client.clearReceived(); - client2.clearReceived(); - - // Client A starts the permission turn - dispatchTurnStarted(client, sessionUri, 'turn-cross-perm', 'permission', 1); - - // Both clients should see tool_start and tool_ready - await client.waitForNotification(n => isActionNotification(n, 'session/toolCallStart')); - await client2.waitForNotification(n => isActionNotification(n, 'session/toolCallStart')); - await client.waitForNotification(n => isActionNotification(n, 'session/toolCallReady')); - await client2.waitForNotification(n => isActionNotification(n, 'session/toolCallReady')); - - // Client B confirms the tool call - client2.notify('dispatchAction', { - clientSeq: 1, - action: { - type: 'session/toolCallConfirmed', - session: sessionUri, - turnId: 'turn-cross-perm', - toolCallId: 'tc-perm-1', - approved: true, - }, - }); - - // Both clients should see the response and turn completion - await client.waitForNotification(n => isActionNotification(n, 'session/responsePart')); - await client2.waitForNotification(n => isActionNotification(n, 'session/responsePart')); - await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); - await client2.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); - - client2.close(); - }); - - test('reconnect replays missed actions', async function () { - this.timeout(15_000); - - const sessionUri = await createAndSubscribeSession(client, 'test-reconnect'); - dispatchTurnStarted(client, sessionUri, 'turn-recon', 'hello', 1); - await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); - - const allActions = client.receivedNotifications(n => n.method === 'action'); - assert.ok(allActions.length > 0); - const missedFromSeq = getActionEnvelope(allActions[0]).serverSeq - 1; - - client.close(); - - const client2 = new TestProtocolClient(server.port); - await client2.connect(); - const result = await client2.call('reconnect', { - clientId: 'test-reconnect', - lastSeenServerSeq: missedFromSeq, - subscriptions: [sessionUri], - }); - - assert.ok(result.type === 'replay' || result.type === 'snapshot', 'should receive replay or snapshot'); - if (result.type === 'replay') { - assert.ok(result.actions.length > 0, 'should have replayed actions'); - } - - client2.close(); - }); -}); diff --git a/src/vs/platform/agentHost/test/node/protocol/sessionConfig.integrationTest.ts b/src/vs/platform/agentHost/test/node/protocol/sessionConfig.integrationTest.ts deleted file mode 100644 index e0619ef1..00000000 --- a/src/vs/platform/agentHost/test/node/protocol/sessionConfig.integrationTest.ts +++ /dev/null @@ -1,247 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import assert from 'assert'; -import { mkdtempSync, rmSync } from 'fs'; -import { tmpdir } from 'os'; -import { URI } from '../../../../../base/common/uri.js'; -import type { ResolveSessionConfigResult, SessionConfigCompletionsResult, SubscribeResult } from '../../../common/state/protocol/commands.js'; -import { ActionType, type SessionAddedNotification } from '../../../common/state/sessionActions.js'; -import { PROTOCOL_VERSION } from '../../../common/state/sessionCapabilities.js'; -import type { INotificationBroadcastParams } from '../../../common/state/sessionProtocol.js'; -import type { SessionState } from '../../../common/state/sessionState.js'; -import { - getActionEnvelope, - isActionNotification, - IServerHandle, - nextSessionUri, - startServer, - TestProtocolClient, -} from './testHelpers.js'; - -suite('Protocol WebSocket - Session Config', function () { - - let server: IServerHandle; - let client: TestProtocolClient; - - suiteSetup(async function () { - this.timeout(15_000); - server = await startServer(); - }); - - suiteTeardown(function () { - server.process.kill(); - }); - - setup(async function () { - this.timeout(10_000); - client = new TestProtocolClient(server.port); - await client.connect(); - await client.call('initialize', { protocolVersion: PROTOCOL_VERSION, clientId: 'test-session-config' }); - }); - - teardown(function () { - client.close(); - }); - - test('resolveSessionConfig returns schema and re-resolves dependent read-only state', async function () { - this.timeout(10_000); - - const workingDirectory = URI.file('/mock/workspace').toString(); - const initial = await client.call('resolveSessionConfig', { - provider: 'mock', - workingDirectory, - }); - - assert.deepStrictEqual(initial.values, { isolation: 'worktree', branch: 'main' }); - assert.deepStrictEqual(Object.keys(initial.schema.properties), ['isolation', 'branch']); - assert.deepStrictEqual(initial.schema.properties.branch.enum, ['main']); - assert.strictEqual(initial.schema.properties.branch.enumDynamic, true); - assert.strictEqual(initial.schema.properties.branch.readOnly, false); - - const folder = await client.call('resolveSessionConfig', { - provider: 'mock', - workingDirectory, - config: { isolation: 'folder', branch: 'feature/config' }, - }); - - assert.deepStrictEqual(folder.values, { isolation: 'folder', branch: 'main' }); - assert.strictEqual(folder.schema.properties.branch.enumDynamic, false); - assert.strictEqual(folder.schema.properties.branch.readOnly, true); - }); - - test('sessionConfigCompletions returns dynamic branch matches', async function () { - this.timeout(10_000); - - const result = await client.call('sessionConfigCompletions', { - provider: 'mock', - workingDirectory: URI.file('/mock/workspace').toString(), - config: { isolation: 'worktree' }, - property: 'branch', - query: 'feat', - }); - - assert.deepStrictEqual(result, { - items: [{ value: 'feature/config', label: 'feature/config' }], - }); - }); - - test('createSession stores config schema and values on session state', async function () { - this.timeout(10_000); - - const config = { isolation: 'worktree', branch: 'feature/config' }; - await client.call('createSession', { - session: nextSessionUri(), - provider: 'mock', - workingDirectory: URI.file('/mock/workspace').toString(), - config, - }); - - const notif = await client.waitForNotification(n => - n.method === 'notification' && (n.params as INotificationBroadcastParams).notification.type === 'notify/sessionAdded' - ); - const notification = (notif.params as INotificationBroadcastParams).notification as SessionAddedNotification; - assert.strictEqual(Object.hasOwn(notification.summary, 'config'), false); - - const snapshot = await client.call('subscribe', { resource: notification.summary.resource }); - const state = snapshot.snapshot.state as SessionState; - assert.deepStrictEqual(state.config?.values, config); - assert.deepStrictEqual(Object.keys(state.config?.schema.properties ?? {}), ['isolation', 'branch']); - }); - - test('session/configChanged merges config values into session state', async function () { - this.timeout(10_000); - - await client.call('createSession', { - session: nextSessionUri(), - provider: 'mock', - config: { isolation: 'folder', branch: 'main' }, - }); - - const notif = await client.waitForNotification(n => - n.method === 'notification' && (n.params as INotificationBroadcastParams).notification.type === 'notify/sessionAdded' - ); - const session = ((notif.params as INotificationBroadcastParams).notification as SessionAddedNotification).summary.resource; - await client.call('subscribe', { resource: session }); - client.clearReceived(); - - client.notify('dispatchAction', { - clientSeq: 1, - action: { - type: ActionType.SessionConfigChanged, - session, - config: { branch: 'release' }, - }, - }); - - const configChanged = await client.waitForNotification(n => isActionNotification(n, ActionType.SessionConfigChanged)); - assert.strictEqual(getActionEnvelope(configChanged).action.type, ActionType.SessionConfigChanged); - - const snapshot = await client.call('subscribe', { resource: session }); - const state = snapshot.snapshot.state as SessionState; - assert.deepStrictEqual(state.config?.values, { isolation: 'folder', branch: 'release' }); - }); -}); - -suite('Protocol WebSocket - Session Config persistence across restarts', function () { - - let userDataDir: string; - - setup(function () { - userDataDir = mkdtempSync(`${tmpdir()}/vscode-agent-host-config-`); - }); - - teardown(function () { - try { - rmSync(userDataDir, { recursive: true, force: true }); - } catch { - // Best-effort cleanup; the OS will reap the temp dir eventually. - } - }); - - test('persisted config values are restored on subscribe after server restart', async function () { - this.timeout(30_000); - - const initialConfig = { isolation: 'worktree', branch: 'main' }; - const updatedBranch = 'release'; - let sessionUri: string; - - // ---- Phase 1: create session, change config, wait for persistence ---- - const server1 = await startServer({ userDataDir }); - try { - const client1 = new TestProtocolClient(server1.port); - await client1.connect(); - await client1.call('initialize', { protocolVersion: PROTOCOL_VERSION, clientId: 'test-config-restore-1' }); - - await client1.call('createSession', { - session: nextSessionUri(), - provider: 'mock', - workingDirectory: URI.file('/mock/workspace').toString(), - config: initialConfig, - }); - const addedNotif = await client1.waitForNotification(n => - n.method === 'notification' && (n.params as INotificationBroadcastParams).notification.type === 'notify/sessionAdded' - ); - // The mock agent assigns its own URI rather than honoring the - // requested one, so capture the real URI from the notification. - sessionUri = ((addedNotif.params as INotificationBroadcastParams).notification as SessionAddedNotification).summary.resource; - - await client1.call('subscribe', { resource: sessionUri }); - - client1.notify('dispatchAction', { - clientSeq: 1, - action: { - type: ActionType.SessionConfigChanged, - session: sessionUri, - config: { branch: updatedBranch }, - }, - }); - const configChanged = await client1.waitForNotification(n => isActionNotification(n, ActionType.SessionConfigChanged)); - assert.strictEqual(getActionEnvelope(configChanged).action.type, ActionType.SessionConfigChanged); - - client1.close(); - } finally { - // Trigger graceful shutdown by closing stdin rather than sending - // SIGTERM — on Windows, `child.kill()` (SIGTERM) unconditionally - // terminates the process without invoking the shutdown handler, - // so in-flight `setMetadata` writes never reach SQLite. Closing - // stdin fires `process.stdin.on('end', shutdown)` in the server - // on every platform. - server1.process.stdin!.end(); - await new Promise(resolve => server1.process.once('exit', () => resolve())); - } - - // ---- Phase 2: restart server, subscribe, verify restored config ---- - // The mock agent does not persist its in-memory session list across - // restarts, so seed it via env var so `agent.listSessions()` includes - // our session and `restoreSession` proceeds. - const server2 = await startServer({ - userDataDir, - env: { VSCODE_AGENT_HOST_MOCK_SEED_SESSIONS: sessionUri }, - }); - try { - const client2 = new TestProtocolClient(server2.port); - await client2.connect(); - await client2.call('initialize', { protocolVersion: PROTOCOL_VERSION, clientId: 'test-config-restore-2' }); - - // Subscribing triggers the restore-on-subscribe path on the server, - // which reads `configValues` from the per-session DB and overlays - // them on the freshly-resolved schema. - const snapshot = await client2.call('subscribe', { resource: sessionUri }); - const state = snapshot.snapshot.state as SessionState; - - assert.ok(state.config, 'restored session should have state.config populated'); - // Schema is re-resolved by the provider (worktree-mode mock returns - // dynamic branch enum), so just check that our persisted user - // selections survived the round trip. - assert.deepStrictEqual(state.config.values, { isolation: 'worktree', branch: updatedBranch }); - - client2.close(); - } finally { - server2.process.stdin!.end(); - await new Promise(resolve => server2.process.once('exit', () => resolve())); - } - }); -}); diff --git a/src/vs/platform/agentHost/test/node/protocol/sessionDiffs.integrationTest.ts b/src/vs/platform/agentHost/test/node/protocol/sessionDiffs.integrationTest.ts deleted file mode 100644 index 236fbb3b..00000000 --- a/src/vs/platform/agentHost/test/node/protocol/sessionDiffs.integrationTest.ts +++ /dev/null @@ -1,104 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import assert from 'assert'; -import * as cp from 'child_process'; -import { mkdtempSync, rmSync, writeFileSync } from 'fs'; -import { tmpdir } from 'os'; -import { join } from '../../../../../base/common/path.js'; -import { URI } from '../../../../../base/common/uri.js'; -import { SubscribeResult } from '../../../common/state/protocol/commands.js'; -import type { SessionAddedNotification, SessionDiffsChangedAction } from '../../../common/state/sessionActions.js'; -import { PROTOCOL_VERSION } from '../../../common/state/sessionCapabilities.js'; -import type { INotificationBroadcastParams } from '../../../common/state/sessionProtocol.js'; -import { - dispatchTurnStarted, - getActionEnvelope, - IServerHandle, - isActionNotification, - nextSessionUri, - startServer, - TestProtocolClient, -} from './testHelpers.js'; - -const hasGit = (() => { - try { cp.execFileSync('git', ['--version'], { stdio: 'ignore' }); return true; } catch { return false; } -})(); - -(hasGit ? suite : suite.skip)('Protocol WebSocket — Git-driven session diffs', function () { - - let server: IServerHandle; - let client: TestProtocolClient; - let tmpRoot: string; - - suiteSetup(async function () { - this.timeout(15_000); - server = await startServer(); - }); - - suiteTeardown(function () { - server.process.kill(); - }); - - setup(async function () { - this.timeout(10_000); - // Initialize a tmp git repo as the session's working directory. - tmpRoot = mkdtempSync(join(tmpdir(), 'agent-host-proto-diff-')); - const env = { ...process.env, GIT_AUTHOR_NAME: 't', GIT_AUTHOR_EMAIL: 't@t', GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@t' }; - const run = (...args: string[]) => cp.execFileSync('git', args, { cwd: tmpRoot, env, stdio: 'pipe' }); - run('init', '-q', '-b', 'main'); - writeFileSync(join(tmpRoot, 'seed.txt'), 'seed\n'); - run('add', '.'); - run('commit', '-q', '-m', 'init'); - - client = new TestProtocolClient(server.port); - await client.connect(); - }); - - teardown(function () { - client.close(); - if (tmpRoot) { - rmSync(tmpRoot, { recursive: true, force: true }); - } - }); - - test('terminal-driven file edit (no ToolResultFileEditContent) is reported via summary.diffs', async function () { - this.timeout(15_000); - - // Create a session whose working directory is the tmp git repo. - await client.call('initialize', { protocolVersion: PROTOCOL_VERSION, clientId: 'test-git-diffs' }); - - const workingDirectory = URI.file(tmpRoot).toString(); - await client.call('createSession', { session: nextSessionUri(), provider: 'mock', workingDirectory }); - - const addedNotif = await client.waitForNotification(n => - n.method === 'notification' && (n.params as INotificationBroadcastParams).notification.type === 'notify/sessionAdded' - ); - const sessionUri = ((addedNotif.params as INotificationBroadcastParams).notification as SessionAddedNotification).summary.resource; - - await client.call('subscribe', { resource: sessionUri }); - client.clearReceived(); - - // Fire a turn that runs the `terminal-edit:` mock prompt. The mock - // agent writes the file via fs.writeFile (no ToolResultFileEditContent), - // so the diff must come from the git-driven path. - const editedFile = join(tmpRoot, 'from-terminal.txt'); - dispatchTurnStarted(client, sessionUri, 'turn-1', `terminal-edit:${editedFile}`, 1); - - // Wait for the diff broadcast that comes after the idle event. - const diffNotif = await client.waitForNotification(n => isActionNotification(n, 'session/diffsChanged'), 10_000); - const action = getActionEnvelope(diffNotif).action as SessionDiffsChangedAction; - - // On macOS, git's `--show-toplevel` resolves symlinks (/var → /private/var) - // so the diff URI may differ in prefix; match by basename instead. - const matching = action.diffs.find(d => { - const u = d.after?.uri ?? d.before?.uri; - return typeof u === 'string' && u.endsWith('/from-terminal.txt'); - }); - assert.ok(matching, `expected diff for from-terminal.txt; got ${JSON.stringify(action.diffs.map(d => d.after?.uri ?? d.before?.uri))}`); - assert.ok(matching!.after, 'expected after-side for newly added file'); - assert.ok(!matching!.before, 'newly added file should have no before-side'); - }); -}); diff --git a/src/vs/platform/agentHost/test/node/protocol/sessionDiffsRealSdk.integrationTest.ts b/src/vs/platform/agentHost/test/node/protocol/sessionDiffsRealSdk.integrationTest.ts deleted file mode 100644 index 0916117d..00000000 --- a/src/vs/platform/agentHost/test/node/protocol/sessionDiffsRealSdk.integrationTest.ts +++ /dev/null @@ -1,188 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -/** - * Real-SDK integration test for the git-driven session diff path. - * - * Disabled by default. Run with: - * - * AGENT_HOST_REAL_SDK=1 ./scripts/test-integration.sh \ - * --run src/vs/platform/agentHost/test/node/protocol/sessionDiffsRealSdk.integrationTest.ts - * - * Authentication: token from `gh auth token` (or `GITHUB_TOKEN`). - * - * SAFETY: Working directory is always a freshly-`git init`-ed temp folder - * scoped to a single test, removed in teardown. - */ - -import assert from 'assert'; -import * as cp from 'child_process'; -import { execSync } from 'child_process'; -import { mkdtempSync, readdirSync, rmSync, writeFileSync } from 'fs'; -import { tmpdir } from 'os'; -import { join } from '../../../../../base/common/path.js'; -import { URI } from '../../../../../base/common/uri.js'; -import { SubscribeResult } from '../../../common/state/protocol/commands.js'; -import { PROTOCOL_VERSION } from '../../../common/state/sessionCapabilities.js'; -import type { INotificationBroadcastParams } from '../../../common/state/sessionProtocol.js'; -import type { SessionState } from '../../../common/state/sessionState.js'; -import type { SessionAddedNotification, SessionDiffsChangedAction, SessionToolCallReadyAction } from '../../../common/state/sessionActions.js'; -import { - getActionEnvelope, - IServerHandle, - isActionNotification, - startRealServer, - TestProtocolClient, -} from './testHelpers.js'; - -const REAL_SDK_ENABLED = process.env['AGENT_HOST_REAL_SDK'] === '1'; - -const hasGit = (() => { - try { cp.execFileSync('git', ['--version'], { stdio: 'ignore' }); return true; } catch { return false; } -})(); - -function resolveGitHubToken(): string { - const envToken = process.env['GITHUB_TOKEN']; - if (envToken) { - return envToken; - } - return execSync('gh auth token', { encoding: 'utf-8' }).trim(); -} - -(REAL_SDK_ENABLED && hasGit ? suite : suite.skip)('Protocol WebSocket — Real Copilot SDK git-driven diffs', function () { - - let server: IServerHandle; - let client: TestProtocolClient; - const createdSessions: string[] = []; - const tempDirs: string[] = []; - - suiteSetup(async function () { - this.timeout(60_000); - server = await startRealServer(); - }); - - suiteTeardown(function () { - server?.process.kill(); - }); - - setup(async function () { - this.timeout(30_000); - client = new TestProtocolClient(server.port); - await client.connect(); - }); - - teardown(async function () { - for (const session of createdSessions) { - try { await client.call('disposeSession', { session }, 5000); } catch { /* best-effort */ } - } - createdSessions.length = 0; - client.close(); - for (const dir of tempDirs) { - rmSync(dir, { recursive: true, force: true }); - } - tempDirs.length = 0; - }); - - test('terminal-driven file edit shows up in summary.diffs (no ToolResultFileEditContent emitted)', async function () { - this.timeout(180_000); - - // Initialize a tmp git repo as the working directory. - const tempDir = mkdtempSync(`${tmpdir()}/ahp-real-diff-`); - tempDirs.push(tempDir); - const env = { ...process.env, GIT_AUTHOR_NAME: 't', GIT_AUTHOR_EMAIL: 't@t', GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@t' }; - const runGit = (...args: string[]) => execSync(`git ${args.join(' ')}`, { cwd: tempDir, env, stdio: 'pipe' }); - runGit('init', '-q', '-b', 'main'); - writeFileSync(join(tempDir, 'seed.txt'), 'seed\n'); - runGit('add', '.'); - runGit('commit', '-q', '-m', 'init'); - - const workingDirUri = URI.file(tempDir).toString(); - - await client.call('initialize', { protocolVersion: PROTOCOL_VERSION, clientId: 'real-sdk-git-diffs' }, 30_000); - await client.call('authenticate', { resource: 'https://api.github.com', token: resolveGitHubToken() }, 30_000); - - const sessionUri = URI.from({ scheme: 'copilotcli', path: `/real-diff-${Date.now()}` }).toString(); - await client.call('createSession', { session: sessionUri, provider: 'copilotcli', workingDirectory: workingDirUri }, 30_000); - - const addedNotif = await client.waitForNotification(n => - n.method === 'notification' && (n.params as INotificationBroadcastParams).notification.type === 'notify/sessionAdded', - 15_000, - ); - const realSessionUri = ((addedNotif.params as INotificationBroadcastParams).notification as SessionAddedNotification).summary.resource; - createdSessions.push(realSessionUri); - - await client.call('subscribe', { resource: realSessionUri }); - client.clearReceived(); - - // Approve any tool call the agent issues. Restricted to `bash`-style - // shell tools so the model can't trick the test into running arbitrary - // other tools. - let approvalSeq = 1; - const approve = (action: SessionToolCallReadyAction & { session: string; turnId: string }) => { - client.notify('dispatchAction', { - clientSeq: ++approvalSeq, - action: { - type: 'session/toolCallConfirmed', - session: action.session, - turnId: action.turnId, - toolCallId: action.toolCallId, - approved: true, - }, - }); - }; - const seenSeqs = new Set(); - let approverActive = true; - const approverLoop = (async () => { - while (approverActive) { - try { - const ready = await client.waitForNotification(n => isActionNotification(n, 'session/toolCallReady') && !seenSeqs.has(getActionEnvelope(n).serverSeq), 2_000); - const env = getActionEnvelope(ready); - seenSeqs.add(env.serverSeq); - approve(env.action as SessionToolCallReadyAction & { session: string; turnId: string }); - } catch { /* timeout — keep polling */ } - } - })(); - - // Ask the agent to use bash to write a specific file. The exact filename - // is fixed so we can assert on it. The model is instructed to use bash - // (not a write_file tool) so the edit isn't reported via the SDK's - // file-edit content events — the diff has to come from git. - const targetFile = join(tempDir, 'from-bash.txt'); - // Quote/escape targetFile for the shell so paths containing spaces or - // shell metacharacters don't break the test. - const shellQuotedTargetFile = `'${targetFile.replace(/'/g, `'\\''`)}'`; - const prompt = `Use the bash shell tool to run exactly: echo hello > ${shellQuotedTargetFile}\nDo not use any file-write tool. Use only bash.`; - client.notify('dispatchAction', { - clientSeq: 1, - action: { type: 'session/turnStarted', session: realSessionUri, turnId: 'turn-diff', userMessage: { text: prompt } }, - }); - - await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete'), 150_000); - approverActive = false; - await approverLoop; - - // Sanity: file was actually written by the agent. - const files = readdirSync(tempDir); - assert.ok(files.includes('from-bash.txt'), `agent did not write the requested file. dir contents: ${files.join(', ')}`); - - // The diff broadcast may have already arrived during the turn — accept - // any matching one received during the run, or look at the final state. - const targetUri = URI.file(targetFile).toString(); - const diffNotifs = client.receivedNotifications(n => isActionNotification(n, 'session/diffsChanged')); - const sawInLive = diffNotifs.some(n => { - const a = getActionEnvelope(n).action as SessionDiffsChangedAction; - return a.diffs.some(d => d.after?.uri === targetUri || d.before?.uri === targetUri); - }); - - if (!sawInLive) { - // Fall back to the final snapshot. - const result = await client.call('subscribe', { resource: realSessionUri }); - const state = result.snapshot.state as SessionState; - const diffs = state.summary.diffs ?? []; - const matching = diffs.find(d => d.after?.uri === targetUri || d.before?.uri === targetUri); - assert.ok(matching, `expected git-driven diff for ${targetUri}; live notifications=${diffNotifs.length}; snapshot diffs=${JSON.stringify(diffs.map(d => d.after?.uri ?? d.before?.uri))}`); - } - }); -}); diff --git a/src/vs/platform/agentHost/test/node/protocol/sessionFeatures.integrationTest.ts b/src/vs/platform/agentHost/test/node/protocol/sessionFeatures.integrationTest.ts deleted file mode 100644 index 5f0cbbd3..00000000 --- a/src/vs/platform/agentHost/test/node/protocol/sessionFeatures.integrationTest.ts +++ /dev/null @@ -1,529 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import assert from 'assert'; -import { timeout } from '../../../../../base/common/async.js'; -import { SubscribeResult } from '../../../common/state/protocol/commands.js'; -import type { IModelChangedAction, IResponsePartAction, SessionAddedNotification, ITitleChangedAction } from '../../../common/state/sessionActions.js'; -import { PROTOCOL_VERSION } from '../../../common/state/sessionCapabilities.js'; -import type { ListSessionsResult, INotificationBroadcastParams } from '../../../common/state/sessionProtocol.js'; -import { PendingMessageKind, ResponsePartKind, type SessionState } from '../../../common/state/sessionState.js'; -import { MOCK_AUTO_TITLE } from '../mockAgent.js'; -import { - createAndSubscribeSession, - dispatchTurnStarted, - getActionEnvelope, - isActionNotification, - IServerHandle, - nextSessionUri, - startServer, - TestProtocolClient, -} from './testHelpers.js'; - -suite('Protocol WebSocket — Session Features', function () { - - let server: IServerHandle; - let client: TestProtocolClient; - - suiteSetup(async function () { - this.timeout(15_000); - server = await startServer(); - }); - - suiteTeardown(function () { - server.process.kill(); - }); - - setup(async function () { - this.timeout(10_000); - client = new TestProtocolClient(server.port); - await client.connect(); - }); - - teardown(function () { - client.close(); - }); - - // ---- Session rename / title ------------------------------------------------ - - test('client titleChanged updates session state snapshot', async function () { - this.timeout(10_000); - - const sessionUri = await createAndSubscribeSession(client, 'test-titleChanged'); - - client.notify('dispatchAction', { - clientSeq: 1, - action: { - type: 'session/titleChanged', - session: sessionUri, - title: 'My Custom Title', - }, - }); - - const titleNotif = await client.waitForNotification(n => isActionNotification(n, 'session/titleChanged')); - const titleAction = getActionEnvelope(titleNotif).action as ITitleChangedAction; - assert.strictEqual(titleAction.title, 'My Custom Title'); - - const snapshot = await client.call('subscribe', { resource: sessionUri }); - const state = snapshot.snapshot.state as SessionState; - assert.strictEqual(state.summary.title, 'My Custom Title'); - }); - - test('agent-generated titleChanged is broadcast', async function () { - this.timeout(10_000); - - const sessionUri = await createAndSubscribeSession(client, 'test-agent-title'); - dispatchTurnStarted(client, sessionUri, 'turn-title', 'with-title', 1); - - // The first titleChanged is the immediate fallback (user message text). - // Wait for the agent-generated title which arrives second. - const titleNotif = await client.waitForNotification(n => { - if (!isActionNotification(n, 'session/titleChanged')) { - return false; - } - const action = getActionEnvelope(n).action as ITitleChangedAction; - return action.title === MOCK_AUTO_TITLE; - }); - const titleAction = getActionEnvelope(titleNotif).action as ITitleChangedAction; - assert.strictEqual(titleAction.title, MOCK_AUTO_TITLE); - - await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); - - const snapshot = await client.call('subscribe', { resource: sessionUri }); - const state = snapshot.snapshot.state as SessionState; - assert.strictEqual(state.summary.title, MOCK_AUTO_TITLE); - }); - - test('first turn immediately sets title to user message', async function () { - this.timeout(10_000); - - const sessionUri = await createAndSubscribeSession(client, 'test-immediate-title'); - - // Verify the session starts with the default placeholder title - const before = await client.call('subscribe', { resource: sessionUri }); - assert.strictEqual((before.snapshot.state as SessionState).summary.title, ''); - - // Send first turn — side effects should dispatch an immediate titleChanged - // with the user's message text before the agent produces its own title. - dispatchTurnStarted(client, sessionUri, 'turn-immediate', 'Fix the login bug', 1); - - // The first titleChanged should carry the user message text - const titleNotif = await client.waitForNotification(n => isActionNotification(n, 'session/titleChanged')); - const titleAction = getActionEnvelope(titleNotif).action as ITitleChangedAction; - assert.strictEqual(titleAction.title, 'Fix the login bug'); - - // listSessions should also reflect the updated title - const result = await client.call('listSessions'); - const session = result.items.find(s => s.resource === sessionUri); - assert.ok(session, 'session should appear in listSessions'); - assert.strictEqual(session.title, 'Fix the login bug'); - }); - - test('renamed session title persists across listSessions', async function () { - this.timeout(10_000); - - const sessionUri = await createAndSubscribeSession(client, 'test-title-list'); - - client.notify('dispatchAction', { - clientSeq: 1, - action: { - type: 'session/titleChanged', - session: sessionUri, - title: 'Persisted Title', - }, - }); - - await client.waitForNotification(n => isActionNotification(n, 'session/titleChanged')); - - // Poll listSessions until the persisted title appears (async DB write) - let session: { title: string } | undefined; - for (let i = 0; i < 20; i++) { - const result = await client.call('listSessions'); - session = result.items.find(s => s.resource === sessionUri); - if (session?.title === 'Persisted Title') { - break; - } - await timeout(100); - } - assert.ok(session, 'session should appear in listSessions'); - assert.strictEqual(session.title, 'Persisted Title'); - }); - - // ---- Session model -------------------------------------------------------- - - test('session model flows through create, subscribe, listSessions, and modelChanged', async function () { - this.timeout(10_000); - - await client.call('initialize', { protocolVersion: PROTOCOL_VERSION, clientId: 'test-model-summary' }); - - const sessionUri = nextSessionUri(); - await client.call('createSession', { session: sessionUri, provider: 'mock', model: { id: 'mock-model' } }); - - const addedNotif = await client.waitForNotification(n => - n.method === 'notification' && (n.params as INotificationBroadcastParams).notification.type === 'notify/sessionAdded' - ); - const addedSession = (addedNotif.params as INotificationBroadcastParams).notification as SessionAddedNotification; - assert.deepStrictEqual(addedSession.summary.model, { id: 'mock-model' }); - const createdSessionUri = addedSession.summary.resource; - - const initialSnapshot = await client.call('subscribe', { resource: createdSessionUri }); - const initialState = initialSnapshot.snapshot.state as SessionState; - assert.deepStrictEqual(initialState.summary.model, { id: 'mock-model' }); - - const initialList = await client.call('listSessions'); - assert.deepStrictEqual(initialList.items.find(s => s.resource === createdSessionUri)?.model, { id: 'mock-model' }); - - client.notify('dispatchAction', { - clientSeq: 1, - action: { - type: 'session/modelChanged', - session: createdSessionUri, - model: { id: 'mock-model-2' }, - }, - }); - - const modelNotif = await client.waitForNotification(n => isActionNotification(n, 'session/modelChanged')); - const modelAction = getActionEnvelope(modelNotif).action as IModelChangedAction; - assert.deepStrictEqual(modelAction.model, { id: 'mock-model-2' }); - - const updatedSnapshot = await client.call('subscribe', { resource: createdSessionUri }); - const updatedState = updatedSnapshot.snapshot.state as SessionState; - assert.deepStrictEqual(updatedState.summary.model, { id: 'mock-model-2' }); - - const updatedList = await client.call('listSessions'); - assert.deepStrictEqual(updatedList.items.find(s => s.resource === createdSessionUri)?.model, { id: 'mock-model-2' }); - }); - - // ---- Reasoning events ------------------------------------------------------ - - test('reasoning events produce reasoning response parts and append actions', async function () { - this.timeout(10_000); - - const sessionUri = await createAndSubscribeSession(client, 'test-reasoning'); - dispatchTurnStarted(client, sessionUri, 'turn-reasoning', 'with-reasoning', 1); - - // The first reasoning event produces a responsePart with kind Reasoning - const reasoningPart = await client.waitForNotification(n => { - if (!isActionNotification(n, 'session/responsePart')) { - return false; - } - const action = getActionEnvelope(n).action as IResponsePartAction; - return action.part.kind === ResponsePartKind.Reasoning; - }); - const reasoningAction = getActionEnvelope(reasoningPart).action as IResponsePartAction; - assert.strictEqual(reasoningAction.part.kind, ResponsePartKind.Reasoning); - - // The second reasoning chunk produces a session/reasoning append action - const appendNotif = await client.waitForNotification(n => isActionNotification(n, 'session/reasoning')); - const appendAction = getActionEnvelope(appendNotif).action; - assert.strictEqual(appendAction.type, 'session/reasoning'); - if (appendAction.type === 'session/reasoning') { - assert.strictEqual(appendAction.content, ' about this...'); - } - - // Then the markdown response part - const mdPart = await client.waitForNotification(n => { - if (!isActionNotification(n, 'session/responsePart')) { - return false; - } - const action = getActionEnvelope(n).action as IResponsePartAction; - return action.part.kind === ResponsePartKind.Markdown; - }); - assert.ok(mdPart); - - await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); - }); - - // ---- Queued messages ------------------------------------------------------- - - test('queued message is auto-consumed when session is idle', async function () { - this.timeout(10_000); - - const sessionUri = await createAndSubscribeSession(client, 'test-queue-idle'); - client.clearReceived(); - - // Queue a message when the session is idle — server should immediately consume it - client.notify('dispatchAction', { - clientSeq: 1, - action: { - type: 'session/pendingMessageSet', - session: sessionUri, - kind: PendingMessageKind.Queued, - id: 'q-1', - userMessage: { text: 'hello' }, - }, - }); - - // The server should auto-consume the queued message and start a turn - await client.waitForNotification(n => isActionNotification(n, 'session/turnStarted')); - await client.waitForNotification(n => isActionNotification(n, 'session/responsePart')); - await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); - - // Verify the turn was created from the queued message - const snapshot = await client.call('subscribe', { resource: sessionUri }); - const state = snapshot.snapshot.state as SessionState; - assert.ok(state.turns.length >= 1); - assert.strictEqual(state.turns[state.turns.length - 1].userMessage.text, 'hello'); - // Queue should be empty after consumption - assert.ok(!state.queuedMessages?.length, 'queued messages should be empty after consumption'); - }); - - test('queued message waits for in-progress turn to complete', async function () { - this.timeout(15_000); - - const sessionUri = await createAndSubscribeSession(client, 'test-queue-wait'); - - // Start a turn first - dispatchTurnStarted(client, sessionUri, 'turn-first', 'hello', 1); - - // Wait for the first turn's response to confirm it is in progress - await client.waitForNotification(n => isActionNotification(n, 'session/responsePart')); - - // Queue a message while the turn is in progress - client.notify('dispatchAction', { - clientSeq: 2, - action: { - type: 'session/pendingMessageSet', - session: sessionUri, - kind: PendingMessageKind.Queued, - id: 'q-wait-1', - userMessage: { text: 'hello' }, - }, - }); - - // First turn should complete - const firstComplete = await client.waitForNotification(n => { - if (!isActionNotification(n, 'session/turnComplete')) { - return false; - } - return (getActionEnvelope(n).action as { turnId: string }).turnId === 'turn-first'; - }); - const firstSeq = getActionEnvelope(firstComplete).serverSeq; - - // The queued message's turn should complete AFTER the first turn - const secondComplete = await client.waitForNotification(n => { - if (!isActionNotification(n, 'session/turnComplete')) { - return false; - } - const envelope = getActionEnvelope(n); - return (envelope.action as { turnId: string }).turnId !== 'turn-first' - && envelope.serverSeq > firstSeq; - }); - assert.ok(secondComplete, 'should receive a second turnComplete from the queued message'); - - const snapshot = await client.call('subscribe', { resource: sessionUri }); - const state = snapshot.snapshot.state as SessionState; - assert.ok(state.turns.length >= 2, `expected >= 2 turns but got ${state.turns.length}`); - }); - - // ---- Steering messages ---------------------------------------------------- - - test('steering message is set and consumed by agent', async function () { - this.timeout(10_000); - - const sessionUri = await createAndSubscribeSession(client, 'test-steering'); - - // Start a turn first - dispatchTurnStarted(client, sessionUri, 'turn-steer', 'hello', 1); - - // Set a steering message while the turn is in progress - client.notify('dispatchAction', { - clientSeq: 2, - action: { - type: 'session/pendingMessageSet', - session: sessionUri, - kind: PendingMessageKind.Steering, - id: 'steer-1', - userMessage: { text: 'Please be concise' }, - }, - }); - - // The steering message should be set in state initially - const setNotif = await client.waitForNotification(n => isActionNotification(n, 'session/pendingMessageSet')); - assert.ok(setNotif, 'should see pendingMessageSet action'); - - // The mock agent consumes steering and fires steering_consumed, - // which causes the server to dispatch pendingMessageRemoved - const removedNotif = await client.waitForNotification(n => isActionNotification(n, 'session/pendingMessageRemoved')); - assert.ok(removedNotif, 'should see pendingMessageRemoved after agent consumes steering'); - - await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); - - // Steering should be cleared from state - const snapshot = await client.call('subscribe', { resource: sessionUri }); - const state = snapshot.snapshot.state as SessionState; - assert.ok(!state.steeringMessage, 'steering message should be cleared after consumption'); - }); - - // ---- Truncation ----------------------------------------------------------- - - test('truncate session removes turns after specified turn', async function () { - this.timeout(15_000); - - const sessionUri = await createAndSubscribeSession(client, 'test-truncate'); - - // Create two turns - dispatchTurnStarted(client, sessionUri, 'turn-t1', 'hello', 1); - await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete') && (getActionEnvelope(n).action as { turnId: string }).turnId === 'turn-t1'); - - client.clearReceived(); - dispatchTurnStarted(client, sessionUri, 'turn-t2', 'hello', 2); - await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete') && (getActionEnvelope(n).action as { turnId: string }).turnId === 'turn-t2'); - - // Verify 2 turns exist - let snapshot = await client.call('subscribe', { resource: sessionUri }); - let state = snapshot.snapshot.state as SessionState; - assert.strictEqual(state.turns.length, 2); - - client.clearReceived(); - - // Truncate: keep only turn-t1 - client.notify('dispatchAction', { - clientSeq: 3, - action: { type: 'session/truncated', session: sessionUri, turnId: 'turn-t1' }, - }); - - await client.waitForNotification(n => isActionNotification(n, 'session/truncated')); - - snapshot = await client.call('subscribe', { resource: sessionUri }); - state = snapshot.snapshot.state as SessionState; - assert.strictEqual(state.turns.length, 1); - assert.strictEqual(state.turns[0].id, 'turn-t1'); - }); - - test('truncate all turns clears session history', async function () { - this.timeout(15_000); - - const sessionUri = await createAndSubscribeSession(client, 'test-truncate-all'); - - dispatchTurnStarted(client, sessionUri, 'turn-ta1', 'hello', 1); - await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); - - client.clearReceived(); - - // Truncate all (no turnId) - client.notify('dispatchAction', { - clientSeq: 2, - action: { type: 'session/truncated', session: sessionUri }, - }); - - await client.waitForNotification(n => isActionNotification(n, 'session/truncated')); - - const snapshot = await client.call('subscribe', { resource: sessionUri }); - const state = snapshot.snapshot.state as SessionState; - assert.strictEqual(state.turns.length, 0); - }); - - test('new turn after truncation works correctly', async function () { - this.timeout(15_000); - - const sessionUri = await createAndSubscribeSession(client, 'test-truncate-resume'); - - dispatchTurnStarted(client, sessionUri, 'turn-tr1', 'hello', 1); - await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete') && (getActionEnvelope(n).action as { turnId: string }).turnId === 'turn-tr1'); - - client.clearReceived(); - dispatchTurnStarted(client, sessionUri, 'turn-tr2', 'hello', 2); - await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete') && (getActionEnvelope(n).action as { turnId: string }).turnId === 'turn-tr2'); - - client.clearReceived(); - - // Truncate to turn-tr1 - client.notify('dispatchAction', { - clientSeq: 3, - action: { type: 'session/truncated', session: sessionUri, turnId: 'turn-tr1' }, - }); - - await client.waitForNotification(n => isActionNotification(n, 'session/truncated')); - - // Send a new turn after truncation - dispatchTurnStarted(client, sessionUri, 'turn-tr3', 'hello', 4); - await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); - - const snapshot = await client.call('subscribe', { resource: sessionUri }); - const state = snapshot.snapshot.state as SessionState; - assert.strictEqual(state.turns.length, 2); - assert.strictEqual(state.turns[0].id, 'turn-tr1'); - assert.strictEqual(state.turns[1].id, 'turn-tr3'); - }); - - // ---- Fork ----------------------------------------------------------------- - - test('fork creates a new session with source history', async function () { - this.timeout(15_000); - - const sessionUri = await createAndSubscribeSession(client, 'test-fork'); - - // Create two turns - dispatchTurnStarted(client, sessionUri, 'turn-f1', 'hello', 1); - await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete') && (getActionEnvelope(n).action as { turnId: string }).turnId === 'turn-f1'); - - client.clearReceived(); - dispatchTurnStarted(client, sessionUri, 'turn-f2', 'hello', 2); - await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete') && (getActionEnvelope(n).action as { turnId: string }).turnId === 'turn-f2'); - - client.clearReceived(); - - // Fork at turn-f1 (keep turns up to and including turn-f1) - const forkedSessionUri = nextSessionUri(); - await client.call('createSession', { - session: forkedSessionUri, - provider: 'mock', - fork: { session: sessionUri, turnId: 'turn-f1' }, - }); - - const addedNotif = await client.waitForNotification(n => - n.method === 'notification' && (n.params as INotificationBroadcastParams).notification.type === 'notify/sessionAdded' - ); - const addedSession = (addedNotif.params as INotificationBroadcastParams).notification as SessionAddedNotification; - - // Subscribe — forked session should have 1 turn - const snapshot = await client.call('subscribe', { resource: addedSession.summary.resource }); - const state = snapshot.snapshot.state as SessionState; - assert.strictEqual(state.lifecycle, 'ready'); - assert.strictEqual(state.turns.length, 1, 'forked session should have 1 turn'); - - // Source session should be unaffected - const sourceSnapshot = await client.call('subscribe', { resource: sessionUri }); - const sourceState = sourceSnapshot.snapshot.state as SessionState; - assert.strictEqual(sourceState.turns.length, 2); - }); - - test('fork with invalid turn ID returns error', async function () { - this.timeout(10_000); - - const sessionUri = await createAndSubscribeSession(client, 'test-fork-invalid'); - - let gotError = false; - try { - await client.call('createSession', { - session: nextSessionUri(), - provider: 'mock', - fork: { session: sessionUri, turnId: 'nonexistent-turn' }, - }); - } catch { - gotError = true; - } - assert.ok(gotError, 'should get error for invalid fork turn ID'); - }); - - test('fork with invalid source session returns error', async function () { - this.timeout(10_000); - - await client.call('initialize', { protocolVersion: PROTOCOL_VERSION, clientId: 'test-fork-no-source' }); - - let gotError = false; - try { - await client.call('createSession', { - session: nextSessionUri(), - provider: 'mock', - fork: { session: 'mock://nonexistent-session', turnId: 'turn-1' }, - }); - } catch { - gotError = true; - } - assert.ok(gotError, 'should get error for invalid fork source session'); - }); -}); diff --git a/src/vs/platform/agentHost/test/node/protocol/sessionLifecycle.integrationTest.ts b/src/vs/platform/agentHost/test/node/protocol/sessionLifecycle.integrationTest.ts deleted file mode 100644 index eeea8984..00000000 --- a/src/vs/platform/agentHost/test/node/protocol/sessionLifecycle.integrationTest.ts +++ /dev/null @@ -1,228 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import assert from 'assert'; -import { timeout } from '../../../../../base/common/async.js'; -import { URI } from '../../../../../base/common/uri.js'; -import { SubscribeResult } from '../../../common/state/protocol/commands.js'; -import type { SessionAddedNotification, SessionRemovedNotification } from '../../../common/state/sessionActions.js'; -import { PROTOCOL_VERSION } from '../../../common/state/sessionCapabilities.js'; -import type { ListSessionsResult, INotificationBroadcastParams } from '../../../common/state/sessionProtocol.js'; -import { ResponsePartKind, SessionStatus, type MarkdownResponsePart, type SessionState, type ToolCallResponsePart } from '../../../common/state/sessionState.js'; -import { PRE_EXISTING_SESSION_URI } from '../mockAgent.js'; -import { - createAndSubscribeSession, - isActionNotification, - IServerHandle, - nextSessionUri, - startServer, - TestProtocolClient -} from './testHelpers.js'; - -suite('Protocol WebSocket — Session Lifecycle', function () { - - let server: IServerHandle; - let client: TestProtocolClient; - - suiteSetup(async function () { - this.timeout(15_000); - server = await startServer(); - }); - - suiteTeardown(function () { - server.process.kill(); - }); - - setup(async function () { - this.timeout(10_000); - client = new TestProtocolClient(server.port); - await client.connect(); - }); - - teardown(function () { - client.close(); - }); - - test('create session triggers sessionAdded notification', async function () { - this.timeout(10_000); - - await client.call('initialize', { protocolVersion: PROTOCOL_VERSION, clientId: 'test-create-session' }); - - await client.call('createSession', { session: nextSessionUri(), provider: 'mock' }); - - const notif = await client.waitForNotification(n => - n.method === 'notification' && (n.params as INotificationBroadcastParams).notification.type === 'notify/sessionAdded' - ); - const notification = (notif.params as INotificationBroadcastParams).notification as SessionAddedNotification; - assert.strictEqual(URI.parse(notification.summary.resource).scheme, 'mock'); - assert.strictEqual(notification.summary.provider, 'mock'); - }); - - test('listSessions returns sessions', async function () { - this.timeout(10_000); - - await client.call('initialize', { protocolVersion: PROTOCOL_VERSION, clientId: 'test-list-sessions' }); - - await client.call('createSession', { session: nextSessionUri(), provider: 'mock' }); - await client.waitForNotification(n => - n.method === 'notification' && (n.params as INotificationBroadcastParams).notification.type === 'notify/sessionAdded' - ); - - const result = await client.call('listSessions'); - assert.ok(Array.isArray(result.items)); - assert.ok(result.items.length >= 1, 'should have at least one session'); - }); - - test('dispose session sends sessionRemoved notification', async function () { - this.timeout(10_000); - - const sessionUri = await createAndSubscribeSession(client, 'test-dispose'); - await client.call('disposeSession', { session: sessionUri }); - - const notif = await client.waitForNotification(n => - n.method === 'notification' && (n.params as INotificationBroadcastParams).notification.type === 'notify/sessionRemoved' - ); - const removed = (notif.params as INotificationBroadcastParams).notification as SessionRemovedNotification; - assert.strictEqual(removed.session.toString(), sessionUri.toString()); - }); - - test('subscribe to a pre-existing session restores turns from agent history', async function () { - this.timeout(10_000); - - await client.call('initialize', { protocolVersion: PROTOCOL_VERSION, clientId: 'test-restore' }); - - // The mock agent seeds a pre-existing session that was never created - // through the server's handleCreateSession -- simulating a session - // from a previous server lifetime. - const preExistingUri = PRE_EXISTING_SESSION_URI.toString(); - const list = await client.call('listSessions'); - const preExisting = list.items.find(s => s.resource === preExistingUri); - assert.ok(preExisting, 'listSessions should include the pre-existing session'); - - // Clear notifications so we can verify no duplicate sessionAdded fires. - client.clearReceived(); - - // Subscribing to this session should trigger the restore path: the - // server fetches message history from the agent and reconstructs turns. - const result = await client.call('subscribe', { resource: preExistingUri }); - const state = result.snapshot.state as SessionState; - - assert.strictEqual(state.lifecycle, 'ready', 'restored session should be in ready state'); - assert.ok(state.turns.length >= 1, `expected at least 1 restored turn but got ${state.turns.length}`); - - const turn = state.turns[0]; - assert.strictEqual(turn.userMessage.text, 'What files are here?'); - assert.strictEqual(turn.state, 'complete'); - const toolCallParts = turn.responseParts.filter((p): p is ToolCallResponsePart => p.kind === ResponsePartKind.ToolCall); - assert.ok(toolCallParts.length >= 1, 'turn should have tool call response parts'); - assert.strictEqual(toolCallParts[0].toolCall.toolName, 'list_files'); - const mdParts = turn.responseParts.filter((p): p is MarkdownResponsePart => p.kind === ResponsePartKind.Markdown); - assert.ok(mdParts.some(p => p.content.includes('file1.ts')), 'turn should have markdown part mentioning file1.ts'); - - // Restoring should NOT emit a duplicate sessionAdded notification - // (the session is already known to clients via listSessions). - await new Promise(resolve => setTimeout(resolve, 200)); - const sessionAddedNotifs = client.receivedNotifications(n => - n.method === 'notification' && (n.params as INotificationBroadcastParams).notification.type === 'notify/sessionAdded' - ); - assert.strictEqual(sessionAddedNotifs.length, 0, 'restore should not emit sessionAdded'); - }); - - test('isRead and isArchived flags survive in listSessions after dispatch', async function () { - this.timeout(15_000); - - const sessionUri = await createAndSubscribeSession(client, 'test-read-archived-flags'); - - // Dispatch isArchived=true - client.notify('dispatchAction', { - clientSeq: 1, - action: { - type: 'session/isArchivedChanged', - session: sessionUri, - isArchived: true, - }, - }); - - await client.waitForNotification(n => isActionNotification(n, 'session/isArchivedChanged')); - - // Dispatch isRead=true - client.notify('dispatchAction', { - clientSeq: 2, - action: { - type: 'session/isReadChanged', - session: sessionUri, - isRead: true, - }, - }); - - await client.waitForNotification(n => isActionNotification(n, 'session/isReadChanged')); - - // Verify the flags are reflected in the subscribed session state - const snapshot = await client.call('subscribe', { resource: sessionUri }); - const state = snapshot.snapshot.state as SessionState; - assert.ok(state.summary.status & SessionStatus.IsArchived, 'IsArchived flag should be set in snapshot'); - assert.ok(state.summary.status & SessionStatus.IsRead, 'IsRead flag should be set in snapshot'); - - // Poll listSessions until the persisted flags appear (async DB write) - client.close(); - const client2 = new TestProtocolClient(server.port); - await client2.connect(); - await client2.call('initialize', { protocolVersion: PROTOCOL_VERSION, clientId: 'test-read-archived-flags-2' }); - - let session: ListSessionsResult['items'][0] | undefined; - for (let i = 0; i < 20; i++) { - const result = await client2.call('listSessions'); - session = result.items.find(s => s.resource === sessionUri); - if (session && (session.status & SessionStatus.IsArchived) && (session.status & SessionStatus.IsRead)) { - break; - } - await timeout(100); - } - assert.ok(session, 'session should appear in listSessions'); - assert.ok(session.status & SessionStatus.IsArchived, 'IsArchived should be persisted in listSessions'); - assert.ok(session.status & SessionStatus.IsRead, 'IsRead should be persisted in listSessions'); - - client2.close(); - }); - - test('dispatching isRead=false explicitly persists as false', async function () { - this.timeout(15_000); - - const sessionUri = await createAndSubscribeSession(client, 'test-isread-false'); - - // On a fresh session, isRead is undefined in the DB. Dispatching - // isRead=false should persist the value so that listSessions - // returns an explicit `false` rather than omitting the field. - client.notify('dispatchAction', { - clientSeq: 1, - action: { - type: 'session/isReadChanged', - session: sessionUri, - isRead: false, - }, - }); - - await client.waitForNotification(n => isActionNotification(n, 'session/isReadChanged')); - - client.close(); - const client2 = new TestProtocolClient(server.port); - await client2.connect(); - await client2.call('initialize', { protocolVersion: PROTOCOL_VERSION, clientId: 'test-isread-false-2' }); - - let session: ListSessionsResult['items'][0] | undefined; - for (let i = 0; i < 20; i++) { - const result = await client2.call('listSessions'); - session = result.items.find(s => s.resource === sessionUri); - if (session && !(session.status & SessionStatus.IsRead)) { - break; - } - await timeout(100); - } - assert.ok(session, 'session should appear in listSessions'); - assert.strictEqual(session.status & SessionStatus.IsRead, 0, 'IsRead flag should not be set'); - - client2.close(); - }); -}); diff --git a/src/vs/platform/agentHost/test/node/protocol/testHelpers.ts b/src/vs/platform/agentHost/test/node/protocol/testHelpers.ts deleted file mode 100644 index ffd4b325..00000000 --- a/src/vs/platform/agentHost/test/node/protocol/testHelpers.ts +++ /dev/null @@ -1,313 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { ChildProcess, fork } from 'child_process'; -import { fileURLToPath } from 'url'; -import { WebSocket } from 'ws'; -import { URI } from '../../../../../base/common/uri.js'; -import { SubscribeResult } from '../../../common/state/protocol/commands.js'; -import type { ActionEnvelope, SessionAddedNotification } from '../../../common/state/sessionActions.js'; -import { PROTOCOL_VERSION } from '../../../common/state/sessionCapabilities.js'; -import { - isJsonRpcNotification, - isJsonRpcResponse, - type AhpNotification, - type JsonRpcErrorResponse, - type JsonRpcSuccessResponse, - type INotificationBroadcastParams, - type ProtocolMessage, -} from '../../../common/state/sessionProtocol.js'; - -// ---- JSON-RPC test client --------------------------------------------------- - -interface IPendingCall { - resolve: (result: unknown) => void; - reject: (err: Error) => void; -} - -export class TestProtocolClient { - private readonly _ws: WebSocket; - private _nextId = 1; - private readonly _pendingCalls = new Map(); - private readonly _notifications: AhpNotification[] = []; - private readonly _notifWaiters: { predicate: (n: AhpNotification) => boolean; resolve: (n: AhpNotification) => void; reject: (err: Error) => void }[] = []; - - constructor(port: number) { - this._ws = new WebSocket(`ws://127.0.0.1:${port}`); - } - - async connect(): Promise { - return new Promise((resolve, reject) => { - this._ws.on('open', () => { - this._ws.on('message', (data: Buffer | string) => { - const text = typeof data === 'string' ? data : data.toString('utf-8'); - const msg = JSON.parse(text); - this._handleMessage(msg); - }); - resolve(); - }); - this._ws.on('error', reject); - }); - } - - private _handleMessage(msg: ProtocolMessage): void { - if (isJsonRpcResponse(msg)) { - const pending = this._pendingCalls.get(msg.id); - if (pending) { - this._pendingCalls.delete(msg.id); - const errResp = msg as JsonRpcErrorResponse; - if (errResp.error) { - pending.reject(new Error(errResp.error.message)); - } else { - pending.resolve((msg as JsonRpcSuccessResponse).result); - } - } - } else if (isJsonRpcNotification(msg)) { - const notif = msg; - for (let i = this._notifWaiters.length - 1; i >= 0; i--) { - if (this._notifWaiters[i].predicate(notif)) { - const waiter = this._notifWaiters.splice(i, 1)[0]; - waiter.resolve(notif); - } - } - this._notifications.push(notif); - } - } - - /** Send a JSON-RPC notification (fire-and-forget). */ - notify(method: string, params?: unknown): void { - this._ws.send(JSON.stringify({ jsonrpc: '2.0', method, params })); - } - - /** Send a JSON-RPC request and await the response. */ - call(method: string, params?: unknown, timeoutMs = 5000): Promise { - const id = this._nextId++; - this._ws.send(JSON.stringify({ jsonrpc: '2.0', id, method, params })); - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - this._pendingCalls.delete(id); - reject(new Error(`Timeout waiting for response to ${method} (id=${id}, ${timeoutMs}ms)`)); - }, timeoutMs); - - this._pendingCalls.set(id, { - resolve: result => { clearTimeout(timer); resolve(result as T); }, - reject: err => { clearTimeout(timer); reject(err); }, - }); - }); - } - - /** Wait for a server notification matching a predicate. */ - waitForNotification(predicate: (n: AhpNotification) => boolean, timeoutMs = 5000): Promise { - const existing = this._notifications.find(predicate); - if (existing) { - return Promise.resolve(existing); - } - - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - const idx = this._notifWaiters.findIndex(w => w.resolve === resolve); - if (idx >= 0) { - this._notifWaiters.splice(idx, 1); - } - reject(new Error(`Timeout waiting for notification (${timeoutMs}ms)`)); - }, timeoutMs); - - this._notifWaiters.push({ - predicate, - resolve: n => { clearTimeout(timer); resolve(n); }, - reject, - }); - }); - } - - /** Return all received notifications matching a predicate. */ - receivedNotifications(predicate?: (n: AhpNotification) => boolean): AhpNotification[] { - return predicate ? this._notifications.filter(predicate) : [...this._notifications]; - } - - /** Send a raw string over the WebSocket without JSON serialization. */ - sendRaw(data: string): void { - this._ws.send(data); - } - - /** Wait for the next raw message from the server. */ - waitForRawMessage(timeoutMs = 5000): Promise { - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - cleanup(); - reject(new Error(`Timeout waiting for raw message (${timeoutMs}ms)`)); - }, timeoutMs); - const onMsg = (data: Buffer | string) => { - cleanup(); - const text = typeof data === 'string' ? data : data.toString('utf-8'); - resolve(JSON.parse(text)); - }; - const cleanup = () => { - clearTimeout(timer); - this._ws.removeListener('message', onMsg); - }; - this._ws.on('message', onMsg); - }); - } - - close(): void { - for (const w of this._notifWaiters) { - w.reject(new Error('Client closed')); - } - this._notifWaiters.length = 0; - for (const [, p] of this._pendingCalls) { - p.reject(new Error('Client closed')); - } - this._pendingCalls.clear(); - this._ws.close(); - } - - clearReceived(): void { - this._notifications.length = 0; - } -} - -// ---- Server process lifecycle ----------------------------------------------- - -export interface IServerHandle { - process: ChildProcess; - port: number; -} - -export async function startServer(options?: { readonly quiet?: boolean; readonly userDataDir?: string; readonly env?: NodeJS.ProcessEnv }): Promise { - return new Promise((resolve, reject) => { - const serverPath = fileURLToPath(new URL('../../../node/agentHostServerMain.js', import.meta.url)); - const args = ['--enable-mock-agent', '--port', '0', '--without-connection-token']; - if (options?.quiet ?? true) { - args.push('--quiet'); - } - if (options?.userDataDir) { - args.push('--user-data-dir', options.userDataDir); - } - const child = fork(serverPath, args, { - stdio: ['pipe', 'pipe', 'pipe', 'ipc'], - env: options?.env ? { ...process.env, ...options.env } : process.env, - }); - - const timer = setTimeout(() => { - child.kill(); - reject(new Error('Server startup timed out')); - }, 10_000); - - child.stdout!.on('data', (data: Buffer) => { - const text = data.toString(); - const match = text.match(/READY:(\d+)/); - if (match) { - clearTimeout(timer); - resolve({ process: child, port: parseInt(match[1], 10) }); - } - }); - - child.stderr!.on('data', () => { - // Intentionally swallowed - the test runner fails if console.error is used. - }); - - child.on('error', err => { - clearTimeout(timer); - reject(err); - }); - - child.on('exit', code => { - clearTimeout(timer); - reject(new Error(`Server exited prematurely with code ${code}`)); - }); - }); -} - -/** - * Start the agent host server with the real Copilot SDK agent (no mock agent). - * The server is started with logging enabled so the CopilotAgent is registered. - */ -export async function startRealServer(): Promise { - return new Promise((resolve, reject) => { - const serverPath = fileURLToPath(new URL('../../../node/agentHostServerMain.js', import.meta.url)); - const args = ['--port', '0', '--without-connection-token']; - const child = fork(serverPath, args, { - stdio: ['pipe', 'pipe', 'pipe', 'ipc'], - }); - - const timer = setTimeout(() => { - child.kill(); - reject(new Error('Real server startup timed out')); - }, 30_000); - - child.stdout!.on('data', (data: Buffer) => { - const text = data.toString(); - const match = text.match(/READY:(\d+)/); - if (match) { - clearTimeout(timer); - resolve({ process: child, port: parseInt(match[1], 10) }); - } - }); - - child.stderr!.on('data', () => { - // Intentionally swallowed - the test runner fails if console.error is used. - }); - - child.on('error', err => { - clearTimeout(timer); - reject(err); - }); - - child.on('exit', code => { - clearTimeout(timer); - reject(new Error(`Real server exited prematurely with code ${code}`)); - }); - }); -} - -// ---- Helpers ---------------------------------------------------------------- - -let sessionCounter = 0; - -export function nextSessionUri(): string { - return URI.from({ scheme: 'mock', path: `/test-session-${++sessionCounter}` }).toString(); -} - -export function isActionNotification(n: AhpNotification, actionType: string): boolean { - if (n.method !== 'action') { - return false; - } - const envelope = n.params as unknown as ActionEnvelope; - return envelope.action.type === actionType; -} - -export function getActionEnvelope(n: AhpNotification): ActionEnvelope { - return n.params as unknown as ActionEnvelope; -} - -/** Perform handshake, create a session, subscribe, and return its URI. */ -export async function createAndSubscribeSession(c: TestProtocolClient, clientId: string, workingDirectory?: string): Promise { - await c.call('initialize', { protocolVersion: PROTOCOL_VERSION, clientId }); - - await c.call('createSession', { session: nextSessionUri(), provider: 'mock', workingDirectory }); - - const notif = await c.waitForNotification(n => - n.method === 'notification' && (n.params as INotificationBroadcastParams).notification.type === 'notify/sessionAdded' - ); - const realSessionUri = ((notif.params as INotificationBroadcastParams).notification as SessionAddedNotification).summary.resource; - - await c.call('subscribe', { resource: realSessionUri }); - c.clearReceived(); - - return realSessionUri; -} - -export function dispatchTurnStarted(c: TestProtocolClient, session: string, turnId: string, text: string, clientSeq: number): void { - c.notify('dispatchAction', { - clientSeq, - action: { - type: 'session/turnStarted', - session, - turnId, - userMessage: { text }, - }, - }); -} diff --git a/src/vs/platform/agentHost/test/node/protocol/toolApproval.integrationTest.ts b/src/vs/platform/agentHost/test/node/protocol/toolApproval.integrationTest.ts deleted file mode 100644 index 13a2bc6c..00000000 --- a/src/vs/platform/agentHost/test/node/protocol/toolApproval.integrationTest.ts +++ /dev/null @@ -1,188 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import assert from 'assert'; -import type { IResponsePartAction } from '../../../common/state/sessionActions.js'; -import { ResponsePartKind, type MarkdownResponsePart } from '../../../common/state/sessionState.js'; -import { - createAndSubscribeSession, - dispatchTurnStarted, - getActionEnvelope, - IServerHandle, - isActionNotification, - startServer, - TestProtocolClient, -} from './testHelpers.js'; - -suite('Protocol WebSocket — Permissions & Auto-Approve', function () { - - let server: IServerHandle; - let client: TestProtocolClient; - - suiteSetup(async function () { - this.timeout(15_000); - server = await startServer(); - }); - - suiteTeardown(function () { - server.process.kill(); - }); - - setup(async function () { - this.timeout(10_000); - client = new TestProtocolClient(server.port); - await client.connect(); - }); - - teardown(function () { - client.close(); - }); - - // ---- Manual permission flow ------------------------------------------------ - - test('permission request → resolve → response', async function () { - this.timeout(10_000); - - const sessionUri = await createAndSubscribeSession(client, 'test-permission'); - dispatchTurnStarted(client, sessionUri, 'turn-perm', 'permission', 1); - - // The mock agent fires tool_start + tool_ready instead of permission_request - await client.waitForNotification(n => isActionNotification(n, 'session/toolCallStart')); - await client.waitForNotification(n => isActionNotification(n, 'session/toolCallReady')); - - // Confirm the tool call - client.notify('dispatchAction', { - clientSeq: 2, - action: { - type: 'session/toolCallConfirmed', - session: sessionUri, - turnId: 'turn-perm', - toolCallId: 'tc-perm-1', - approved: true, - }, - }); - - const responsePart = await client.waitForNotification(n => isActionNotification(n, 'session/responsePart')); - const responsePartAction = getActionEnvelope(responsePart).action as IResponsePartAction; - assert.strictEqual(responsePartAction.part.kind, ResponsePartKind.Markdown); - assert.strictEqual((responsePartAction.part as MarkdownResponsePart).content, 'Allowed.'); - - await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); - }); - - // ---- Edit auto-approve patterns ------------------------------------------- - - test('auto-approves write to regular file (no pending confirmation)', async function () { - this.timeout(10_000); - - const sessionUri = await createAndSubscribeSession(client, 'test-autoapprove', 'file:///workspace'); - client.clearReceived(); - - // Start a turn that triggers a write permission request for a regular .ts file - dispatchTurnStarted(client, sessionUri, 'turn-autoapprove', 'write-file', 1); - - // The write should be auto-approved — we should see tool_start, tool_complete, and turn_complete - // but NOT a pending-confirmation toolCallReady (one without `confirmed`). - await client.waitForNotification(n => isActionNotification(n, 'session/toolCallStart')); - await client.waitForNotification(n => isActionNotification(n, 'session/toolCallComplete')); - await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); - - // Verify no pending-confirmation toolCallReady was received - const pendingConfirmNotifs = client.receivedNotifications(n => { - if (!isActionNotification(n, 'session/toolCallReady')) { - return false; - } - const action = getActionEnvelope(n).action as { confirmed?: string }; - return !action.confirmed; - }); - assert.strictEqual(pendingConfirmNotifs.length, 0, 'should not have received pending-confirmation toolCallReady for auto-approved write'); - }); - - test('blocks write to .env file (requires manual confirmation)', async function () { - this.timeout(10_000); - - const sessionUri = await createAndSubscribeSession(client, 'test-autoapprove-deny', 'file:///workspace'); - client.clearReceived(); - - // Start a turn that tries to write .env (blocked by default patterns) - dispatchTurnStarted(client, sessionUri, 'turn-deny', 'write-env', 1); - - // The .env write should NOT be auto-approved — we should see toolCallReady (pending confirmation) - await client.waitForNotification(n => isActionNotification(n, 'session/toolCallStart')); - await client.waitForNotification(n => isActionNotification(n, 'session/toolCallReady')); - - // Confirm it manually to let the turn complete - client.notify('dispatchAction', { - clientSeq: 2, - action: { - type: 'session/toolCallConfirmed', - session: sessionUri, - turnId: 'turn-deny', - toolCallId: 'tc-write-env-1', - approved: true, - confirmed: 'user-action', - }, - }); - - await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); - }); - - // ---- Shell auto-approve --------------------------------------------------- - - test('auto-approves allowed shell command (no pending confirmation)', async function () { - this.timeout(10_000); - - const sessionUri = await createAndSubscribeSession(client, 'test-shell-approve'); - client.clearReceived(); - - // Start a turn that triggers a shell permission request for "ls -la" (allowed command) - dispatchTurnStarted(client, sessionUri, 'turn-shell-approve', 'run-safe-command', 1); - - // The shell command should be auto-approved — we should see tool_start, tool_complete, and turn_complete - // but NOT a pending-confirmation toolCallReady. - await client.waitForNotification(n => isActionNotification(n, 'session/toolCallStart')); - await client.waitForNotification(n => isActionNotification(n, 'session/toolCallComplete')); - await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); - - // Verify no pending-confirmation toolCallReady was received - const pendingConfirmNotifs = client.receivedNotifications(n => { - if (!isActionNotification(n, 'session/toolCallReady')) { - return false; - } - const action = getActionEnvelope(n).action as { confirmed?: string }; - return !action.confirmed; - }); - assert.strictEqual(pendingConfirmNotifs.length, 0, 'should not have received pending-confirmation toolCallReady for allowed shell command'); - }); - - test('blocks denied shell command (requires manual confirmation)', async function () { - this.timeout(10_000); - - const sessionUri = await createAndSubscribeSession(client, 'test-shell-deny'); - client.clearReceived(); - - // Start a turn that triggers a shell permission request for "rm -rf /" (denied command) - dispatchTurnStarted(client, sessionUri, 'turn-shell-deny', 'run-dangerous-command', 1); - - // The denied command should NOT be auto-approved — we should see toolCallReady (pending confirmation) - await client.waitForNotification(n => isActionNotification(n, 'session/toolCallStart')); - await client.waitForNotification(n => isActionNotification(n, 'session/toolCallReady')); - - // Confirm it manually to let the turn complete - client.notify('dispatchAction', { - clientSeq: 2, - action: { - type: 'session/toolCallConfirmed', - session: sessionUri, - turnId: 'turn-shell-deny', - toolCallId: 'tc-shell-deny-1', - approved: true, - confirmed: 'user-action', - }, - }); - - await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); - }); -}); diff --git a/src/vs/platform/agentHost/test/node/protocol/toolApprovalRealSdk.integrationTest.ts b/src/vs/platform/agentHost/test/node/protocol/toolApprovalRealSdk.integrationTest.ts deleted file mode 100644 index 533d3507..00000000 --- a/src/vs/platform/agentHost/test/node/protocol/toolApprovalRealSdk.integrationTest.ts +++ /dev/null @@ -1,1148 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -/** - * Integration tests using the real Copilot SDK instead of a mock agent. - * - * These tests are **disabled by default**. To run them, set `AGENT_HOST_REAL_SDK=1`: - * - * AGENT_HOST_REAL_SDK=1 ./scripts/test-integration.sh --run src/vs/platform/agentHost/test/node/protocol/toolApprovalRealSdk.integrationTest.ts - * - * Authentication: By default the token is obtained from `gh auth token`. - * You can override it by setting `GITHUB_TOKEN=ghp_xxx`. - * - * SAFETY: These tests create real agent sessions backed by the Copilot SDK. - * The agent may execute tool calls on the user's machine. Prompts should be - * carefully chosen to avoid destructive side-effects — prefer read-only - * questions, safe commands like `echo`, and use isolated temp directories as - * working directories. Never ask the agent to delete, modify, or install - * anything outside of a test-owned temp directory. - */ - -import assert from 'assert'; -import { execSync } from 'child_process'; -import { mkdtempSync, rmSync, writeFileSync } from 'fs'; -import { tmpdir } from 'os'; -import { removeAnsiEscapeCodes } from '../../../../../base/common/strings.js'; -import { URI } from '../../../../../base/common/uri.js'; -import type { SessionToolCallStartAction } from '../../../common/state/protocol/actions.js'; -import { SubscribeResult } from '../../../common/state/protocol/commands.js'; -import { PROTOCOL_VERSION } from '../../../common/state/sessionCapabilities.js'; -import { ResponsePartKind, ROOT_STATE_URI, SessionInputAnswerState, SessionInputAnswerValueKind, SessionInputQuestionKind, SessionInputResponseKind, ToolResultContentType, isSubagentSession, type SessionInputAnswer, type SessionInputRequest, type SessionState, type TerminalState, type ToolResultContent, type ToolResultSubagentContent } from '../../../common/state/sessionState.js'; -import type { RootState } from '../../../common/state/protocol/state.js'; -import type { RootAgentsChangedAction, SessionAddedNotification, SessionInputRequestedAction, SessionToolCallReadyAction } from '../../../common/state/sessionActions.js'; -import type { INotificationBroadcastParams } from '../../../common/state/sessionProtocol.js'; -import { - getActionEnvelope, - isActionNotification, - IServerHandle, - startRealServer, - TestProtocolClient, -} from './testHelpers.js'; - -const REAL_SDK_ENABLED = process.env['AGENT_HOST_REAL_SDK'] === '1'; - -/** Resolve GitHub token from env or `gh auth token`. */ -function resolveGitHubToken(): string { - const envToken = process.env['GITHUB_TOKEN']; - if (envToken) { - return envToken; - } - try { - return execSync('gh auth token', { encoding: 'utf-8' }).trim(); - } catch { - throw new Error('No GITHUB_TOKEN set and `gh auth token` failed. Run `gh auth login` first.'); - } -} - -/** Create a session using the real copilot provider, authenticate, subscribe, and return the session URI. */ -async function createRealSession(c: TestProtocolClient, clientId: string, trackingList: string[], workingDirectory?: string): Promise { - const result = await createRealSessionFull(c, clientId, trackingList, workingDirectory); - return result.sessionUri; -} - -interface IRealSessionResult { - sessionUri: string; - addedNotification: SessionAddedNotification; - subscribeSnapshot: SessionState; -} - -/** Full version that returns the sessionAdded notification and subscribe snapshot for assertions. */ -async function createRealSessionFull(c: TestProtocolClient, clientId: string, trackingList: string[], workingDirectory?: string): Promise { - await c.call('initialize', { protocolVersion: PROTOCOL_VERSION, clientId }, 30_000); - - await c.call('authenticate', { resource: 'https://api.github.com', token: resolveGitHubToken() }, 30_000); - - const sessionUri = URI.from({ scheme: 'copilotcli', path: `/real-test-${Date.now()}` }).toString(); - await c.call('createSession', { session: sessionUri, provider: 'copilotcli', workingDirectory }, 30_000); - - const notif = await c.waitForNotification(n => - n.method === 'notification' && (n.params as INotificationBroadcastParams).notification.type === 'notify/sessionAdded', - 15_000, - ); - const addedNotification = (notif.params as INotificationBroadcastParams).notification as SessionAddedNotification; - const realSessionUri = addedNotification.summary.resource; - trackingList.push(realSessionUri); - - const subscribeResult = await c.call('subscribe', { resource: realSessionUri }); - const subscribeSnapshot = subscribeResult.snapshot.state as SessionState; - c.clearReceived(); - - return { sessionUri: realSessionUri, addedNotification, subscribeSnapshot }; -} - -/** Dispatch a turn with the given user message text. */ -function dispatchTurn(c: TestProtocolClient, session: string, turnId: string, text: string, clientSeq: number): void { - c.notify('dispatchAction', { - clientSeq, - action: { - type: 'session/turnStarted', - session, - turnId, - userMessage: { text }, - }, - }); -} - -function getAcceptedAnswers(request: SessionInputRequest): Record | undefined { - if (!request.questions?.length) { - return undefined; - } - - return Object.fromEntries(request.questions.map(question => { - switch (question.kind) { - case SessionInputQuestionKind.Text: - return [question.id, { - state: SessionInputAnswerState.Submitted, - value: { - kind: SessionInputAnswerValueKind.Text, - value: question.defaultValue ?? 'interactive', - }, - } satisfies SessionInputAnswer]; - case SessionInputQuestionKind.Number: - case SessionInputQuestionKind.Integer: - return [question.id, { - state: SessionInputAnswerState.Submitted, - value: { - kind: SessionInputAnswerValueKind.Number, - value: question.defaultValue ?? question.min ?? 1, - }, - } satisfies SessionInputAnswer]; - case SessionInputQuestionKind.Boolean: - return [question.id, { - state: SessionInputAnswerState.Submitted, - value: { - kind: SessionInputAnswerValueKind.Boolean, - value: question.defaultValue ?? true, - }, - } satisfies SessionInputAnswer]; - case SessionInputQuestionKind.SingleSelect: { - const preferredOption = question.options.find(option => /interactive/i.test(option.id) || /interactive/i.test(option.label)) - ?? question.options.find(option => option.recommended) - ?? question.options[0]; - return [question.id, { - state: SessionInputAnswerState.Submitted, - value: { - kind: SessionInputAnswerValueKind.Selected, - value: preferredOption.id, - }, - } satisfies SessionInputAnswer]; - } - case SessionInputQuestionKind.MultiSelect: { - const preferredOptions = question.options.filter(option => option.recommended); - const selectedOptions = preferredOptions.length > 0 ? preferredOptions : question.options.slice(0, 1); - return [question.id, { - state: SessionInputAnswerState.Submitted, - value: { - kind: SessionInputAnswerValueKind.SelectedMany, - value: selectedOptions.map(option => option.id), - }, - } satisfies SessionInputAnswer]; - } - } - })); -} - -function getMarkdownResponseText(c: TestProtocolClient): string { - // Markdown content arrives as a `session/responsePart` action that opens - // the part with the first chunk, followed by `session/delta` actions - // appending subsequent chunks. Concatenate both to get the full text. - const markdownPartIds = new Set(); - const pieces: string[] = []; - for (const notification of c.receivedNotifications(n => - isActionNotification(n, 'session/responsePart') || isActionNotification(n, 'session/delta') - )) { - const action = getActionEnvelope(notification).action; - if (action.type === 'session/responsePart' && action.part.kind === ResponsePartKind.Markdown) { - markdownPartIds.add(action.part.id); - pieces.push(action.part.content); - } else if (action.type === 'session/delta' && markdownPartIds.has(action.partId)) { - pieces.push(action.content); - } - } - return pieces.join(''); -} - -interface IDrivenTurnResult { - sawInputRequest: boolean; - sawPendingConfirmation: boolean; - responseText: string; -} - -async function driveTurnToCompletion(c: TestProtocolClient, session: string, turnId: string, text: string, clientSeq: number): Promise { - c.clearReceived(); - dispatchTurn(c, session, turnId, text, clientSeq); - - const seenNotifications = new Set(); - let nextClientSeq = clientSeq + 1; - let sawInputRequest = false; - let sawPendingConfirmation = false; - - while (true) { - const notification = await c.waitForNotification(n => !seenNotifications.has(n as object) && ( - isActionNotification(n, 'session/toolCallReady') - || isActionNotification(n, 'session/inputRequested') - || isActionNotification(n, 'session/turnComplete') - || isActionNotification(n, 'session/error') - ), 90_000); - seenNotifications.add(notification as object); - - if (isActionNotification(notification, 'session/error')) { - throw new Error(`Session error while driving ${turnId}`); - } - - if (isActionNotification(notification, 'session/toolCallReady')) { - const action = getActionEnvelope(notification).action as SessionToolCallReadyAction; - if (!action.confirmed) { - sawPendingConfirmation = true; - c.notify('dispatchAction', { - clientSeq: nextClientSeq++, - action: { - type: 'session/toolCallConfirmed', - session, - turnId, - toolCallId: action.toolCallId, - approved: true, - }, - }); - } - continue; - } - - if (isActionNotification(notification, 'session/inputRequested')) { - sawInputRequest = true; - const action = getActionEnvelope(notification).action as SessionInputRequestedAction; - c.notify('dispatchAction', { - clientSeq: nextClientSeq++, - action: { - type: 'session/inputCompleted', - session, - requestId: action.request.id, - response: SessionInputResponseKind.Accept, - answers: getAcceptedAnswers(action.request), - }, - }); - continue; - } - - break; - } - - return { - sawInputRequest, - sawPendingConfirmation, - responseText: getMarkdownResponseText(c), - }; -} - -function terminalResourceFromContent(content: readonly ToolResultContent[]): string | undefined { - const terminalContent = content.find(c => c.type === ToolResultContentType.Terminal); - return terminalContent?.resource; -} - -function terminalText(state: TerminalState): string { - return removeAnsiEscapeCodes(state.content.map(part => part.type === 'command' ? `${part.commandLine}\n${part.output}` : part.value).join('')); -} - -/** Looks up the toolName for a toolCallReady by joining against the matching toolCallStart. */ -function findToolNameForCall(c: TestProtocolClient, toolCallId: string): string | undefined { - return c.receivedNotifications(n => isActionNotification(n, 'session/toolCallStart')) - .map(n => getActionEnvelope(n).action as SessionToolCallStartAction) - .find(a => a.toolCallId === toolCallId)?.toolName; -} - -interface IApprovalRule { - /** Tool name this rule applies to (e.g. `'bash'`, `'write_bash'`). */ - toolName: string; - /** Optional predicate over the tool input. If omitted, any input matches. */ - matchInput?: (toolInput: string | undefined) => boolean; - /** - * Optional inspector run for every matched call before approval. - * Push assertion failure messages onto `errors` to fail the test. - */ - inspect?: (info: { - action: SessionToolCallReadyAction; - errors: string[]; - }) => void; -} - -interface IBackgroundApprovalLoopOptions { - /** Starting clientSeq for dispatched toolCallConfirmed actions. Avoids collisions with the test's own dispatches. */ - approvalSeqStart: number; - /** - * Allow-list of tool calls the loop is permitted to auto-approve. Each - * pending confirmation must match exactly one rule (by `toolName` plus - * optional `matchInput` predicate). Calls that don't match are recorded - * as errors and denied — the loop refuses to rubber-stamp anything the - * test didn't anticipate (e.g. an unexpected `rm` from the model). - */ - allow: readonly IApprovalRule[]; -} - -interface IBackgroundApprovalLoop { - /** Errors collected during the run (unmatched tool calls + inspector failures). */ - readonly errors: readonly string[]; - /** Tool names that were observed and approved at least once. */ - readonly approvedToolNames: ReadonlySet; - /** - * Tool names for every permission request observed by the loop, regardless - * of whether they matched the allow-list. Useful for asserting that a - * tool with `skipPermission: true` never triggered a permission flow. - */ - readonly observedToolNames: ReadonlySet; - /** Stops the loop and waits for it to drain. */ - stop(): Promise; -} - -/** - * Starts a background loop that auto-approves pending tool call confirmations - * during a real-SDK turn, but only if they match the supplied allow-list. - * Anything outside the allow-list is denied and recorded as an error so the - * test fails loudly instead of silently approving model-chosen tool calls. - * - * Implementation note: `waitForNotification` does NOT consume notifications from - * the client's queue, so we dedupe by `serverSeq`. - */ -function startBackgroundApprovalLoop(c: TestProtocolClient, options: IBackgroundApprovalLoopOptions): IBackgroundApprovalLoop { - const errors: string[] = []; - const approvedToolNames = new Set(); - const observedToolNames = new Set(); - const processedSeqs = new Set(); - let active = true; - let approvalSeq = options.approvalSeqStart; - - const loop = (async () => { - while (active) { - try { - const ready = await c.waitForNotification(n => { - if (!isActionNotification(n, 'session/toolCallReady')) { - return false; - } - return !processedSeqs.has(getActionEnvelope(n).serverSeq); - }, 2_000); - const envelope = getActionEnvelope(ready); - processedSeqs.add(envelope.serverSeq); - const action = envelope.action as SessionToolCallReadyAction & { session: string; turnId: string }; - if (action.confirmed) { - continue; - } - - const toolName = findToolNameForCall(c, action.toolCallId); - if (toolName) { - observedToolNames.add(toolName); - } - const matchingRule = options.allow.find(rule => - rule.toolName === toolName - && (rule.matchInput?.(action.toolInput) ?? true)); - - if (!matchingRule) { - errors.push(`unexpected tool call: toolName=${toolName ?? ''} input=${JSON.stringify(action.toolInput)}`); - c.notify('dispatchAction', { - clientSeq: ++approvalSeq, - action: { - type: 'session/toolCallConfirmed', - session: action.session, - turnId: action.turnId, - toolCallId: action.toolCallId, - approved: false, - }, - }); - continue; - } - - matchingRule.inspect?.({ action, errors }); - approvedToolNames.add(matchingRule.toolName); - - c.notify('dispatchAction', { - clientSeq: ++approvalSeq, - action: { - type: 'session/toolCallConfirmed', - session: action.session, - turnId: action.turnId, - toolCallId: action.toolCallId, - approved: true, - }, - }); - } catch (e) { - // Only ignore the expected 2-second poll timeout. Any other error - // (e.g. 'Client closed', exception from matchingRule.inspect) is a - // real failure — record it so the test fails deterministically. - const msg = e instanceof Error ? e.message : String(e); - if (!msg.includes('Timed out') && !msg.includes('timed out')) { - errors.push(`approval loop error: ${msg}`); - active = false; - } - } - } - })(); - - return { - errors, - approvedToolNames, - observedToolNames, - async stop(): Promise { - active = false; - await loop; - }, - }; -} - -(REAL_SDK_ENABLED ? suite : suite.skip)('Protocol WebSocket — Real Copilot SDK', function () { - - let server: IServerHandle; - let client: TestProtocolClient; - /** Session URIs created during the current test, disposed in teardown. */ - const createdSessions: string[] = []; - /** Temp directories created during the current test, removed in teardown. */ - const tempDirs: string[] = []; - - suiteSetup(async function () { - this.timeout(60_000); - server = await startRealServer(); - }); - - suiteTeardown(function () { - server?.process.kill(); - }); - - setup(async function () { - this.timeout(30_000); - client = new TestProtocolClient(server.port); - await client.connect(); - }); - - teardown(async function () { - // Dispose all sessions created during this test - for (const session of createdSessions) { - try { - await client.call('disposeSession', { session }, 5000); - } catch { - // Best-effort cleanup — the session may already be gone - } - } - createdSessions.length = 0; - client.close(); - - // Remove temp directories created during this test. On Windows the - // agent subprocess can still hold handles to the working directory for - // a brief moment after `disposeSession` returns, which surfaces as - // EBUSY. Retry a few times to give the OS a chance to release the - // handle before failing the teardown. - for (const dir of tempDirs) { - try { - rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }); - } catch { - // Best-effort cleanup — leftover temp dirs in os.tmpdir() are - // harmless and shouldn't fail an otherwise passing test. - } - } - tempDirs.length = 0; - }); - - // ---- Basic turn execution ------------------------------------------------ - - test('sends a simple message and receives a response', async function () { - this.timeout(120_000); - - const sessionUri = await createRealSession(client, 'real-sdk-simple', createdSessions, URI.file(tmpdir()).toString()); - dispatchTurn(client, sessionUri, 'turn-1', 'Say exactly "hello" and nothing else', 1); - - // Wait for the turn to complete — the real SDK may take a while - await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete'), 90_000); - - // Verify we received at least one response part - const responseParts = client.receivedNotifications(n => isActionNotification(n, 'session/responsePart')); - assert.ok(responseParts.length > 0, 'should have received at least one response part'); - }); - - // ---- Tool call with permission flow ------------------------------------- - - test('tool call triggers permission request and can be approved', async function () { - this.timeout(120_000); - - const tempDir = mkdtempSync(`${tmpdir()}/ahp-perm-test-`); - tempDirs.push(tempDir); - const sessionUri = await createRealSession(client, 'real-sdk-permission', createdSessions, URI.file(tempDir).toString()); - dispatchTurn(client, sessionUri, 'turn-perm', 'Run the shell command: echo "hello from test"', 1); - - // The real SDK should fire a tool call that needs permission - const toolStartNotif = await client.waitForNotification( - n => isActionNotification(n, 'session/toolCallStart'), - 60_000, - ); - const toolStartAction = getActionEnvelope(toolStartNotif).action as { toolCallId: string }; - - // Wait for toolCallReady (pending confirmation) - const toolReadyNotif = await client.waitForNotification( - n => isActionNotification(n, 'session/toolCallReady'), - 30_000, - ); - const toolReadyAction = getActionEnvelope(toolReadyNotif).action as { toolCallId: string; confirmed?: string }; - - // If the tool was auto-approved, confirmed will be set; if pending, confirm it - if (!toolReadyAction.confirmed) { - client.notify('dispatchAction', { - clientSeq: 2, - action: { - type: 'session/toolCallConfirmed', - session: sessionUri, - turnId: 'turn-perm', - toolCallId: toolStartAction.toolCallId, - approved: true, - }, - }); - } - - // Wait for the turn to complete - await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete'), 90_000); - }); - - test('planning-mode session-state writes are auto-approved in default mode', async function () { - this.timeout(180_000); - - const tempDir = mkdtempSync(`${tmpdir()}/ahp-plan-test-`); - tempDirs.push(tempDir); - const sessionUri = await createRealSession(client, 'real-sdk-plan-mode', createdSessions, URI.file(tempDir).toString()); - - // Switch the session into plan mode via the standard config-change flow - // before sending the first turn. The agent host reads this value at - // turn-start time and pushes it to the SDK via `rpc.mode.set`. - client.notify('dispatchAction', { - clientSeq: 1, - action: { - type: 'session/configChanged', - session: sessionUri, - config: { mode: 'plan' }, - }, - }); - await client.waitForNotification(n => isActionNotification(n, 'session/configChanged')); - - const planTurn = await driveTurnToCompletion(client, sessionUri, 'turn-plan', - 'Help me implement a Python script that prints "hello world" to stdout. Write the shortest possible plan to your session plan.md and use the exit_plan_mode tool to ask me to approve it before writing any code.', 2); - assert.strictEqual(planTurn.sawPendingConfirmation, false, 'should not have received pending-confirmation toolCallReady while writing session-state plan.md'); - assert.ok(planTurn.sawInputRequest, 'should reach the exit_plan_mode question so the test can continue the same session'); - - const extraSessionNotificationsAfterPlan = client.receivedNotifications(n => - n.method === 'notification' && (n.params as INotificationBroadcastParams).notification.type === 'notify/sessionAdded', - ); - assert.strictEqual(extraSessionNotificationsAfterPlan.length, 0, 'should not create a second session while answering the plan-mode question'); - - // Mirror what a real UI client would do after the user accepted the - // plan: update the session config so subsequent turns no longer run - // in plan mode. Without this the agent host would re-set the SDK's - // mode to 'plan' at the next send because the session config still - // holds the original 'plan' value. - client.notify('dispatchAction', { - clientSeq: 50, - action: { - type: 'session/configChanged', - session: sessionUri, - config: { mode: 'interactive' }, - }, - }); - await client.waitForNotification(n => isActionNotification(n, 'session/configChanged')); - - const followupTurn = await driveTurnToCompletion(client, sessionUri, 'turn-followup', - 'What did the plan I just approved say to print? Reply with exactly "hello world".', 100, - ); - assert.strictEqual(followupTurn.sawPendingConfirmation, false, 'follow-up turn should not surface new pending confirmations'); - assert.match(followupTurn.responseText, /hello world/i, 'follow-up turn should retain the original plan context'); - - const extraSessionNotificationsAfterFollowup = client.receivedNotifications(n => - n.method === 'notification' && (n.params as INotificationBroadcastParams).notification.type === 'notify/sessionAdded', - ); - assert.strictEqual(extraSessionNotificationsAfterFollowup.length, 0, 'sending another message should stay on the same session instead of forking'); - - const resubscribeResult = await client.call('subscribe', { resource: sessionUri }); - const finalSnapshot = resubscribeResult.snapshot.state as SessionState; - assert.strictEqual(finalSnapshot.summary.resource, sessionUri, 'follow-up turn should keep the original session resource'); - }); - - // ---- Abort / cancel ----------------------------------------------------- - - test('can abort a running turn', async function () { - this.timeout(120_000); - - const sessionUri = await createRealSession(client, 'real-sdk-abort', createdSessions, URI.file(tmpdir()).toString()); - dispatchTurn(client, sessionUri, 'turn-abort', 'Write a very long essay about the history of computing', 1); - - // Wait a moment for the turn to start processing, then abort - await client.waitForNotification( - n => isActionNotification(n, 'session/responsePart') || isActionNotification(n, 'session/toolCallStart'), - 60_000, - ); - - client.notify('dispatchAction', { - clientSeq: 2, - action: { - type: 'session/abortTurn', - session: sessionUri, - }, - }); - - // Verify the abort action was echoed back by the server. - // We don't wait for turnComplete because the real Copilot SDK may - // continue streaming after abort and the turn may not terminate within - // the test timeout. - await client.waitForNotification( - n => isActionNotification(n, 'session/abortTurn'), - 10_000, - ); - }); - - // ---- Working directory correctness -------------------------------------- - - test('session is created with the correct working directory', async function () { - this.timeout(120_000); - - // Use a real temp directory so the path exists on disk. - // Clean it up at the end to avoid leaving test artifacts. - const tempDir = mkdtempSync(`${tmpdir()}/ahp-test-`); - tempDirs.push(tempDir); - const workingDirUri = URI.file(tempDir).toString(); - - await client.call('initialize', { protocolVersion: PROTOCOL_VERSION, clientId: 'real-sdk-workdir' }); - await client.call('authenticate', { resource: 'https://api.github.com', token: resolveGitHubToken() }); - - const sessionUri = URI.from({ scheme: 'copilotcli', path: `/real-test-wd-${Date.now()}` }).toString(); - await client.call('createSession', { session: sessionUri, provider: 'copilotcli', workingDirectory: workingDirUri }); - - // 1. Verify workingDirectory in the sessionAdded notification - const addedNotif = await client.waitForNotification(n => - n.method === 'notification' && (n.params as INotificationBroadcastParams).notification.type === 'notify/sessionAdded', - 15_000, - ); - const addedSummary = ((addedNotif.params as INotificationBroadcastParams).notification as SessionAddedNotification).summary; - createdSessions.push(addedSummary.resource); - assert.strictEqual( - addedSummary.workingDirectory, - workingDirUri, - `sessionAdded notification should carry the requested working directory`, - ); - - // 2. Subscribe and verify workingDirectory in the session state snapshot - const subscribeResult = await client.call('subscribe', { resource: addedSummary.resource }); - const sessionState = subscribeResult.snapshot.state as SessionState; - assert.strictEqual( - sessionState.summary.workingDirectory, - workingDirUri, - `subscribe snapshot summary should carry the requested working directory`, - ); - }); - - // ---- Worktree isolation ------------------------------------------------- - - test('worktree session uses the resolved worktree as working directory', async function () { - this.timeout(120_000); - - // Set up a minimal git repo so the server can create a worktree - const tempDir = mkdtempSync(`${tmpdir()}/ahp-wt-test-`); - tempDirs.push(tempDir, `${tempDir}.worktrees`); - execSync('git init', { cwd: tempDir }); - execSync('git config user.name "Agent Host Test"', { cwd: tempDir }); - execSync('git config user.email "agent-host-test@example.com"', { cwd: tempDir }); - execSync('git commit --allow-empty -m "init"', { cwd: tempDir }); - const defaultBranch = execSync('git branch --show-current', { cwd: tempDir, encoding: 'utf-8' }).trim(); - const workingDirUri = URI.file(tempDir).toString(); - - await client.call('initialize', { protocolVersion: PROTOCOL_VERSION, clientId: 'real-sdk-worktree' }); - await client.call('authenticate', { resource: 'https://api.github.com', token: resolveGitHubToken() }); - - const sessionUri = URI.from({ scheme: 'copilotcli', path: `/real-test-wt-${Date.now()}` }).toString(); - await client.call('createSession', { - session: sessionUri, - provider: 'copilotcli', - workingDirectory: workingDirUri, - config: { isolation: 'worktree', branch: defaultBranch }, - }); - - const addedNotif = await client.waitForNotification(n => - n.method === 'notification' && (n.params as INotificationBroadcastParams).notification.type === 'notify/sessionAdded', - 15_000, - ); - const addedSummary = ((addedNotif.params as INotificationBroadcastParams).notification as SessionAddedNotification).summary; - createdSessions.push(addedSummary.resource); - - // Subscribe so we receive action broadcasts for this session - await client.call('subscribe', { resource: addedSummary.resource }); - - // Verify the worktree path is in the summary - assert.ok( - addedSummary.workingDirectory, - 'sessionAdded notification should have a workingDirectory', - ); - assert.ok( - addedSummary.workingDirectory!.includes('.worktrees'), - `workingDirectory should be under the .worktrees folder, got: ${addedSummary.workingDirectory}`, - ); - const resolvedWorkingDirectoryPath = URI.parse(addedSummary.workingDirectory!).fsPath; - - // Set the active client with tools (matching real VS Code flow where - // activeClientChanged is dispatched AFTER createSession). When the next - // sendMessage detects the tools changed vs the session's creation-time - // snapshot, it disposes the SDK session and re-creates it via - // _resumeSession. That resume path must use the worktree working - // directory, not the original repo path. - client.notify('dispatchAction', { - clientSeq: 1, - action: { - type: 'session/activeClientChanged', - session: addedSummary.resource, - activeClient: { - clientId: 'real-sdk-worktree', - displayName: 'Test Client', - tools: [ - { - name: 'test_echo', - description: 'A harmless echo tool for testing', - inputSchema: { type: 'object', properties: { message: { type: 'string' } } }, - }, - ], - }, - }, - }); - - // Send a turn — this triggers sendMessage, which will detect the tools - // changed and refresh the session (dispose + _resumeSession). The - // resumed session should still have the worktree as its working - // directory. Ask a safe, read-only question about the working directory. - client.clearReceived(); - dispatchTurn(client, addedSummary.resource, 'turn-wt', - 'What is your current working directory? Reply with just the absolute path and nothing else.', 2); - - // Wait for the turn to complete or error - await client.waitForNotification( - n => isActionNotification(n, 'session/turnComplete') || isActionNotification(n, 'session/error'), - 90_000, - ); - - // The session refresh should succeed — if it errors with - // "workingDirectory is required to resume", the worktree path was lost. - const errors = client.receivedNotifications(n => isActionNotification(n, 'session/error')); - assert.strictEqual(errors.length, 0, - errors.length > 0 - ? `Session error during turn (worktree path lost on resume): ${(getActionEnvelope(errors[0]).action as { error?: { message?: string } }).error?.message}` - : '', - ); - - // Verify the turn got a response (the session resumed successfully) - const responseParts = client.receivedNotifications(n => isActionNotification(n, 'session/responsePart')); - assert.ok(responseParts.length > 0, 'should have received at least one response part after session refresh'); - - client.clearReceived(); - dispatchTurn(client, addedSummary.resource, 'turn-wt-terminal', 'Run the shell command: pwd', 3); - - const toolStartNotif = await client.waitForNotification( - n => isActionNotification(n, 'session/toolCallStart'), - 60_000, - ); - const toolStartAction = getActionEnvelope(toolStartNotif).action as { toolCallId: string }; - - const toolReadyNotif = await client.waitForNotification( - n => isActionNotification(n, 'session/toolCallReady'), - 30_000, - ); - const toolReadyAction = getActionEnvelope(toolReadyNotif).action as { confirmed?: string }; - if (!toolReadyAction.confirmed) { - client.notify('dispatchAction', { - clientSeq: 4, - action: { - type: 'session/toolCallConfirmed', - session: addedSummary.resource, - turnId: 'turn-wt-terminal', - toolCallId: toolStartAction.toolCallId, - approved: true, - }, - }); - } - - const terminalContentNotif = await client.waitForNotification(n => { - if (!isActionNotification(n, 'session/toolCallContentChanged')) { - return false; - } - const action = getActionEnvelope(n).action as { toolCallId: string; content: readonly ToolResultContent[] }; - return action.toolCallId === toolStartAction.toolCallId && terminalResourceFromContent(action.content) !== undefined; - }, 30_000); - const terminalContentAction = getActionEnvelope(terminalContentNotif).action as { content: readonly ToolResultContent[] }; - const terminalUri = terminalResourceFromContent(terminalContentAction.content); - assert.ok(terminalUri, 'shell tool should expose its terminal resource'); - - const terminalSubscribeResult = await client.call('subscribe', { resource: terminalUri }); - const initialTerminalState = terminalSubscribeResult.snapshot.state as TerminalState; - assert.strictEqual(initialTerminalState.cwd, resolvedWorkingDirectoryPath, 'terminal should be created in the resolved worktree directory'); - - await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete'), 90_000); - const terminalSnapshot = await client.call('subscribe', { resource: terminalUri }); - const terminalState = terminalSnapshot.snapshot.state as TerminalState; - assert.ok(terminalText(terminalState).includes(resolvedWorkingDirectoryPath), `pwd output should include the resolved worktree path ${resolvedWorkingDirectoryPath}`); - }); - - // ---- Subagent tool call grouping ---------------------------------------- - - test('subagent tool calls are routed to the subagent session, not flat in the parent', async function () { - this.timeout(180_000); - - // Set up a small fixture directory so the subagent has something to view. - const tempDir = mkdtempSync(`${tmpdir()}/ahp-subagent-test-`); - tempDirs.push(tempDir); - writeFileSync(`${tempDir}/file-a.txt`, 'alpha'); - writeFileSync(`${tempDir}/file-b.txt`, 'beta'); - - const sessionUri = await createRealSession(client, 'real-sdk-subagent', createdSessions, URI.file(tempDir).toString()); - - // Auto-approve every tool that needs confirmation while the turn runs. - // Multiple inner tool calls may need approval; doing this in a background - // loop keeps the turn unblocked. Track processed serverSeqs so we don't - // busy-spin on already-handled notifications (waitForNotification returns - // matching notifications from the queue without consuming them). Using - // serverSeq rather than toolCallId allows the same tool to be legitimately - // re-confirmed in a later notification. - let approvalsActive = true; - let approvalSeq = 1000; - const processedSeqs = new Set(); - const approvalLoop = (async () => { - while (approvalsActive) { - try { - const ready = await client.waitForNotification(n => { - if (!isActionNotification(n, 'session/toolCallReady')) { - return false; - } - const envelope = getActionEnvelope(n); - const a = envelope.action as { confirmed?: string }; - return !a.confirmed && !processedSeqs.has(envelope.serverSeq); - }, 2_000); - const envelope = getActionEnvelope(ready); - if (!processedSeqs.has(envelope.serverSeq)) { - processedSeqs.add(envelope.serverSeq); - const action = envelope.action as { session: string; turnId: string; toolCallId: string; confirmed?: string }; - if (!action.confirmed) { - client.notify('dispatchAction', { - clientSeq: ++approvalSeq, - action: { - type: 'session/toolCallConfirmed', - session: action.session, - turnId: action.turnId, - toolCallId: action.toolCallId, - approved: true, - }, - }); - } - } - } catch { - // Timeout — re-poll. Loop exits when approvalsActive flips. - } - } - })(); - - // Encourage the model to delegate via the `task` subagent tool. The exact - // behaviour is non-deterministic — if the model declines we fail the test - // with a clear message rather than silently passing. - dispatchTurn(client, sessionUri, 'turn-sa', - 'Use the `task` tool to spawn a subagent to list the files in the current working directory. ' + - 'The subagent should call a single read-only tool (e.g. `view` or `bash` with `ls`) to enumerate the directory. ' + - 'Do not enumerate the directory yourself — delegate to the subagent.', - 1); - - // Wait for the parent's `task` tool call to expose a Subagent content - // block carrying the subagent session URI. - const subagentContentNotif = await client.waitForNotification(n => { - if (!isActionNotification(n, 'session/toolCallContentChanged')) { - return false; - } - const action = getActionEnvelope(n).action as { session: string; content: readonly ToolResultContent[] }; - return action.session === sessionUri && action.content.some(c => c.type === ToolResultContentType.Subagent); - }, 120_000); - - const parentContent = (getActionEnvelope(subagentContentNotif).action as { content: readonly ToolResultContent[] }).content; - const subagentRef = parentContent.find((c): c is ToolResultSubagentContent => c.type === ToolResultContentType.Subagent)!; - const subagentSessionUri = subagentRef.resource as unknown as string; - assert.ok(typeof subagentSessionUri === 'string' && isSubagentSession(subagentSessionUri), - `subagent session URI should be subagent-shaped, got: ${JSON.stringify(subagentSessionUri)}`); - - // Subscribe so we receive the subagent session's own action broadcasts. - await client.call('subscribe', { resource: subagentSessionUri }); - - // Wait for the parent turn to complete (with a generous timeout — the - // subagent's turn must finish first). - await client.waitForNotification(n => { - if (!isActionNotification(n, 'session/turnComplete')) { - return false; - } - return (getActionEnvelope(n).action as { session: string }).session === sessionUri; - }, 150_000); - - approvalsActive = false; - await approvalLoop; - - // Group all received toolCallStart actions by the session they target. - // This is the bug's signature: when inner tool_start arrives before - // subagent_started, the inner tool calls leak into the parent session. - const toolStarts = client.receivedNotifications(n => isActionNotification(n, 'session/toolCallStart')) - .map(n => getActionEnvelope(n).action as SessionToolCallStartAction); - - const parentStarts = toolStarts.filter(a => (a.session as unknown as string) === sessionUri); - const subagentStarts = toolStarts.filter(a => (a.session as unknown as string) === subagentSessionUri); - - // Parent should only carry the outer `task` tool call. Any other - // tool call on the parent indicates the inner-tool routing bug. - const parentNonTaskStarts = parentStarts.filter(a => a.toolName !== 'task'); - assert.deepStrictEqual( - parentNonTaskStarts.map(a => a.toolName), - [], - `parent session should not contain inner tool calls; found: ${JSON.stringify(parentNonTaskStarts.map(a => a.toolName))}`, - ); - - // Subagent session must have at least one inner tool call. If this - // fails, the subagent never actually executed any work — likely the - // model didn't delegate as instructed. - assert.ok(subagentStarts.length >= 1, - `subagent session should contain at least one inner tool call, got ${subagentStarts.length}. ` + - `Parent tool calls: ${JSON.stringify(parentStarts.map(a => a.toolName))}`); - }); - - // ---- Model discovery ----------------------------------------------------- - - test('listModels returns well-shaped model entries after authenticate', async function () { - this.timeout(60_000); - - await client.call('initialize', { protocolVersion: PROTOCOL_VERSION, clientId: 'real-sdk-list-models' }, 30_000); - - // Subscribe to root state *before* authenticating so we can observe - // the agentsChanged action that carries the populated model list. - const rootResult = await client.call('subscribe', { resource: ROOT_STATE_URI }, 30_000); - const initial = rootResult.snapshot.state as RootState; - const copilotAgent = initial.agents.find(a => a.provider === 'copilotcli'); - assert.ok(copilotAgent, `Expected copilotcli agent in root state, got: ${initial.agents.map(a => a.provider).join(', ')}`); - - await client.call('authenticate', { resource: 'https://api.github.com', token: resolveGitHubToken() }, 30_000); - - // Models are loaded asynchronously after authenticate. Wait for the - // agentsChanged action that populates them. - const notif = await client.waitForNotification(n => { - if (!isActionNotification(n, 'root/agentsChanged')) { - return false; - } - const action = getActionEnvelope(n).action as RootAgentsChangedAction; - const agent = action.agents.find(a => a.provider === 'copilotcli'); - return !!agent && agent.models.length > 0; - }, 30_000); - - const action = getActionEnvelope(notif).action as RootAgentsChangedAction; - const agent = action.agents.find(a => a.provider === 'copilotcli')!; - - assert.ok(agent.models.length > 0, 'Expected at least one model from listModels'); - - // Assert every model has the shape CopilotAgent._listModels produces. - // maxContextWindow is optional because synthetic SDK entries (e.g. the - // `auto` router) ship with `capabilities: {}` and no fixed window. - for (const model of agent.models) { - assert.strictEqual(typeof model.id, 'string', `model.id should be a string: ${JSON.stringify(model)}`); - assert.ok(model.id.length > 0, `model.id should be non-empty: ${JSON.stringify(model)}`); - assert.strictEqual(typeof model.name, 'string', `model.name should be a string: ${JSON.stringify(model)}`); - assert.strictEqual(model.provider, 'copilotcli', `model.provider should be copilotcli: ${JSON.stringify(model)}`); - assert.ok(model.maxContextWindow === undefined || (typeof model.maxContextWindow === 'number' && model.maxContextWindow > 0), - `model.maxContextWindow should be undefined or a positive number: ${JSON.stringify(model)}`); - assert.ok(model.supportsVision === undefined || typeof model.supportsVision === 'boolean', `model.supportsVision should be boolean or undefined: ${JSON.stringify(model)}`); - } - - // The `auto` synthetic router model should be present even though it - // has no fixed context window. - assert.ok(agent.models.some(m => m.id === 'auto'), `Expected 'auto' model in list, got: ${agent.models.map(m => m.id).join(', ')}`); - }); - - // ---- Redundant cd-prefix stripping -------------------------------------- - - test('strips redundant `cd &&` prefix from shell tool calls', async function () { - this.timeout(180_000); - - const tempDir = mkdtempSync(`${tmpdir()}/ahp-cd-strip-test-`); - tempDirs.push(tempDir); - const expectedWorkingDirPath = tempDir; - const sessionUri = await createRealSession(client, 'real-sdk-cd-strip', createdSessions, URI.file(tempDir).toString()); - - // Coax the model into producing a `cd && X` form. The exact text is - // non-deterministic, so the test asserts on rewrite behavior conditional - // on actually receiving a cd-prefixed command. - client.clearReceived(); - dispatchTurn(client, sessionUri, 'turn-cd-strip', - `Run this exact shell command, do not modify it: cd ${expectedWorkingDirPath} && echo strip-me-please`, - 1); - - // Wait for the toolCallReady action that carries the rewritten toolInput. - const toolReadyNotif = await client.waitForNotification(n => { - if (!isActionNotification(n, 'session/toolCallReady')) { - return false; - } - const action = getActionEnvelope(n).action as { toolInput?: string }; - return typeof action.toolInput === 'string' && action.toolInput.includes('echo strip-me-please'); - }, 90_000); - - const toolReadyAction = getActionEnvelope(toolReadyNotif).action as { toolCallId: string; toolInput?: string; confirmed?: string }; - const toolInput = toolReadyAction.toolInput!; - - // The core assertion: regardless of whether the model emitted the cd - // prefix verbatim or already pre-stripped it, the toolInput surfaced to - // the client must NOT contain the redundant `cd &&` prefix. - // Use a regex that anchors to the start of the command and tolerates - // optional surrounding quotes around the directory plus either `&&` - // or `;` as the chain operator (so quoted variants like - // `cd "" && …` and pwsh-style `cd ; …` are both detected). - const escapedWorkingDirPath = expectedWorkingDirPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - const redundantWorkingDirCdPrefix = new RegExp( - `^\\s*cd\\s+(?:"${escapedWorkingDirPath}"|'${escapedWorkingDirPath}'|${escapedWorkingDirPath})\\s*(?:&&|;)\\s*`, - ); - assert.ok( - !redundantWorkingDirCdPrefix.test(toolInput), - `toolInput should not contain a redundant cd-prefix targeting the working directory; got: ${JSON.stringify(toolInput)}`, - ); - assert.ok( - toolInput.includes('echo strip-me-please'), - `toolInput should contain the rewritten command body; got: ${JSON.stringify(toolInput)}`, - ); - - // Approve so the turn can complete. If it was already auto-confirmed - // (`confirmed` is set), skip the manual approval. - if (!toolReadyAction.confirmed) { - client.notify('dispatchAction', { - clientSeq: 2, - action: { - type: 'session/toolCallConfirmed', - session: sessionUri, - turnId: 'turn-cd-strip', - toolCallId: toolReadyAction.toolCallId, - approved: true, - }, - }); - } - - // Drive any further confirmations to completion so teardown is clean. - while (true) { - const next = await client.waitForNotification( - n => isActionNotification(n, 'session/toolCallReady') || isActionNotification(n, 'session/turnComplete') || isActionNotification(n, 'session/error'), - 90_000, - ); - if (isActionNotification(next, 'session/turnComplete') || isActionNotification(next, 'session/error')) { - break; - } - const action = getActionEnvelope(next).action as { session: string; turnId: string; toolCallId: string; confirmed?: string }; - if (!action.confirmed) { - client.notify('dispatchAction', { - clientSeq: 3, - action: { - type: 'session/toolCallConfirmed', - session: action.session, - turnId: action.turnId, - toolCallId: action.toolCallId, - approved: true, - }, - }); - } - } - }); - - // ---- write_bash skipPermission regression test -------------------------- - - test('write_bash never triggers a permission request (skipPermission flag)', async function () { - this.timeout(180_000); - - // What this test verifies: - // `write_bash` (and `read_bash` / `bash_shutdown` / `list_bash`) are - // registered as external tools with `skipPermission: true`, mirroring - // the SDK's built-in shell helpers which never call `permissions.request`. - // This regression test catches accidental removal of that flag — if it's - // removed, the SDK will route write_bash through our permission flow and - // the test will fail with `observedToolNames` containing 'write_bash'. - // - // How it works: - // 1. Allow-list permits ONLY `bash` (the interactive prompt). write_bash - // is intentionally absent from the allow list. - // 2. The model is instructed to use `write_bash`. If any permission - // request appears for write_bash, the loop records it in - // `observedToolNames` and we fail the assertion. - // 3. We assert that bash actually ran AND that write_bash appeared in - // toolCallStart notifications (so the test is non-vacuous — the model - // actually tried to use the tool, not just piped input via bash). - - const tempDir = mkdtempSync(`${tmpdir()}/ahp-write-bash-skip-perm-`); - tempDirs.push(tempDir); - const sessionUri = await createRealSession(client, 'real-sdk-write-bash-skip-perm', createdSessions, URI.file(tempDir).toString()); - - const approvalLoop = startBackgroundApprovalLoop(client, { - approvalSeqStart: 100, - allow: [ - { - // Setup bash command — the interactive `read` prompt. - toolName: 'bash', - matchInput: input => !!input && input.includes('read') && input.includes('Got:'), - }, - // Note: write_bash is intentionally NOT in the allow list. With - // skipPermission: true, the SDK won't ask us — so the test passes. - // Without it, the SDK would ask, the loop would deny + record an - // error, and the test would fail loudly. - ], - }); - - dispatchTurn(client, sessionUri, 'turn-write-bash-skip-perm', - 'You MUST demonstrate the `write_bash` tool. Steps, in order:\n' + - '1. Use the `bash` tool to run exactly: read -p "Enter: " v; echo "Got: $v"\n' + - ' This will block waiting for stdin.\n' + - '2. While that bash call is waiting, you MUST use the `write_bash` tool to send the input "hello\\n" to it.\n' + - ' Do NOT pipe the input via the original bash command. Do NOT use `echo hello | ...`.\n' + - ' You MUST go through the `write_bash` tool — that is the entire point of this task.\n' + - '3. After the shell prints "Got: hello", reply with the single word "done".', - 1); - - await client.waitForNotification( - n => isActionNotification(n, 'session/turnComplete') || isActionNotification(n, 'session/error'), - 150_000, - ); - await approvalLoop.stop(); - - // Sanity check: the bash setup command actually ran. Otherwise the - // model ignored the prompt and the write_bash assertion below is vacuous. - assert.ok(approvalLoop.approvedToolNames.has('bash'), - `expected the model to invoke bash for setup; observed approved tools: ${[...approvalLoop.approvedToolNames].join(', ') || ''}`); - - // Non-vacuousness check: write_bash must have actually been invoked - // (seen in a toolCallStart notification). If the model piped input via - // the original bash command instead of using write_bash, this fails. - const writeBashStarts = client.receivedNotifications(n => isActionNotification(n, 'session/toolCallStart')) - .map(n => getActionEnvelope(n).action as { toolName?: string }) - .filter(a => a.toolName === 'write_bash'); - assert.ok(writeBashStarts.length > 0, - `expected write_bash to be invoked at least once (toolCallStart), but it was never called. The model may have piped input via the original bash command instead.`); - - // The actual regression check: write_bash must never reach our - // permission handler. If this fails, `skipPermission: true` was likely - // removed from copilotShellTools.ts. - assert.ok(!approvalLoop.observedToolNames.has('write_bash'), - `write_bash should be auto-approved by the SDK (skipPermission: true) and never trigger a permission request, but the test observed one. Observed permission requests: ${[...approvalLoop.observedToolNames].join(', ')}`); - - // Any other unexpected permission requests (e.g. an unrelated tool the - // model decided to use) would also have been recorded as errors. - assert.deepStrictEqual(approvalLoop.errors, [], - `unexpected approval-loop errors: ${approvalLoop.errors.join('; ')}`); - }); -}); diff --git a/src/vs/platform/agentHost/test/node/protocol/turnExecution.integrationTest.ts b/src/vs/platform/agentHost/test/node/protocol/turnExecution.integrationTest.ts deleted file mode 100644 index 5a4cf072..00000000 --- a/src/vs/platform/agentHost/test/node/protocol/turnExecution.integrationTest.ts +++ /dev/null @@ -1,215 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import assert from 'assert'; -import { SubscribeResult } from '../../../common/state/protocol/commands.js'; -import type { IResponsePartAction } from '../../../common/state/sessionActions.js'; -import type { FetchTurnsResult } from '../../../common/state/sessionProtocol.js'; -import { ResponsePartKind, buildSubagentSessionUri, type MarkdownResponsePart, type SessionState } from '../../../common/state/sessionState.js'; -import { - createAndSubscribeSession, - dispatchTurnStarted, - getActionEnvelope, - IServerHandle, - isActionNotification, - startServer, - TestProtocolClient, -} from './testHelpers.js'; - -suite('Protocol WebSocket — Turn Execution', function () { - - let server: IServerHandle; - let client: TestProtocolClient; - - suiteSetup(async function () { - this.timeout(15_000); - server = await startServer(); - }); - - suiteTeardown(function () { - server.process.kill(); - }); - - setup(async function () { - this.timeout(10_000); - client = new TestProtocolClient(server.port); - await client.connect(); - }); - - teardown(function () { - client.close(); - }); - - test('send message and receive responsePart + turnComplete', async function () { - this.timeout(10_000); - - const sessionUri = await createAndSubscribeSession(client, 'test-send-message'); - dispatchTurnStarted(client, sessionUri, 'turn-1', 'hello', 1); - - const responsePart = await client.waitForNotification(n => isActionNotification(n, 'session/responsePart')); - const responsePartAction = getActionEnvelope(responsePart).action as IResponsePartAction; - assert.strictEqual(responsePartAction.part.kind, ResponsePartKind.Markdown); - assert.strictEqual((responsePartAction.part as MarkdownResponsePart).content, 'Hello, world!'); - - await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); - }); - - test('tool invocation: toolCallStart → toolCallComplete → responsePart → turnComplete', async function () { - this.timeout(10_000); - - const sessionUri = await createAndSubscribeSession(client, 'test-tool-invocation'); - dispatchTurnStarted(client, sessionUri, 'turn-tool', 'use-tool', 1); - - await client.waitForNotification(n => isActionNotification(n, 'session/toolCallStart')); - await client.waitForNotification(n => isActionNotification(n, 'session/toolCallReady')); - const toolComplete = await client.waitForNotification(n => isActionNotification(n, 'session/toolCallComplete')); - const tcAction = getActionEnvelope(toolComplete).action; - if (tcAction.type === 'session/toolCallComplete') { - assert.strictEqual(tcAction.result.success, true); - } - await client.waitForNotification(n => isActionNotification(n, 'session/responsePart')); - await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); - }); - - test('error prompt triggers session/error', async function () { - this.timeout(10_000); - - const sessionUri = await createAndSubscribeSession(client, 'test-error'); - dispatchTurnStarted(client, sessionUri, 'turn-err', 'error', 1); - - const errorNotif = await client.waitForNotification(n => isActionNotification(n, 'session/error')); - const errorAction = getActionEnvelope(errorNotif).action; - if (errorAction.type === 'session/error') { - assert.strictEqual(errorAction.error.message, 'Something went wrong'); - } - }); - - test('cancel turn stops in-progress processing', async function () { - this.timeout(10_000); - - const sessionUri = await createAndSubscribeSession(client, 'test-cancel'); - dispatchTurnStarted(client, sessionUri, 'turn-cancel', 'slow', 1); - - client.notify('dispatchAction', { - clientSeq: 2, - action: { type: 'session/turnCancelled', session: sessionUri, turnId: 'turn-cancel' }, - }); - - await client.waitForNotification(n => isActionNotification(n, 'session/turnCancelled')); - - const snapshot = await client.call('subscribe', { resource: sessionUri }); - const state = snapshot.snapshot.state as SessionState; - assert.ok(state.turns.length >= 1); - assert.strictEqual(state.turns[state.turns.length - 1].state, 'cancelled'); - }); - - test('multiple sequential turns accumulate in history', async function () { - this.timeout(15_000); - - const sessionUri = await createAndSubscribeSession(client, 'test-multi-turns'); - - dispatchTurnStarted(client, sessionUri, 'turn-m1', 'hello', 1); - await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); - - dispatchTurnStarted(client, sessionUri, 'turn-m2', 'hello', 2); - await new Promise(resolve => setTimeout(resolve, 200)); - await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); - - const snapshot = await client.call('subscribe', { resource: sessionUri }); - const state = snapshot.snapshot.state as SessionState; - assert.ok(state.turns.length >= 2, `expected >= 2 turns but got ${state.turns.length}`); - assert.strictEqual(state.turns[0].id, 'turn-m1'); - assert.strictEqual(state.turns[1].id, 'turn-m2'); - }); - - test('fetchTurns returns completed turn history', async function () { - this.timeout(15_000); - - const sessionUri = await createAndSubscribeSession(client, 'test-fetchTurns'); - - dispatchTurnStarted(client, sessionUri, 'turn-ft-1', 'hello', 1); - await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); - - dispatchTurnStarted(client, sessionUri, 'turn-ft-2', 'hello', 2); - await new Promise(resolve => setTimeout(resolve, 200)); - await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); - - const result = await client.call('fetchTurns', { session: sessionUri, limit: 10 }); - assert.ok(result.turns.length >= 2); - assert.strictEqual(typeof result.hasMore, 'boolean'); - }); - - test('usage info is captured on completed turn', async function () { - this.timeout(10_000); - - const sessionUri = await createAndSubscribeSession(client, 'test-usage'); - dispatchTurnStarted(client, sessionUri, 'turn-usage', 'with-usage', 1); - - const usageNotif = await client.waitForNotification(n => isActionNotification(n, 'session/usage')); - const usageAction = getActionEnvelope(usageNotif).action as { type: string; usage: { inputTokens: number; outputTokens: number } }; - assert.strictEqual(usageAction.usage.inputTokens, 100); - assert.strictEqual(usageAction.usage.outputTokens, 50); - - await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); - - const snapshot = await client.call('subscribe', { resource: sessionUri }); - const state = snapshot.snapshot.state as SessionState; - assert.ok(state.turns.length >= 1); - const turn = state.turns[state.turns.length - 1]; - assert.ok(turn.usage); - assert.strictEqual(turn.usage!.inputTokens, 100); - assert.strictEqual(turn.usage!.outputTokens, 50); - }); - - test('modifiedAt updates on turn completion', async function () { - this.timeout(10_000); - - const sessionUri = await createAndSubscribeSession(client, 'test-modifiedAt'); - - const initialSnapshot = await client.call('subscribe', { resource: sessionUri }); - const initialModifiedAt = (initialSnapshot.snapshot.state as SessionState).summary.modifiedAt; - - await new Promise(resolve => setTimeout(resolve, 50)); - - dispatchTurnStarted(client, sessionUri, 'turn-mod', 'hello', 1); - await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); - - const updatedSnapshot = await client.call('subscribe', { resource: sessionUri }); - const updatedModifiedAt = (updatedSnapshot.snapshot.state as SessionState).summary.modifiedAt; - assert.ok(updatedModifiedAt >= initialModifiedAt); - }); - - test('subagent: inner tool calls land in child session, not parent', async function () { - this.timeout(15_000); - - const sessionUri = await createAndSubscribeSession(client, 'test-subagent'); - dispatchTurnStarted(client, sessionUri, 'turn-sa', 'subagent', 1); - - // Wait for the parent turn to complete. - await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); - - // Subscribe to the child subagent session — its URI is derived from - // the parent session URI + parent toolCallId. - const childUri = buildSubagentSessionUri(sessionUri, 'tc-task-1'); - - const parentSnapshot = await client.call('subscribe', { resource: sessionUri }); - const parentState = parentSnapshot.snapshot.state as SessionState; - const childSnapshot = await client.call('subscribe', { resource: childUri }); - const childState = childSnapshot.snapshot.state as SessionState; - - // Parent turn should contain the `task` tool call but NOT the inner one. - const parentTurn = parentState.turns[parentState.turns.length - 1]; - const parentToolCalls = parentTurn.responseParts.filter(p => p.kind === ResponsePartKind.ToolCall); - const parentToolNames = parentToolCalls.map(p => p.toolCall.toolName); - assert.deepStrictEqual(parentToolNames, ['task'], 'parent turn should only contain the `task` tool call (inner tool must route to subagent)'); - - // Child session should have one turn with the inner `echo_tool` call. - assert.ok(childState.turns.length >= 1, 'child subagent session should have at least one turn'); - const childTurn = childState.turns[childState.turns.length - 1]; - const childToolCalls = childTurn.responseParts.filter(p => p.kind === ResponsePartKind.ToolCall); - const childToolNames = childToolCalls.map(p => p.toolCall.toolName); - assert.deepStrictEqual(childToolNames, ['echo_tool'], 'child subagent session should contain the inner `echo_tool` call'); - }); -}); diff --git a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts deleted file mode 100644 index 9f30d5f6..00000000 --- a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts +++ /dev/null @@ -1,1023 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import assert from 'assert'; -import { Emitter, Event } from '../../../../base/common/event.js'; -import { DisposableStore } from '../../../../base/common/lifecycle.js'; -import { URI } from '../../../../base/common/uri.js'; -import { runWithFakedTimers } from '../../../../base/test/common/timeTravelScheduler.js'; -import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { NullLogService } from '../../../log/common/log.js'; -import { type IAgentCreateSessionConfig, type IAgentResolveSessionConfigParams, type IAgentService, type IAgentSessionConfigCompletionsParams, type IAgentSessionMetadata, type AuthenticateParams, type AuthenticateResult } from '../../common/agentService.js'; -import { ListSessionsResult, ResourceReadResult, ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../../common/state/protocol/commands.js'; -import { ActionType, type IRootConfigChangedAction, type SessionAction, type TerminalAction } from '../../common/state/sessionActions.js'; -import { PROTOCOL_VERSION } from '../../common/state/sessionCapabilities.js'; -import { isJsonRpcNotification, isJsonRpcResponse, JSON_RPC_INTERNAL_ERROR, ProtocolError, type AhpNotification, type InitializeResult, type ProtocolMessage, type ReconnectResult, type ResourceListResult, type ResourceWriteParams, type ResourceWriteResult, type IStateSnapshot } from '../../common/state/sessionProtocol.js'; -import { ResponsePartKind, SessionStatus, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, type SessionSummary } from '../../common/state/sessionState.js'; -import type { IProtocolServer, IProtocolTransport } from '../../common/state/sessionTransport.js'; -import { ProtocolServerHandler } from '../../node/protocolServerHandler.js'; -import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; -import { AgentHostFileSystemProvider } from '../../common/agentHostFileSystemProvider.js'; - -// ---- Mock helpers ----------------------------------------------------------- - -class MockProtocolTransport implements IProtocolTransport { - private readonly _onMessage = new Emitter(); - readonly onMessage = this._onMessage.event; - private readonly _onDidSend = new Emitter(); - readonly onDidSend = this._onDidSend.event; - private readonly _onClose = new Emitter(); - readonly onClose = this._onClose.event; - - readonly sent: ProtocolMessage[] = []; - - send(message: ProtocolMessage): void { - this.sent.push(message); - this._onDidSend.fire(message); - } - - simulateMessage(msg: ProtocolMessage): void { - this._onMessage.fire(msg); - } - - simulateClose(): void { - this._onClose.fire(); - } - - dispose(): void { - this._onMessage.dispose(); - this._onDidSend.dispose(); - this._onClose.dispose(); - } -} - -class MockProtocolServer implements IProtocolServer { - private readonly _onConnection = new Emitter(); - readonly onConnection = this._onConnection.event; - readonly address = 'mock://test'; - - simulateConnection(transport: IProtocolTransport): void { - this._onConnection.fire(transport); - } - - dispose(): void { - this._onConnection.dispose(); - } -} - -class MockAgentService implements IAgentService { - declare readonly _serviceBrand: undefined; - readonly handledActions: (SessionAction | TerminalAction | IRootConfigChangedAction)[] = []; - readonly browsedUris: URI[] = []; - readonly browseErrors = new Map(); - readonly listedSessions: IAgentSessionMetadata[] = []; - readonly createSessionConfigs: (IAgentCreateSessionConfig | undefined)[] = []; - - private readonly _onDidAction = new Emitter(); - readonly onDidAction = this._onDidAction.event; - private readonly _onDidNotification = new Emitter(); - readonly onDidNotification = this._onDidNotification.event; - - private _stateManager!: AgentHostStateManager; - - /** Connect to the state manager so dispatchAction works correctly. */ - setStateManager(sm: AgentHostStateManager): void { - this._stateManager = sm; - } - - dispatchAction(action: SessionAction | TerminalAction | IRootConfigChangedAction, clientId: string, clientSeq: number): void { - this.handledActions.push(action); - const origin = { clientId, clientSeq }; - this._stateManager.dispatchClientAction(action, origin); - } - async createSession(config?: IAgentCreateSessionConfig): Promise { - this.createSessionConfigs.push(config); - const session = config?.session ?? URI.parse('copilot:///new-session'); - this._stateManager.createSession({ - resource: session.toString(), - provider: config?.provider ?? 'copilot', - title: '', - status: SessionStatus.Idle, - createdAt: Date.now(), - modifiedAt: Date.now(), - project: { uri: 'file:///created-project', displayName: 'Created Project' }, - workingDirectory: config?.workingDirectory?.toString(), - }); - return session; - } - - async resolveSessionConfig(_params: IAgentResolveSessionConfigParams): Promise { return { schema: { type: 'object', properties: {} }, values: {} }; } - async sessionConfigCompletions(_params: IAgentSessionConfigCompletionsParams): Promise { return { items: [] }; } - async disposeSession(_session: URI): Promise { } - async listSessions(): Promise { return this.listedSessions; } - async subscribe(resource: URI, _clientId: string): Promise { - const snapshot = this._stateManager.getSnapshot(resource.toString()); - if (!snapshot) { - throw new Error(`Cannot subscribe to unknown resource: ${resource.toString()}`); - } - return snapshot; - } - addSubscriber(_resource: URI, _clientId: string): void { } - unsubscribe(_resource: URI, _clientId: string): void { } - async shutdown(): Promise { } - async authenticate(_params: AuthenticateParams): Promise { return { authenticated: true }; } - async resourceWrite(_params: ResourceWriteParams): Promise { return {}; } - async resourceList(uri: URI): Promise { - this.browsedUris.push(uri); - const error = this.browseErrors.get(uri.toString()); - if (error) { - throw error; - } - return { - entries: [ - { name: 'src', type: 'directory' }, - { name: 'README.md', type: 'file' }, - ], - }; - } - async resourceRead(_uri: URI): Promise { - throw new Error('Not implemented'); - } - async resourceCopy(): Promise<{}> { return {}; } - async resourceDelete(): Promise<{}> { return {}; } - async resourceMove(): Promise<{}> { return {}; } - async createTerminal(): Promise { } - async disposeTerminal(): Promise { } - - dispose(): void { - this._onDidAction.dispose(); - this._onDidNotification.dispose(); - } -} - -// ---- Helpers ---------------------------------------------------------------- - -function notification(method: string, params?: unknown): ProtocolMessage { - return { jsonrpc: '2.0', method, params } as ProtocolMessage; -} - -function request(id: number, method: string, params?: unknown): ProtocolMessage { - return { jsonrpc: '2.0', id, method, params } as ProtocolMessage; -} - -function findNotifications(sent: ProtocolMessage[], method: string): AhpNotification[] { - return sent.filter(isJsonRpcNotification) as AhpNotification[]; -} - -function findResponse(sent: ProtocolMessage[], id: number): ProtocolMessage | undefined { - return sent.find(isJsonRpcResponse) as ProtocolMessage | undefined; -} - -function waitForResponse(transport: MockProtocolTransport, id: number): Promise { - return Event.toPromise(Event.filter(transport.onDidSend, message => isJsonRpcResponse(message) && message.id === id)); -} - -// ---- Tests ------------------------------------------------------------------ - -suite('ProtocolServerHandler', () => { - - let disposables: DisposableStore; - let stateManager: AgentHostStateManager; - let server: MockProtocolServer; - let agentService: MockAgentService; - let handler: ProtocolServerHandler; - - const sessionUri = URI.from({ scheme: 'copilot', path: '/test-session' }).toString(); - - function makeSessionSummary(resource?: string): SessionSummary { - return { - resource: resource ?? sessionUri, - provider: 'copilot', - title: 'Test', - status: SessionStatus.Idle, - createdAt: Date.now(), - modifiedAt: Date.now(), - project: { uri: 'file:///test-project', displayName: 'Test Project' }, - }; - } - - function connectClient(clientId: string, initialSubscriptions?: readonly string[]): MockProtocolTransport { - const transport = new MockProtocolTransport(); - server.simulateConnection(transport); - transport.simulateMessage(request(1, 'initialize', { - protocolVersion: PROTOCOL_VERSION, - clientId, - initialSubscriptions, - })); - return transport; - } - - setup(() => { - disposables = new DisposableStore(); - stateManager = disposables.add(new AgentHostStateManager(new NullLogService())); - server = disposables.add(new MockProtocolServer()); - agentService = new MockAgentService(); - agentService.setStateManager(stateManager); - disposables.add(agentService); - disposables.add(handler = new ProtocolServerHandler( - agentService, - stateManager, - server, - { defaultDirectory: URI.file('/home/testuser').toString() }, - disposables.add(new AgentHostFileSystemProvider()), - new NullLogService(), - )); - }); - - teardown(() => { - disposables.dispose(); - }); - - ensureNoDisposablesAreLeakedInTestSuite(); - - test('handshake returns initialize response', () => { - const transport = connectClient('client-1'); - - const resp = findResponse(transport.sent, 1); - assert.ok(resp, 'should have sent initialize response'); - const result = (resp as { result: InitializeResult }).result; - assert.strictEqual(result.protocolVersion, PROTOCOL_VERSION); - assert.strictEqual(result.serverSeq, stateManager.serverSeq); - }); - - test('handshake with initialSubscriptions returns snapshots', () => { - stateManager.createSession(makeSessionSummary()); - - const transport = connectClient('client-1', [sessionUri]); - - const resp = findResponse(transport.sent, 1); - assert.ok(resp); - const result = (resp as { result: InitializeResult }).result; - assert.strictEqual(result.snapshots.length, 1); - assert.strictEqual(result.snapshots[0].resource.toString(), sessionUri.toString()); - }); - - test('subscribe request returns snapshot', async () => { - stateManager.createSession(makeSessionSummary()); - - const transport = connectClient('client-1'); - transport.sent.length = 0; - const responsePromise = waitForResponse(transport, 1); - - transport.simulateMessage(request(1, 'subscribe', { resource: sessionUri })); - const resp = await responsePromise; - - assert.ok(resp, 'should have sent response'); - const result = (resp as unknown as { result: { snapshot: IStateSnapshot } }).result; - assert.strictEqual(result.snapshot.resource.toString(), sessionUri.toString()); - }); - - test('client action is dispatched and echoed', () => { - stateManager.createSession(makeSessionSummary()); - stateManager.dispatchServerAction({ type: ActionType.SessionReady, session: sessionUri }); - - const transport = connectClient('client-1', [sessionUri]); - transport.sent.length = 0; - - transport.simulateMessage(notification('dispatchAction', { - clientSeq: 1, - action: { - type: ActionType.SessionTurnStarted, - session: sessionUri, - turnId: 'turn-1', - userMessage: { text: 'hello' }, - }, - })); - - const actionMsgs = findNotifications(transport.sent, 'action'); - const turnStarted = actionMsgs.find(m => { - const envelope = m.params as unknown as { action: { type: string } }; - return envelope.action.type === ActionType.SessionTurnStarted; - }); - assert.ok(turnStarted, 'should have echoed turnStarted'); - const envelope = turnStarted!.params as unknown as { origin: { clientId: string; clientSeq: number } }; - assert.strictEqual(envelope.origin.clientId, 'client-1'); - assert.strictEqual(envelope.origin.clientSeq, 1); - }); - - test('actions are scoped to subscribed sessions', () => { - stateManager.createSession(makeSessionSummary()); - stateManager.dispatchServerAction({ type: ActionType.SessionReady, session: sessionUri }); - - const transportA = connectClient('client-a', [sessionUri]); - const transportB = connectClient('client-b'); - - transportA.sent.length = 0; - transportB.sent.length = 0; - - stateManager.dispatchServerAction({ - type: ActionType.SessionTitleChanged, - session: sessionUri, - title: 'New Title', - }); - - assert.strictEqual(findNotifications(transportA.sent, 'action').length, 1); - assert.strictEqual(findNotifications(transportB.sent, 'action').length, 0); - }); - - test('notifications are broadcast to all clients', () => { - const transportA = connectClient('client-a'); - const transportB = connectClient('client-b'); - - transportA.sent.length = 0; - transportB.sent.length = 0; - - stateManager.createSession(makeSessionSummary()); - - assert.strictEqual(findNotifications(transportA.sent, 'notification').length, 1); - assert.strictEqual(findNotifications(transportB.sent, 'notification').length, 1); - }); - - test('listSessions includes project metadata', async () => { - agentService.listedSessions.push({ - session: URI.parse(sessionUri), - startTime: 1000, - modifiedTime: 2000, - project: { uri: URI.file('/workspace/project'), displayName: 'Project' }, - summary: 'Session Summary', - }); - - const transport = connectClient('client-list'); - transport.sent.length = 0; - const responsePromise = waitForResponse(transport, 2); - - transport.simulateMessage(request(2, 'listSessions')); - const resp = await responsePromise; - - const result = (resp as unknown as { result: ListSessionsResult }).result; - assert.deepStrictEqual(result.items.map(item => item.project), [{ uri: URI.file('/workspace/project').toString(), displayName: 'Project' }]); - }); - - test('listSessions omits project metadata when absent', async () => { - agentService.listedSessions.push({ - session: URI.parse(sessionUri), - startTime: 1000, - modifiedTime: 2000, - summary: 'Session Summary', - }); - - const transport = connectClient('client-list-no-project'); - transport.sent.length = 0; - const responsePromise = waitForResponse(transport, 2); - - transport.simulateMessage(request(2, 'listSessions')); - const resp = await responsePromise; - - const result = (resp as unknown as { result: ListSessionsResult }).result; - assert.deepStrictEqual(result.items.map(item => item.project), [undefined]); - }); - - test('listSessions includes diffs with before/after URIs and content refs', async () => { - agentService.listedSessions.push({ - session: URI.parse(sessionUri), - startTime: 1000, - modifiedTime: 2000, - summary: 'Session With Diffs', - diffs: [ - { - before: { uri: URI.file('/workspace/file.ts').toString(), content: { uri: 'content://before-ref' } }, - after: { uri: URI.file('/workspace/file.ts').toString(), content: { uri: 'content://after-ref' } }, - diff: { added: 5, removed: 2 }, - }, - { - after: { uri: URI.file('/workspace/new-file.ts').toString(), content: { uri: 'content://new-ref' } }, - }, - { - before: { uri: URI.file('/workspace/deleted.ts').toString(), content: { uri: 'content://deleted-ref' } }, - }, - ], - }); - - const transport = connectClient('client-list-diffs'); - transport.sent.length = 0; - const responsePromise = waitForResponse(transport, 2); - - transport.simulateMessage(request(2, 'listSessions')); - const resp = await responsePromise; - - const result = (resp as unknown as { result: ListSessionsResult }).result; - assert.deepStrictEqual(result.items[0].diffs, [ - { - before: { uri: URI.file('/workspace/file.ts').toString(), content: { uri: 'content://before-ref' } }, - after: { uri: URI.file('/workspace/file.ts').toString(), content: { uri: 'content://after-ref' } }, - diff: { added: 5, removed: 2 }, - }, - { - after: { uri: URI.file('/workspace/new-file.ts').toString(), content: { uri: 'content://new-ref' } }, - }, - { - before: { uri: URI.file('/workspace/deleted.ts').toString(), content: { uri: 'content://deleted-ref' } }, - }, - ]); - }); - - test('createSession returns null and broadcasts project in sessionAdded summary', async () => { - const transport = connectClient('client-create'); - transport.sent.length = 0; - const responsePromise = waitForResponse(transport, 2); - - const newSession = URI.parse('copilot:///created-session').toString(); - transport.simulateMessage(request(2, 'createSession', { session: newSession })); - const resp = await responsePromise; - - const added = findNotifications(transport.sent, 'notification').find(message => { - const params = message.params as { notification: { type: string } }; - return params.notification.type === 'notify/sessionAdded'; - }); - assert.deepStrictEqual({ - result: (resp as { result: null }).result, - project: (added!.params as { notification: { summary: SessionSummary } }).notification.summary.project, - }, { - result: null, - project: { uri: 'file:///created-project', displayName: 'Created Project' }, - }); - }); - - test('reconnect replays missed actions', async () => { - stateManager.createSession(makeSessionSummary()); - stateManager.dispatchServerAction({ type: ActionType.SessionReady, session: sessionUri }); - - const transport1 = connectClient('client-r', [sessionUri]); - const resp = findResponse(transport1.sent, 1); - const initSeq = (resp as { result: InitializeResult }).result.serverSeq; - transport1.simulateClose(); - - stateManager.dispatchServerAction({ type: ActionType.SessionTitleChanged, session: sessionUri, title: 'Title A' }); - stateManager.dispatchServerAction({ type: ActionType.SessionTitleChanged, session: sessionUri, title: 'Title B' }); - - const transport2 = new MockProtocolTransport(); - server.simulateConnection(transport2); - const reconnectRespPromise = waitForResponse(transport2, 1); - transport2.simulateMessage(request(1, 'reconnect', { - clientId: 'client-r', - lastSeenServerSeq: initSeq, - subscriptions: [sessionUri], - })); - - const reconnectResp = await reconnectRespPromise; - const result = (reconnectResp as { result: ReconnectResult }).result; - assert.strictEqual(result.type, 'replay'); - if (result.type === 'replay') { - assert.strictEqual(result.actions.length, 2); - } - }); - - test('reconnect sends fresh snapshots when gap too large', async () => { - stateManager.createSession(makeSessionSummary()); - stateManager.dispatchServerAction({ type: ActionType.SessionReady, session: sessionUri }); - - const transport1 = connectClient('client-g', [sessionUri]); - transport1.simulateClose(); - - for (let i = 0; i < 1100; i++) { - stateManager.dispatchServerAction({ type: ActionType.SessionTitleChanged, session: sessionUri, title: `Title ${i}` }); - } - - const transport2 = new MockProtocolTransport(); - server.simulateConnection(transport2); - const reconnectRespPromise = waitForResponse(transport2, 1); - transport2.simulateMessage(request(1, 'reconnect', { - clientId: 'client-g', - lastSeenServerSeq: 0, - subscriptions: [sessionUri], - })); - - const reconnectResp = await reconnectRespPromise; - const result = (reconnectResp as { result: ReconnectResult }).result; - assert.strictEqual(result.type, 'snapshot'); - if (result.type === 'snapshot') { - assert.ok(result.snapshots.length > 0, 'should contain snapshots'); - } - }); - - test('reconnect rehydrates server-side state that was evicted while disconnected', async () => { - stateManager.createSession(makeSessionSummary()); - stateManager.dispatchServerAction({ type: ActionType.SessionReady, session: sessionUri }); - - // MockAgentService.subscribe normally just returns the existing snapshot. - // Override it so a missing session is restored on subscribe — this is the - // behavior the real AgentService provides and that reconnect now relies on. - const subscribeCalls: string[] = []; - agentService.subscribe = async (resource, _clientId) => { - subscribeCalls.push(resource.toString()); - let snapshot = stateManager.getSnapshot(resource.toString()); - if (!snapshot) { - stateManager.restoreSession(makeSessionSummary(), []); - snapshot = stateManager.getSnapshot(resource.toString())!; - } - return snapshot; - }; - - const transport1 = connectClient('client-e', [sessionUri]); - const initResp = findResponse(transport1.sent, 1); - const initSeq = (initResp as { result: InitializeResult }).result.serverSeq; - transport1.simulateClose(); - - // Simulate the AgentService evicting the idle session while the client - // was disconnected (this is what `_maybeEvictIdleSession` does in the - // real service). - stateManager.removeSession(sessionUri); - assert.strictEqual(stateManager.getSnapshot(sessionUri), undefined, 'precondition: state evicted'); - - const transport2 = new MockProtocolTransport(); - server.simulateConnection(transport2); - const reconnectRespPromise = waitForResponse(transport2, 1); - transport2.simulateMessage(request(1, 'reconnect', { - clientId: 'client-e', - lastSeenServerSeq: initSeq, - subscriptions: [sessionUri], - })); - - await reconnectRespPromise; - assert.deepStrictEqual(subscribeCalls, [sessionUri], 'reconnect should call subscribe to restore evicted state'); - assert.ok(stateManager.getSnapshot(sessionUri), 'state should have been re-hydrated by reconnect'); - }); - - test('client disconnect cleans up', () => { - stateManager.createSession(makeSessionSummary()); - stateManager.dispatchServerAction({ type: ActionType.SessionReady, session: sessionUri }); - - const transport = connectClient('client-d', [sessionUri]); - transport.sent.length = 0; - - transport.simulateClose(); - - stateManager.dispatchServerAction({ type: ActionType.SessionTitleChanged, session: sessionUri, title: 'After Disconnect' }); - - assert.strictEqual(transport.sent.length, 0); - }); - - test('client disconnect clears active client and fails owned tool calls after grace period', () => { - return runWithFakedTimers({ useFakeTimers: true }, async () => { - stateManager.createSession(makeSessionSummary()); - stateManager.dispatchServerAction({ type: ActionType.SessionReady, session: sessionUri }); - stateManager.dispatchServerAction({ - type: ActionType.SessionActiveClientChanged, - session: sessionUri, - activeClient: { - clientId: 'client-tools', - tools: [{ name: 'runTask', description: 'Runs a task' }], - }, - }); - stateManager.dispatchServerAction({ - type: ActionType.SessionTurnStarted, - session: sessionUri, - turnId: 'turn-1', - userMessage: { text: 'run it' }, - }); - stateManager.dispatchServerAction({ - type: ActionType.SessionToolCallStart, - session: sessionUri, - turnId: 'turn-1', - toolCallId: 'tool-1', - toolName: 'runTask', - displayName: 'Run Task', - toolClientId: 'client-tools', - }); - stateManager.dispatchServerAction({ - type: ActionType.SessionToolCallReady, - session: sessionUri, - turnId: 'turn-1', - toolCallId: 'tool-1', - invocationMessage: 'Run Task', - toolInput: '{}', - confirmed: ToolCallConfirmationReason.NotNeeded, - }); - - const transport = connectClient('client-tools', [sessionUri]); - transport.simulateClose(); - - assert.strictEqual(stateManager.getSessionState(sessionUri)?.activeClient, undefined); - let part = stateManager.getSessionState(sessionUri)?.activeTurn?.responseParts[0]; - assert.strictEqual(part?.kind, ResponsePartKind.ToolCall); - assert.strictEqual(part?.kind === ResponsePartKind.ToolCall ? part.toolCall.status : undefined, ToolCallStatus.Running); - - await new Promise(r => setTimeout(r, 30_001)); - - part = stateManager.getSessionState(sessionUri)?.activeTurn?.responseParts[0]; - assert.strictEqual(part?.kind, ResponsePartKind.ToolCall); - assert.deepStrictEqual(part?.kind === ResponsePartKind.ToolCall ? { - status: part.toolCall.status, - success: part.toolCall.status === ToolCallStatus.Completed ? part.toolCall.success : undefined, - error: part.toolCall.status === ToolCallStatus.Completed ? part.toolCall.error?.message : undefined, - } : undefined, { - status: ToolCallStatus.Completed, - success: false, - error: 'Client client-tools disconnected before completing Run Task', - }); - }); - }); - - test('client disconnect fails owned streaming tool calls after grace period', () => { - return runWithFakedTimers({ useFakeTimers: true }, async () => { - stateManager.createSession(makeSessionSummary()); - stateManager.dispatchServerAction({ type: ActionType.SessionReady, session: sessionUri }); - stateManager.dispatchServerAction({ - type: ActionType.SessionActiveClientChanged, - session: sessionUri, - activeClient: { - clientId: 'client-tools', - tools: [{ name: 'runTask', description: 'Runs a task' }], - }, - }); - stateManager.dispatchServerAction({ - type: ActionType.SessionTurnStarted, - session: sessionUri, - turnId: 'turn-1', - userMessage: { text: 'run it' }, - }); - stateManager.dispatchServerAction({ - type: ActionType.SessionToolCallStart, - session: sessionUri, - turnId: 'turn-1', - toolCallId: 'tool-1', - toolName: 'runTask', - displayName: 'Run Task', - toolClientId: 'client-tools', - }); - - const transport = connectClient('client-tools', [sessionUri]); - transport.simulateClose(); - - let part = stateManager.getSessionState(sessionUri)?.activeTurn?.responseParts[0]; - assert.strictEqual(part?.kind, ResponsePartKind.ToolCall); - assert.strictEqual(part?.kind === ResponsePartKind.ToolCall ? part.toolCall.status : undefined, ToolCallStatus.Streaming); - - await new Promise(r => setTimeout(r, 30_001)); - - part = stateManager.getSessionState(sessionUri)?.activeTurn?.responseParts[0]; - assert.strictEqual(part?.kind, ResponsePartKind.ToolCall); - assert.deepStrictEqual(part?.kind === ResponsePartKind.ToolCall ? { - status: part.toolCall.status, - success: part.toolCall.status === ToolCallStatus.Completed ? part.toolCall.success : undefined, - error: part.toolCall.status === ToolCallStatus.Completed ? part.toolCall.error?.message : undefined, - } : undefined, { - status: ToolCallStatus.Completed, - success: false, - error: 'Client client-tools disconnected before completing Run Task', - }); - }); - }); - - test('client reconnect without session subscription does not clear tool call disconnect timeout', () => { - return runWithFakedTimers({ useFakeTimers: true }, async () => { - stateManager.createSession(makeSessionSummary()); - stateManager.dispatchServerAction({ type: ActionType.SessionReady, session: sessionUri }); - stateManager.dispatchServerAction({ - type: ActionType.SessionActiveClientChanged, - session: sessionUri, - activeClient: { - clientId: 'client-tools', - tools: [{ name: 'runTask', description: 'Runs a task' }], - }, - }); - stateManager.dispatchServerAction({ - type: ActionType.SessionTurnStarted, - session: sessionUri, - turnId: 'turn-1', - userMessage: { text: 'run it' }, - }); - stateManager.dispatchServerAction({ - type: ActionType.SessionToolCallStart, - session: sessionUri, - turnId: 'turn-1', - toolCallId: 'tool-1', - toolName: 'runTask', - displayName: 'Run Task', - toolClientId: 'client-tools', - }); - stateManager.dispatchServerAction({ - type: ActionType.SessionToolCallReady, - session: sessionUri, - turnId: 'turn-1', - toolCallId: 'tool-1', - invocationMessage: 'Run Task', - toolInput: '{}', - confirmed: ToolCallConfirmationReason.NotNeeded, - }); - - const transport = connectClient('client-tools', [sessionUri]); - transport.simulateClose(); - - const reconnectTransport = new MockProtocolTransport(); - server.simulateConnection(reconnectTransport); - reconnectTransport.simulateMessage(request(1, 'reconnect', { - clientId: 'client-tools', - lastSeenServerSeq: stateManager.serverSeq, - subscriptions: [], - })); - - await new Promise(r => setTimeout(r, 30_001)); - - const part = stateManager.getSessionState(sessionUri)?.activeTurn?.responseParts[0]; - assert.strictEqual(part?.kind, ResponsePartKind.ToolCall); - assert.deepStrictEqual(part?.kind === ResponsePartKind.ToolCall ? { - status: part.toolCall.status, - success: part.toolCall.status === ToolCallStatus.Completed ? part.toolCall.success : undefined, - } : undefined, { - status: ToolCallStatus.Completed, - success: false, - }); - }); - }); - - test('client reconnect with session subscription clears tool call disconnect timeout for that session', () => { - return runWithFakedTimers({ useFakeTimers: true }, async () => { - stateManager.createSession(makeSessionSummary()); - stateManager.dispatchServerAction({ type: ActionType.SessionReady, session: sessionUri }); - stateManager.dispatchServerAction({ - type: ActionType.SessionActiveClientChanged, - session: sessionUri, - activeClient: { - clientId: 'client-tools', - tools: [{ name: 'runTask', description: 'Runs a task' }], - }, - }); - stateManager.dispatchServerAction({ - type: ActionType.SessionTurnStarted, - session: sessionUri, - turnId: 'turn-1', - userMessage: { text: 'run it' }, - }); - stateManager.dispatchServerAction({ - type: ActionType.SessionToolCallStart, - session: sessionUri, - turnId: 'turn-1', - toolCallId: 'tool-1', - toolName: 'runTask', - displayName: 'Run Task', - toolClientId: 'client-tools', - }); - stateManager.dispatchServerAction({ - type: ActionType.SessionToolCallReady, - session: sessionUri, - turnId: 'turn-1', - toolCallId: 'tool-1', - invocationMessage: 'Run Task', - toolInput: '{}', - confirmed: ToolCallConfirmationReason.NotNeeded, - }); - - const transport = connectClient('client-tools', [sessionUri]); - transport.simulateClose(); - - const reconnectTransport = new MockProtocolTransport(); - server.simulateConnection(reconnectTransport); - reconnectTransport.simulateMessage(request(1, 'reconnect', { - clientId: 'client-tools', - lastSeenServerSeq: stateManager.serverSeq, - subscriptions: [sessionUri], - })); - - await new Promise(r => setTimeout(r, 30_001)); - - const part = stateManager.getSessionState(sessionUri)?.activeTurn?.responseParts[0]; - assert.strictEqual(part?.kind, ResponsePartKind.ToolCall); - assert.strictEqual(part?.kind === ResponsePartKind.ToolCall ? part.toolCall.status : undefined, ToolCallStatus.Running); - }); - }); - - test('client tool timeout tells model it may retry when replacement active client provides the tool', () => { - return runWithFakedTimers({ useFakeTimers: true }, async () => { - stateManager.createSession(makeSessionSummary()); - stateManager.dispatchServerAction({ type: ActionType.SessionReady, session: sessionUri }); - stateManager.dispatchServerAction({ - type: ActionType.SessionActiveClientChanged, - session: sessionUri, - activeClient: { - clientId: 'client-tools', - tools: [{ name: 'runTask', description: 'Runs a task' }], - }, - }); - stateManager.dispatchServerAction({ - type: ActionType.SessionTurnStarted, - session: sessionUri, - turnId: 'turn-1', - userMessage: { text: 'run it' }, - }); - stateManager.dispatchServerAction({ - type: ActionType.SessionToolCallStart, - session: sessionUri, - turnId: 'turn-1', - toolCallId: 'tool-1', - toolName: 'runTask', - displayName: 'Run Task', - toolClientId: 'client-tools', - }); - stateManager.dispatchServerAction({ - type: ActionType.SessionToolCallReady, - session: sessionUri, - turnId: 'turn-1', - toolCallId: 'tool-1', - invocationMessage: 'Run Task', - toolInput: '{}', - confirmed: ToolCallConfirmationReason.NotNeeded, - }); - - const transport = connectClient('client-tools', [sessionUri]); - transport.simulateClose(); - stateManager.dispatchServerAction({ - type: ActionType.SessionActiveClientChanged, - session: sessionUri, - activeClient: { - clientId: 'client-replacement', - tools: [{ name: 'runTask', description: 'Runs a task' }], - }, - }); - - await new Promise(r => setTimeout(r, 30_001)); - - const part = stateManager.getSessionState(sessionUri)?.activeTurn?.responseParts[0]; - assert.strictEqual(part?.kind, ResponsePartKind.ToolCall); - assert.deepStrictEqual(part?.kind === ResponsePartKind.ToolCall && part.toolCall.status === ToolCallStatus.Completed ? { - status: part.toolCall.status, - success: part.toolCall.success, - content: part.toolCall.content, - } : undefined, { - status: ToolCallStatus.Completed, - success: false, - content: [{ type: ToolResultContentType.Text, text: 'The client that was running Run Task disconnected, but another active client now provides Run Task. You may try calling the tool again.' }], - }); - }); - }); - - test('handshake includes defaultDirectory from side effects', () => { - const transport = connectClient('client-home'); - - const resp = findResponse(transport.sent, 1); - assert.ok(resp); - const result = (resp as { result: InitializeResult }).result; - assert.strictEqual(URI.parse(result.defaultDirectory!).path, '/home/testuser'); - }); - - test('resourceList routes to side effect handler', async () => { - const transport = connectClient('client-browse'); - transport.sent.length = 0; - - const dirUri = URI.file('/home/user/project').toString(); - const responsePromise = waitForResponse(transport, 2); - transport.simulateMessage(request(2, 'resourceList', { uri: dirUri })); - const resp = await responsePromise; - - assert.strictEqual(agentService.browsedUris.length, 1); - assert.strictEqual(agentService.browsedUris[0].path, '/home/user/project'); - - assert.ok(resp); - const result = (resp as unknown as { result: { entries: { name: string; uri: unknown; type: string }[] } }).result; - assert.strictEqual(result.entries.length, 2); - assert.strictEqual(result.entries[0].name, 'src'); - assert.strictEqual(result.entries[0].type, 'directory'); - assert.strictEqual(result.entries[1].name, 'README.md'); - assert.strictEqual(result.entries[1].type, 'file'); - }); - - test('resourceList returns a JSON-RPC error when the target is invalid', async () => { - const transport = connectClient('client-browse-error'); - transport.sent.length = 0; - - const dirUri = URI.file('/missing').toString(); - agentService.browseErrors.set(URI.file('/missing').toString(), new ProtocolError(JSON_RPC_INTERNAL_ERROR, `Directory not found: ${dirUri}`)); - const responsePromise = waitForResponse(transport, 2); - transport.simulateMessage(request(2, 'resourceList', { uri: dirUri })); - const resp = await responsePromise as { error?: { code: number; message: string } }; - - assert.ok(resp?.error); - assert.strictEqual(resp.error!.code, JSON_RPC_INTERNAL_ERROR); - assert.match(resp.error!.message, /Directory not found/); - }); - - // ---- Extension methods: auth ---------------------------------------- - - test('authenticate returns result via typed request', async () => { - const transport = connectClient('client-auth'); - transport.sent.length = 0; - - const responsePromise = waitForResponse(transport, 2); - transport.simulateMessage(request(2, 'authenticate', { resource: 'https://api.github.com', token: 'test-token' })); - const resp = await responsePromise as { result?: Record; error?: { code: number; message: string } }; - - assert.ok(!resp.error, `unexpected error: ${resp.error?.message}`); - assert.deepStrictEqual(resp.result, {}); - }); - - test('extension request preserves ProtocolError code and data', async () => { - // Override authenticate to throw a ProtocolError with data - const origHandler = agentService.authenticate; - agentService.authenticate = async () => { throw new ProtocolError(-32007, 'Auth required', { hint: 'sign in' }); }; - - const transport = connectClient('client-auth-error'); - transport.sent.length = 0; - - const responsePromise = waitForResponse(transport, 2); - transport.simulateMessage(request(2, 'authenticate', { resource: 'test', token: 'bad' })); - const resp = await responsePromise as { error?: { code: number; message: string; data?: unknown } }; - - assert.ok(resp?.error); - assert.strictEqual(resp.error!.code, -32007); - assert.strictEqual(resp.error!.message, 'Auth required'); - assert.deepStrictEqual(resp.error!.data, { hint: 'sign in' }); - - agentService.authenticate = origHandler; - }); - - // ---- Connection count event ----------------------------------------- - - test('onDidChangeConnectionCount fires on connect and disconnect', () => { - const counts: number[] = []; - disposables.add(handler.onDidChangeConnectionCount(c => counts.push(c))); - - const transport = connectClient('client-count-1'); - connectClient('client-count-2'); - transport.simulateClose(); - - assert.deepStrictEqual(counts, [1, 2, 1]); - }); - - test('onDidChangeConnectionCount is not decremented by stale reconnect close', () => { - const counts: number[] = []; - disposables.add(handler.onDidChangeConnectionCount(c => counts.push(c))); - - // Connect - const transport1 = connectClient('client-rc'); - assert.deepStrictEqual(counts, [1]); - - // Reconnect with same clientId (new transport) - const transport2 = new MockProtocolTransport(); - server.simulateConnection(transport2); - transport2.simulateMessage(request(1, 'reconnect', { - clientId: 'client-rc', - lastSeenServerSeq: 0, - subscriptions: [], - })); - // Count is unchanged because same clientId was overwritten - assert.deepStrictEqual(counts, [1, 1]); - - // Old transport closes - should NOT decrement since it's stale - transport1.simulateClose(); - assert.deepStrictEqual(counts, [1, 1]); - - // New transport closes - should decrement - transport2.simulateClose(); - assert.deepStrictEqual(counts, [1, 1, 0]); - }); - - // ---- createSession activeClient ------------------------------------- - - suite('createSession activeClient', () => { - - test('forwards activeClient to the agent service', async () => { - const newSession = URI.parse('copilot:///eager-session').toString(); - - const transport = connectClient('client-1'); - transport.sent.length = 0; - - const responsePromise = waitForResponse(transport, 2); - transport.simulateMessage(request(2, 'createSession', { - session: newSession, - provider: 'copilot', - activeClient: { - clientId: 'client-1', - tools: [{ name: 't1', description: 'd', inputSchema: { type: 'object' } }], - customizations: [{ uri: 'file:///plugin-a', displayName: 'A' }], - }, - })); - const resp = await responsePromise as { result?: unknown; error?: unknown }; - - assert.strictEqual(resp.error, undefined, 'createSession should succeed'); - const config = agentService.createSessionConfigs.at(-1); - assert.deepStrictEqual({ - clientId: config?.activeClient?.clientId, - toolName: config?.activeClient?.tools[0]?.name, - customizationUri: config?.activeClient?.customizations?.[0].uri, - }, { - clientId: 'client-1', - toolName: 't1', - customizationUri: 'file:///plugin-a', - }); - }); - - test('rejects createSession when activeClient.clientId mismatches', async () => { - const newSession = URI.parse('copilot:///mismatch-session').toString(); - - const transport = connectClient('client-1'); - transport.sent.length = 0; - - const responsePromise = waitForResponse(transport, 2); - transport.simulateMessage(request(2, 'createSession', { - session: newSession, - provider: 'copilot', - activeClient: { - clientId: 'other-client', - tools: [], - }, - })); - const resp = await responsePromise as { result?: unknown; error?: { code: number; message: string } }; - - assert.ok(resp.error, 'response should be an error'); - assert.strictEqual(resp.result, undefined); - assert.strictEqual(agentService.createSessionConfigs.length, 0, 'agent service should not have been called'); - }); - }); -}); diff --git a/src/vs/platform/agentHost/test/node/reducers.test.ts b/src/vs/platform/agentHost/test/node/reducers.test.ts deleted file mode 100644 index 0c333e38..00000000 --- a/src/vs/platform/agentHost/test/node/reducers.test.ts +++ /dev/null @@ -1,194 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import assert from 'assert'; -import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { sessionReducer } from '../../common/state/protocol/reducers.js'; -import { ActionType } from '../../common/state/sessionActions.js'; -import { SessionInputAnswerState, SessionInputAnswerValueKind, SessionInputQuestionKind, SessionInputResponseKind, SessionLifecycle, SessionStatus, ToolCallConfirmationReason, type SessionState } from '../../common/state/sessionState.js'; - -function makeSession(): SessionState { - return { - summary: { - resource: 'copilot:/test', - provider: 'copilot', - title: 'Test', - status: SessionStatus.Idle, - createdAt: Date.now(), - modifiedAt: Date.now(), - project: { uri: 'file:///test-project', displayName: 'Test Project' }, - }, - lifecycle: SessionLifecycle.Ready, - turns: [], - }; -} - -function withActiveTurnAndToolCall(state: SessionState): SessionState { - state = sessionReducer(state, { - type: ActionType.SessionTurnStarted, - session: 'copilot:/test', - turnId: 'turn-1', - userMessage: { text: 'hello' }, - }); - state = sessionReducer(state, { - type: ActionType.SessionToolCallStart, - session: 'copilot:/test', - turnId: 'turn-1', - toolCallId: 'tc-1', - toolName: 'readFile', - displayName: 'Read File', - }); - return state; -} - -suite('sessionReducer – summaryStatus with tool call confirmations and input requests', () => { - - ensureNoDisposablesAreLeakedInTestSuite(); - - test('SessionStatus is InputNeeded when a tool call is PendingConfirmation', () => { - let state = withActiveTurnAndToolCall(makeSession()); - - // Transition to PendingConfirmation (no `confirmed` field) - state = sessionReducer(state, { - type: ActionType.SessionToolCallReady, - session: 'copilot:/test', - turnId: 'turn-1', - toolCallId: 'tc-1', - invocationMessage: 'Read file?', - toolInput: '/foo.ts', - }); - - assert.strictEqual(state.summary.status, SessionStatus.InputNeeded); - }); - - test('SessionStatus is InputNeeded when a tool call is PendingResultConfirmation', () => { - let state = withActiveTurnAndToolCall(makeSession()); - - // Transition to Running first - state = sessionReducer(state, { - type: ActionType.SessionToolCallReady, - session: 'copilot:/test', - turnId: 'turn-1', - toolCallId: 'tc-1', - invocationMessage: 'Read file', - toolInput: '/foo.ts', - confirmed: ToolCallConfirmationReason.NotNeeded, - }); - - // Then complete with requiresResultConfirmation - state = sessionReducer(state, { - type: ActionType.SessionToolCallComplete, - session: 'copilot:/test', - turnId: 'turn-1', - toolCallId: 'tc-1', - requiresResultConfirmation: true, - result: { - success: true, - pastTenseMessage: 'Read file', - }, - }); - - assert.strictEqual(state.summary.status, SessionStatus.InputNeeded); - }); - - test('SessionStatus transitions from InputNeeded to InProgress when tool call is confirmed', () => { - let state = withActiveTurnAndToolCall(makeSession()); - - // Transition to PendingConfirmation - state = sessionReducer(state, { - type: ActionType.SessionToolCallReady, - session: 'copilot:/test', - turnId: 'turn-1', - toolCallId: 'tc-1', - invocationMessage: 'Read file?', - toolInput: '/foo.ts', - }); - assert.strictEqual(state.summary.status, SessionStatus.InputNeeded); - - // Confirm it - state = sessionReducer(state, { - type: ActionType.SessionToolCallConfirmed, - session: 'copilot:/test', - turnId: 'turn-1', - toolCallId: 'tc-1', - approved: true, - confirmed: ToolCallConfirmationReason.UserAction, - }); - - assert.strictEqual(state.summary.status, SessionStatus.InProgress); - }); - - test('SessionStatus is InputNeeded with inputRequests', () => { - let state = withActiveTurnAndToolCall(makeSession()); - - state = sessionReducer(state, { - type: ActionType.SessionInputRequested, - session: 'copilot:/test', - request: { - id: 'req-1', - message: 'What is your name?', - questions: [{ - kind: SessionInputQuestionKind.Text, - id: 'q-1', - message: 'What is your name?', - required: true, - }], - }, - }); - - assert.strictEqual(state.summary.status, SessionStatus.InputNeeded); - }); - - test('SessionStatus transitions from InputNeeded to InProgress after SessionInputCompleted', () => { - let state = withActiveTurnAndToolCall(makeSession()); - - // Add an input request - state = sessionReducer(state, { - type: ActionType.SessionInputRequested, - session: 'copilot:/test', - request: { - id: 'req-1', - message: 'What is your name?', - questions: [{ - kind: SessionInputQuestionKind.Text, - id: 'q-1', - message: 'What is your name?', - required: true, - }], - }, - }); - assert.strictEqual(state.summary.status, SessionStatus.InputNeeded); - - // Complete the input request - state = sessionReducer(state, { - type: ActionType.SessionInputCompleted, - session: 'copilot:/test', - requestId: 'req-1', - response: SessionInputResponseKind.Accept, - answers: { 'q-1': { state: SessionInputAnswerState.Submitted, value: { kind: SessionInputAnswerValueKind.Text, value: 'Alice' } } }, - }); - - assert.strictEqual(state.summary.status, SessionStatus.InProgress); - }); - - test('Tool call transition to PendingConfirmation updates summary status to InputNeeded', () => { - let state = withActiveTurnAndToolCall(makeSession()); - - // After SessionToolCallStart, status should be InProgress (tool is Streaming) - assert.strictEqual(state.summary.status, SessionStatus.InProgress); - - // Transition to PendingConfirmation via SessionToolCallReady (no confirmed) - state = sessionReducer(state, { - type: ActionType.SessionToolCallReady, - session: 'copilot:/test', - turnId: 'turn-1', - toolCallId: 'tc-1', - invocationMessage: 'Read file?', - toolInput: '/foo.ts', - }); - - assert.strictEqual(state.summary.status, SessionStatus.InputNeeded); - }); -}); diff --git a/src/vs/platform/agentHost/test/node/serverUrls.test.ts b/src/vs/platform/agentHost/test/node/serverUrls.test.ts deleted file mode 100644 index 61394085..00000000 --- a/src/vs/platform/agentHost/test/node/serverUrls.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import assert from 'assert'; -import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { formatWebSocketUrl, resolveServerUrls } from '../../node/serverUrls.js'; - -suite('serverUrls', () => { - ensureNoDisposablesAreLeakedInTestSuite(); - - test('uses localhost for default local-only binding', () => { - assert.deepStrictEqual(resolveServerUrls(undefined, 8081), { - local: ['ws://localhost:8081'], - network: [], - }); - }); - - test('formats IPv6 websocket URLs with brackets', () => { - assert.strictEqual(formatWebSocketUrl('::1', 8081), 'ws://[::1]:8081'); - assert.deepStrictEqual(resolveServerUrls('::1', 8081), { - local: ['ws://[::1]:8081'], - network: [], - }); - assert.deepStrictEqual(resolveServerUrls('0000:0000:0000:0000:0000:0000:0000:0001', 8081), { - local: ['ws://[0000:0000:0000:0000:0000:0000:0000:0001]:8081'], - network: [], - }); - }); - - test('treats wildcard binding as localhost plus network urls', () => { - assert.deepStrictEqual(resolveServerUrls('0.0.0.0', 8081, { - lo0: [ - { address: '127.0.0.1', netmask: '255.0.0.0', family: 'IPv4', mac: '00:00:00:00:00:00', internal: true, cidr: '127.0.0.1/8' }, - ], - en0: [ - { address: '192.168.1.20', netmask: '255.255.255.0', family: 'IPv4', mac: '11:22:33:44:55:66', internal: false, cidr: '192.168.1.20/24' }, - { address: 'fe80::1', netmask: 'ffff:ffff:ffff:ffff::', family: 'IPv6', mac: '11:22:33:44:55:66', internal: false, cidr: 'fe80::1/64', scopeid: 0 }, - ], - }), { - local: ['ws://localhost:8081'], - network: ['ws://192.168.1.20:8081'], - }); - - assert.deepStrictEqual(resolveServerUrls('0000:0000:0000:0000:0000:0000:0000:0000', 8081, { - lo0: [ - { address: '127.0.0.1', netmask: '255.0.0.0', family: 'IPv4', mac: '00:00:00:00:00:00', internal: true, cidr: '127.0.0.1/8' }, - ], - en0: [ - { address: '192.168.1.20', netmask: '255.255.255.0', family: 'IPv4', mac: '11:22:33:44:55:66', internal: false, cidr: '192.168.1.20/24' }, - ], - }), { - local: ['ws://localhost:8081'], - network: ['ws://192.168.1.20:8081'], - }); - }); - - test('treats explicit non-loopback host as a network url', () => { - assert.deepStrictEqual(resolveServerUrls('example.test', 8081), { - local: [], - network: ['ws://example.test:8081'], - }); - }); -}); diff --git a/src/vs/platform/agentHost/test/node/sessionDataService.test.ts b/src/vs/platform/agentHost/test/node/sessionDataService.test.ts deleted file mode 100644 index f57a37d6..00000000 --- a/src/vs/platform/agentHost/test/node/sessionDataService.test.ts +++ /dev/null @@ -1,153 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import assert from 'assert'; -import { VSBuffer } from '../../../../base/common/buffer.js'; -import { DisposableStore } from '../../../../base/common/lifecycle.js'; -import { Schemas } from '../../../../base/common/network.js'; -import { URI } from '../../../../base/common/uri.js'; -import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { FileService } from '../../../files/common/fileService.js'; -import { InMemoryFileSystemProvider } from '../../../files/common/inMemoryFilesystemProvider.js'; -import { NullLogService } from '../../../log/common/log.js'; -import { AgentSession } from '../../common/agentService.js'; -import { SessionDataService } from '../../node/sessionDataService.js'; - -suite('SessionDataService', () => { - - const disposables = new DisposableStore(); - let fileService: FileService; - let service: SessionDataService; - const basePath = URI.from({ scheme: Schemas.inMemory, path: '/userData' }); - - setup(() => { - fileService = disposables.add(new FileService(new NullLogService())); - disposables.add(fileService.registerProvider(Schemas.inMemory, disposables.add(new InMemoryFileSystemProvider()))); - service = new SessionDataService(basePath, fileService, new NullLogService()); - }); - - teardown(() => disposables.clear()); - ensureNoDisposablesAreLeakedInTestSuite(); - - test('getSessionDataDir returns correct URI', () => { - const session = AgentSession.uri('copilot', 'abc-123'); - const dir = service.getSessionDataDir(session); - assert.strictEqual(dir.toString(), URI.joinPath(basePath, 'agentSessionData', 'abc-123').toString()); - }); - - test('getSessionDataDir sanitizes unsafe characters', () => { - const session = AgentSession.uri('copilot', 'foo/bar:baz\\qux'); - const dir = service.getSessionDataDir(session); - assert.strictEqual(dir.toString(), URI.joinPath(basePath, 'agentSessionData', 'foo-bar-baz-qux').toString()); - }); - - test('deleteSessionData removes directory', async () => { - const session = AgentSession.uri('copilot', 'session-1'); - const dir = service.getSessionDataDir(session); - await fileService.createFolder(dir); - await fileService.writeFile(URI.joinPath(dir, 'snapshot.json'), VSBuffer.fromString('{}')); - - assert.ok(await fileService.exists(dir)); - await service.deleteSessionData(session); - assert.ok(!(await fileService.exists(dir))); - }); - - test('deleteSessionData is a no-op when directory does not exist', async () => { - const session = AgentSession.uri('copilot', 'nonexistent'); - // Should not throw - await service.deleteSessionData(session); - }); - - test('cleanupOrphanedData deletes orphans but keeps known sessions', async () => { - const baseDir = URI.joinPath(basePath, 'agentSessionData'); - await fileService.createFolder(URI.joinPath(baseDir, 'keep-1')); - await fileService.createFolder(URI.joinPath(baseDir, 'keep-2')); - await fileService.createFolder(URI.joinPath(baseDir, 'orphan-1')); - await fileService.createFolder(URI.joinPath(baseDir, 'orphan-2')); - - await service.cleanupOrphanedData(new Set(['keep-1', 'keep-2'])); - - assert.ok(await fileService.exists(URI.joinPath(baseDir, 'keep-1'))); - assert.ok(await fileService.exists(URI.joinPath(baseDir, 'keep-2'))); - assert.ok(!(await fileService.exists(URI.joinPath(baseDir, 'orphan-1')))); - assert.ok(!(await fileService.exists(URI.joinPath(baseDir, 'orphan-2')))); - }); - - test('cleanupOrphanedData is a no-op when base directory does not exist', async () => { - // Should not throw - await service.cleanupOrphanedData(new Set()); - }); -}); - -suite('SessionDataService — openDatabase ref-counting', () => { - - const disposables = new DisposableStore(); - const basePath = URI.from({ scheme: Schemas.inMemory, path: '/userData' }); - let service: SessionDataService; - - setup(() => { - const fileService = disposables.add(new FileService(new NullLogService())); - disposables.add(fileService.registerProvider(Schemas.inMemory, disposables.add(new InMemoryFileSystemProvider()))); - service = new SessionDataService(basePath, fileService, new NullLogService(), () => ':memory:'); - }); - - teardown(() => { - disposables.clear(); - }); - ensureNoDisposablesAreLeakedInTestSuite(); - - test('returns a functional database reference', async () => { - const session = AgentSession.uri('copilot', 'ref-test'); - const ref = service.openDatabase(session); - disposables.add(ref); - - await ref.object.createTurn('turn-1'); - const edits = await ref.object.getFileEdits([]); - assert.deepStrictEqual(edits, []); - await ref.object.close(); - }); - - test('multiple references share the same database', async () => { - const session = AgentSession.uri('copilot', 'shared-test'); - const ref1 = service.openDatabase(session); - const ref2 = service.openDatabase(session); - - assert.strictEqual(ref1.object, ref2.object); - - ref1.dispose(); - ref2.dispose(); - await ref1.object.close(); - }); - - test('database remains usable until last reference is disposed', async () => { - const session = AgentSession.uri('copilot', 'refcount-test'); - const ref1 = service.openDatabase(session); - const ref2 = service.openDatabase(session); - - ref1.dispose(); - - // ref2 still works - await ref2.object.createTurn('turn-1'); - - ref2.dispose(); - - await ref1.object.close(); - }); - - test('new reference after all disposed gets a fresh database', async () => { - const session = AgentSession.uri('copilot', 'reopen-test'); - const ref1 = service.openDatabase(session); - const db1 = ref1.object; - ref1.dispose(); - - const ref2 = service.openDatabase(session); - disposables.add(ref2); - // New reference — may or may not be the same object, but must be functional - await ref2.object.createTurn('turn-1'); - assert.notStrictEqual(ref2.object, db1); - - await ref2.object.close(); - }); -}); diff --git a/src/vs/platform/agentHost/test/node/sessionDatabase.test.ts b/src/vs/platform/agentHost/test/node/sessionDatabase.test.ts deleted file mode 100644 index 9253836f..00000000 --- a/src/vs/platform/agentHost/test/node/sessionDatabase.test.ts +++ /dev/null @@ -1,527 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import assert from 'assert'; -import { tmpdir } from 'os'; -import * as fs from 'fs/promises'; -import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { DisposableStore } from '../../../../base/common/lifecycle.js'; -import { SessionDatabase, runMigrations, sessionDatabaseMigrations, type ISessionDatabaseMigration } from '../../node/sessionDatabase.js'; -import { FileEditKind } from '../../common/state/sessionState.js'; -import type { Database } from '@vscode/sqlite3'; -import { generateUuid } from '../../../../base/common/uuid.js'; -import { join } from '../../../../base/common/path.js'; - -suite('SessionDatabase', () => { - - const disposables = new DisposableStore(); - let db: SessionDatabase | undefined; - let db2: SessionDatabase | undefined; - - teardown(async () => { - disposables.clear(); - await Promise.all([db?.close(), db2?.close()]); - }); - ensureNoDisposablesAreLeakedInTestSuite(); - - /** - * Extends SessionDatabase to allow ejecting/injecting the raw sqlite3 - * Database instance, enabling reopen tests with :memory: databases. - */ - class TestableSessionDatabase extends SessionDatabase { - static override async open(path: string, migrations: readonly ISessionDatabaseMigration[] = sessionDatabaseMigrations): Promise { - const inst = new TestableSessionDatabase(path, migrations); - await inst._ensureDb(); - return inst; - } - - /** Extract the raw db connection; this instance becomes inert. */ - async ejectDb(): Promise { - const rawDb = await this._ensureDb(); - this._dbPromise = undefined; - this._closed = true; - return rawDb; - } - - /** Create a TestableSessionDatabase wrapping an existing raw db. */ - static async fromDb( - rawDb: Database, - migrations: readonly ISessionDatabaseMigration[] = sessionDatabaseMigrations, - ): Promise { - await runMigrations(rawDb, migrations); - const inst = new TestableSessionDatabase(':memory:', migrations); - inst._dbPromise = Promise.resolve(rawDb); - return inst; - } - } - - // ---- Migration system ----------------------------------------------- - - suite('migrations', () => { - - test('applies all migrations on a fresh database', async () => { - const migrations: ISessionDatabaseMigration[] = [ - { version: 1, sql: 'CREATE TABLE t1 (id INTEGER PRIMARY KEY)' }, - { version: 2, sql: 'CREATE TABLE t2 (id INTEGER PRIMARY KEY)' }, - ]; - - db = disposables.add(await SessionDatabase.open(':memory:', migrations)); - - const tables = (await db.getAllTables()).sort(); - assert.deepStrictEqual(tables, ['t1', 't2']); - }); - - test('reopening with same migrations is a no-op', async () => { - const migrations: ISessionDatabaseMigration[] = [ - { version: 1, sql: 'CREATE TABLE t1 (id INTEGER PRIMARY KEY)' }, - ]; - - const db1 = await TestableSessionDatabase.open(':memory:', migrations); - const rawDb = await db1.ejectDb(); - - // Reopen — should not throw (table already exists, migration skipped) - db2 = disposables.add(await TestableSessionDatabase.fromDb(rawDb, migrations)); - assert.deepStrictEqual(await db2.getAllTables(), ['t1']); - }); - - test('only applies new migrations on reopen', async () => { - const v1: ISessionDatabaseMigration[] = [ - { version: 1, sql: 'CREATE TABLE t1 (id INTEGER PRIMARY KEY)' }, - ]; - const db1 = await TestableSessionDatabase.open(':memory:', v1); - const rawDb = await db1.ejectDb(); - - const v2: ISessionDatabaseMigration[] = [ - ...v1, - { version: 2, sql: 'CREATE TABLE t2 (id INTEGER PRIMARY KEY)' }, - ]; - db2 = disposables.add(await TestableSessionDatabase.fromDb(rawDb, v2)); - - const tables = (await db2.getAllTables()).sort(); - assert.deepStrictEqual(tables, ['t1', 't2']); - }); - - test('rolls back on migration failure', async () => { - const migrations: ISessionDatabaseMigration[] = [ - { version: 1, sql: 'CREATE TABLE t1 (id INTEGER PRIMARY KEY)' }, - { version: 2, sql: 'THIS IS INVALID SQL' }, - ]; - - await assert.rejects(() => SessionDatabase.open(':memory:', migrations)); - - // A fresh :memory: open with valid migrations succeeds - db = disposables.add(await SessionDatabase.open(':memory:', [ - { version: 1, sql: 'CREATE TABLE t1 (id INTEGER PRIMARY KEY)' }, - ])); - assert.deepStrictEqual(await db.getAllTables(), ['t1']); - }); - }); - - // ---- File edits ----------------------------------------------------- - - suite('file edits', () => { - - test('store and retrieve a file edit', async () => { - db = disposables.add(await SessionDatabase.open(':memory:')); - - await db.createTurn('turn-1'); - await db.storeFileEdit({ - turnId: 'turn-1', - toolCallId: 'tc-1', - kind: FileEditKind.Edit, - filePath: '/workspace/file.ts', - beforeContent: new TextEncoder().encode('before'), - afterContent: new TextEncoder().encode('after'), - addedLines: 5, - removedLines: 2, - }); - - const edits = await db.getFileEdits(['tc-1']); - assert.deepStrictEqual(edits, [{ - turnId: 'turn-1', - toolCallId: 'tc-1', - kind: FileEditKind.Edit, - filePath: '/workspace/file.ts', - originalPath: undefined, - addedLines: 5, - removedLines: 2, - }]); - }); - - test('retrieve multiple edits for a single tool call', async () => { - db = disposables.add(await SessionDatabase.open(':memory:')); - - await db.createTurn('turn-1'); - await db.storeFileEdit({ - turnId: 'turn-1', - toolCallId: 'tc-1', - kind: FileEditKind.Edit, - filePath: '/workspace/a.ts', - beforeContent: new TextEncoder().encode('a-before'), - afterContent: new TextEncoder().encode('a-after'), - addedLines: undefined, - removedLines: undefined, - }); - await db.storeFileEdit({ - turnId: 'turn-1', - toolCallId: 'tc-1', - kind: FileEditKind.Edit, - filePath: '/workspace/b.ts', - beforeContent: new TextEncoder().encode('b-before'), - afterContent: new TextEncoder().encode('b-after'), - addedLines: 1, - removedLines: 0, - }); - - const edits = await db.getFileEdits(['tc-1']); - assert.strictEqual(edits.length, 2); - assert.strictEqual(edits[0].filePath, '/workspace/a.ts'); - assert.strictEqual(edits[1].filePath, '/workspace/b.ts'); - }); - - test('retrieve edits across multiple tool calls', async () => { - db = disposables.add(await SessionDatabase.open(':memory:')); - - await db.createTurn('turn-1'); - await db.storeFileEdit({ - turnId: 'turn-1', - toolCallId: 'tc-1', - kind: FileEditKind.Edit, - filePath: '/workspace/a.ts', - beforeContent: new Uint8Array(0), - afterContent: new TextEncoder().encode('hello'), - addedLines: undefined, - removedLines: undefined, - }); - await db.storeFileEdit({ - turnId: 'turn-1', - toolCallId: 'tc-2', - kind: FileEditKind.Edit, - filePath: '/workspace/b.ts', - beforeContent: new Uint8Array(0), - afterContent: new TextEncoder().encode('world'), - addedLines: undefined, - removedLines: undefined, - }); - - const edits = await db.getFileEdits(['tc-1', 'tc-2']); - assert.strictEqual(edits.length, 2); - - // Only tc-2 - const edits2 = await db.getFileEdits(['tc-2']); - assert.strictEqual(edits2.length, 1); - assert.strictEqual(edits2[0].toolCallId, 'tc-2'); - }); - - test('returns empty array for unknown tool call IDs', async () => { - db = disposables.add(await SessionDatabase.open(':memory:')); - const edits = await db.getFileEdits(['nonexistent']); - assert.deepStrictEqual(edits, []); - }); - - test.skip('returns empty array when given empty array' /* Flaky https://github.com/microsoft/vscode/issues/306057 */, async () => { - db = disposables.add(await SessionDatabase.open(':memory:')); - const edits = await db.getFileEdits([]); - assert.deepStrictEqual(edits, []); - }); - - test('replace on conflict (same toolCallId + filePath)', async () => { - db = disposables.add(await SessionDatabase.open(':memory:')); - - await db.createTurn('turn-1'); - await db.storeFileEdit({ - turnId: 'turn-1', - toolCallId: 'tc-1', - kind: FileEditKind.Edit, - filePath: '/workspace/file.ts', - beforeContent: new TextEncoder().encode('v1'), - afterContent: new TextEncoder().encode('v1-after'), - addedLines: 1, - removedLines: 0, - }); - await db.storeFileEdit({ - turnId: 'turn-1', - toolCallId: 'tc-1', - kind: FileEditKind.Edit, - filePath: '/workspace/file.ts', - beforeContent: new TextEncoder().encode('v2'), - afterContent: new TextEncoder().encode('v2-after'), - addedLines: 3, - removedLines: 1, - }); - - const edits = await db.getFileEdits(['tc-1']); - assert.strictEqual(edits.length, 1); - assert.strictEqual(edits[0].addedLines, 3); - - const content = await db.readFileEditContent('tc-1', '/workspace/file.ts'); - assert.ok(content); - assert.deepStrictEqual(new TextDecoder().decode(content.beforeContent), 'v2'); - }); - - test('readFileEditContent returns content on demand', async () => { - db = disposables.add(await SessionDatabase.open(':memory:')); - - await db.createTurn('turn-1'); - await db.storeFileEdit({ - turnId: 'turn-1', - toolCallId: 'tc-1', - kind: FileEditKind.Edit, - filePath: '/workspace/file.ts', - beforeContent: new TextEncoder().encode('before'), - afterContent: new TextEncoder().encode('after'), - addedLines: undefined, - removedLines: undefined, - }); - - const content = await db.readFileEditContent('tc-1', '/workspace/file.ts'); - assert.ok(content); - assert.deepStrictEqual(content.beforeContent, new TextEncoder().encode('before')); - assert.deepStrictEqual(content.afterContent, new TextEncoder().encode('after')); - }); - - test('readFileEditContent returns undefined for missing edit', async () => { - db = disposables.add(await SessionDatabase.open(':memory:')); - const content = await db.readFileEditContent('tc-missing', '/no/such/file'); - assert.strictEqual(content, undefined); - }); - - test('persists binary content correctly', async () => { - db = disposables.add(await SessionDatabase.open(':memory:')); - const binary = new Uint8Array([0, 1, 2, 255, 128, 64]); - - await db.createTurn('turn-1'); - await db.storeFileEdit({ - turnId: 'turn-1', - toolCallId: 'tc-bin', - kind: FileEditKind.Edit, - filePath: '/workspace/image.png', - beforeContent: new Uint8Array(0), - afterContent: binary, - addedLines: undefined, - removedLines: undefined, - }); - - const content = await db.readFileEditContent('tc-bin', '/workspace/image.png'); - assert.ok(content); - assert.deepStrictEqual(content.afterContent, binary); - }); - - test('auto-creates turn if it does not exist', async () => { - db = disposables.add(await SessionDatabase.open(':memory:')); - - // storeFileEdit should succeed even without a prior createTurn call - await db.storeFileEdit({ - turnId: 'auto-turn', - toolCallId: 'tc-1', - kind: FileEditKind.Edit, - filePath: '/x', - beforeContent: new Uint8Array(0), - afterContent: new Uint8Array(0), - addedLines: undefined, - removedLines: undefined, - }); - - const edits = await db.getFileEdits(['tc-1']); - assert.strictEqual(edits.length, 1); - assert.strictEqual(edits[0].turnId, 'auto-turn'); - }); - }); - - // ---- Turns ---------------------------------------------------------- - - suite('turns', () => { - - test('createTurn is idempotent', async () => { - db = disposables.add(await SessionDatabase.open(':memory:')); - await db.createTurn('turn-1'); - await db.createTurn('turn-1'); // should not throw - }); - - test('deleteTurn cascades to file edits', async () => { - db = disposables.add(await SessionDatabase.open(':memory:')); - - await db.createTurn('turn-1'); - await db.storeFileEdit({ - turnId: 'turn-1', - toolCallId: 'tc-1', - kind: FileEditKind.Edit, - filePath: '/workspace/a.ts', - beforeContent: new TextEncoder().encode('before'), - afterContent: new TextEncoder().encode('after'), - addedLines: undefined, - removedLines: undefined, - }); - - // Edits exist - assert.strictEqual((await db.getFileEdits(['tc-1'])).length, 1); - - // Delete the turn — edits should be gone - await db.deleteTurn('turn-1'); - assert.deepStrictEqual(await db.getFileEdits(['tc-1']), []); - }); - - test('deleteTurn only removes its own edits', async () => { - db = disposables.add(await SessionDatabase.open(':memory:')); - - await db.createTurn('turn-1'); - await db.createTurn('turn-2'); - await db.storeFileEdit({ - turnId: 'turn-1', - toolCallId: 'tc-1', - kind: FileEditKind.Edit, - filePath: '/workspace/a.ts', - beforeContent: new Uint8Array(0), - afterContent: new TextEncoder().encode('a'), - addedLines: undefined, - removedLines: undefined, - }); - await db.storeFileEdit({ - turnId: 'turn-2', - toolCallId: 'tc-2', - kind: FileEditKind.Edit, - filePath: '/workspace/b.ts', - beforeContent: new Uint8Array(0), - afterContent: new TextEncoder().encode('b'), - addedLines: undefined, - removedLines: undefined, - }); - - await db.deleteTurn('turn-1'); - - assert.deepStrictEqual(await db.getFileEdits(['tc-1']), []); - assert.strictEqual((await db.getFileEdits(['tc-2'])).length, 1); - }); - - test('deleteTurn is a no-op for unknown turn', async () => { - db = disposables.add(await SessionDatabase.open(':memory:')); - await db.deleteTurn('nonexistent'); // should not throw - }); - }); - - // ---- Dispose -------------------------------------------------------- - - suite('dispose', () => { - - test('methods throw after dispose', async () => { - db = await SessionDatabase.open(':memory:'); - db.close(); - - await assert.rejects( - () => db!.createTurn('turn-1'), - /disposed/, - ); - }); - - test('double dispose is safe', async () => { - db = await SessionDatabase.open(':memory:'); - await db.close(); - await db.close(); // should not throw - }); - }); - - // ---- Lazy open ------------------------------------------------------ - - suite('lazy open', () => { - - test('constructor does not open the database', () => { - db = new SessionDatabase(':memory:'); - disposables.add(db); - // No error — the database is not opened until first use - }); - - test('first async call opens and migrates the database', async () => { - db = disposables.add(new SessionDatabase(':memory:')); - await db.createTurn('turn-1'); - const edits = await db.getFileEdits(['nonexistent']); - assert.deepStrictEqual(edits, []); - }); - - test('multiple concurrent calls share the same open promise', async () => { - db = disposables.add(new SessionDatabase(':memory:')); - // Fire multiple calls concurrently — all should succeed - await Promise.all([ - db.createTurn('turn-1'), - db.createTurn('turn-2'), - db.getFileEdits([]), - ]); - }); - - test('dispose during open rejects subsequent calls', async () => { - db = new SessionDatabase(':memory:'); - await db.close(); - await assert.rejects(() => db!.createTurn('turn-1'), /disposed/); - }); - }); - - // ---- Session metadata ----------------------------------------------- - - suite('session metadata', () => { - - test('getMetadata returns undefined for missing key', async () => { - db = disposables.add(await SessionDatabase.open(':memory:')); - assert.strictEqual(await db.getMetadata('nonexistent'), undefined); - }); - - test('setMetadata and getMetadata round-trip', async () => { - db = disposables.add(await SessionDatabase.open(':memory:')); - await db.setMetadata('customTitle', 'My Session'); - assert.strictEqual(await db.getMetadata('customTitle'), 'My Session'); - }); - - test('setMetadata overwrites existing value', async () => { - db = disposables.add(await SessionDatabase.open(':memory:')); - await db.setMetadata('customTitle', 'First'); - await db.setMetadata('customTitle', 'Second'); - assert.strictEqual(await db.getMetadata('customTitle'), 'Second'); - }); - - test('metadata persists across reopen', async () => { - const db1 = disposables.add(await TestableSessionDatabase.open(':memory:')); - await db1.setMetadata('customTitle', 'Persistent Title'); - const rawDb = await db1.ejectDb(); - - db = disposables.add(await TestableSessionDatabase.fromDb(rawDb)); - assert.strictEqual(await db.getMetadata('customTitle'), 'Persistent Title'); - }); - - test('migration v2 creates session_metadata table', async () => { - db = disposables.add(await SessionDatabase.open(':memory:')); - const tables = await db.getAllTables(); - assert.ok(tables.includes('session_metadata')); - }); - }); - - // ---- vacuumInto ----------------------------------------------------- - - suite('vacuumInto', () => { - - let tmpDir: string; - - setup(async () => { - tmpDir = await fs.mkdtemp(join(tmpdir(), 'session-db-test-' + generateUuid())); - }); - - teardown(async () => { - await Promise.all([db?.close(), db2?.close()]); - db = db2 = undefined; - await fs.rm(tmpDir, { recursive: true, force: true }); - }); - - test('produces a copy with the same data', async () => { - db = disposables.add(await SessionDatabase.open(':memory:')); - await db.createTurn('turn-1'); - await db.setTurnEventId('turn-1', 'evt-1'); - await db.setMetadata('key', 'value'); - - const targetPath = join(tmpDir, 'copy.db'); - await db.vacuumInto(targetPath); - - db2 = disposables.add(await SessionDatabase.open(targetPath)); - assert.strictEqual(await db2.getTurnEventId('turn-1'), 'evt-1'); - assert.strictEqual(await db2.getMetadata('key'), 'value'); - }); - }); -}); diff --git a/src/vs/platform/agentHost/test/node/sessionDiffAggregator.test.ts b/src/vs/platform/agentHost/test/node/sessionDiffAggregator.test.ts deleted file mode 100644 index f58a762e..00000000 --- a/src/vs/platform/agentHost/test/node/sessionDiffAggregator.test.ts +++ /dev/null @@ -1,492 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import assert from 'assert'; -import { URI } from '../../../../base/common/uri.js'; -import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { FileEditKind, type ISessionFileDiff } from '../../common/state/sessionState.js'; -import { encodeString, TestDiffComputeService, TestSessionDatabase } from '../common/sessionTestHelpers.js'; -import { computeSessionDiffs } from '../../node/sessionDiffAggregator.js'; -import { parseSessionDbUri } from '../../node/copilot/fileEditTracker.js'; - -const TEST_SESSION_URI = 'session://test-session'; - -const createTestDiffService = () => new TestDiffComputeService(); - -function fileDiff(path: string, added: number, removed: number): ISessionFileDiff { - const uri = URI.file(path).toString(); - return { after: { uri, content: { uri } }, diff: { added, removed } }; -} - -function getDiffUri(diff: ISessionFileDiff): string | undefined { - return diff.after?.uri ?? diff.before?.uri; -} - -interface ISimpleDiff { - uri: string | undefined; - added: number; - removed: number; -} - -function simplify(diff: ISessionFileDiff): ISimpleDiff { - return { - uri: getDiffUri(diff), - added: diff.diff?.added ?? 0, - removed: diff.diff?.removed ?? 0, - }; -} - -function simpleDiff(path: string, added: number, removed: number): ISimpleDiff { - return { uri: URI.file(path).toString(), added, removed }; -} - -suite('computeSessionDiffs', () => { - - ensureNoDisposablesAreLeakedInTestSuite(); - - // ---- Full-mode tests (no incremental options) --------------------------- - - test('returns empty array for no edits', async () => { - const db = new TestSessionDatabase(); - const diffService = createTestDiffService(); - const result = await computeSessionDiffs(TEST_SESSION_URI, db, diffService); - assert.deepStrictEqual(result, []); - }); - - test('computes diffs for a single edited file', async () => { - const db = new TestSessionDatabase(); - db.addEdit({ - turnId: 't1', toolCallId: 'tc1', filePath: '/a.txt', kind: FileEditKind.Edit, - addedLines: undefined, removedLines: undefined, - beforeContent: encodeString('line1\nline2'), afterContent: encodeString('line1\nline2\nline3'), - }); - - const diffService = createTestDiffService(); - const result = await computeSessionDiffs(TEST_SESSION_URI, db, diffService); - - assert.deepStrictEqual(result.map(simplify), [simpleDiff('/a.txt', 1, 0)]); - assert.strictEqual(diffService.callCount, 1); - }); - - test('populates before/after with session-db content URIs for edits', async () => { - const db = new TestSessionDatabase(); - db.addEdit({ - turnId: 't1', toolCallId: 'tc1', filePath: '/a.txt', kind: FileEditKind.Edit, - addedLines: undefined, removedLines: undefined, - beforeContent: encodeString('v1'), afterContent: encodeString('v2'), - }); - db.addEdit({ - turnId: 't2', toolCallId: 'tc2', filePath: '/a.txt', kind: FileEditKind.Edit, - addedLines: undefined, removedLines: undefined, - beforeContent: encodeString('v2'), afterContent: encodeString('v3'), - }); - - const result = await computeSessionDiffs(TEST_SESSION_URI, db, createTestDiffService()); - - assert.strictEqual(result.length, 1); - const [diff] = result; - const fileUri = URI.file('/a.txt').toString(); - assert.strictEqual(diff.before?.uri, fileUri); - assert.strictEqual(diff.after?.uri, fileUri); - - // before content points to the FIRST snapshot (tc1) - const beforeFields = parseSessionDbUri(diff.before!.content.uri); - assert.deepStrictEqual(beforeFields, { - sessionUri: TEST_SESSION_URI, - toolCallId: 'tc1', - filePath: '/a.txt', - part: 'before', - }); - - // after content points to the LAST snapshot (tc2) - const afterFields = parseSessionDbUri(diff.after!.content.uri); - assert.deepStrictEqual(afterFields, { - sessionUri: TEST_SESSION_URI, - toolCallId: 'tc2', - filePath: '/a.txt', - part: 'after', - }); - }); - - test('omits before for creates and after for deletes', async () => { - const db = new TestSessionDatabase(); - db.addEdit({ - turnId: 't1', toolCallId: 'tc1', filePath: '/created.txt', kind: FileEditKind.Create, - addedLines: undefined, removedLines: undefined, - afterContent: encodeString('new'), - }); - db.addEdit({ - turnId: 't1', toolCallId: 'tc2', filePath: '/deleted.txt', kind: FileEditKind.Delete, - addedLines: undefined, removedLines: undefined, - beforeContent: encodeString('bye'), - }); - - const result = await computeSessionDiffs(TEST_SESSION_URI, db, createTestDiffService()); - result.sort((a, b) => (getDiffUri(a) ?? '').localeCompare(getDiffUri(b) ?? '')); - - assert.strictEqual(result.length, 2); - const [created, deleted] = result; - assert.strictEqual(created.before, undefined, 'create has no before'); - assert.ok(created.after, 'create has after'); - assert.ok(deleted.before, 'delete has before'); - assert.strictEqual(deleted.after, undefined, 'delete has no after'); - }); - - test('skips files with no net change', async () => { - const db = new TestSessionDatabase(); - db.addEdit({ - turnId: 't1', toolCallId: 'tc1', filePath: '/a.txt', kind: FileEditKind.Edit, - addedLines: undefined, removedLines: undefined, - beforeContent: encodeString('same'), afterContent: encodeString('different'), - }); - db.addEdit({ - turnId: 't2', toolCallId: 'tc2', filePath: '/a.txt', kind: FileEditKind.Edit, - addedLines: undefined, removedLines: undefined, - beforeContent: encodeString('different'), afterContent: encodeString('same'), - }); - - const diffService = createTestDiffService(); - const result = await computeSessionDiffs(TEST_SESSION_URI, db, diffService); - - // Before = tc1.before = 'same', After = tc2.after = 'same' → zero net change - assert.deepStrictEqual(result, []); - assert.strictEqual(diffService.callCount, 0, 'no diff computation needed for zero net change'); - }); - - test('tracks rename chains correctly', async () => { - const db = new TestSessionDatabase(); - db.addEdit({ - turnId: 't1', toolCallId: 'tc1', filePath: '/a.txt', kind: FileEditKind.Create, - addedLines: undefined, removedLines: undefined, - afterContent: encodeString('hello'), - }); - db.addEdit({ - turnId: 't2', toolCallId: 'tc2', filePath: '/b.txt', kind: FileEditKind.Rename, originalPath: '/a.txt', - addedLines: undefined, removedLines: undefined, - beforeContent: encodeString('hello'), afterContent: encodeString('hello world'), - }); - - const diffService = createTestDiffService(); - const result = await computeSessionDiffs(TEST_SESSION_URI, db, diffService); - - assert.strictEqual(result.length, 1); - assert.strictEqual(getDiffUri(result[0]), URI.file('/b.txt').toString(), 'uses terminal path after rename'); - }); - - // ---- Incremental-mode tests --------------------------------------------- - - test('incremental: reuses previousDiffs for untouched files', async () => { - const db = new TestSessionDatabase(); - // File A edited in turn 1 only - db.addEdit({ - turnId: 't1', toolCallId: 'tc1', filePath: '/a.txt', kind: FileEditKind.Edit, - addedLines: undefined, removedLines: undefined, - beforeContent: encodeString('a-before'), afterContent: encodeString('a-after'), - }); - // File B edited in turn 2 - db.addEdit({ - turnId: 't2', toolCallId: 'tc2', filePath: '/b.txt', kind: FileEditKind.Edit, - addedLines: undefined, removedLines: undefined, - beforeContent: encodeString('b-before'), afterContent: encodeString('b-after\nnew'), - }); - - const previousDiffs: ISessionFileDiff[] = [ - fileDiff('/a.txt', 42, 7), - ]; - - const diffService = createTestDiffService(); - const result = await computeSessionDiffs( - TEST_SESSION_URI, - db, - diffService, - { changedTurnId: 't2', previousDiffs }, - ); - - // Sort to ensure stable comparison - result.sort((a, b) => (getDiffUri(a) ?? '').localeCompare(getDiffUri(b) ?? '')); - - assert.deepStrictEqual(result.map(simplify), [ - simpleDiff('/a.txt', 42, 7), // carried over - simpleDiff('/b.txt', 1, 0), // recomputed - ]); - // Only file B should have triggered a diff computation - assert.strictEqual(diffService.callCount, 1, 'only touched file should be diffed'); - }); - - test('incremental: recomputes file edited in current turn', async () => { - const db = new TestSessionDatabase(); - // File A edited in turn 1 and turn 2 - db.addEdit({ - turnId: 't1', toolCallId: 'tc1', filePath: '/a.txt', kind: FileEditKind.Edit, - addedLines: undefined, removedLines: undefined, - beforeContent: encodeString('original'), afterContent: encodeString('after-turn1'), - }); - db.addEdit({ - turnId: 't2', toolCallId: 'tc2', filePath: '/a.txt', kind: FileEditKind.Edit, - addedLines: undefined, removedLines: undefined, - beforeContent: encodeString('after-turn1'), afterContent: encodeString('after-turn2\nextra'), - }); - - const previousDiffs: ISessionFileDiff[] = [ - fileDiff('/a.txt', 100, 100), // stale - ]; - - const diffService = createTestDiffService(); - const result = await computeSessionDiffs( - TEST_SESSION_URI, - db, - diffService, - { changedTurnId: 't2', previousDiffs }, - ); - - // Should compare tc1.before='original' vs tc2.after='after-turn2\nextra' - assert.deepStrictEqual(result.map(simplify), [simpleDiff('/a.txt', 1, 0)]); - assert.strictEqual(diffService.callCount, 1); - }); - - test('incremental: rename in current turn drops old URI from previousDiffs', async () => { - const db = new TestSessionDatabase(); - // File created in turn 1 - db.addEdit({ - turnId: 't1', toolCallId: 'tc1', filePath: '/old.txt', kind: FileEditKind.Create, - addedLines: undefined, removedLines: undefined, - afterContent: encodeString('content'), - }); - // Renamed in turn 2 - db.addEdit({ - turnId: 't2', toolCallId: 'tc2', filePath: '/new.txt', kind: FileEditKind.Rename, - originalPath: '/old.txt', - addedLines: undefined, removedLines: undefined, - beforeContent: encodeString('content'), afterContent: encodeString('content'), - }); - - const previousDiffs: ISessionFileDiff[] = [ - fileDiff('/old.txt', 5, 0), - ]; - - const diffService = createTestDiffService(); - const result = await computeSessionDiffs( - TEST_SESSION_URI, - db, - diffService, - { changedTurnId: 't2', previousDiffs }, - ); - - // Create → Rename with same content: before='' (create), after='content' (rename) - assert.strictEqual(result.length, 1); - assert.strictEqual(getDiffUri(result[0]), URI.file('/new.txt').toString(), 'uses new URI after rename'); - }); - - test('incremental: file with zero net change in current turn is excluded even if in previousDiffs', async () => { - const db = new TestSessionDatabase(); - db.addEdit({ - turnId: 't1', toolCallId: 'tc1', filePath: '/a.txt', kind: FileEditKind.Edit, - addedLines: undefined, removedLines: undefined, - beforeContent: encodeString('original'), afterContent: encodeString('modified'), - }); - // Turn 2 reverts the change - db.addEdit({ - turnId: 't2', toolCallId: 'tc2', filePath: '/a.txt', kind: FileEditKind.Edit, - addedLines: undefined, removedLines: undefined, - beforeContent: encodeString('modified'), afterContent: encodeString('original'), - }); - - const previousDiffs: ISessionFileDiff[] = [ - fileDiff('/a.txt', 10, 5), - ]; - - const diffService = createTestDiffService(); - const result = await computeSessionDiffs( - TEST_SESSION_URI, - db, - diffService, - { changedTurnId: 't2', previousDiffs }, - ); - - // Net change is zero (reverted), so file should be excluded - assert.deepStrictEqual(result, []); - }); - - test('incremental: previousDiffs entry for file not in current identities is dropped (slow path)', async () => { - const db = new TestSessionDatabase(); - // File A was edited in turn 1 and is in previousDiffs - db.addEdit({ - turnId: 't1', toolCallId: 'tc1', filePath: '/a.txt', kind: FileEditKind.Edit, - addedLines: undefined, removedLines: undefined, - beforeContent: encodeString('before'), afterContent: encodeString('after'), - }); - // File A is edited again in turn 2 → triggers slow path (re-edit of existing file) - db.addEdit({ - turnId: 't2', toolCallId: 'tc2', filePath: '/a.txt', kind: FileEditKind.Edit, - addedLines: undefined, removedLines: undefined, - beforeContent: encodeString('after'), afterContent: encodeString('latest\nline'), - }); - - const previousDiffs: ISessionFileDiff[] = [ - fileDiff('/a.txt', 1, 0), - fileDiff('/orphan.txt', 99, 99), // no longer in DB - ]; - - const diffService = createTestDiffService(); - const result = await computeSessionDiffs( - TEST_SESSION_URI, - db, - diffService, - { changedTurnId: 't2', previousDiffs }, - ); - - // Slow path: orphan is dropped because it has no identity in the full graph - assert.strictEqual(result.length, 1); - assert.strictEqual(getDiffUri(result[0]), URI.file('/a.txt').toString()); - }); - - test('full mode recomputes all files (no incremental options)', async () => { - const db = new TestSessionDatabase(); - db.addEdit({ - turnId: 't1', toolCallId: 'tc1', filePath: '/a.txt', kind: FileEditKind.Edit, - addedLines: undefined, removedLines: undefined, - beforeContent: encodeString('a'), afterContent: encodeString('a\nb'), - }); - db.addEdit({ - turnId: 't1', toolCallId: 'tc2', filePath: '/b.txt', kind: FileEditKind.Create, - addedLines: undefined, removedLines: undefined, - afterContent: encodeString('new'), - }); - - const diffService = createTestDiffService(); - const result = await computeSessionDiffs(TEST_SESSION_URI, db, diffService); - - assert.strictEqual(result.length, 2); - assert.strictEqual(diffService.callCount, 2, 'both files should be diffed in full mode'); - }); - - // ---- Fast-path tests (turn-scoped query optimization) ------------------- - - test('incremental fast path: new files only uses getFileEditsByTurn, not getAllFileEdits', async () => { - const db = new TestSessionDatabase(); - // Turn 1: existing file untouched in turn 2 - db.addEdit({ - turnId: 't1', toolCallId: 'tc1', filePath: '/old.txt', kind: FileEditKind.Edit, - addedLines: undefined, removedLines: undefined, - beforeContent: encodeString('old-before'), afterContent: encodeString('old-after'), - }); - // Turn 2: creates a new file - db.addEdit({ - turnId: 't2', toolCallId: 'tc2', filePath: '/new.txt', kind: FileEditKind.Create, - addedLines: undefined, removedLines: undefined, - afterContent: encodeString('brand new'), - }); - - const previousDiffs: ISessionFileDiff[] = [ - fileDiff('/old.txt', 3, 1), - ]; - - const diffService = createTestDiffService(); - const result = await computeSessionDiffs( - TEST_SESSION_URI, - db, - diffService, - { changedTurnId: 't2', previousDiffs }, - ); - - // Fast path: only getFileEditsByTurn called, not getAllFileEdits - assert.strictEqual(db.getFileEditsByTurnCalls, 1); - assert.strictEqual(db.getAllFileEditsCalls, 0, 'fast path should not call getAllFileEdits'); - - result.sort((a, b) => (getDiffUri(a) ?? '').localeCompare(getDiffUri(b) ?? '')); - assert.deepStrictEqual(result.map(simplify), [ - simpleDiff('/new.txt', 1, 0), - simpleDiff('/old.txt', 3, 1), // carried over - ]); - }); - - test('incremental slow path: re-edit of existing file falls back to getAllFileEdits', async () => { - const db = new TestSessionDatabase(); - // Turn 1: edit file A - db.addEdit({ - turnId: 't1', toolCallId: 'tc1', filePath: '/a.txt', kind: FileEditKind.Edit, - addedLines: undefined, removedLines: undefined, - beforeContent: encodeString('original'), afterContent: encodeString('turn1'), - }); - // Turn 2: edit file A again - db.addEdit({ - turnId: 't2', toolCallId: 'tc2', filePath: '/a.txt', kind: FileEditKind.Edit, - addedLines: undefined, removedLines: undefined, - beforeContent: encodeString('turn1'), afterContent: encodeString('turn2\nextra'), - }); - - const previousDiffs: ISessionFileDiff[] = [ - fileDiff('/a.txt', 5, 0), - ]; - - const diffService = createTestDiffService(); - const result = await computeSessionDiffs( - TEST_SESSION_URI, - db, - diffService, - { changedTurnId: 't2', previousDiffs }, - ); - - // Slow path: falls back to getAllFileEdits because /a.txt is in previousDiffs - assert.strictEqual(db.getFileEditsByTurnCalls, 1, 'should try turn-scoped query first'); - assert.strictEqual(db.getAllFileEditsCalls, 1, 'should fall back to getAllFileEdits'); - - // Cumulative diff: original → turn2\nextra - assert.deepStrictEqual(result.map(simplify), [simpleDiff('/a.txt', 1, 0)]); - }); - - test('incremental slow path: rename in current turn falls back to getAllFileEdits', async () => { - const db = new TestSessionDatabase(); - db.addEdit({ - turnId: 't1', toolCallId: 'tc1', filePath: '/a.txt', kind: FileEditKind.Create, - addedLines: undefined, removedLines: undefined, - afterContent: encodeString('content'), - }); - db.addEdit({ - turnId: 't2', toolCallId: 'tc2', filePath: '/b.txt', kind: FileEditKind.Rename, - originalPath: '/a.txt', - addedLines: undefined, removedLines: undefined, - beforeContent: encodeString('content'), afterContent: encodeString('content'), - }); - - const previousDiffs: ISessionFileDiff[] = [ - fileDiff('/a.txt', 1, 0), - ]; - - const diffService = createTestDiffService(); - await computeSessionDiffs( - TEST_SESSION_URI, - db, - diffService, - { changedTurnId: 't2', previousDiffs }, - ); - - assert.strictEqual(db.getAllFileEditsCalls, 1, 'should fall back for renames'); - }); - - test('incremental: no edits in turn returns previousDiffs unchanged', async () => { - const db = new TestSessionDatabase(); - db.addEdit({ - turnId: 't1', toolCallId: 'tc1', filePath: '/a.txt', kind: FileEditKind.Edit, - addedLines: undefined, removedLines: undefined, - beforeContent: encodeString('before'), afterContent: encodeString('after'), - }); - - const previousDiffs: ISessionFileDiff[] = [ - fileDiff('/a.txt', 5, 2), - ]; - - const diffService = createTestDiffService(); - const result = await computeSessionDiffs( - TEST_SESSION_URI, - db, - diffService, - { changedTurnId: 't2', previousDiffs }, - ); - - assert.strictEqual(db.getAllFileEditsCalls, 0, 'no computation needed'); - assert.deepStrictEqual(result, previousDiffs); - }); -}); diff --git a/src/vs/platform/agentHost/test/node/shared/copilotApiService.test.ts b/src/vs/platform/agentHost/test/node/shared/copilotApiService.test.ts deleted file mode 100644 index ca0c02e1..00000000 --- a/src/vs/platform/agentHost/test/node/shared/copilotApiService.test.ts +++ /dev/null @@ -1,1563 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import assert from 'assert'; -import type Anthropic from '@anthropic-ai/sdk'; -import { Iterable } from '../../../../../base/common/iterator.js'; -import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; -import { COPILOT_API_ERROR_STATUS_STREAMING, CopilotApiError, CopilotApiService, type FetchFunction } from '../../../node/shared/copilotApiService.js'; -import { NullLogService } from '../../../../log/common/log.js'; -import { IProductService } from '../../../../product/common/productService.js'; -import product from '../../../../product/common/product.js'; - -// #region Test Helpers - -const testProductService: IProductService = { _serviceBrand: undefined, ...product }; - -function sseLines(...lines: string[]): Uint8Array { - return new TextEncoder().encode(lines.join('\n') + '\n'); -} - -function makeSseBody(chunks: Uint8Array[]): ReadableStream { - let index = 0; - return new ReadableStream({ - pull(controller) { - if (index < chunks.length) { - controller.enqueue(chunks[index++]); - } else { - controller.close(); - } - } - }); -} - -const collect = Iterable.asyncToArray; - -function getUrl(input: string | URL | Request): string { - if (typeof input === 'string') { - return input; - } - return input instanceof URL ? input.href : input.url; -} - -function getText(msg: Anthropic.Message): string { - return msg.content - .filter((b): b is Anthropic.TextBlock => b.type === 'text') - .map(b => b.text) - .join(''); -} - -function tokenResponse(overrides?: Record): Response { - return new Response(JSON.stringify({ - token: 'copilot-tok-abc', - expires_at: Date.now() / 1000 + 3600, - refresh_in: 1800, - ...overrides, - }), { status: 200 }); -} - -function anthropicResponse(content: Array<{ type: string; text?: string }>, stopReason = 'end_turn'): Response { - return new Response(JSON.stringify({ - id: 'msg_test', - type: 'message', - role: 'assistant', - content, - model: 'claude-sonnet-4-5-20250514', - stop_reason: stopReason, - usage: { input_tokens: 10, output_tokens: 50 }, - }), { status: 200, headers: { 'Content-Type': 'application/json' } }); -} - -function sseResponse(chunks: Uint8Array[]): Response { - return new Response(makeSseBody(chunks), { - status: 200, - headers: { 'Content-Type': 'text/event-stream' }, - }); -} - -function modelsResponse(models: object[]): Response { - return new Response(JSON.stringify({ data: models }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }); -} - -function createService(fetchImpl: FetchFunction): CopilotApiService { - return new CopilotApiService(fetchImpl, new NullLogService(), testProductService); -} - -type CapturedRequest = { url: string; init: RequestInit | undefined }; - -function routingFetch( - messageResponse: (captured: CapturedRequest) => Response, - tokenOverrides?: Record, -): { fetch: FetchFunction; captured: () => CapturedRequest } { - let lastCapture: CapturedRequest = { url: '', init: undefined }; - const impl: FetchFunction = async (input, init) => { - const url = getUrl(input); - if (url.includes('/token') || url.includes('/copilot_internal')) { - return tokenResponse(tokenOverrides); - } - lastCapture = { url, init }; - return messageResponse(lastCapture); - }; - return { fetch: impl, captured: () => lastCapture }; -} - -const userMsg: Anthropic.MessageParam[] = [{ role: 'user', content: 'hello' }]; -const baseRequest = { - model: 'claude-sonnet-4-5', - messages: userMsg, - max_tokens: 8192, - stream: false as const, -}; - -function streamService(chunks: Uint8Array[], tokenOverrides?: Record): CopilotApiService { - const { fetch: fetchFn } = routingFetch(() => sseResponse(chunks), tokenOverrides); - return createService(fetchFn); -} - -// #endregion - -suite('CopilotApiService', () => { - - ensureNoDisposablesAreLeakedInTestSuite(); - - // #region Token Minting - - suite('Token Minting', () => { - - test('mints a token on first request', async () => { - let mintCount = 0; - const service = createService(async (input) => { - const url = getUrl(input); - if (url.includes('/copilot_internal')) { - mintCount++; - return tokenResponse(); - } - return anthropicResponse([{ type: 'text', text: 'hi' }]); - }); - - await service.messages('gh-tok', baseRequest); - assert.strictEqual(mintCount, 1); - }); - - test('reuses cached token for consecutive calls with same github token', async () => { - let mintCount = 0; - const service = createService(async (input) => { - const url = getUrl(input); - if (url.includes('/copilot_internal')) { - mintCount++; - return tokenResponse(); - } - return anthropicResponse([{ type: 'text', text: 'hi' }]); - }); - - await service.messages('gh-tok', baseRequest); - await service.messages('gh-tok', baseRequest); - await service.messages('gh-tok', baseRequest); - assert.strictEqual(mintCount, 1); - }); - - test('re-mints when the github token changes', async () => { - let mintCount = 0; - const service = createService(async (input) => { - const url = getUrl(input); - if (url.includes('/copilot_internal')) { - mintCount++; - return tokenResponse(); - } - return anthropicResponse([{ type: 'text', text: 'hi' }]); - }); - - await service.messages('gh-tok-A', baseRequest); - await service.messages('gh-tok-B', baseRequest); - assert.strictEqual(mintCount, 2); - }); - - test('re-mints when the copilot token is within 5 minutes of expiry', async () => { - let mintCount = 0; - const service = createService(async (input) => { - const url = getUrl(input); - if (url.includes('/copilot_internal')) { - mintCount++; - // Both expires_at AND refresh_in must point to a soon-expiring token, - // because cache validity prefers refresh_in over expires_at. - return tokenResponse({ expires_at: Date.now() / 1000 + 120, refresh_in: 0 }); - } - return anthropicResponse([{ type: 'text', text: 'hi' }]); - }); - - await service.messages('gh-tok', baseRequest); - await service.messages('gh-tok', baseRequest); - assert.strictEqual(mintCount, 2); - }); - - test('uses refresh_in (not expires_at) for cache validity to tolerate clock skew', async () => { - // Server says expires_at is in the past (simulating client clock ahead of server), - // but refresh_in is comfortably long. Cache must still be valid. - let mintCount = 0; - const service = createService(async (input) => { - const url = getUrl(input); - if (url.includes('/copilot_internal')) { - mintCount++; - return tokenResponse({ expires_at: Date.now() / 1000 - 999, refresh_in: 1800 }); - } - return anthropicResponse([{ type: 'text', text: 'hi' }]); - }); - - await service.messages('gh-tok', baseRequest); - await service.messages('gh-tok', baseRequest); - assert.strictEqual(mintCount, 1); - }); - - test('invalidates cached token on 401 from messages so the next call re-mints', async () => { - let mintCount = 0; - let messageCallCount = 0; - const service = createService(async (input) => { - const url = getUrl(input); - if (url.includes('/copilot_internal')) { - mintCount++; - return tokenResponse(); - } - messageCallCount++; - if (messageCallCount === 1) { - return new Response('unauthorized', { status: 401, statusText: 'Unauthorized' }); - } - return anthropicResponse([{ type: 'text', text: 'ok' }]); - }); - - await assert.rejects(() => service.messages('gh-tok', baseRequest)); - await service.messages('gh-tok', baseRequest); - assert.strictEqual(mintCount, 2); - }); - - test('invalidates cached token on 403 from models so the next call re-mints', async () => { - let mintCount = 0; - let modelsCallCount = 0; - const service = createService(async (input) => { - const url = getUrl(input); - if (url.includes('/copilot_internal')) { - mintCount++; - return tokenResponse(); - } - modelsCallCount++; - if (modelsCallCount === 1) { - return new Response('forbidden', { status: 403, statusText: 'Forbidden' }); - } - return modelsResponse([]); - }); - - await assert.rejects(() => service.models('gh-tok')); - await service.models('gh-tok'); - assert.strictEqual(mintCount, 2); - }); - - test('does not re-mint when copilot token has plenty of time left', async () => { - let mintCount = 0; - const service = createService(async (input) => { - const url = getUrl(input); - if (url.includes('/copilot_internal')) { - mintCount++; - return tokenResponse({ expires_at: Date.now() / 1000 + 7200 }); - } - return anthropicResponse([{ type: 'text', text: 'hi' }]); - }); - - await service.messages('gh-tok', baseRequest); - await service.messages('gh-tok', baseRequest); - assert.strictEqual(mintCount, 1); - }); - - test('uses endpoints.api from the token envelope as the CAPI base', async () => { - const { fetch: fetchFn, captured } = routingFetch( - () => anthropicResponse([{ type: 'text', text: 'ok' }]), - { endpoints: { api: 'https://custom.copilot.example.com' } }, - ); - const service = createService(fetchFn); - - await service.messages('gh-tok', baseRequest); - assert.strictEqual(captured().url, 'https://custom.copilot.example.com/v1/messages'); - }); - - test('falls back to default API base when endpoints.api is missing', async () => { - const { fetch: fetchFn, captured } = routingFetch( - () => anthropicResponse([{ type: 'text', text: 'ok' }]), - ); - const service = createService(fetchFn); - - await service.messages('gh-tok', baseRequest); - assert.strictEqual(captured().url, 'https://api.githubcopilot.com/v1/messages'); - }); - - test('sends the github token as Authorization header to the mint endpoint', async () => { - let capturedAuthHeader: string | undefined; - const service = createService(async (input, init) => { - const url = getUrl(input); - if (url.includes('/copilot_internal')) { - const headers = init?.headers as Record; - capturedAuthHeader = headers?.['Authorization']; - return tokenResponse(); - } - return anthropicResponse([{ type: 'text', text: 'ok' }]); - }); - - await service.messages('my-secret-gh-token', baseRequest); - assert.strictEqual(capturedAuthHeader, 'token my-secret-gh-token'); - }); - - test('throws on 403 from token mint', async () => { - const service = createService(async () => new Response('{"message":"Not authorized"}', { status: 403, statusText: 'Forbidden' })); - await assert.rejects( - () => service.messages('bad-tok', baseRequest), - (err: Error) => err.message.includes('Copilot token minting failed: 403'), - ); - }); - - test('throws on 500 from token mint', async () => { - const service = createService(async () => new Response('internal error', { status: 500, statusText: 'Internal Server Error' })); - await assert.rejects( - () => service.messages('gh-tok', baseRequest), - (err: Error) => err.message.includes('Copilot token minting failed: 500'), - ); - }); - - test('does not double-mint when concurrent requests race on first call', async () => { - let mintCount = 0; - const service = createService(async (input) => { - const url = getUrl(input); - if (url.includes('/copilot_internal')) { - mintCount++; - await new Promise(r => setTimeout(r, 10)); // ensure overlap - return tokenResponse(); - } - return anthropicResponse([{ type: 'text', text: 'ok' }]); - }); - - await Promise.all([ - service.messages('gh-tok', baseRequest), - service.messages('gh-tok', baseRequest), - ]); - assert.strictEqual(mintCount, 1); - }); - - test('in-flight mint dedup spans concurrent messages + models calls', async () => { - let mintCount = 0; - const service = createService(async (input) => { - const url = getUrl(input); - if (url.includes('/copilot_internal')) { - mintCount++; - await new Promise(r => setTimeout(r, 10)); - return tokenResponse(); - } - if (url.includes('/models')) { - return modelsResponse([]); - } - return anthropicResponse([{ type: 'text', text: 'ok' }]); - }); - - await Promise.all([ - service.messages('gh-tok', baseRequest), - service.models('gh-tok'), - ]); - assert.strictEqual(mintCount, 1); - }); - - test('error from token mint does not include the github token', async () => { - const service = createService(async () => new Response('forbidden', { status: 403, statusText: 'Forbidden' })); - await assert.rejects( - () => service.messages('super-secret-gh-token-xyz', baseRequest), - (err: Error) => !err.message.includes('super-secret-gh-token-xyz'), - ); - }); - - test('error from CAPI does not include the copilot or github token', async () => { - const service = createService(async (input) => { - const url = getUrl(input); - if (url.includes('/copilot_internal')) { - return tokenResponse({ token: 'super-secret-copilot-token-xyz' }); - } - return new Response('rate limited', { status: 429, statusText: 'Too Many Requests' }); - }); - await assert.rejects( - () => service.messages('super-secret-gh-token-xyz', baseRequest), - (err: Error) => !err.message.includes('super-secret-copilot-token-xyz') && !err.message.includes('super-secret-gh-token-xyz'), - ); - }); - - test('mints independently for concurrent requests with different github tokens', async () => { - const minted: string[] = []; - const service = createService(async (input, init) => { - const url = getUrl(input); - if (url.includes('/copilot_internal')) { - const auth = (init?.headers as Record)?.['Authorization'] ?? ''; - minted.push(auth); - await new Promise(r => setTimeout(r, 10)); // ensure overlap - return tokenResponse(); - } - return anthropicResponse([{ type: 'text', text: 'ok' }]); - }); - - await Promise.all([ - service.messages('gh-tok-A', baseRequest), - service.messages('gh-tok-B', baseRequest), - ]); - assert.strictEqual(minted.length, 2); - assert.ok(minted.some(h => h.includes('gh-tok-A'))); - assert.ok(minted.some(h => h.includes('gh-tok-B'))); - }); - }); - - // #endregion - - // #region Request Format - - suite('Request Format', () => { - - test('sends system as a top-level text-block array', async () => { - const { fetch: fetchFn, captured } = routingFetch( - () => anthropicResponse([{ type: 'text', text: 'ok' }]), - ); - const service = createService(fetchFn); - - await service.messages('gh-tok', { ...baseRequest, system: 'You are helpful.' }); - const body = JSON.parse(captured().init?.body as string); - - assert.deepStrictEqual(body.system, [{ type: 'text', text: 'You are helpful.' }]); - }); - - test('omits system field entirely when not provided', async () => { - const { fetch: fetchFn, captured } = routingFetch( - () => anthropicResponse([{ type: 'text', text: 'ok' }]), - ); - const service = createService(fetchFn); - - await service.messages('gh-tok', baseRequest); - const body = JSON.parse(captured().init?.body as string); - - assert.strictEqual(body.system, undefined); - }); - - test('sends max_tokens in the body', async () => { - const { fetch: fetchFn, captured } = routingFetch( - () => anthropicResponse([{ type: 'text', text: 'ok' }]), - ); - const service = createService(fetchFn); - - await service.messages('gh-tok', { ...baseRequest, max_tokens: 8192 }); - const body = JSON.parse(captured().init?.body as string); - - assert.strictEqual(body.max_tokens, 8192); - }); - - test('non-streaming sends stream=false in the body', async () => { - const { fetch: fetchFn, captured } = routingFetch( - () => anthropicResponse([{ type: 'text', text: 'ok' }]), - ); - const service = createService(fetchFn); - - await service.messages('gh-tok', baseRequest); - const body = JSON.parse(captured().init?.body as string); - - assert.strictEqual(body.stream, false); - }); - - test('defaults to non-streaming when stream is omitted', async () => { - const { fetch: fetchFn, captured } = routingFetch( - () => anthropicResponse([{ type: 'text', text: 'ok' }]), - ); - const service = createService(fetchFn); - - await service.messages('gh-tok', baseRequest); - const body = JSON.parse(captured().init?.body as string); - - assert.strictEqual(body.stream, false); - }); - - test('streaming sends stream=true in the body', async () => { - const { fetch: fetchFn, captured } = routingFetch( - () => sseResponse([sseLines('data: {"type":"message_stop"}')]), - ); - const service = createService(fetchFn); - - await collect(service.messages('gh-tok', { ...baseRequest, stream: true as const })); - const body = JSON.parse(captured().init?.body as string); - - assert.strictEqual(body.stream, true); - }); - - test('sends correct CAPI headers', async () => { - const { fetch: fetchFn, captured } = routingFetch( - () => anthropicResponse([{ type: 'text', text: 'ok' }]), - ); - const service = createService(fetchFn); - - await service.messages('gh-tok', baseRequest); - const headers = captured().init?.headers as Record; - - assert.strictEqual(headers['Content-Type'], 'application/json'); - assert.strictEqual(headers['Authorization'], 'Bearer copilot-tok-abc'); - assert.strictEqual(headers['OpenAI-Intent'], 'conversation'); - assert.ok(headers['X-Request-Id'], 'should have a request id'); - assert.ok(headers['X-GitHub-Api-Version'], 'CAPIClient should inject API version'); - assert.ok(headers['VScode-SessionId'], 'CAPIClient should inject session id'); - }); - - test('passes messages through as-is', async () => { - const { fetch: fetchFn, captured } = routingFetch( - () => anthropicResponse([{ type: 'text', text: 'ok' }]), - ); - const service = createService(fetchFn); - - const messages: Anthropic.MessageParam[] = [ - { role: 'user', content: 'What is 2+2?' }, - { role: 'assistant', content: '4' }, - { role: 'user', content: 'Thanks!' }, - ]; - await service.messages('gh-tok', { ...baseRequest, messages }); - const body = JSON.parse(captured().init?.body as string); - - assert.deepStrictEqual(body.messages, messages); - }); - - test('sends model in the body', async () => { - const { fetch: fetchFn, captured } = routingFetch( - () => anthropicResponse([{ type: 'text', text: 'ok' }]), - ); - const service = createService(fetchFn); - - await service.messages('gh-tok', { ...baseRequest, model: 'claude-opus-4-1-20250805' }); - const body = JSON.parse(captured().init?.body as string); - - assert.strictEqual(body.model, 'claude-opus-4-1-20250805'); - }); - - test('merges caller-provided headers into the request', async () => { - const { fetch: fetchFn, captured } = routingFetch( - () => anthropicResponse([{ type: 'text', text: 'ok' }]), - ); - const service = createService(fetchFn); - - await service.messages('gh-tok', baseRequest, { - headers: { 'X-Custom-Trace': 'abc-123', 'X-Session-Id': 'sess-456' }, - }); - const headers = captured().init?.headers as Record; - - assert.strictEqual(headers['X-Custom-Trace'], 'abc-123'); - assert.strictEqual(headers['X-Session-Id'], 'sess-456'); - assert.strictEqual(headers['Authorization'], 'Bearer copilot-tok-abc', 'standard headers should not be overridden'); - }); - - test('caller-supplied headers cannot override security-sensitive standard headers', async () => { - // Documented invariant: Authorization, Content-Type, X-Request-Id, OpenAI-Intent - // must always reflect the values the service computes — never the caller's. - const { fetch: fetchFn, captured } = routingFetch( - () => anthropicResponse([{ type: 'text', text: 'ok' }]), - ); - const service = createService(fetchFn); - - await service.messages('gh-tok', baseRequest, { - headers: { - 'Authorization': 'Bearer attacker-token', - 'Content-Type': 'text/plain', - 'X-Request-Id': 'attacker-id', - 'OpenAI-Intent': 'attacker-intent', - }, - }); - const headers = captured().init?.headers as Record; - - assert.strictEqual(headers['Authorization'], 'Bearer copilot-tok-abc'); - assert.strictEqual(headers['Content-Type'], 'application/json'); - assert.notStrictEqual(headers['X-Request-Id'], 'attacker-id'); - assert.strictEqual(headers['OpenAI-Intent'], 'conversation'); - }); - }); - - // #endregion - - // #region Non-Streaming Responses - - suite('Non-Streaming Responses', () => { - - test('returns text content from a single text block', async () => { - const { fetch: fetchFn } = routingFetch( - () => anthropicResponse([{ type: 'text', text: 'The answer is 42.' }]), - ); - const service = createService(fetchFn); - - const result = await service.messages('gh-tok', baseRequest); - assert.strictEqual(getText(result), 'The answer is 42.'); - }); - - test('concatenates multiple text blocks', async () => { - const { fetch: fetchFn } = routingFetch( - () => anthropicResponse([ - { type: 'text', text: 'First part. ' }, - { type: 'text', text: 'Second part.' }, - ]), - ); - const service = createService(fetchFn); - - const result = await service.messages('gh-tok', baseRequest); - assert.strictEqual(getText(result), 'First part. Second part.'); - }); - - test('skips non-text content blocks (tool_use, thinking)', async () => { - const { fetch: fetchFn } = routingFetch( - () => anthropicResponse([ - { type: 'thinking', text: 'let me think...' }, - { type: 'text', text: 'the answer' }, - { type: 'tool_use' }, - ]), - ); - const service = createService(fetchFn); - - const result = await service.messages('gh-tok', baseRequest); - assert.strictEqual(getText(result), 'the answer'); - }); - - test('returns empty string when no text blocks are present', async () => { - const { fetch: fetchFn } = routingFetch( - () => anthropicResponse([{ type: 'tool_use' }]), - ); - const service = createService(fetchFn); - - const result = await service.messages('gh-tok', baseRequest); - assert.strictEqual(getText(result), ''); - }); - - test('returns the stop reason', async () => { - const { fetch: fetchFn } = routingFetch( - () => anthropicResponse([{ type: 'text', text: 'ok' }], 'max_tokens'), - ); - const service = createService(fetchFn); - - const result = await service.messages('gh-tok', baseRequest); - assert.strictEqual(result.stop_reason, 'max_tokens'); - }); - - test('stop_reason is null when missing from server response', async () => { - const { fetch: fetchFn } = routingFetch(() => { - return new Response(JSON.stringify({ - content: [{ type: 'text', text: 'ok' }], - }), { status: 200 }); - }); - const service = createService(fetchFn); - - const result = await service.messages('gh-tok', baseRequest); - assert.strictEqual(result.stop_reason ?? null, null); - }); - - test('throws on 429 rate limit', async () => { - const { fetch: fetchFn } = routingFetch( - () => new Response('{"error":"rate_limited"}', { status: 429, statusText: 'Too Many Requests' }), - ); - const service = createService(fetchFn); - - await assert.rejects( - () => service.messages('gh-tok', baseRequest), - (err: unknown) => err instanceof CopilotApiError - && err.status === 429 - && err.message.includes('CAPI request failed: 429'), - ); - }); - - test('throws on 500 server error', async () => { - const { fetch: fetchFn } = routingFetch( - () => new Response('internal server error', { status: 500, statusText: 'Internal Server Error' }), - ); - const service = createService(fetchFn); - - await assert.rejects( - () => service.messages('gh-tok', baseRequest), - (err: unknown) => err instanceof CopilotApiError - && err.status === 500 - && err.message.includes('CAPI request failed: 500'), - ); - }); - }); - - // #endregion - - // #region Streaming Responses - - suite('Streaming Responses', () => { - - function collectTextDeltas(events: Anthropic.MessageStreamEvent[]): string[] { - return events - .filter((e): e is Anthropic.RawContentBlockDeltaEvent => - e.type === 'content_block_delta' && e.delta.type === 'text_delta') - .map(e => (e.delta as Anthropic.TextDelta).text); - } - - test('yields text deltas from content_block_delta events', async () => { - const service = streamService([ - sseLines( - 'event: content_block_delta', - 'data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}}', - '', - 'event: content_block_delta', - 'data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" world"}}', - ), - sseLines( - 'event: message_stop', - 'data: {"type":"message_stop"}', - ), - ]); - - const events = await collect(service.messages('gh-tok', { ...baseRequest, stream: true as const })); - assert.deepStrictEqual(collectTextDeltas(events), ['Hello', ' world']); - }); - - test('handles data split across multiple network chunks', async () => { - const encoder = new TextEncoder(); - const service = streamService([ - encoder.encode('event: content_block_delta\ndata: {"type":"content_bl'), - encoder.encode('ock_delta","index":0,"delta":{"type":"text_delta","text":"split"}}\n'), - sseLines( - 'event: message_stop', - 'data: {"type":"message_stop"}', - ), - ]); - - const events = await collect(service.messages('gh-tok', { ...baseRequest, stream: true as const })); - assert.deepStrictEqual(collectTextDeltas(events), ['split']); - }); - - test('handles a data line split right at the newline boundary', async () => { - const encoder = new TextEncoder(); - const service = streamService([ - encoder.encode('data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"chunk1"}}'), - encoder.encode('\ndata: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"chunk2"}}\n'), - sseLines('data: {"type":"message_stop"}'), - ]); - - const events = await collect(service.messages('gh-tok', { ...baseRequest, stream: true as const })); - assert.deepStrictEqual(collectTextDeltas(events), ['chunk1', 'chunk2']); - }); - - test('skips event: lines, comment lines, and blank lines', async () => { - const service = streamService([ - sseLines( - ': keep-alive comment', - 'event: content_block_delta', - '', - 'data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"ok"}}', - '', - 'event: message_stop', - 'data: {"type":"message_stop"}', - ), - ]); - - const events = await collect(service.messages('gh-tok', { ...baseRequest, stream: true as const })); - assert.deepStrictEqual(collectTextDeltas(events), ['ok']); - }); - - test('handles many small deltas', async () => { - const deltas = Array.from({ length: 100 }, (_, i) => - `data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"w${i}"}}` - ); - const service = streamService([ - sseLines(...deltas), - sseLines('data: {"type":"message_stop"}'), - ]); - - const texts = collectTextDeltas(await collect(service.messages('gh-tok', { ...baseRequest, stream: true as const }))); - assert.strictEqual(texts.length, 100); - assert.strictEqual(texts[0], 'w0'); - assert.strictEqual(texts[99], 'w99'); - }); - - test('throws on error event with message', async () => { - const service = streamService([ - sseLines( - 'event: error', - 'data: {"type":"error","error":{"message":"overloaded"}}', - ), - ]); - - await assert.rejects( - () => collect(service.messages('gh-tok', { ...baseRequest, stream: true as const })), - (err: unknown) => err instanceof CopilotApiError - && err.status === COPILOT_API_ERROR_STATUS_STREAMING - && err.message === 'overloaded', - ); - }); - - test('throws on error event without message', async () => { - const service = streamService([ - sseLines( - 'event: error', - 'data: {"type":"error","error":{}}', - ), - ]); - - await assert.rejects( - () => collect(service.messages('gh-tok', { ...baseRequest, stream: true as const })), - (err: unknown) => err instanceof CopilotApiError - && err.status === COPILOT_API_ERROR_STATUS_STREAMING - && err.message === 'Unknown streaming error', - ); - }); - - test('throws on non-200 CAPI response', async () => { - const { fetch: fetchFn } = routingFetch( - () => new Response('overloaded', { status: 529, statusText: 'Overloaded' }), - ); - const service = createService(fetchFn); - - await assert.rejects( - () => collect(service.messages('gh-tok', { ...baseRequest, stream: true as const })), - (err: unknown) => err instanceof CopilotApiError - && err.status === 529 - && err.message.includes('CAPI request failed: 529'), - ); - }); - - test('throws when response has no body', async () => { - const { fetch: fetchFn } = routingFetch( - () => new Response(null, { status: 200 }), - ); - const service = createService(fetchFn); - - await assert.rejects( - () => collect(service.messages('gh-tok', { ...baseRequest, stream: true as const })), - (err: Error) => err.message.includes('no body'), - ); - }); - - test('survives malformed JSON in the stream (skips the line)', async () => { - const service = streamService([ - sseLines( - 'data: not-valid-json', - 'data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"ok"}}', - 'data: {"type":"message_stop"}', - ), - ]); - - const events = await collect(service.messages('gh-tok', { ...baseRequest, stream: true as const })); - assert.deepStrictEqual(collectTextDeltas(events), ['ok']); - }); - }); - - // #endregion - - // #region Raw Event Stream (messages()) - - suite('Raw Event Stream (messages())', () => { - - test('yields all six protocol event types in order', async () => { - const service = streamService([ - sseLines( - 'data: {"type":"message_start","message":{"id":"msg_1","type":"message","role":"assistant","content":[],"model":"claude-sonnet-4-5","stop_reason":null,"usage":{"input_tokens":1,"output_tokens":1}}}', - 'data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}', - 'data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"hi"}}', - 'data: {"type":"content_block_stop","index":0}', - 'data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":1}}', - 'data: {"type":"message_stop"}', - ), - ]); - - const events = await collect(service.messages('gh-tok', { ...baseRequest, stream: true as const })); - assert.deepStrictEqual(events.map(e => e.type), [ - 'message_start', - 'content_block_start', - 'content_block_delta', - 'content_block_stop', - 'message_delta', - 'message_stop', - ]); - }); - - test('message_stop is the last yielded event', async () => { - const service = streamService([ - sseLines( - 'data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"a"}}', - 'data: {"type":"message_stop"}', - ), - ]); - - const events = await collect(service.messages('gh-tok', { ...baseRequest, stream: true as const })); - assert.strictEqual(events.length, 2); - assert.strictEqual(events[events.length - 1].type, 'message_stop'); - }); - - test('stops after message_stop even if extra SSE data follows', async () => { - const service = streamService([ - sseLines( - 'data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"a"}}', - 'data: {"type":"message_stop"}', - 'data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"SHOULD_NOT_APPEAR"}}', - ), - ]); - - const events = await collect(service.messages('gh-tok', { ...baseRequest, stream: true as const })); - const texts = events - .filter((e): e is Anthropic.RawContentBlockDeltaEvent => e.type === 'content_block_delta') - .map(e => e.delta.type === 'text_delta' ? e.delta.text : ''); - assert.deepStrictEqual(texts, ['a']); - }); - - test('yields thinking_delta events (not filtered by messages())', async () => { - const service = streamService([ - sseLines( - 'data: {"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"hmm"}}', - 'data: {"type":"message_stop"}', - ), - ]); - - const events = await collect(service.messages('gh-tok', { ...baseRequest, stream: true as const })); - const delta = events.find((e): e is Anthropic.RawContentBlockDeltaEvent => e.type === 'content_block_delta'); - assert.ok(delta); - assert.strictEqual(delta.delta.type, 'thinking_delta'); - }); - - test('yields input_json_delta events', async () => { - const service = streamService([ - sseLines( - 'data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\\"k\\":1}"}}', - 'data: {"type":"message_stop"}', - ), - ]); - - const events = await collect(service.messages('gh-tok', { ...baseRequest, stream: true as const })); - const delta = events.find((e): e is Anthropic.RawContentBlockDeltaEvent => e.type === 'content_block_delta'); - assert.ok(delta); - assert.strictEqual(delta.delta.type, 'input_json_delta'); - }); - - test('yields message_delta with stop_reason payload', async () => { - const service = streamService([ - sseLines( - 'data: {"type":"message_delta","delta":{"stop_reason":"max_tokens","stop_sequence":null},"usage":{"output_tokens":7}}', - 'data: {"type":"message_stop"}', - ), - ]); - - const events = await collect(service.messages('gh-tok', { ...baseRequest, stream: true as const })); - const msgDelta = events.find((e): e is Anthropic.RawMessageDeltaEvent => e.type === 'message_delta'); - assert.ok(msgDelta); - assert.strictEqual(msgDelta.delta.stop_reason, 'max_tokens'); - }); - - test('tool_use block events round-trip through messages()', async () => { - const service = streamService([ - sseLines( - 'data: {"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"tu_1","name":"read_file","input":{}}}', - 'data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\\"path\\":"}}', - 'data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"\\"/tmp/x\\"}"}}', - 'data: {"type":"content_block_stop","index":0}', - 'data: {"type":"message_stop"}', - ), - ]); - - const events = await collect(service.messages('gh-tok', { ...baseRequest, stream: true as const })); - const blockStart = events.find((e): e is Anthropic.RawContentBlockStartEvent => e.type === 'content_block_start'); - assert.ok(blockStart, 'expected content_block_start event'); - assert.strictEqual(blockStart.content_block.type, 'tool_use'); - assert.strictEqual((blockStart.content_block as Anthropic.ToolUseBlock).name, 'read_file'); - - const jsonDeltas = events.filter( - (e): e is Anthropic.RawContentBlockDeltaEvent => - e.type === 'content_block_delta' && e.delta.type === 'input_json_delta', - ); - assert.strictEqual(jsonDeltas.length, 2); - assert.strictEqual(events[events.length - 1].type, 'message_stop'); - }); - }); - - // #endregion - - // #region countTokens - - suite('countTokens', () => { - - test('throws "countTokens not supported by CAPI"', async () => { - const service = createService(async () => new Response('{}', { status: 200 })); - await assert.rejects( - () => service.countTokens('gh-tok', { model: 'claude-sonnet-4-5', messages: [{ role: 'user', content: 'hi' }] }), - (err: Error) => err.message.includes('countTokens not supported by CAPI'), - ); - }); - - test('does not mint a token before throwing', async () => { - let mintCount = 0; - const service = createService(async (input) => { - const url = getUrl(input); - if (url.includes('/copilot_internal')) { - mintCount++; - return tokenResponse(); - } - return new Response('{}', { status: 200 }); - }); - - await assert.rejects( - () => service.countTokens('gh-tok', { model: 'claude-sonnet-4-5', messages: [{ role: 'user', content: 'hi' }] }), - ); - assert.strictEqual(mintCount, 0); - }); - }); - - // #endregion - - // #region Streaming + Non-Streaming Shared Behavior - - suite('Shared Behavior', () => { - - test('streaming and non-streaming hit the same /v1/messages endpoint', async () => { - const urls: string[] = []; - const service = createService(async (input) => { - const url = getUrl(input); - if (url.includes('/copilot_internal')) { - return tokenResponse(); - } - urls.push(url); - if (urls.length === 1) { - return anthropicResponse([{ type: 'text', text: 'ok' }]); - } - return sseResponse([sseLines('data: {"type":"message_stop"}')]); - }); - - await service.messages('gh-tok', baseRequest); - await collect(service.messages('gh-tok', { ...baseRequest, stream: true as const })); - - assert.strictEqual(urls.length, 2); - assert.ok(urls[0].endsWith('/v1/messages')); - assert.ok(urls[1].endsWith('/v1/messages')); - }); - - test('both modes share the same cached copilot token', async () => { - let mintCount = 0; - const service = createService(async (input) => { - const url = getUrl(input); - if (url.includes('/copilot_internal')) { - mintCount++; - return tokenResponse(); - } - return anthropicResponse([{ type: 'text', text: 'ok' }]); - }); - - await service.messages('gh-tok', baseRequest); - await service.messages('gh-tok', baseRequest); - assert.strictEqual(mintCount, 1); - }); - }); - - // #endregion - - // #region CopilotApiError contract - - suite('CopilotApiError contract', () => { - - async function captureCopilotApiError(promise: Promise): Promise { - try { - await promise; - } catch (err) { - assert.ok(err instanceof CopilotApiError, `expected CopilotApiError, got: ${err instanceof Error ? err.message : String(err)}`); - return err; - } - assert.fail('expected to throw CopilotApiError'); - } - - test('non-2xx with conforming Anthropic envelope: passthrough verbatim', async () => { - const upstreamEnvelope: Anthropic.ErrorResponse = { - type: 'error', - error: { type: 'rate_limit_error', message: 'You are sending requests too fast.' }, - request_id: 'req_abc', - }; - const { fetch: fetchFn } = routingFetch( - () => new Response(JSON.stringify(upstreamEnvelope), { status: 429, statusText: 'Too Many Requests' }), - ); - const service = createService(fetchFn); - - const err = await captureCopilotApiError(service.messages('gh-tok', baseRequest)); - assert.deepStrictEqual( - { status: err.status, envelope: err.envelope }, - { status: 429, envelope: upstreamEnvelope }, - ); - }); - - test('non-2xx with non-Anthropic JSON body: synthesizes api_error envelope', async () => { - const { fetch: fetchFn } = routingFetch( - () => new Response('{"error":"rate_limited"}', { status: 429, statusText: 'Too Many Requests' }), - ); - const service = createService(fetchFn); - - const err = await captureCopilotApiError(service.messages('gh-tok', baseRequest)); - assert.deepStrictEqual( - { status: err.status, envelope: err.envelope }, - { - status: 429, - envelope: { - type: 'error', - error: { type: 'api_error', message: '{"error":"rate_limited"}' }, - request_id: null, - }, - }, - ); - }); - - test('non-2xx with plain-text body: synthesizes api_error envelope using body', async () => { - const { fetch: fetchFn } = routingFetch( - () => new Response('internal server error', { status: 500, statusText: 'Internal Server Error' }), - ); - const service = createService(fetchFn); - - const err = await captureCopilotApiError(service.messages('gh-tok', baseRequest)); - assert.deepStrictEqual( - { status: err.status, envelope: err.envelope }, - { - status: 500, - envelope: { - type: 'error', - error: { type: 'api_error', message: 'internal server error' }, - request_id: null, - }, - }, - ); - }); - - test('non-2xx with empty body: synthesizes api_error envelope using status text', async () => { - const { fetch: fetchFn } = routingFetch( - () => new Response('', { status: 502, statusText: 'Bad Gateway' }), - ); - const service = createService(fetchFn); - - const err = await captureCopilotApiError(service.messages('gh-tok', baseRequest)); - assert.deepStrictEqual( - { status: err.status, envelope: err.envelope }, - { - status: 502, - envelope: { - type: 'error', - error: { type: 'api_error', message: '502 Bad Gateway' }, - request_id: null, - }, - }, - ); - }); - - test('SSE error frame with full envelope: passthrough type and message', async () => { - const service = streamService([ - sseLines( - 'event: error', - 'data: {"type":"error","error":{"type":"overloaded_error","message":"Overloaded"},"request_id":"req_42"}', - ), - ]); - - const err = await captureCopilotApiError( - collect(service.messages('gh-tok', { ...baseRequest, stream: true as const })), - ); - assert.deepStrictEqual( - { status: err.status, envelope: err.envelope }, - { - status: COPILOT_API_ERROR_STATUS_STREAMING, - envelope: { - type: 'error', - error: { type: 'overloaded_error', message: 'Overloaded' }, - request_id: 'req_42', - }, - }, - ); - }); - - test('SSE error frame missing type: defaults to api_error', async () => { - const service = streamService([ - sseLines( - 'event: error', - 'data: {"type":"error","error":{"message":"oh no"}}', - ), - ]); - - const err = await captureCopilotApiError( - collect(service.messages('gh-tok', { ...baseRequest, stream: true as const })), - ); - assert.deepStrictEqual(err.envelope, { - type: 'error', - error: { type: 'api_error', message: 'oh no' }, - request_id: null, - }); - }); - - test('SSE error frame missing message: defaults to "Unknown streaming error"', async () => { - const service = streamService([ - sseLines( - 'event: error', - 'data: {"type":"error","error":{"type":"api_error"}}', - ), - ]); - - const err = await captureCopilotApiError( - collect(service.messages('gh-tok', { ...baseRequest, stream: true as const })), - ); - assert.deepStrictEqual(err.envelope, { - type: 'error', - error: { type: 'api_error', message: 'Unknown streaming error' }, - request_id: null, - }); - }); - - test('SSE error frame with conforming envelope is preserved verbatim (extra fields propagate)', async () => { - // The Phase 2 proxy must be able to re-emit the original error frame - // with full fidelity — any extra fields the upstream emits should - // survive the round-trip through CopilotApiError.envelope. - const service = streamService([ - sseLines( - 'event: error', - 'data: {"type":"error","error":{"type":"overloaded_error","message":"Overloaded","request_id":"req_xyz"}}', - ), - ]); - - const err = await captureCopilotApiError( - collect(service.messages('gh-tok', { ...baseRequest, stream: true as const })), - ); - assert.deepStrictEqual(err.envelope, { - type: 'error', - error: { type: 'overloaded_error', message: 'Overloaded', request_id: 'req_xyz' }, - }); - }); - - test('SSE error frame with unstructured-string error: uses the string as message', async () => { - const service = streamService([ - sseLines( - 'event: error', - 'data: {"type":"error","error":"rate_limited"}', - ), - ]); - - const err = await captureCopilotApiError( - collect(service.messages('gh-tok', { ...baseRequest, stream: true as const })), - ); - assert.deepStrictEqual(err.envelope, { - type: 'error', - error: { type: 'api_error', message: 'rate_limited' }, - request_id: null, - }); - }); - - test('models() non-2xx throws typed error with synthesized envelope', async () => { - const { fetch: fetchFn } = routingFetch( - () => new Response('upstream down', { status: 503, statusText: 'Service Unavailable' }), - ); - const service = createService(fetchFn); - - const err = await captureCopilotApiError(service.models('gh-tok')); - assert.deepStrictEqual( - { status: err.status, envelope: err.envelope }, - { - status: 503, - envelope: { - type: 'error', - error: { type: 'api_error', message: 'upstream down' }, - request_id: null, - }, - }, - ); - assert.ok(err.message.includes('CAPI models request failed: 503')); - }); - - test('models() non-2xx with conforming Anthropic envelope: passthrough verbatim', async () => { - const upstreamEnvelope: Anthropic.ErrorResponse = { - type: 'error', - error: { type: 'authentication_error', message: 'Invalid token.' }, - request_id: 'req_def', - }; - const { fetch: fetchFn } = routingFetch( - () => new Response(JSON.stringify(upstreamEnvelope), { status: 401, statusText: 'Unauthorized' }), - ); - const service = createService(fetchFn); - - const err = await captureCopilotApiError(service.models('gh-tok')); - assert.deepStrictEqual( - { status: err.status, envelope: err.envelope }, - { status: 401, envelope: upstreamEnvelope }, - ); - }); - - test('error message never embeds auth tokens', async () => { - const service = createService(async (input) => { - const url = getUrl(input); - if (url.includes('/copilot_internal')) { - return tokenResponse({ token: 'super-secret-copilot-token-xyz' }); - } - return new Response('rate limited', { status: 429, statusText: 'Too Many Requests' }); - }); - - const err = await captureCopilotApiError(service.messages('super-secret-gh-token-xyz', baseRequest)); - const serialized = JSON.stringify({ message: err.message, envelope: err.envelope }); - assert.ok(!serialized.includes('super-secret-copilot-token-xyz')); - assert.ok(!serialized.includes('super-secret-gh-token-xyz')); - }); - - test('401 still invalidates the cached token (regression)', async () => { - let mintCount = 0; - let next401 = true; - const service = createService(async (input) => { - const url = getUrl(input); - if (url.includes('/copilot_internal')) { - mintCount++; - return tokenResponse(); - } - if (next401) { - next401 = false; - return new Response('unauthorized', { status: 401, statusText: 'Unauthorized' }); - } - return anthropicResponse([{ type: 'text', text: 'ok' }]); - }); - - await captureCopilotApiError(service.messages('gh-tok', baseRequest)); - await service.messages('gh-tok', baseRequest); - assert.strictEqual(mintCount, 2); - }); - }); - - // #endregion - - // #region Cancellation - - suite('Cancellation', () => { - - test('forwards AbortSignal to fetch for messages', async () => { - const controller = new AbortController(); - let capturedSignal: AbortSignal | undefined; - const service = createService(async (input, init) => { - const url = getUrl(input); - if (url.includes('/copilot_internal')) { - return tokenResponse(); - } - capturedSignal = init?.signal as AbortSignal; - return anthropicResponse([{ type: 'text', text: 'ok' }]); - }); - - await service.messages('gh-tok', baseRequest, { signal: controller.signal }); - assert.strictEqual(capturedSignal, controller.signal); - }); - - test('forwards AbortSignal to fetch for models', async () => { - const controller = new AbortController(); - let capturedSignal: AbortSignal | undefined; - const service = createService(async (input, init) => { - const url = getUrl(input); - if (url.includes('/copilot_internal')) { - return tokenResponse(); - } - capturedSignal = init?.signal as AbortSignal; - return modelsResponse([]); - }); - - await service.models('gh-tok', { signal: controller.signal }); - assert.strictEqual(capturedSignal, controller.signal); - }); - - test('does not forward AbortSignal to the shared token mint fetch', async () => { - const controller = new AbortController(); - let mintSignal: AbortSignal | undefined; - const service = createService(async (input, init) => { - const url = getUrl(input); - if (url.includes('/copilot_internal')) { - mintSignal = init?.signal as AbortSignal; - return tokenResponse(); - } - return anthropicResponse([{ type: 'text', text: 'ok' }]); - }); - - await service.messages('gh-tok', baseRequest, { signal: controller.signal }); - assert.strictEqual(mintSignal, undefined); - }); - - test('cancels the underlying SSE stream when the consumer breaks early', async () => { - let cancelled = false; - const body = new ReadableStream({ - pull(controller) { - controller.enqueue(sseLines( - 'data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"Hello"}}', - )); - }, - cancel() { - cancelled = true; - }, - }); - const service = createService(async (input) => { - const url = getUrl(input); - if (url.includes('/copilot_internal')) { - return tokenResponse(); - } - return new Response(body, { status: 200, headers: { 'Content-Type': 'text/event-stream' } }); - }); - - const iter = service.messages('gh-tok', { ...baseRequest, stream: true }); - for await (const _ of iter) { - break; // abandon after first chunk - } - assert.strictEqual(cancelled, true); - }); - - test('cancels the underlying SSE stream after message_stop terminates the generator', async () => { - let cancelled = false; - const body = new ReadableStream({ - start(controller) { - controller.enqueue(sseLines( - 'data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"Hello"}}', - 'data: {"type":"message_stop"}', - )); - // Server is still alive — connection must be released by the client - // even though the producer hasn't closed yet. - }, - cancel() { - cancelled = true; - }, - }); - const service = createService(async (input) => { - const url = getUrl(input); - if (url.includes('/copilot_internal')) { - return tokenResponse(); - } - return new Response(body, { status: 200, headers: { 'Content-Type': 'text/event-stream' } }); - }); - - await collect(service.messages('gh-tok', { ...baseRequest, stream: true })); - assert.strictEqual(cancelled, true); - }); - - test('cancels the underlying SSE stream when the generator throws', async () => { - let cancelled = false; - const body = new ReadableStream({ - start(controller) { - controller.enqueue(sseLines( - 'data: {"type":"error","error":{"message":"boom"}}', - )); - }, - cancel() { - cancelled = true; - }, - }); - const service = createService(async (input) => { - const url = getUrl(input); - if (url.includes('/copilot_internal')) { - return tokenResponse(); - } - return new Response(body, { status: 200, headers: { 'Content-Type': 'text/event-stream' } }); - }); - - await assert.rejects(() => collect(service.messages('gh-tok', { ...baseRequest, stream: true }))); - assert.strictEqual(cancelled, true); - }); - }); - - // #endregion - - // #region Models - - suite('Models', () => { - - test('returns models from the data array', async () => { - const fakeModels = [ - { id: 'claude-sonnet-4-5', name: 'Claude Sonnet 4.5', vendor: 'anthropic', supported_endpoints: ['chat/messages'] }, - { id: 'claude-opus-4', name: 'Claude Opus 4', vendor: 'anthropic', supported_endpoints: ['chat/messages'] }, - ]; - const service = createService(async (input) => { - const url = getUrl(input); - if (url.includes('/copilot_internal')) { - return tokenResponse(); - } - return modelsResponse(fakeModels); - }); - - const result = await service.models('gh-tok'); - assert.deepStrictEqual(result, fakeModels); - }); - - test('returns empty array when data is missing', async () => { - const service = createService(async (input) => { - const url = getUrl(input); - if (url.includes('/copilot_internal')) { - return tokenResponse(); - } - return new Response(JSON.stringify({}), { status: 200 }); - }); - - const result = await service.models('gh-tok'); - assert.deepStrictEqual(result, []); - }); - - test('sends Bearer token in Authorization header', async () => { - let capturedAuthHeader: string | undefined; - const service = createService(async (input, init) => { - const url = getUrl(input); - if (url.includes('/copilot_internal')) { - return tokenResponse(); - } - capturedAuthHeader = (init?.headers as Record)?.['Authorization']; - return modelsResponse([]); - }); - - await service.models('gh-tok'); - assert.strictEqual(capturedAuthHeader, 'Bearer copilot-tok-abc'); - }); - - test('throws on non-200 response', async () => { - const service = createService(async (input) => { - const url = getUrl(input); - if (url.includes('/copilot_internal')) { - return tokenResponse(); - } - return new Response('forbidden', { status: 403, statusText: 'Forbidden' }); - }); - - await assert.rejects( - () => service.models('gh-tok'), - (err: unknown) => err instanceof CopilotApiError - && err.status === 403 - && err.message.includes('CAPI models request failed: 403'), - ); - }); - - test('reuses cached token across messages and models calls', async () => { - let mintCount = 0; - const service = createService(async (input) => { - const url = getUrl(input); - if (url.includes('/copilot_internal')) { - mintCount++; - return tokenResponse(); - } - if (url.includes('/models')) { - return modelsResponse([]); - } - return anthropicResponse([{ type: 'text', text: 'ok' }]); - }); - - await service.messages('gh-tok', baseRequest); - await service.models('gh-tok'); - assert.strictEqual(mintCount, 1); - }); - - test('routes to the models endpoint URL', async () => { - const { fetch: fetchFn, captured } = routingFetch(() => modelsResponse([])); - const service = createService(fetchFn); - - await service.models('gh-tok'); - assert.ok(captured().url.includes('/models'), `expected models URL, got: ${captured().url}`); - }); - - test('caller-supplied headers cannot override Authorization in models()', async () => { - let capturedHeaders: Record | undefined; - const service = createService(async (input, init) => { - const url = getUrl(input); - if (url.includes('/copilot_internal')) { - return tokenResponse(); - } - capturedHeaders = init?.headers as Record; - return modelsResponse([]); - }); - - await service.models('gh-tok', { - headers: { 'Authorization': 'Bearer attacker-token' }, - }); - assert.strictEqual(capturedHeaders?.['Authorization'], 'Bearer copilot-tok-abc'); - }); - }); - - // #endregion -}); diff --git a/src/vs/platform/agentHost/test/node/sshRemoteAgentHostHelpers.test.ts b/src/vs/platform/agentHost/test/node/sshRemoteAgentHostHelpers.test.ts deleted file mode 100644 index fca60679..00000000 --- a/src/vs/platform/agentHost/test/node/sshRemoteAgentHostHelpers.test.ts +++ /dev/null @@ -1,496 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import assert from 'assert'; -import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { NullLogService } from '../../../log/common/log.js'; -import { - buildCLIDownloadUrl, - cleanupRemoteAgentHost, - findRunningAgentHost, - getAgentHostStateFile, - getRemoteCLIBin, - getRemoteCLIDir, - redactToken, - resolveRemotePlatform, - shellEscape, - validateShellToken, - writeAgentHostState, - type ISshExec, -} from '../../node/sshRemoteAgentHostHelpers.js'; - -suite('SSH Remote Agent Host Helpers', () => { - - ensureNoDisposablesAreLeakedInTestSuite(); - - const logService = new NullLogService(); - - suite('validateShellToken', () => { - test('accepts alphanumeric strings', () => { - assert.strictEqual(validateShellToken('insider', 'quality'), 'insider'); - assert.strictEqual(validateShellToken('stable', 'quality'), 'stable'); - assert.strictEqual(validateShellToken('exploration', 'quality'), 'exploration'); - }); - - test('accepts dots, dashes, and underscores', () => { - assert.strictEqual(validateShellToken('my-build_1.0', 'quality'), 'my-build_1.0'); - }); - - test('rejects strings with spaces', () => { - assert.throws(() => validateShellToken('foo bar', 'quality'), /Unsafe quality/); - }); - - test('rejects strings with shell metacharacters', () => { - assert.throws(() => validateShellToken('foo;rm -rf /', 'quality'), /Unsafe quality/); - assert.throws(() => validateShellToken('$(whoami)', 'quality'), /Unsafe quality/); - assert.throws(() => validateShellToken('foo\'bar', 'quality'), /Unsafe quality/); - }); - - test('rejects empty string', () => { - assert.throws(() => validateShellToken('', 'quality'), /Unsafe quality/); - }); - }); - - suite('getRemoteCLIDir', () => { - test('returns standard path for stable', () => { - assert.strictEqual(getRemoteCLIDir('stable'), '~/.vscode-cli'); - }); - - test('returns quality-suffixed path for insider', () => { - assert.strictEqual(getRemoteCLIDir('insider'), '~/.vscode-cli-insider'); - }); - - test('returns quality-suffixed path for exploration', () => { - assert.strictEqual(getRemoteCLIDir('exploration'), '~/.vscode-cli-exploration'); - }); - }); - - suite('getRemoteCLIBin', () => { - test('returns code for stable', () => { - assert.strictEqual(getRemoteCLIBin('stable'), '~/.vscode-cli/code'); - }); - - test('returns code-insiders for insider', () => { - assert.strictEqual(getRemoteCLIBin('insider'), '~/.vscode-cli-insider/code-insiders'); - }); - }); - - suite('shellEscape', () => { - test('wraps simple string in single quotes', () => { - assert.strictEqual(shellEscape('hello'), '\'hello\''); - }); - - test('escapes embedded single quotes', () => { - assert.strictEqual(shellEscape('it\'s'), '\'it\'\\\'\'s\''); - }); - - test('handles empty string', () => { - assert.strictEqual(shellEscape(''), '\'\''); - }); - - test('passes through special chars safely wrapped', () => { - assert.strictEqual(shellEscape('$(rm -rf /)'), '\'$(rm -rf /)\''); - }); - }); - - suite('resolveRemotePlatform', () => { - test('detects Linux x64', () => { - assert.deepStrictEqual(resolveRemotePlatform('Linux', 'x86_64'), { os: 'linux', arch: 'x64' }); - }); - - test('detects Linux amd64', () => { - assert.deepStrictEqual(resolveRemotePlatform('Linux', 'amd64'), { os: 'linux', arch: 'x64' }); - }); - - test('detects Linux arm64 (aarch64)', () => { - assert.deepStrictEqual(resolveRemotePlatform('Linux', 'aarch64'), { os: 'linux', arch: 'arm64' }); - }); - - test('detects Linux arm64', () => { - assert.deepStrictEqual(resolveRemotePlatform('Linux', 'arm64'), { os: 'linux', arch: 'arm64' }); - }); - - test('detects Linux armhf', () => { - assert.deepStrictEqual(resolveRemotePlatform('Linux', 'armv7l'), { os: 'linux', arch: 'armhf' }); - }); - - test('detects Darwin x64', () => { - assert.deepStrictEqual(resolveRemotePlatform('Darwin', 'x86_64'), { os: 'darwin', arch: 'x64' }); - }); - - test('detects Darwin arm64', () => { - assert.deepStrictEqual(resolveRemotePlatform('Darwin', 'arm64'), { os: 'darwin', arch: 'arm64' }); - }); - - test('handles whitespace in uname output', () => { - assert.deepStrictEqual(resolveRemotePlatform(' Linux\n', ' x86_64\n'), { os: 'linux', arch: 'x64' }); - }); - - test('returns undefined for Windows', () => { - assert.strictEqual(resolveRemotePlatform('MINGW64_NT-10.0-19041', 'x86_64'), undefined); - }); - - test('returns undefined for unknown OS', () => { - assert.strictEqual(resolveRemotePlatform('FreeBSD', 'amd64'), undefined); - }); - - test('returns undefined for unknown arch', () => { - assert.strictEqual(resolveRemotePlatform('Linux', 'ppc64le'), undefined); - }); - }); - - suite('buildCLIDownloadUrl', () => { - test('constructs correct URL', () => { - assert.strictEqual( - buildCLIDownloadUrl('linux', 'x64', 'insider'), - 'https://update.code.visualstudio.com/latest/cli-linux-x64/insider' - ); - }); - - test('works for darwin arm64 stable', () => { - assert.strictEqual( - buildCLIDownloadUrl('darwin', 'arm64', 'stable'), - 'https://update.code.visualstudio.com/latest/cli-darwin-arm64/stable' - ); - }); - }); - - suite('redactToken', () => { - test('redacts token in WebSocket URL', () => { - assert.strictEqual( - redactToken('ws://127.0.0.1:12345?tkn=secret123'), - 'ws://127.0.0.1:12345?tkn=***' - ); - }); - - test('redacts token with following whitespace', () => { - assert.strictEqual( - redactToken('ws://127.0.0.1:12345?tkn=abc123 done'), - 'ws://127.0.0.1:12345?tkn=*** done' - ); - }); - - test('preserves text without tokens', () => { - assert.strictEqual(redactToken('no token here'), 'no token here'); - }); - - test('redacts multiple tokens', () => { - assert.strictEqual( - redactToken('?tkn=one and ?tkn=two'), - '?tkn=*** and ?tkn=***' - ); - }); - }); - - suite('getAgentHostStateFile', () => { - test('returns path under CLI dir', () => { - assert.strictEqual( - getAgentHostStateFile('insider'), - '~/.vscode-cli-insider/.agent-host-state' - ); - }); - - test('returns path for stable', () => { - assert.strictEqual( - getAgentHostStateFile('stable'), - '~/.vscode-cli/.agent-host-state' - ); - }); - }); - - suite('findRunningAgentHost', () => { - - function createMockExec(responses: Map): ISshExec { - return async (command: string, _opts?: { ignoreExitCode?: boolean }) => { - for (const [pattern, response] of responses) { - if (command.includes(pattern)) { - return response; - } - } - return { stdout: '', stderr: '', code: 1 }; - }; - } - - test('returns undefined when no state file exists', async () => { - const exec = createMockExec(new Map([ - ['cat', { stdout: '', stderr: '', code: 1 }], - ])); - const result = await findRunningAgentHost(exec, logService, 'insider'); - assert.strictEqual(result, undefined); - }); - - test('returns undefined when state file is empty', async () => { - const exec = createMockExec(new Map([ - ['cat', { stdout: ' \n', stderr: '', code: 0 }], - ])); - const result = await findRunningAgentHost(exec, logService, 'insider'); - assert.strictEqual(result, undefined); - }); - - test('cleans up corrupt state file', async () => { - const commands: string[] = []; - const exec: ISshExec = async (command: string) => { - commands.push(command); - if (command.includes('cat')) { - return { stdout: 'not json at all', stderr: '', code: 0 }; - } - return { stdout: '', stderr: '', code: 0 }; - }; - const result = await findRunningAgentHost(exec, logService, 'insider'); - assert.strictEqual(result, undefined); - assert.ok(commands.some(c => c.includes('rm -f'))); - }); - - test('cleans up state file with missing pid', async () => { - const commands: string[] = []; - const exec: ISshExec = async (command: string) => { - commands.push(command); - if (command.includes('cat')) { - return { stdout: JSON.stringify({ pid: 0, port: 8080, connectionToken: null }), stderr: '', code: 0 }; - } - return { stdout: '', stderr: '', code: 0 }; - }; - const result = await findRunningAgentHost(exec, logService, 'insider'); - assert.strictEqual(result, undefined); - assert.ok(commands.some(c => c.includes('rm -f'))); - }); - - test('cleans up state file with missing port', async () => { - const commands: string[] = []; - const exec: ISshExec = async (command: string) => { - commands.push(command); - if (command.includes('cat')) { - return { stdout: JSON.stringify({ pid: 1234, port: 0, connectionToken: null }), stderr: '', code: 0 }; - } - return { stdout: '', stderr: '', code: 0 }; - }; - const result = await findRunningAgentHost(exec, logService, 'insider'); - assert.strictEqual(result, undefined); - assert.ok(commands.some(c => c.includes('rm -f'))); - }); - - test('rejects state file with string pid', async () => { - const exec = createMockExec(new Map([ - ['cat', { stdout: JSON.stringify({ pid: '1234', port: 8080, connectionToken: null }), stderr: '', code: 0 }], - ])); - const result = await findRunningAgentHost(exec, logService, 'insider'); - assert.strictEqual(result, undefined); - }); - - test('rejects state file with negative pid', async () => { - const exec = createMockExec(new Map([ - ['cat', { stdout: JSON.stringify({ pid: -1, port: 8080, connectionToken: null }), stderr: '', code: 0 }], - ])); - const result = await findRunningAgentHost(exec, logService, 'insider'); - assert.strictEqual(result, undefined); - }); - - test('rejects state file with non-integer port', async () => { - const exec = createMockExec(new Map([ - ['cat', { stdout: JSON.stringify({ pid: 1234, port: 8080.5, connectionToken: null }), stderr: '', code: 0 }], - ])); - const result = await findRunningAgentHost(exec, logService, 'insider'); - assert.strictEqual(result, undefined); - }); - - test('rejects state file with numeric connectionToken', async () => { - const exec = createMockExec(new Map([ - ['cat', { stdout: JSON.stringify({ pid: 1234, port: 8080, connectionToken: 42 }), stderr: '', code: 0 }], - ])); - const result = await findRunningAgentHost(exec, logService, 'insider'); - assert.strictEqual(result, undefined); - }); - - test('rejects state file with port above 65535', async () => { - const exec = createMockExec(new Map([ - ['cat', { stdout: JSON.stringify({ pid: 1234, port: 70000, connectionToken: null }), stderr: '', code: 0 }], - ])); - const result = await findRunningAgentHost(exec, logService, 'insider'); - assert.strictEqual(result, undefined); - }); - - test('cleans up stale state when PID is not running', async () => { - const state = { pid: 9999, port: 8080, connectionToken: 'tok123' }; - const commands: string[] = []; - const exec: ISshExec = async (command: string) => { - commands.push(command); - if (command.includes('cat')) { - return { stdout: JSON.stringify(state), stderr: '', code: 0 }; - } - if (command.includes('kill -0')) { - return { stdout: '', stderr: '', code: 1 }; // PID not running - } - return { stdout: '', stderr: '', code: 0 }; - }; - const result = await findRunningAgentHost(exec, logService, 'insider'); - assert.strictEqual(result, undefined); - assert.ok(commands.some(c => c.includes('rm -f'))); - }); - - test('returns port and token when PID is alive', async () => { - const state = { pid: 1234, port: 8080, connectionToken: 'mytoken' }; - const exec = createMockExec(new Map([ - ['cat', { stdout: JSON.stringify(state), stderr: '', code: 0 }], - ['kill -0', { stdout: '', stderr: '', code: 0 }], - ])); - const result = await findRunningAgentHost(exec, logService, 'insider'); - assert.deepStrictEqual(result, { port: 8080, connectionToken: 'mytoken' }); - }); - - test('returns undefined connectionToken when state has null token', async () => { - const state = { pid: 1234, port: 8080, connectionToken: null }; - const exec = createMockExec(new Map([ - ['cat', { stdout: JSON.stringify(state), stderr: '', code: 0 }], - ['kill -0', { stdout: '', stderr: '', code: 0 }], - ])); - const result = await findRunningAgentHost(exec, logService, 'insider'); - assert.deepStrictEqual(result, { port: 8080, connectionToken: undefined }); - }); - }); - - suite('writeAgentHostState', () => { - - test('does not write when pid is undefined', async () => { - const commands: string[] = []; - const exec: ISshExec = async (command: string) => { - commands.push(command); - return { stdout: '', stderr: '', code: 0 }; - }; - await writeAgentHostState(exec, logService, 'insider', undefined, 8080, 'token'); - assert.strictEqual(commands.length, 0); - }); - - test('does not write when pid is 0', async () => { - const commands: string[] = []; - const exec: ISshExec = async (command: string) => { - commands.push(command); - return { stdout: '', stderr: '', code: 0 }; - }; - await writeAgentHostState(exec, logService, 'insider', 0, 8080, 'token'); - assert.strictEqual(commands.length, 0); - }); - - test('writes state file with correct JSON', async () => { - const commands: string[] = []; - const exec: ISshExec = async (command: string) => { - commands.push(command); - return { stdout: '', stderr: '', code: 0 }; - }; - await writeAgentHostState(exec, logService, 'insider', 1234, 8080, 'mytoken'); - assert.strictEqual(commands.length, 1); - assert.ok(commands[0].includes('.agent-host-state')); - // Verify the JSON content is present in the echo command - assert.ok(commands[0].includes('"pid":1234')); - assert.ok(commands[0].includes('"port":8080')); - assert.ok(commands[0].includes('"connectionToken":"mytoken"')); - // Verify restrictive umask in a subshell is used to protect the connection token - assert.ok(commands[0].includes('rm -f')); - assert.ok(commands[0].includes('(umask 077')); - }); - - test('writes null connectionToken when undefined', async () => { - const commands: string[] = []; - const exec: ISshExec = async (command: string) => { - commands.push(command); - return { stdout: '', stderr: '', code: 0 }; - }; - await writeAgentHostState(exec, logService, 'insider', 1234, 8080, undefined); - assert.strictEqual(commands.length, 1); - assert.ok(commands[0].includes('"connectionToken":null')); - }); - - test('logs warning when write command fails', async () => { - const exec: ISshExec = async () => { - return { stdout: '', stderr: 'Permission denied', code: 1 }; - }; - const warnings: string[] = []; - const capturingLog = new NullLogService(); - capturingLog.warn = (...args: unknown[]) => { warnings.push(args.map(String).join(' ')); }; - await writeAgentHostState(exec, capturingLog, 'insider', 1234, 8080, 'tok'); - // Should log a warning with exit code and stderr - assert.strictEqual(warnings.length, 1); - assert.ok(warnings[0].includes('Failed to write')); - assert.ok(warnings[0].includes('exit code 1')); - assert.ok(warnings[0].includes('Permission denied')); - }); - }); - - suite('cleanupRemoteAgentHost', () => { - - test('removes state file even when no state exists', async () => { - const commands: string[] = []; - const exec: ISshExec = async (command: string) => { - commands.push(command); - if (command.includes('cat')) { - return { stdout: '', stderr: '', code: 1 }; - } - return { stdout: '', stderr: '', code: 0 }; - }; - await cleanupRemoteAgentHost(exec, logService, 'insider'); - assert.ok(commands.some(c => c.includes('rm -f'))); - }); - - test('kills process and removes state file', async () => { - const state = { pid: 5678, port: 9090, connectionToken: null }; - const commands: string[] = []; - const exec: ISshExec = async (command: string) => { - commands.push(command); - if (command.includes('cat')) { - return { stdout: JSON.stringify(state), stderr: '', code: 0 }; - } - return { stdout: '', stderr: '', code: 0 }; - }; - await cleanupRemoteAgentHost(exec, logService, 'insider'); - assert.ok(commands.some(c => c.includes('kill 5678'))); - assert.ok(commands.some(c => c.includes('rm -f'))); - }); - - test('handles corrupt state file gracefully', async () => { - const commands: string[] = []; - const exec: ISshExec = async (command: string) => { - commands.push(command); - if (command.includes('cat')) { - return { stdout: '{invalid json', stderr: '', code: 0 }; - } - return { stdout: '', stderr: '', code: 0 }; - }; - // Should not throw - await cleanupRemoteAgentHost(exec, logService, 'insider'); - // Should still clean up the file - assert.ok(commands.some(c => c.includes('rm -f'))); - // Should not have attempted to kill anything - assert.ok(!commands.some(c => c.startsWith('kill'))); - }); - - test('does not kill when pid is 0', async () => { - const state = { pid: 0, port: 9090, connectionToken: null }; - const commands: string[] = []; - const exec: ISshExec = async (command: string) => { - commands.push(command); - if (command.includes('cat')) { - return { stdout: JSON.stringify(state), stderr: '', code: 0 }; - } - return { stdout: '', stderr: '', code: 0 }; - }; - await cleanupRemoteAgentHost(exec, logService, 'insider'); - assert.ok(!commands.some(c => c.match(/^kill \d/))); - assert.ok(commands.some(c => c.includes('rm -f'))); - }); - - test('kills process for stable quality', async () => { - const state = { pid: 1234, port: 8080, connectionToken: null }; - const commands: string[] = []; - const exec: ISshExec = async (command: string) => { - commands.push(command); - if (command.includes('cat')) { - return { stdout: JSON.stringify(state), stderr: '', code: 0 }; - } - return { stdout: '', stderr: '', code: 0 }; - }; - await cleanupRemoteAgentHost(exec, logService, 'stable'); - assert.ok(commands.some(c => c.includes('kill 1234'))); - }); - }); -}); diff --git a/src/vs/platform/agentHost/test/node/sshRemoteAgentHostService.test.ts b/src/vs/platform/agentHost/test/node/sshRemoteAgentHostService.test.ts deleted file mode 100644 index 6b0007c4..00000000 --- a/src/vs/platform/agentHost/test/node/sshRemoteAgentHostService.test.ts +++ /dev/null @@ -1,1236 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import assert from 'assert'; -import { DisposableStore } from '../../../../base/common/lifecycle.js'; -import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { NullLogService } from '../../../log/common/log.js'; -import { IProductService } from '../../../product/common/productService.js'; -import { SSHAuthMethod, type ISSHAgentHostConfig, type ISSHConnectProgress } from '../../common/sshRemoteAgentHost.js'; -import { SSHRemoteAgentHostMainService, makeAuthHandler, type SSHAuthAttempt } from '../../node/sshRemoteAgentHostService.js'; - -/** Minimal mock SSHChannel for testing. */ -class MockSSHChannel { - readonly stderr = { on: () => { } }; - on(_event: string, _listener?: (...args: never[]) => void): this { return this; } - close(): void { } -} - -/** - * Mock SSHClient that records exec calls and returns configured responses. - * Each call to `exec` shifts the next response from the queue. - */ -class MockSSHClient { - readonly execCalls: string[] = []; - ended = false; - - private readonly _execResponses: Array<{ stdout: string; code: number }>; - private readonly _closeListeners: Array<() => void> = []; - private readonly _errorListeners: Array<() => void> = []; - - constructor(execResponses: Array<{ stdout: string; code: number }> = []) { - this._execResponses = execResponses; - } - - on(event: string, listener: (...args: never[]) => void): this { - if (event === 'close') { - this._closeListeners.push(listener as () => void); - } else if (event === 'error') { - this._errorListeners.push(listener as () => void); - } - return this; - } - - removeListener(event: string, listener: (...args: unknown[]) => void): this { - const list = event === 'close' ? this._closeListeners : event === 'error' ? this._errorListeners : undefined; - if (list) { - const idx = list.indexOf(listener as () => void); - if (idx >= 0) { - list.splice(idx, 1); - } - } - return this; - } - - fireClose(): void { - for (const listener of this._closeListeners) { - listener(); - } - } - - get closeListenerCount(): number { - return this._closeListeners.length; - } - - get errorListenerCount(): number { - return this._errorListeners.length; - } - - connect(): void { /* no-op */ } - - exec(command: string, callback: (err: Error | undefined, stream: unknown) => void): this { - this.execCalls.push(command); - const response = this._execResponses.shift() ?? { stdout: '', code: 0 }; - const channel = new MockSSHChannel(); - // Simulate async SSH exec: resolve immediately via microtask - queueMicrotask(() => { - // Fire data events - if (response.stdout) { - const origOn = channel.on.bind(channel); - // Re-bind on to capture data handler - let dataHandler: ((data: Buffer) => void) | undefined; - let closeHandler: ((code: number) => void) | undefined; - channel.on = ((event: string, listener: (...args: unknown[]) => void) => { - if (event === 'data') { - dataHandler = listener as (data: Buffer) => void; - } else if (event === 'close') { - closeHandler = listener as (code: number) => void; - } - return origOn(event, listener); - }) as typeof channel.on; - callback(undefined, channel); - if (dataHandler) { - dataHandler(Buffer.from(response.stdout)); - } - if (closeHandler) { - closeHandler(response.code); - } - } else { - // No stdout — just call back and fire close - let closeHandler: ((code: number) => void) | undefined; - const origOn = channel.on.bind(channel); - channel.on = ((event: string, listener: (...args: unknown[]) => void) => { - if (event === 'close') { - closeHandler = listener as (code: number) => void; - } - return origOn(event, listener); - }) as typeof channel.on; - callback(undefined, channel); - if (closeHandler) { - closeHandler(response.code); - } - } - }); - return this; - } - - forwardOut( - _srcIP: string, _srcPort: number, _dstIP: string, _dstPort: number, - _callback: (err: Error | undefined, channel: unknown) => void, - ): this { - return this; - } - - end(): void { - this.ended = true; - } -} - -function makeConfig(overrides?: Partial): ISSHAgentHostConfig { - return { - host: '10.0.0.1', - username: 'testuser', - authMethod: SSHAuthMethod.Agent, - name: 'test-host', - ...overrides, - }; -} - -/** - * Testable subclass of SSHRemoteAgentHostMainService. - * Overrides the SSH/WebSocket layer so the entire connect flow runs in-process - * without needing `ssh2` or `ws` modules. - */ -class TestableSSHRemoteAgentHostMainService extends SSHRemoteAgentHostMainService { - - readonly mockClients: MockSSHClient[] = []; - - /** Responses that _connectSSH will hand to MockSSHClient for its exec queue. */ - execResponses: Array<{ stdout: string; code: number }> = []; - - /** What _startRemoteAgentHost will resolve with. */ - startResult: { port: number; connectionToken: string | undefined; pid: number | undefined } = { - port: 9999, connectionToken: 'tok-abc', pid: 42, - }; - startCalled = 0; - - /** What _createWebSocketRelay will resolve with. Set to an Error to reject. */ - relayResult: { send: (data: string) => void; close: () => void } | Error = { - send: () => { }, - close: () => { }, - }; - relayCalled = 0; - - /** Override to intercept relay creation in specific tests. */ - relayHook: ((call: number) => { send: (data: string) => void; close: () => void } | Error | undefined) | undefined; - - /** Stored onMessage callbacks from relays, most recent last. */ - private readonly _relayMessageCallbacks: Array<(data: string) => void> = []; - /** Stored onClose callbacks from relays, most recent last. */ - private readonly _relayCloseCallbacks: Array<() => void> = []; - /** Stored relay result objects, most recent last (for makePreviousRelaySyncClose). */ - private readonly _relayResults: Array<{ send: (data: string) => void; close: () => void }> = []; - - protected override async _connectSSH( - _config: ISSHAgentHostConfig, - ) { - const client = new MockSSHClient(this.execResponses); - this.mockClients.push(client); - return client as never; - } - - protected override async _startRemoteAgentHost( - _client: unknown, _quality: string, _commandOverride?: string, - ) { - this.startCalled++; - return { ...this.startResult, stream: new MockSSHChannel() as never }; - } - - protected override async _createWebSocketRelay( - _client: unknown, _dstHost: string, _dstPort: number, _connectionToken: string | undefined, - onMessage: (data: string) => void, onClose: () => void, - ) { - this.relayCalled++; - this._relayMessageCallbacks.push(onMessage); - this._relayCloseCallbacks.push(onClose); - const hookResult = this.relayHook?.(this.relayCalled); - if (hookResult !== undefined) { - if (hookResult instanceof Error) { - throw hookResult; - } - this._relayResults.push(hookResult); - return hookResult; - } - const result = this.relayResult; - if (result instanceof Error) { - throw result; - } - // Return a distinct object per call so each SSHConnection gets its own relay - const relayObj = { send: result.send, close: result.close }; - this._relayResults.push(relayObj); - return relayObj; - } - - override async resolveSSHConfig(_host: string): ReturnType { - return { - hostname: '10.0.0.1', - port: 22, - user: 'testuser', - identityFile: [], - forwardAgent: false, - }; - } - - /** - * Simulate the old (superseded) relay's WebSocket close event firing. - * This calls the onClose callback of the second-to-last relay. - */ - simulateOldRelayClose(): void { - if (this._relayCloseCallbacks.length >= 2) { - this._relayCloseCallbacks[this._relayCloseCallbacks.length - 2](); - } - } - - /** - * Modify the most recently created relay so that calling close() - * synchronously fires its onClose callback. This simulates a WebSocket - * implementation that fires the 'close' event inline during ws.close(). - */ - makePreviousRelaySyncClose(): void { - const idx = this._relayResults.length - 1; - if (idx >= 0 && this._relayCloseCallbacks.length > idx) { - const onClose = this._relayCloseCallbacks[idx]; - this._relayResults[idx].close = () => { onClose(); }; - } - } - - /** - * Simulate a message arriving on a specific relay (0-indexed). - * Defaults to the most recent relay. - */ - simulateRelayMessage(data: string, relayIndex?: number): void { - const idx = relayIndex ?? this._relayMessageCallbacks.length - 1; - this._relayMessageCallbacks[idx]?.(data); - } - - /** - * Simulate the current (active) relay's WebSocket close event firing. - */ - simulateCurrentRelayClose(): void { - if (this._relayCloseCallbacks.length > 0) { - this._relayCloseCallbacks[this._relayCloseCallbacks.length - 1](); - } - } -} - -suite('SSHRemoteAgentHostMainService - connect flow', () => { - - const disposables = new DisposableStore(); - let service: TestableSSHRemoteAgentHostMainService; - - setup(() => { - const logService = new NullLogService(); - const productService: Pick = { - _serviceBrand: undefined, - quality: 'insider', - }; - service = new TestableSSHRemoteAgentHostMainService( - logService, - productService as IProductService, - ); - disposables.add(service); - }); - - teardown(() => disposables.clear()); - - ensureNoDisposablesAreLeakedInTestSuite(); - - test('returns existing connection on duplicate connect without replacing relay', async () => { - // First connect: uname, CLI check, findRunningAgentHost (no state), write state - service.execResponses = [ - { stdout: 'Linux\n', code: 0 }, // uname -s - { stdout: 'x86_64\n', code: 0 }, // uname -m - { stdout: '1.0.0\n', code: 0 }, // CLI --version (already installed) - { stdout: '', code: 1 }, // cat state file (not found) - { stdout: '', code: 0 }, // echo state file (write) - ]; - - const config = makeConfig({ sshConfigHost: 'myalias' }); - const result1 = await service.connect(config); - assert.strictEqual(result1.connectionId, 'ssh:myalias'); - assert.strictEqual(result1.sshConfigHost, 'myalias'); - assert.strictEqual(service.startCalled, 1); - assert.strictEqual(service.relayCalled, 1); - - // Second connect without replaceRelay — returns existing info - // without creating a new relay or restarting the agent - const result2 = await service.connect(config); - assert.strictEqual(result2.connectionId, result1.connectionId); - assert.strictEqual(result2.connectionToken, result1.connectionToken); - assert.strictEqual(result2.sshConfigHost, 'myalias'); - assert.strictEqual(service.startCalled, 1); - assert.strictEqual(service.relayCalled, 1); // no new relay - }); - - test('creates fresh relay on reconnect without restarting agent', async () => { - // First connect: uname, CLI check, findRunningAgentHost (no state), write state - service.execResponses = [ - { stdout: 'Linux\n', code: 0 }, // uname -s - { stdout: 'x86_64\n', code: 0 }, // uname -m - { stdout: '1.0.0\n', code: 0 }, // CLI --version (already installed) - { stdout: '', code: 1 }, // cat state file (not found) - { stdout: '', code: 0 }, // echo state file (write) - ]; - - const config = makeConfig({ sshConfigHost: 'myalias' }); - const result1 = await service.connect(config); - assert.strictEqual(service.startCalled, 1); - assert.strictEqual(service.relayCalled, 1); - - // Reconnect — creates fresh relay on existing SSH tunnel - const result2 = await service.reconnect('myalias', 'test-agent'); - assert.strictEqual(result2.connectionId, result1.connectionId); - assert.strictEqual(result2.connectionToken, result1.connectionToken); - assert.strictEqual(service.startCalled, 1); // no restart - assert.strictEqual(service.relayCalled, 2); // fresh relay - }); - - test('reconnect does not fire onDidRelayClose for superseded relay', async () => { - service.execResponses = [ - { stdout: 'Linux\n', code: 0 }, - { stdout: 'x86_64\n', code: 0 }, - { stdout: '1.0.0\n', code: 0 }, - { stdout: '', code: 1 }, - { stdout: '', code: 0 }, - ]; - - const config = makeConfig({ sshConfigHost: 'myalias' }); - await service.connect(config); - - const closeEvents: string[] = []; - disposables.add(service.onDidRelayClose(id => closeEvents.push(id))); - - // Reconnect replaces the relay — old relay close should be suppressed - await service.reconnect('myalias', 'test-agent'); - - // Simulate the old relay's close event firing asynchronously - service.simulateOldRelayClose(); - - assert.deepStrictEqual(closeEvents, []); - }); - - test('reconnect suppresses synchronous close from old relay during replacement', async () => { - service.execResponses = [ - { stdout: 'Linux\n', code: 0 }, - { stdout: 'x86_64\n', code: 0 }, - { stdout: '1.0.0\n', code: 0 }, - { stdout: '', code: 1 }, - { stdout: '', code: 0 }, - ]; - - const config = makeConfig({ sshConfigHost: 'myalias' }); - await service.connect(config); - - const closeEvents: string[] = []; - disposables.add(service.onDidRelayClose(id => closeEvents.push(id))); - - // Make the first relay's close() synchronously fire its onClose callback, - // simulating a WebSocket that fires 'close' synchronously on ws.close(). - service.makePreviousRelaySyncClose(); - - await service.reconnect('myalias', 'test-agent'); - assert.deepStrictEqual(closeEvents, []); - }); - - test('uses sshConfigHost as connection key when present', async () => { - service.execResponses = [ - { stdout: 'Linux\n', code: 0 }, - { stdout: 'x86_64\n', code: 0 }, - { stdout: '1.0.0\n', code: 0 }, - { stdout: '', code: 1 }, - { stdout: '', code: 0 }, - ]; - - const result = await service.connect(makeConfig({ sshConfigHost: 'myhost' })); - assert.strictEqual(result.connectionId, 'ssh:myhost'); - assert.strictEqual(result.sshConfigHost, 'myhost'); - }); - - test('skips platform detection and CLI install with remoteAgentHostCommand', async () => { - // With a custom command, only state file check + write should happen - service.execResponses = [ - { stdout: '', code: 1 }, // cat state file (not found) - { stdout: '', code: 0 }, // echo state file (write) - ]; - - const result = await service.connect(makeConfig({ - remoteAgentHostCommand: '/custom/agent --port 0', - })); - assert.strictEqual(result.connectionId, 'testuser@10.0.0.1:22'); - assert.strictEqual(service.startCalled, 1); - - // Verify no uname calls were made (custom command skips platform detection) - const client = service.mockClients[0]; - assert.ok(!client.execCalls.some(c => c.includes('uname'))); - }); - - test('reuses existing agent host when state file has valid PID', async () => { - const existingState = JSON.stringify({ pid: 1234, port: 7777, connectionToken: 'existing-tok' }); - service.execResponses = [ - { stdout: 'Linux\n', code: 0 }, // uname -s - { stdout: 'x86_64\n', code: 0 }, // uname -m - { stdout: '1.0.0\n', code: 0 }, // CLI --version - { stdout: existingState, code: 0 }, // cat state file (found) - { stdout: '', code: 0 }, // kill -0 (PID alive) - ]; - - const result = await service.connect(makeConfig()); - - // Should NOT have started a new agent host - assert.strictEqual(service.startCalled, 0); - // Should have connected the WebSocket relay - assert.strictEqual(service.relayCalled, 1); - // Connection token should come from the state file - assert.strictEqual(result.connectionToken, 'existing-tok'); - }); - - test('starts fresh when state file PID is dead', async () => { - const staleState = JSON.stringify({ pid: 9999, port: 7777, connectionToken: 'old-tok' }); - service.execResponses = [ - { stdout: 'Linux\n', code: 0 }, // uname -s - { stdout: 'x86_64\n', code: 0 }, // uname -m - { stdout: '1.0.0\n', code: 0 }, // CLI --version - { stdout: staleState, code: 0 }, // cat state file - { stdout: '', code: 1 }, // kill -0 (PID dead) - { stdout: '', code: 0 }, // rm -f state file - { stdout: '', code: 0 }, // echo state file (write new) - ]; - - const result = await service.connect(makeConfig()); - - // Should have started a new agent host since PID was dead - assert.strictEqual(service.startCalled, 1); - // Token should come from new start, not the stale state - assert.strictEqual(result.connectionToken, 'tok-abc'); - }); - - test('falls back to fresh start when relay to reused agent fails', async () => { - const existingState = JSON.stringify({ pid: 1234, port: 7777, connectionToken: 'existing-tok' }); - service.execResponses = [ - { stdout: 'Linux\n', code: 0 }, // uname -s - { stdout: 'x86_64\n', code: 0 }, // uname -m - { stdout: '1.0.0\n', code: 0 }, // CLI --version - { stdout: existingState, code: 0 }, // cat state file (found) - { stdout: '', code: 0 }, // kill -0 (PID alive) - // cleanup: cat state file, kill PID, rm state file - { stdout: existingState, code: 0 }, - { stdout: '', code: 0 }, - { stdout: '', code: 0 }, - // write new state file after fresh start - { stdout: '', code: 0 }, - ]; - - // First relay attempt fails, second succeeds - let relayCallCount = 0; - service.relayHook = () => { - relayCallCount++; - if (relayCallCount === 1) { - return new Error('connection refused'); - } - return { send: () => { }, close: () => { } }; - }; - - const result = await service.connect(makeConfig()); - - // Should have started a fresh agent host after relay failure - assert.strictEqual(service.startCalled, 1); - assert.strictEqual(relayCallCount, 2); - assert.strictEqual(result.connectionToken, 'tok-abc'); - }); - - test('does not retry when relay fails on freshly started agent', async () => { - service.execResponses = [ - { stdout: 'Linux\n', code: 0 }, - { stdout: 'x86_64\n', code: 0 }, - { stdout: '1.0.0\n', code: 0 }, - { stdout: '', code: 1 }, // no state file - { stdout: '', code: 0 }, // write state - ]; - - service.relayResult = new Error('connection refused'); - - await assert.rejects( - () => service.connect(makeConfig()), - /connection refused/, - ); - assert.strictEqual(service.startCalled, 1); - }); - - test('cleans up SSH client on error', async () => { - service.execResponses = [ - { stdout: 'Linux\n', code: 0 }, - { stdout: 'x86_64\n', code: 0 }, - { stdout: '1.0.0\n', code: 0 }, - { stdout: '', code: 1 }, - { stdout: '', code: 0 }, - ]; - - service.relayResult = new Error('boom'); - - await assert.rejects(() => service.connect(makeConfig())); - - // SSH client should have been ended in the catch block - assert.strictEqual(service.mockClients[0].ended, true); - }); - - test('sanitizes config in result (strips password and privateKeyPath)', async () => { - service.execResponses = [ - { stdout: '', code: 1 }, - { stdout: '', code: 0 }, - ]; - - const result = await service.connect(makeConfig({ - remoteAgentHostCommand: '/agent', - authMethod: SSHAuthMethod.Password, - password: 'secret123', - privateKeyPath: '/home/user/.ssh/id_rsa', - })); - - assert.strictEqual((result.config as Record)['password'], undefined); - assert.strictEqual((result.config as Record)['privateKeyPath'], undefined); - assert.strictEqual(result.config.host, '10.0.0.1'); - }); - - test('disconnect removes connection and allows reconnect', async () => { - service.execResponses = [ - { stdout: '', code: 1 }, - { stdout: '', code: 0 }, - ]; - - const result = await service.connect(makeConfig({ - remoteAgentHostCommand: '/agent', - })); - - // Disconnect - await service.disconnect(result.connectionId); - - // Next connect should create a new connection - service.execResponses = [ - { stdout: '', code: 1 }, - { stdout: '', code: 0 }, - ]; - service.startCalled = 0; - - const result2 = await service.connect(makeConfig({ - remoteAgentHostCommand: '/agent', - })); - assert.strictEqual(service.startCalled, 1); - assert.strictEqual(result2.connectionId, result.connectionId); - }); - - test('fires onDidChangeConnections on connect and disconnect', async () => { - service.execResponses = [ - { stdout: '', code: 1 }, - { stdout: '', code: 0 }, - ]; - - const events: string[] = []; - disposables.add(service.onDidChangeConnections(() => events.push('changed'))); - disposables.add(service.onDidCloseConnection(id => events.push(`closed:${id}`))); - - const result = await service.connect(makeConfig({ - remoteAgentHostCommand: '/agent', - })); - assert.strictEqual(events.length, 1); - assert.strictEqual(events[0], 'changed'); - - await service.disconnect(result.connectionId); - // disconnect fires close before change - assert.deepStrictEqual(events, [ - 'changed', - `closed:${result.connectionId}`, - 'changed', - ]); - }); - - // --- Relay message routing --- - - test('relay messages fire onDidRelayMessage with correct connectionId', async () => { - service.execResponses = [ - { stdout: '', code: 1 }, - { stdout: '', code: 0 }, - ]; - - const result = await service.connect(makeConfig({ - remoteAgentHostCommand: '/agent', - })); - - const messages: Array<{ connectionId: string; data: string }> = []; - disposables.add(service.onDidRelayMessage(msg => messages.push(msg))); - - service.simulateRelayMessage('{"jsonrpc":"2.0","id":1}'); - service.simulateRelayMessage('{"jsonrpc":"2.0","id":2}'); - - assert.deepStrictEqual(messages, [ - { connectionId: result.connectionId, data: '{"jsonrpc":"2.0","id":1}' }, - { connectionId: result.connectionId, data: '{"jsonrpc":"2.0","id":2}' }, - ]); - }); - - test('relay close fires onDidRelayClose with correct connectionId', async () => { - service.execResponses = [ - { stdout: '', code: 1 }, - { stdout: '', code: 0 }, - ]; - - const result = await service.connect(makeConfig({ - remoteAgentHostCommand: '/agent', - })); - - const closes: string[] = []; - disposables.add(service.onDidRelayClose(id => closes.push(id))); - - service.simulateCurrentRelayClose(); - - assert.deepStrictEqual(closes, [result.connectionId]); - }); - - test('relaySend delivers data to the correct connection', async () => { - const sentData: string[] = []; - service.relayResult = { - send: (data: string) => sentData.push(data), - close: () => { }, - }; - - service.execResponses = [ - { stdout: '', code: 1 }, - { stdout: '', code: 0 }, - ]; - const result = await service.connect(makeConfig({ - remoteAgentHostCommand: '/agent', - })); - - await service.relaySend(result.connectionId, 'hello'); - await service.relaySend(result.connectionId, 'world'); - - assert.deepStrictEqual(sentData, ['hello', 'world']); - }); - - test('relaySend to unknown connectionId is a no-op', async () => { - service.execResponses = [ - { stdout: '', code: 1 }, - { stdout: '', code: 0 }, - ]; - await service.connect(makeConfig({ remoteAgentHostCommand: '/agent' })); - - // Should not throw - await service.relaySend('nonexistent', 'data'); - }); - - // --- Multiple independent connections --- - - test('connects to two different hosts independently', async () => { - // First host - service.execResponses = [ - { stdout: '', code: 1 }, - { stdout: '', code: 0 }, - ]; - const r1 = await service.connect(makeConfig({ - host: '10.0.0.1', remoteAgentHostCommand: '/agent', - })); - - // Second host - service.execResponses = [ - { stdout: '', code: 1 }, - { stdout: '', code: 0 }, - ]; - const r2 = await service.connect(makeConfig({ - host: '10.0.0.2', remoteAgentHostCommand: '/agent', - })); - - assert.notStrictEqual(r1.connectionId, r2.connectionId); - assert.strictEqual(service.startCalled, 2); - assert.strictEqual(service.relayCalled, 2); - }); - - test('disconnect one host does not affect the other', async () => { - service.execResponses = [ - { stdout: '', code: 1 }, - { stdout: '', code: 0 }, - ]; - const r1 = await service.connect(makeConfig({ - host: '10.0.0.1', remoteAgentHostCommand: '/agent', - })); - - service.execResponses = [ - { stdout: '', code: 1 }, - { stdout: '', code: 0 }, - ]; - const r2 = await service.connect(makeConfig({ - host: '10.0.0.2', remoteAgentHostCommand: '/agent', - })); - - await service.disconnect(r1.connectionId); - - // r2 should still be live — duplicate connect returns existing info - const r2Again = await service.connect(makeConfig({ - host: '10.0.0.2', remoteAgentHostCommand: '/agent', - })); - assert.strictEqual(r2Again.connectionId, r2.connectionId); - // No new start or relay was needed - assert.strictEqual(service.startCalled, 2); - assert.strictEqual(service.relayCalled, 2); - }); - - // --- Relay messages route to correct connection when multiple exist --- - - test('relay messages from two connections are distinguished by connectionId', async () => { - service.execResponses = [ - { stdout: '', code: 1 }, - { stdout: '', code: 0 }, - ]; - const r1 = await service.connect(makeConfig({ - host: '10.0.0.1', remoteAgentHostCommand: '/agent', - })); - - service.execResponses = [ - { stdout: '', code: 1 }, - { stdout: '', code: 0 }, - ]; - const r2 = await service.connect(makeConfig({ - host: '10.0.0.2', remoteAgentHostCommand: '/agent', - })); - - const messages: Array<{ connectionId: string; data: string }> = []; - disposables.add(service.onDidRelayMessage(msg => messages.push(msg))); - - // Message on first connection's relay (index 0) - service.simulateRelayMessage('msg-from-host1', 0); - // Message on second connection's relay (index 1) - service.simulateRelayMessage('msg-from-host2', 1); - - assert.deepStrictEqual(messages, [ - { connectionId: r1.connectionId, data: 'msg-from-host1' }, - { connectionId: r2.connectionId, data: 'msg-from-host2' }, - ]); - }); - - // --- Reconnect creates fresh SSH connection after disconnect --- - - test('reconnect after disconnect establishes a new SSH connection', async () => { - service.execResponses = [ - { stdout: 'Linux\n', code: 0 }, - { stdout: 'x86_64\n', code: 0 }, - { stdout: '1.0.0\n', code: 0 }, - { stdout: '', code: 1 }, - { stdout: '', code: 0 }, - ]; - const r1 = await service.connect(makeConfig({ sshConfigHost: 'myhost' })); - assert.strictEqual(service.mockClients.length, 1); - - await service.disconnect(r1.connectionId); - - service.execResponses = [ - { stdout: 'Linux\n', code: 0 }, - { stdout: 'x86_64\n', code: 0 }, - { stdout: '1.0.0\n', code: 0 }, - { stdout: '', code: 1 }, - { stdout: '', code: 0 }, - ]; - - const r2 = await service.reconnect('myhost', 'test-host'); - // Should have created a fresh SSH client (not reused the old one) - assert.strictEqual(service.mockClients.length, 2); - assert.strictEqual(r2.connectionId, r1.connectionId); - }); - - // --- Progress events --- - - test('fires progress events during connect', async () => { - service.execResponses = [ - { stdout: 'Linux\n', code: 0 }, - { stdout: 'x86_64\n', code: 0 }, - { stdout: '1.0.0\n', code: 0 }, - { stdout: '', code: 1 }, - { stdout: '', code: 0 }, - ]; - - const progress: ISSHConnectProgress[] = []; - disposables.add(service.onDidReportConnectProgress(p => progress.push(p))); - - await service.connect(makeConfig({ sshConfigHost: 'myhost' })); - - // Expect at least: SSH connecting, platform detection, CLI check, start agent, relay - assert.ok(progress.length >= 3, `expected at least 3 progress events, got ${progress.length}`); - assert.ok(progress.every(p => p.connectionKey === 'ssh:myhost')); - assert.ok(progress.every(p => p.message.length > 0), 'all progress messages should be non-empty'); - }); - - // --- SSH client close triggers connection disposal --- - - test('SSH client close event disposes the connection', async () => { - service.execResponses = [ - { stdout: '', code: 1 }, - { stdout: '', code: 0 }, - ]; - - const result = await service.connect(makeConfig({ - remoteAgentHostCommand: '/agent', - })); - - const closeEvents: string[] = []; - disposables.add(service.onDidCloseConnection(id => closeEvents.push(id))); - - // Simulate the SSH client closing (e.g. network drop) - service.mockClients[0].fireClose(); - - assert.deepStrictEqual(closeEvents, [result.connectionId]); - }); - - // --- CLI install flow --- - - test('skips CLI download when CLI is already installed', async () => { - service.execResponses = [ - { stdout: 'Linux\n', code: 0 }, // uname -s - { stdout: 'x86_64\n', code: 0 }, // uname -m - { stdout: '1.0.0\n', code: 0 }, // CLI --version succeeds - { stdout: '', code: 1 }, // cat state file (not found) - { stdout: '', code: 0 }, // echo state file (write) - ]; - - await service.connect(makeConfig()); - - // The exec calls should NOT include any curl/tar/install commands - const execCalls = service.mockClients[0].execCalls; - assert.ok(!execCalls.some(c => c.includes('curl') || c.includes('tar')), - 'should not download CLI when already installed'); - }); - - test('downloads CLI when version check fails', async () => { - service.execResponses = [ - { stdout: 'Linux\n', code: 0 }, // uname -s - { stdout: 'x86_64\n', code: 0 }, // uname -m - { stdout: '', code: 127 }, // CLI --version fails (not found) - { stdout: '', code: 0 }, // curl | tar install - { stdout: '', code: 1 }, // cat state file (not found) - { stdout: '', code: 0 }, // echo state file (write) - ]; - - await service.connect(makeConfig()); - - const execCalls = service.mockClients[0].execCalls; - assert.ok(execCalls.some(c => c.includes('curl')), - 'should download CLI when not installed'); - }); - - // --- Connection key formats --- - - test('uses host:port as connection key without sshConfigHost', async () => { - service.execResponses = [ - { stdout: '', code: 1 }, - { stdout: '', code: 0 }, - ]; - - const result = await service.connect(makeConfig({ - host: '192.168.1.1', - port: 2222, - remoteAgentHostCommand: '/agent', - })); - assert.strictEqual(result.connectionId, 'testuser@192.168.1.1:2222'); - }); - - test('defaults to port 22 in connection key', async () => { - service.execResponses = [ - { stdout: '', code: 1 }, - { stdout: '', code: 0 }, - ]; - - const result = await service.connect(makeConfig({ - host: '192.168.1.1', - remoteAgentHostCommand: '/agent', - })); - assert.strictEqual(result.connectionId, 'testuser@192.168.1.1:22'); - }); - - // --- Reconnect preserves connection token from initial connect --- - - test('reconnect preserves connection token and address', async () => { - service.execResponses = [ - { stdout: 'Linux\n', code: 0 }, - { stdout: 'x86_64\n', code: 0 }, - { stdout: '1.0.0\n', code: 0 }, - { stdout: '', code: 1 }, - { stdout: '', code: 0 }, - ]; - - const original = await service.connect(makeConfig({ sshConfigHost: 'myhost' })); - - const reconnected = await service.reconnect('myhost', 'new-name'); - assert.strictEqual(reconnected.connectionToken, original.connectionToken); - assert.strictEqual(reconnected.address, original.address); - assert.strictEqual(reconnected.connectionId, original.connectionId); - }); - - // --- Relay messages from superseded relay are still routed (not gated) --- - - test('messages from superseded relay still arrive (only close is suppressed)', async () => { - service.execResponses = [ - { stdout: 'Linux\n', code: 0 }, - { stdout: 'x86_64\n', code: 0 }, - { stdout: '1.0.0\n', code: 0 }, - { stdout: '', code: 1 }, - { stdout: '', code: 0 }, - ]; - - const result = await service.connect(makeConfig({ sshConfigHost: 'myhost' })); - - const messages: Array<{ connectionId: string; data: string }> = []; - disposables.add(service.onDidRelayMessage(msg => messages.push(msg))); - - // Reconnect replaces the relay - await service.reconnect('myhost', 'test-host'); - - // Simulate a message arriving from the OLD relay (index 0) - service.simulateRelayMessage('stale-message', 0); - // And from the NEW relay (index 1) - service.simulateRelayMessage('fresh-message', 1); - - // Both messages arrive — message suppression is deliberately NOT done - assert.deepStrictEqual(messages, [ - { connectionId: result.connectionId, data: 'stale-message' }, - { connectionId: result.connectionId, data: 'fresh-message' }, - ]); - }); - - // --- Reconnect failure cleans up detached SSH client --- - - test('reconnect cleans up SSH client when relay recreation fails', async () => { - service.execResponses = [ - { stdout: 'Linux\n', code: 0 }, - { stdout: 'x86_64\n', code: 0 }, - { stdout: '1.0.0\n', code: 0 }, - { stdout: '', code: 1 }, - { stdout: '', code: 0 }, - ]; - - await service.connect(makeConfig({ sshConfigHost: 'myhost' })); - const originalClient = service.mockClients[0]; - assert.strictEqual(originalClient.ended, false); - - // Make relay creation fail on the next call (the reconnect attempt) - service.relayHook = (call) => { - if (call === 2) { - return new Error('relay failed'); - } - return undefined; - }; - - const closeEvents: string[] = []; - disposables.add(service.onDidCloseConnection(id => closeEvents.push(id))); - - await assert.rejects( - () => service.reconnect('myhost', 'test-host'), - /relay failed/, - ); - - // SSH client should have been cleaned up despite the failure - assert.strictEqual(originalClient.ended, true); - // Close event should have fired to notify the renderer - assert.deepStrictEqual(closeEvents, ['ssh:myhost']); - }); - - // --- Reconnect cleans up old SSH client listeners --- - - test('reconnect removes old close/error listeners from shared SSH client', async () => { - service.execResponses = [ - { stdout: 'Linux\n', code: 0 }, - { stdout: 'x86_64\n', code: 0 }, - { stdout: '1.0.0\n', code: 0 }, - { stdout: '', code: 1 }, - { stdout: '', code: 0 }, - ]; - - await service.connect(makeConfig({ sshConfigHost: 'myhost' })); - const client = service.mockClients[0]; - - // After initial connect, the SSH client has close/error listeners from SSHConnection - const closeListenersBefore = client.closeListenerCount; - const errorListenersBefore = client.errorListenerCount; - assert.ok(closeListenersBefore > 0, 'should have close listeners after connect'); - assert.ok(errorListenersBefore > 0, 'should have error listeners after connect'); - - // Reconnect replaces the SSHConnection — old listeners should be removed - await service.reconnect('myhost', 'test-host'); - - // Listener count should not grow — old ones removed, new ones added - assert.strictEqual(client.closeListenerCount, closeListenersBefore); - assert.strictEqual(client.errorListenerCount, errorListenersBefore); - }); -}); - -/** - * Subclass that exposes `_buildAuthAttempts` and stubs out the disk/env seams - * so the auth-attempt building logic can be tested in isolation. - */ -class AuthAttemptsTestService extends SSHRemoteAgentHostMainService { - - agentSock: string | undefined = undefined; - keyFiles: Map = new Map(); - - async testBuildAuthAttempts(config: ISSHAgentHostConfig): Promise { - return this._buildAuthAttempts(config); - } - - protected override _isAgentAvailable(): string | undefined { - return this.agentSock; - } - - protected override async _readKeyFileIfExists(keyPath: string): Promise { - return this.keyFiles.get(keyPath); - } -} - -suite('SSHRemoteAgentHostMainService - _buildAuthAttempts', () => { - - const disposables = new DisposableStore(); - let service: AuthAttemptsTestService; - - setup(() => { - const logService = new NullLogService(); - const productService: Pick = { - _serviceBrand: undefined, - quality: 'insider', - }; - service = new AuthAttemptsTestService( - logService, - productService as IProductService, - ); - disposables.add(service); - }); - - teardown(() => disposables.clear()); - - ensureNoDisposablesAreLeakedInTestSuite(); - - const RSA = Buffer.from('rsa-key-bytes'); - const ED = Buffer.from('ed25519-key-bytes'); - const EXPLICIT = Buffer.from('explicit-key-bytes'); - - test('Agent + no SSH_AUTH_SOCK + only id_rsa exists → publickey id_rsa only', async () => { - service.agentSock = undefined; - service.keyFiles.set('~/.ssh/id_rsa', RSA); - - const attempts = await service.testBuildAuthAttempts(makeConfig({ authMethod: SSHAuthMethod.Agent })); - - assert.deepStrictEqual(attempts, [ - { type: 'publickey', username: 'testuser', key: RSA, keyPath: '~/.ssh/id_rsa' }, - ]); - }); - - test('Agent + SSH_AUTH_SOCK + only id_rsa exists → agent then publickey id_rsa', async () => { - // This is the regression-driving case: agent is set but doesn't have - // the key, so we must still fall through to the on-disk default key. - service.agentSock = '/tmp/ssh-agent.sock'; - service.keyFiles.set('~/.ssh/id_rsa', RSA); - - const attempts = await service.testBuildAuthAttempts(makeConfig({ authMethod: SSHAuthMethod.Agent })); - - assert.deepStrictEqual(attempts, [ - { type: 'agent', username: 'testuser', agent: '/tmp/ssh-agent.sock' }, - { type: 'publickey', username: 'testuser', key: RSA, keyPath: '~/.ssh/id_rsa' }, - ]); - }); - - test('Agent + SSH_AUTH_SOCK + id_ed25519 and id_rsa exist → agent then both keys in default order', async () => { - service.agentSock = '/tmp/ssh-agent.sock'; - service.keyFiles.set('~/.ssh/id_ed25519', ED); - service.keyFiles.set('~/.ssh/id_rsa', RSA); - - const attempts = await service.testBuildAuthAttempts(makeConfig({ authMethod: SSHAuthMethod.Agent })); - - assert.deepStrictEqual(attempts, [ - { type: 'agent', username: 'testuser', agent: '/tmp/ssh-agent.sock' }, - { type: 'publickey', username: 'testuser', key: ED, keyPath: '~/.ssh/id_ed25519' }, - { type: 'publickey', username: 'testuser', key: RSA, keyPath: '~/.ssh/id_rsa' }, - ]); - }); - - test('Agent + SSH_AUTH_SOCK + no default keys → agent only', async () => { - service.agentSock = '/tmp/ssh-agent.sock'; - - const attempts = await service.testBuildAuthAttempts(makeConfig({ authMethod: SSHAuthMethod.Agent })); - - assert.deepStrictEqual(attempts, [ - { type: 'agent', username: 'testuser', agent: '/tmp/ssh-agent.sock' }, - ]); - }); - - test('Agent + explicit privateKeyPath + SSH_AUTH_SOCK + id_rsa → explicit, agent, id_rsa', async () => { - service.agentSock = '/tmp/ssh-agent.sock'; - service.keyFiles.set('/some/explicit/key', EXPLICIT); - service.keyFiles.set('~/.ssh/id_rsa', RSA); - - const attempts = await service.testBuildAuthAttempts(makeConfig({ - authMethod: SSHAuthMethod.Agent, - privateKeyPath: '/some/explicit/key', - })); - - assert.deepStrictEqual(attempts, [ - { type: 'publickey', username: 'testuser', key: EXPLICIT, keyPath: '/some/explicit/key' }, - { type: 'agent', username: 'testuser', agent: '/tmp/ssh-agent.sock' }, - { type: 'publickey', username: 'testuser', key: RSA, keyPath: '~/.ssh/id_rsa' }, - ]); - }); - - test('Agent + explicit privateKeyPath that matches a default → explicit added once', async () => { - // When the user pins ~/.ssh/id_rsa explicitly, we shouldn't end up - // with the same key twice in the queue. - service.agentSock = undefined; - service.keyFiles.set('~/.ssh/id_rsa', RSA); - - const attempts = await service.testBuildAuthAttempts(makeConfig({ - authMethod: SSHAuthMethod.Agent, - privateKeyPath: '~/.ssh/id_rsa', - })); - - assert.deepStrictEqual(attempts, [ - { type: 'publickey', username: 'testuser', key: RSA, keyPath: '~/.ssh/id_rsa' }, - ]); - }); - - test('KeyFile + explicit path → publickey only', async () => { - service.agentSock = '/tmp/ssh-agent.sock'; - service.keyFiles.set('/some/explicit/key', EXPLICIT); - service.keyFiles.set('~/.ssh/id_rsa', RSA); - - const attempts = await service.testBuildAuthAttempts(makeConfig({ - authMethod: SSHAuthMethod.KeyFile, - privateKeyPath: '/some/explicit/key', - })); - - assert.deepStrictEqual(attempts, [ - { type: 'publickey', username: 'testuser', key: EXPLICIT, keyPath: '/some/explicit/key' }, - ]); - }); - - test('KeyFile + missing privateKeyPath throws', async () => { - await assert.rejects( - () => service.testBuildAuthAttempts(makeConfig({ authMethod: SSHAuthMethod.KeyFile })), - /private key path/i, - ); - }); - - test('KeyFile + unreadable key throws with the path in the message', async () => { - await assert.rejects( - () => service.testBuildAuthAttempts(makeConfig({ - authMethod: SSHAuthMethod.KeyFile, - privateKeyPath: '/missing/key', - })), - /\/missing\/key/, - ); - }); - - test('Password → password only', async () => { - service.agentSock = '/tmp/ssh-agent.sock'; - service.keyFiles.set('~/.ssh/id_rsa', RSA); - - const attempts = await service.testBuildAuthAttempts(makeConfig({ - authMethod: SSHAuthMethod.Password, - password: 'pw', - })); - - assert.deepStrictEqual(attempts, [ - { type: 'password', username: 'testuser', password: 'pw' }, - ]); - }); -}); - -suite('SSHRemoteAgentHostMainService - makeAuthHandler', () => { - - ensureNoDisposablesAreLeakedInTestSuite(); - - const KEY = Buffer.from('k'); - const attempts: SSHAuthAttempt[] = [ - { type: 'agent', username: 'u', agent: '/sock' }, - { type: 'publickey', username: 'u', key: KEY, keyPath: '~/.ssh/id_rsa' }, - ]; - - test('walks attempts in order, then signals exhaustion', () => { - const handler = makeAuthHandler(attempts, new NullLogService()); - const calls: Array = []; - handler(null, false, next => calls.push(next)); - handler(['publickey'], false, next => calls.push(next)); - handler(['publickey'], false, next => calls.push(next)); - - assert.deepStrictEqual(calls, [ - { type: 'agent', username: 'u', agent: '/sock' }, - { type: 'publickey', username: 'u', key: KEY }, // keyPath stripped - false, - ]); - }); - - test('skips attempts whose method the server has rejected', () => { - const handler = makeAuthHandler(attempts, new NullLogService()); - const calls: Array = []; - // Server only allows password — both attempts should be skipped and - // the handler should signal exhaustion immediately. - handler(['password'], false, next => calls.push(next)); - - assert.deepStrictEqual(calls, [false]); - }); - - test('agent attempts are kept when server allows publickey', () => { - // `agent` is a publickey-flavored method; servers advertise `publickey`, - // not `agent`, so the agent attempt must not be filtered out here. - const handler = makeAuthHandler( - [{ type: 'agent', username: 'u', agent: '/sock' }], - new NullLogService(), - ); - const calls: Array = []; - handler(['publickey'], false, next => calls.push(next)); - - assert.deepStrictEqual(calls, [{ type: 'agent', username: 'u', agent: '/sock' }]); - }); -}); diff --git a/src/vs/platform/agentHost/test/node/test-cases/subagent-session.jsonl b/src/vs/platform/agentHost/test/node/test-cases/subagent-session.jsonl deleted file mode 100644 index 46d959ef..00000000 --- a/src/vs/platform/agentHost/test/node/test-cases/subagent-session.jsonl +++ /dev/null @@ -1,26 +0,0 @@ -{"type": "session.start"} -{"type": "user.message", "data": {"messageId": "", "content": "Run a sync subagent to do some searches, just testing subagent rendering"}} -{"type": "assistant.turn_start", "data": {}} -{"type": "assistant.message", "data": {"messageId": "86916d6d-68d9-4c0b-83ed-f0b9f564f227", "content": "", "toolRequests": [{"toolCallId": "tooluse_KqCBFCvT6KWh5CW2BNRqrt", "name": "report_intent"}, {"toolCallId": "tooluse_JvJQydzwRoco8aK0FSynhE", "name": "task"}]}} -{"type": "tool.execution_start", "data": {"toolCallId": "tooluse_KqCBFCvT6KWh5CW2BNRqrt", "toolName": "report_intent"}} -{"type": "tool.execution_start", "data": {"toolCallId": "tooluse_JvJQydzwRoco8aK0FSynhE", "toolName": "task", "arguments": {"description": "", "agentName": ""}}} -{"type": "subagent.started", "data": {"toolCallId": "tooluse_JvJQydzwRoco8aK0FSynhE", "agentName": "explore", "agentDisplayName": "Explore Agent", "agentDescription": "Fast codebase exploration and answering questions. Uses code intelligence, grep, glob, view, bash/powershell tools in a separate context window to search files and understand code structure. Safe to call in parallel.\n"}} -{"type": "tool.execution_complete", "data": {"toolCallId": "tooluse_KqCBFCvT6KWh5CW2BNRqrt", "success": true}} -{"type": "assistant.message", "data": {"messageId": "e10025da-18e4-449e-ad65-86b40c2df734", "content": "", "toolRequests": [{"toolCallId": "tooluse_Xfy4dl4QSeYSlpz41wtI6G", "name": "view"}, {"toolCallId": "tooluse_IE6Eh9fuAxZmSl3eFnLXd7", "name": "glob"}, {"toolCallId": "tooluse_IwGL2Zh0XKZrONGMGAnEGo", "name": "glob"}, {"toolCallId": "tooluse_qLbiToWVQLgou5o7yWXS9m", "name": "view"}, {"toolCallId": "tooluse_I7WV3WzgdLr4FK5gyU5MaR", "name": "view"}], "parentToolCallId": "tooluse_JvJQydzwRoco8aK0FSynhE"}} -{"type": "tool.execution_start", "data": {"toolCallId": "tooluse_Xfy4dl4QSeYSlpz41wtI6G", "toolName": "view", "parentToolCallId": "tooluse_JvJQydzwRoco8aK0FSynhE"}} -{"type": "tool.execution_start", "data": {"toolCallId": "tooluse_IE6Eh9fuAxZmSl3eFnLXd7", "toolName": "glob", "parentToolCallId": "tooluse_JvJQydzwRoco8aK0FSynhE"}} -{"type": "tool.execution_start", "data": {"toolCallId": "tooluse_IwGL2Zh0XKZrONGMGAnEGo", "toolName": "glob", "parentToolCallId": "tooluse_JvJQydzwRoco8aK0FSynhE"}} -{"type": "tool.execution_start", "data": {"toolCallId": "tooluse_qLbiToWVQLgou5o7yWXS9m", "toolName": "view", "parentToolCallId": "tooluse_JvJQydzwRoco8aK0FSynhE"}} -{"type": "tool.execution_start", "data": {"toolCallId": "tooluse_I7WV3WzgdLr4FK5gyU5MaR", "toolName": "view", "parentToolCallId": "tooluse_JvJQydzwRoco8aK0FSynhE"}} -{"type": "tool.execution_complete", "data": {"toolCallId": "tooluse_Xfy4dl4QSeYSlpz41wtI6G", "success": true, "parentToolCallId": "tooluse_JvJQydzwRoco8aK0FSynhE"}} -{"type": "tool.execution_complete", "data": {"toolCallId": "tooluse_qLbiToWVQLgou5o7yWXS9m", "success": true, "parentToolCallId": "tooluse_JvJQydzwRoco8aK0FSynhE"}} -{"type": "tool.execution_complete", "data": {"toolCallId": "tooluse_I7WV3WzgdLr4FK5gyU5MaR", "success": true, "parentToolCallId": "tooluse_JvJQydzwRoco8aK0FSynhE"}} -{"type": "tool.execution_complete", "data": {"toolCallId": "tooluse_IwGL2Zh0XKZrONGMGAnEGo", "success": true, "parentToolCallId": "tooluse_JvJQydzwRoco8aK0FSynhE"}} -{"type": "tool.execution_complete", "data": {"toolCallId": "tooluse_IE6Eh9fuAxZmSl3eFnLXd7", "success": true, "parentToolCallId": "tooluse_JvJQydzwRoco8aK0FSynhE"}} -{"type": "assistant.message", "data": {"messageId": "3a9290cb-6637-4cd8-a3bc-ed79c3bb3abc", "content": "Perfect! I now have enough information to answer all the questions. Let me provi", "parentToolCallId": "tooluse_JvJQydzwRoco8aK0FSynhE"}} -{"type": "subagent.completed", "data": {"toolCallId": "tooluse_JvJQydzwRoco8aK0FSynhE", "agentName": "explore", "agentDisplayName": "Explore Agent"}} -{"type": "tool.execution_complete", "data": {"toolCallId": "tooluse_JvJQydzwRoco8aK0FSynhE", "success": true}} -{"type": "assistant.turn_end", "data": {}} -{"type": "assistant.turn_start", "data": {}} -{"type": "assistant.message", "data": {"messageId": "aab83f2f-d9ac-4076-a318-2604bafe7078", "content": "Here's what the sync subagent found:\n\n**Repo overview** \u2014 This is a Python-focus"}} -{"type": "assistant.turn_end", "data": {}} diff --git a/src/vs/platform/agentHost/test/node/testRemoteAgentHost.sh b/src/vs/platform/agentHost/test/node/testRemoteAgentHost.sh deleted file mode 100644 index 18717c27..00000000 --- a/src/vs/platform/agentHost/test/node/testRemoteAgentHost.sh +++ /dev/null @@ -1,431 +0,0 @@ -#!/usr/bin/env bash -# -------------------------------------------------------------------------------------------- -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. See License.txt in the project root for license information. -# -------------------------------------------------------------------------------------------- - -# End-to-end smoke test for the remote agent host feature. -# -# Launches a standalone agent host server, starts the Sessions app with -# `chat.remoteAgentHosts` pre-configured to connect to it, validates that -# the Sessions app discovers the remote, and sends a chat message via the -# remote session target. -# -# Usage: -# ./testRemoteAgentHost.sh -# ./testRemoteAgentHost.sh "Hello, what can you do?" -# ./testRemoteAgentHost.sh --server-port 9090 --cdp-port 9225 "Explain this" -# -# Options: -# --server-port Agent host WebSocket port (default: 8081) -# --cdp-port CDP debugging port for Sessions app (default: 9224) -# --timeout Seconds to wait for response (default: 60) -# --no-kill Don't kill processes after the test -# --skip-message Only validate connection, don't send a message -# -# Requires: @playwright/cli (npm install -g @playwright/cli, or use npx) - -set -e - -ROOT="$(cd "$(dirname "$0")/../../../../../.." && pwd)" -SERVER_PORT=8081 -CDP_PORT=9224 -RESPONSE_TIMEOUT=60 -KILL_AFTER=true -SKIP_MESSAGE=false -MESSAGE="" - -# Parse arguments -while [[ $# -gt 0 ]]; do - case "$1" in - --server-port) - SERVER_PORT="$2" - shift 2 - ;; - --cdp-port) - CDP_PORT="$2" - shift 2 - ;; - --timeout) - RESPONSE_TIMEOUT="$2" - shift 2 - ;; - --no-kill) - KILL_AFTER=false - shift - ;; - --skip-message) - SKIP_MESSAGE=true - shift - ;; - -*) - echo "Unknown option: $1" >&2 - exit 1 - ;; - *) - MESSAGE="$1" - shift - ;; - esac -done - -if [ -z "$MESSAGE" ] && [ "$SKIP_MESSAGE" = false ]; then - MESSAGE="Hello, what can you do?" -fi - -AB="npx @playwright/cli" -SERVER_PID="" -USERDATA_DIR="" - -cleanup() { - echo "" >&2 - echo "=== Cleanup ===" >&2 - - $AB close 2>/dev/null || true - - if [ "$KILL_AFTER" = true ]; then - # Kill Sessions app - local CDP_PIDS - CDP_PIDS=$(lsof -t -i :"$CDP_PORT" 2>/dev/null || true) - if [ -n "$CDP_PIDS" ]; then - echo "Killing Sessions app (CDP port $CDP_PORT)..." >&2 - kill $CDP_PIDS 2>/dev/null || true - fi - - # Kill agent host server - if [ -n "$SERVER_PID" ] && kill -0 "$SERVER_PID" 2>/dev/null; then - echo "Killing agent host server (PID $SERVER_PID)..." >&2 - kill "$SERVER_PID" 2>/dev/null || true - # Give it a moment, then force-kill if still alive - sleep 0.5 - if kill -0 "$SERVER_PID" 2>/dev/null; then - kill -9 "$SERVER_PID" 2>/dev/null || true - fi - fi - - # Kill the sleep process that was keeping the server's stdin open. - # It was started via process substitution and is a child of this shell. - local SLEEP_PIDS - SLEEP_PIDS=$(pgrep -P $$ -f "sleep 86400" 2>/dev/null || true) - if [ -n "$SLEEP_PIDS" ]; then - kill $SLEEP_PIDS 2>/dev/null || true - fi - - # Also kill by port in case PID tracking missed it - local SERVER_PIDS - SERVER_PIDS=$(lsof -t -i :"$SERVER_PORT" 2>/dev/null || true) - if [ -n "$SERVER_PIDS" ]; then - kill $SERVER_PIDS 2>/dev/null || true - fi - fi - - # Clean up temp user data dir - if [ -n "$USERDATA_DIR" ] && [ -d "$USERDATA_DIR" ]; then - echo "Cleaning up temp user data dir: $USERDATA_DIR" >&2 - rm -rf "$USERDATA_DIR" - fi -} -trap cleanup EXIT - -# ---- Step 1: Start the agent host server ------------------------------------ - -echo "=== Step 1: Starting agent host server on port $SERVER_PORT ===" >&2 - -# Ensure port is free -if lsof -i :"$SERVER_PORT" >/dev/null 2>&1; then - echo "ERROR: Port $SERVER_PORT already in use" >&2 - exit 1 -fi -if lsof -i :"$CDP_PORT" >/dev/null 2>&1; then - echo "ERROR: CDP port $CDP_PORT already in use" >&2 - exit 1 -fi - -cd "$ROOT" - -# Start the server directly using Node (not via code-agent-host.sh which -# spawns a subprocess tree that's harder to manage in background mode). -# Use system node rather than the VS Code-managed node binary which may -# not have been downloaded yet. -SERVER_ENTRY="$ROOT/out/vs/platform/agentHost/node/agentHostServerMain.js" - -if [ ! -f "$SERVER_ENTRY" ]; then - echo "ERROR: Server entry point not found: $SERVER_ENTRY" >&2 - echo " Run the build first (npm run compile or the watch task)" >&2 - exit 1 -fi - -# Use a temp file for output and poll for READY. -# The server stays alive until stdin closes (process.stdin.on('end', shutdown)), -# so we keep stdin open using a process substitution with a long sleep. -# This avoids FIFOs and leaked file descriptors that caused cleanup hangs. -SERVER_READY_FILE=$(mktemp) - -node "$SERVER_ENTRY" --port "$SERVER_PORT" --quiet --enable-mock-agent \ - < <(sleep 86400) > "$SERVER_READY_FILE" 2>/dev/null & -SERVER_PID=$! - -echo "Server PID: $SERVER_PID" >&2 - -# Poll the output file for the READY line -echo "Waiting for server to start..." >&2 -SERVER_ADDR="" -for i in $(seq 1 30); do - READY_MATCH=$(grep -o 'READY:[0-9]*' "$SERVER_READY_FILE" 2>/dev/null || true) - if [ -n "$READY_MATCH" ]; then - READY_PORT=$(echo "$READY_MATCH" | cut -d: -f2) - SERVER_ADDR="ws://127.0.0.1:${READY_PORT}" - break - fi - sleep 1 -done -rm -f "$SERVER_READY_FILE" - -if [ -z "$SERVER_ADDR" ]; then - echo "ERROR: Server did not start within 30 seconds" >&2 - exit 1 -fi - -echo "Agent host server ready at $SERVER_ADDR" >&2 - -# ---- Step 2: Prepare user data with remote agent host setting --------------- - -echo "=== Step 2: Configuring Sessions app settings ===" >&2 - -# We use 127.0.0.1: as the address (strip ws:// prefix) -REMOTE_ADDR=$(echo "$SERVER_ADDR" | sed 's|^ws://||') - -USERDATA_DIR=$(mktemp -d) -SETTINGS_DIR="$USERDATA_DIR/User" -mkdir -p "$SETTINGS_DIR" - -cat > "$SETTINGS_DIR/settings.json" << EOF -{ - "chat.remoteAgentHosts": [ - { - "address": "$REMOTE_ADDR", - "name": "Test Remote Agent" - } - ], - "window.titleBarStyle": "custom" -} -EOF - -echo "Settings configured: $SETTINGS_DIR/settings.json" >&2 -echo " Remote address: $REMOTE_ADDR" >&2 - -# ---- Step 3: Launch Sessions app -------------------------------------------- - -echo "=== Step 3: Launching Sessions app ===" >&2 - -cd "$ROOT" -# Unset ELECTRON_RUN_AS_NODE to ensure the app launches as Electron, not Node. -VSCODE_SKIP_PRELAUNCH=1 ELECTRON_RUN_AS_NODE= ./scripts/code.sh \ - --agents \ - --skip-sessions-welcome \ - --remote-debugging-port="$CDP_PORT" \ - --user-data-dir="$USERDATA_DIR" \ - &>/dev/null & - -echo "Waiting for Sessions app to start..." >&2 -for i in $(seq 1 30); do - if $AB attach --cdp=http://127.0.0.1:$CDP_PORT 2>/dev/null; then - break - fi - sleep 2 - if [ "$i" -eq 30 ]; then - echo "ERROR: Sessions app did not start within 60 seconds" >&2 - exit 1 - fi -done - -echo "Connected to Sessions app via CDP" >&2 - -# Give the app a moment to initialize fully -sleep 3 - -# ---- Step 4: Validate the remote connection appeared ------------------------- - -echo "=== Step 4: Validating remote agent host connection ===" >&2 - -# Wait for the remote to appear as a session target -REMOTE_FOUND=false -for i in $(seq 1 20); do - SNAPSHOT=$($AB snapshot 2>&1 || true) - - # Look for the remote in the session target picker or any UI element - if echo "$SNAPSHOT" | grep -qi "Test Remote Agent\|remote.*agent"; then - REMOTE_FOUND=true - break - fi - - # Also check via DOM for the session target radio containing our remote name - REMOTE_CHECK=$($AB eval ' -(() => { - const text = document.body.innerText || ""; - if (text.includes("Test Remote Agent")) return "found"; - // Check radio buttons in the session target picker - const buttons = document.querySelectorAll(".monaco-custom-radio > .monaco-button"); - for (const btn of buttons) { - if (btn.textContent?.includes("Test Remote Agent")) return "found"; - } - return "not found"; -})()' 2>&1 || true) - - if echo "$REMOTE_CHECK" | grep -q "found"; then - REMOTE_FOUND=true - break - fi - - sleep 2 -done - -if [ "$REMOTE_FOUND" = true ]; then - echo "SUCCESS: Remote agent host 'Test Remote Agent' is visible in the Sessions app" >&2 -else - echo "ERROR: Could not find remote agent host 'Test Remote Agent' in the Sessions app UI" >&2 - echo "Snapshot excerpt:" >&2 - echo "$SNAPSHOT" | head -30 >&2 - exit 1 -fi - -# ---- Step 5: Send a message (optional) -------------------------------------- - -if [ "$SKIP_MESSAGE" = true ]; then - echo "=== Skipping message send (--skip-message) ===" >&2 - echo "Remote agent host test completed successfully." >&2 - exit 0 -fi - -echo "=== Step 5: Switching to remote session target and sending message ===" >&2 - -# Take a screenshot before interaction -SCREENSHOT_DIR="/tmp/remote-agent-test-$(date +%Y-%m-%dT%H-%M-%S)" -mkdir -p "$SCREENSHOT_DIR" -$AB screenshot --filename="$SCREENSHOT_DIR/01-before-interaction.png" 2>/dev/null || true - -# Click the session target radio button for the remote agent host -CLICK_RESULT=$($AB eval ' -(() => { - const buttons = document.querySelectorAll(".monaco-custom-radio > .monaco-button"); - for (const btn of buttons) { - if (btn.textContent?.includes("Test Remote Agent")) { - btn.click(); - return "clicked"; - } - } - return "not found"; -})()' 2>&1 || true) - -if echo "$CLICK_RESULT" | grep -q "not found"; then - echo "ERROR: Could not find 'Test Remote Agent' radio button to click" >&2 - $AB screenshot --filename="$SCREENSHOT_DIR/02-click-failed.png" 2>/dev/null || true - exit 1 -fi -echo "Switched to remote session target" >&2 - -sleep 1 - -$AB screenshot --filename="$SCREENSHOT_DIR/02-after-target-switch.png" 2>/dev/null || true - -# Fill in the remote folder path input (required for remote sessions) -echo "Setting remote folder path..." >&2 -FOLDER_SET=$($AB eval ' -(() => { - const input = document.querySelector("input.sessions-chat-remote-folder-text"); - if (!input) return "no input"; - input.focus(); - return "focused"; -})()' 2>&1 || true) - -if echo "$FOLDER_SET" | grep -q "no input"; then - echo "WARNING: Could not find remote folder input, continuing anyway..." >&2 -else - # Type a folder path using clipboard paste for speed - echo "/tmp" | pbcopy - $AB press Meta+a 2>/dev/null || true - $AB press Meta+v 2>/dev/null || true - sleep 0.3 - # Press Enter to confirm the folder path - $AB press Enter 2>/dev/null || true - sleep 0.5 - echo "Remote folder path set to /tmp" >&2 -fi - -$AB screenshot --filename="$SCREENSHOT_DIR/03-after-folder.png" 2>/dev/null || true - -# Type the message into the chat editor using clipboard paste for speed -echo "Typing message: $MESSAGE" >&2 -$AB eval ' -(() => { - // Focus the chat editor textarea - const textarea = document.querySelector(".new-chat-widget .monaco-editor textarea"); - if (textarea) { textarea.focus(); return "focused editor"; } - return "editor not found"; -})()' 2>/dev/null || true - -sleep 0.3 -echo -n "$MESSAGE" | pbcopy -$AB press Meta+v 2>/dev/null || true -sleep 0.5 - -$AB screenshot --filename="$SCREENSHOT_DIR/04-after-type.png" 2>/dev/null || true - -# Send the message via the send button or keyboard -$AB eval ' -(() => { - // Try clicking the send button directly - const sendBtn = document.querySelector(".new-chat-widget .codicon-send"); - if (sendBtn) { - const btn = sendBtn.closest("a, button, .monaco-button"); - if (btn) { btn.click(); return "clicked send"; } - } - return "send button not found"; -})()' 2>/dev/null || true - -$AB screenshot --filename="$SCREENSHOT_DIR/05-after-send.png" 2>/dev/null || true - -# ---- Step 6: Wait for response ---------------------------------------------- - -echo "Waiting for response (timeout: ${RESPONSE_TIMEOUT}s)..." >&2 - -RESPONSE="" -for i in $(seq 1 "$RESPONSE_TIMEOUT"); do - sleep 1 - - # Check for response content in the chat area - RESPONSE=$($AB eval ' -(() => { - // Sessions app uses the main chat area (not sidebar) - const items = document.querySelectorAll(".interactive-item-container"); - if (items.length < 2) return ""; - const lastItem = items[items.length - 1]; - const text = lastItem.textContent || ""; - if (text.length > 20) return text; - return ""; -})()' 2>&1 | sed 's/^"//;s/"$//') - - if [ -n "$RESPONSE" ]; then - break - fi - - # Progress indicator - if (( i % 10 == 0 )); then - echo " Still waiting... (${i}s)" >&2 - fi -done - -$AB screenshot --filename="$SCREENSHOT_DIR/04-response.png" 2>/dev/null || true - -if [ -z "$RESPONSE" ]; then - echo "WARNING: No response received within ${RESPONSE_TIMEOUT}s" >&2 - echo "Screenshots saved to: $SCREENSHOT_DIR" >&2 - exit 1 -fi - -echo "=== Response ===" >&2 -echo "$RESPONSE" - -echo "" >&2 -echo "Screenshots saved to: $SCREENSHOT_DIR" >&2 -echo "Remote agent host test completed successfully." >&2 diff --git a/src/vs/platform/browserView/electron-browser/preload-browserView.ts b/src/vs/platform/browserView/electron-browser/preload-browserView.ts deleted file mode 100644 index 4bd32767..00000000 --- a/src/vs/platform/browserView/electron-browser/preload-browserView.ts +++ /dev/null @@ -1,139 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -/* eslint-disable no-restricted-globals */ - -/** - * Preload script for pages loaded in Integrated Browser - * - * It runs in an isolated context that Electron calls an "isolated world". - * Specifically the isolated world with worldId 999, which shows in DevTools as "Electron Isolated Context". - * Despite being isolated, it still runs on the same page as the JS from the actual loaded website - * which runs on the so-called "main world" (worldId 0. In DevTools as "top"). - * - * Learn more: see Electron docs for Security, contextBridge, and Context Isolation. - */ -(function () { - - const { contextBridge, ipcRenderer } = require('electron'); - - // ####################################################################### - // ### ### - // ### !!! DO NOT USE GET/SET PROPERTIES ANYWHERE HERE !!! ### - // ### !!! UNLESS THE ACCESS IS WITHOUT SIDE EFFECTS !!! ### - // ### (https://github.com/electron/electron/issues/25516) ### - // ### ### - // ####################################################################### - - // Ctrl/Cmd keybindings that correspond to native editing shortcuts and should be handled by the browser / OS and not forwarded to the workbench. - const nativeCtrlCmdKeybindings = { - mac: { - always: new Set(['arrowup', 'arrowdown', 'arrowleft', 'arrowright', 'backspace', 'delete']), - noShift: new Set(['a', 'c', 'v', 'x', 'z']), - withShift: new Set(['v', 'z']), - }, - nonMac: { - always: new Set(['arrowup', 'arrowdown', 'arrowleft', 'arrowright', 'home', 'end', 'backspace', 'delete']), - noShift: new Set(['a', 'c', 'v', 'x', 'z', 'y']), - withShift: new Set(['v', 'z']), - } - }; - - // Listen for keydown events that the page did not handle and forward them for shortcut handling. - window.addEventListener('keydown', (event) => { - // Require that the event is trusted -- i.e. user-initiated. - // eslint-disable-next-line no-restricted-syntax - if (!(event instanceof KeyboardEvent) || !event.isTrusted) { - return; - } - - // If the event was already handled by the page, do not forward it. - if (event.defaultPrevented) { - return; - } - - const isNonEditingKey = - event.key === 'Escape' || - /^F\d+$/.test(event.key) || - event.key.startsWith('Audio') || event.key.startsWith('Media') || event.key.startsWith('Browser'); - - // Only forward if there's a command modifier or it's a non-editing key - // (most plain key events should just be handled natively by the browser and not forwarded) - if (!(event.ctrlKey || event.altKey || event.metaKey) && !isNonEditingKey) { - return; - } - - // Never handle plain modifier key presses as keybindings - if (event.key === 'Control' || event.key === 'Shift' || event.key === 'Alt' || event.key === 'Meta') { - return; - } - - const isMac = navigator.platform.indexOf('Mac') >= 0; - - // Alt+Key special character handling (Alt + Numpad keys on Windows/Linux, Alt + any key on Mac) - if (event.altKey && !event.ctrlKey && !event.metaKey) { - if (isMac || /^Numpad\d+$/.test(event.code)) { - return; - } - } - - // Allow native shortcuts to be handled by the browser - const ctrlCmd = isMac ? event.metaKey : event.ctrlKey; - if (ctrlCmd && !event.altKey) { - const key = event.key.toLowerCase(); - const keySetsToCheck = [ - nativeCtrlCmdKeybindings[isMac ? 'mac' : 'nonMac'].always, - nativeCtrlCmdKeybindings[isMac ? 'mac' : 'nonMac'][event.shiftKey ? 'withShift' : 'noShift'], - ]; - if (keySetsToCheck.some(set => set.has(key))) { - return; - } - - // Emoji picker on Mac - if (isMac && event.ctrlKey && !event.shiftKey && key === ' ') { - return; - } - } - - // Everything else should be forwarded to the workbench for potential shortcut handling. - event.preventDefault(); - event.stopPropagation(); - ipcRenderer.send('vscode:browserView:keydown', { - key: event.key, - keyCode: event.keyCode, - code: event.code, - ctrlKey: event.ctrlKey, - shiftKey: event.shiftKey, - altKey: event.altKey, - metaKey: event.metaKey, - repeat: event.repeat - }); - }); - - const globals = { - /** - * Get the currently selected text in the page. - */ - getSelectedText(): string { - try { - // Even if the page has overridden window.getSelection, our call here will still reach the original - // implementation. That's because Electron proxies functions, such as getSelectedText here, that are - // exposed to a different context via exposeInIsolatedWorld or exposeInMainWorld. - return window.getSelection()?.toString() ?? ''; - } catch { - return ''; - } - } - }; - - try { - // Use `contextBridge` APIs to expose globals to the same isolated world where this preload script runs (worldId 999). - // The globals object will be recursively frozen (and for functions also proxied) by Electron to prevent - // modification within the given context. - contextBridge.exposeInIsolatedWorld(999, 'browserViewAPI', globals); - } catch (error) { - console.error(error); - } -}()); diff --git a/src/vs/platform/browserView/electron-main/browserSession.ts b/src/vs/platform/browserView/electron-main/browserSession.ts deleted file mode 100644 index dc23d2ee..00000000 --- a/src/vs/platform/browserView/electron-main/browserSession.ts +++ /dev/null @@ -1,246 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { session } from 'electron'; -import { joinPath } from '../../../base/common/resources.js'; -import { URI } from '../../../base/common/uri.js'; -import { IApplicationStorageMainService } from '../../storage/electron-main/storageMainService.js'; -import { BrowserViewStorageScope } from '../common/browserView.js'; -import { BrowserSessionTrust, IBrowserSessionTrust } from './browserSessionTrust.js'; -import { FileAccess } from '../../../base/common/network.js'; - -// Same as webviews, minus clipboard-read -const allowedPermissions = new Set([ - 'pointerLock', - 'notifications', - 'clipboard-sanitized-write' -]); - -/** - * Holds an Electron session along with its storage scope and unique browser - * context identifier. Each instance maps one-to-one to an Electron - * {@link Electron.Session} -- the {@link id} is derived from what makes the - * Electron session unique (scope + workspace), **not** from any view id. - * Multiple browser views may reference the same `BrowserSession`. - * - * The class centralises the permission configuration. The {@link id} - * doubles as the CDP `browserContextId`. - * - * This class uses a private constructor with static factory methods - * ({@link getOrCreate}, {@link getOrCreateGlobal}, etc.) and maintains - * an internal registry of live sessions. Use the static methods to - * obtain instances. - */ -export class BrowserSession { - - // #region Static registry - - /** - * Primary store — keyed by Electron session so entries are - * automatically removed when the Electron session is GC'd. - * - * The goal is to ensure that BrowserSessions have the exact same lifespan as their Electron sessions. - */ - private static readonly _bySession = new WeakMap(); - - /** - * String-keyed lookup for {@link get} and {@link getBrowserContextIds}. - * Values are weak references so they don't prevent GC of the - * {@link BrowserSession} (and transitively the Electron session). - * - * ID derivation rules (one-to-one with Electron sessions): - * - Global scope -> `"global"` - * - Workspace scope -> `"workspace:${workspaceId}"` - * - Ephemeral scope -> `"ephemeral:${viewId}"` or `"${type}:${viewId}"` for custom types - */ - private static readonly _byId = new Map>(); - - /** - * Cleans up stale {@link _byId} entries when the Electron session - * they point to is garbage-collected. - */ - private static readonly _finalizer = new FinalizationRegistry((id) => { - BrowserSession._byId.delete(id); - }); - - /** - * Weak set mirroring the Electron sessions owned by any BrowserSession. - * Useful for quickly checking whether a given {@link Electron.WebContents} - * belongs to the integrated browser. - */ - static readonly knownSessions = new WeakSet(); - - /** - * Check if a {@link Electron.WebContents} belongs to an integrated browser - * view backed by a BrowserSession. - */ - static isBrowserViewWebContents(contents: Electron.WebContents): boolean { - return BrowserSession.knownSessions.has(contents.session); - } - - /** - * Return an existing session for the given id, or `undefined`. - */ - static get(id: string): BrowserSession | undefined { - const ref = BrowserSession._byId.get(id); - if (!ref) { - return undefined; - } - const bs = ref.deref(); - if (!bs) { - BrowserSession._byId.delete(id); - } - return bs; - } - - /** - * Return all live browser context IDs (i.e. all session {@link id}s). - */ - static getBrowserContextIds(): string[] { - const ids: string[] = []; - for (const [id, ref] of BrowserSession._byId) { - if (ref.deref()) { - ids.push(id); - } else { - BrowserSession._byId.delete(id); - } - } - return ids; - } - - /** - * Get or create the singleton global-scope session. - */ - static getOrCreateGlobal(): BrowserSession { - const electronSession = session.fromPartition('persist:vscode-browser'); - return BrowserSession._bySession.get(electronSession) - ?? new BrowserSession('global', electronSession, BrowserViewStorageScope.Global); - } - - /** - * Get or create a workspace-scope session for the given workspace. - */ - static getOrCreateWorkspace(workspaceId: string, workspaceStorageHome: URI): BrowserSession { - const storage = joinPath(workspaceStorageHome, workspaceId, 'browserStorage'); - const electronSession = session.fromPath(storage.fsPath); - return BrowserSession._bySession.get(electronSession) - ?? new BrowserSession(`workspace:${workspaceId}`, electronSession, BrowserViewStorageScope.Workspace); - } - - /** - * Get or create an ephemeral session for the given view / target id. - */ - static getOrCreateEphemeral(viewId: string, type?: string): BrowserSession { - if (type === 'workspace' || type === 'ephemeral') { - throw new Error(`Cannot create session with reserved type '${type}'`); - } - - const sessionId = `${type ?? 'ephemeral'}:${viewId}`; - const electronSession = session.fromPartition(`vscode-browser-${type}${viewId}`); - return BrowserSession._bySession.get(electronSession) - ?? new BrowserSession(sessionId, electronSession, BrowserViewStorageScope.Ephemeral); - } - - /** - * Get or create a session for a workbench-originated browser view. - * The session id is derived from the *scope* -- not the view id -- so - * multiple views that share a scope (e.g. two Global views) get the - * same `BrowserSession`. - * - * @param viewId Used only for ephemeral sessions where every view - * needs its own Electron session. - * @param scope Desired storage scope. - * @param workspaceStorageHome Root folder under which per-workspace - * browser storage is created - * (`IEnvironmentMainService.workspaceStorageHome`). - * @param workspaceId Only required when `scope` is `workspace`. - */ - static getOrCreate( - viewId: string, - scope: BrowserViewStorageScope, - workspaceStorageHome: URI, - workspaceId?: string, - ): BrowserSession { - switch (scope) { - case BrowserViewStorageScope.Global: - return BrowserSession.getOrCreateGlobal(); - case BrowserViewStorageScope.Workspace: - if (workspaceId) { - return BrowserSession.getOrCreateWorkspace(workspaceId, workspaceStorageHome); - } - // fallthrough -- no workspace context -> ephemeral - case BrowserViewStorageScope.Ephemeral: - default: - return BrowserSession.getOrCreateEphemeral(viewId); - } - } - - // #endregion - - // #region Instance - - private readonly _trust: BrowserSessionTrust; - - private constructor( - /** - * Unique identifier for this session. Derived from what makes the - * underlying Electron session unique (scope key, workspace id, view - * id, or context uuid) -- NOT from any particular view id. - */ - readonly id: string, - /** The underlying Electron session. */ - readonly electronSession: Electron.Session, - /** Resolved storage scope. */ - readonly storageScope: BrowserViewStorageScope, - ) { - this._trust = new BrowserSessionTrust(this); - this.configure(); - BrowserSession.knownSessions.add(electronSession); - BrowserSession._bySession.set(electronSession, this); - BrowserSession._byId.set(id, new WeakRef(this)); - BrowserSession._finalizer.register(electronSession, id); - } - - /** Public trust interface for consumers that need cert operations. */ - get trust(): IBrowserSessionTrust { - return this._trust; - } - - /** - * Connect application storage to this session so that preferences - * (trusted certificates, permissions, etc.) are persisted across - * restarts. Restores any previously-saved data on first call; - * subsequent calls are no-ops. - */ - connectStorage(storage: IApplicationStorageMainService): void { - this._trust.connectStorage(storage); - } - - /** - * Apply the permission policy and preload scripts to the session. - */ - private configure(): void { - this.electronSession.setPermissionRequestHandler((_webContents, permission, callback) => { - return callback(allowedPermissions.has(permission)); - }); - this.electronSession.setPermissionCheckHandler((_webContents, permission, _origin) => { - return allowedPermissions.has(permission); - }); - this.electronSession.registerPreloadScript({ - type: 'frame', - filePath: FileAccess.asFileUri('vs/platform/browserView/electron-browser/preload-browserView.js').fsPath - }); - } - - /** - * Clear all session data including trust state and all browsing data. - */ - async clearData(): Promise { - await this._trust.clear(); - await this.electronSession.clearData(); - } - - // #endregion -} diff --git a/src/vs/platform/browserView/electron-main/browserSessionTrust.ts b/src/vs/platform/browserView/electron-main/browserSessionTrust.ts deleted file mode 100644 index 2e915e7b..00000000 --- a/src/vs/platform/browserView/electron-main/browserSessionTrust.ts +++ /dev/null @@ -1,317 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { IApplicationStorageMainService } from '../../storage/electron-main/storageMainService.js'; -import { StorageScope, StorageTarget } from '../../storage/common/storage.js'; -import { IBrowserViewCertificateError } from '../common/browserView.js'; -import type { BrowserSession } from './browserSession.js'; - -/** Key used to store trusted certificate data in the application storage. */ -const STORAGE_KEY = 'browserView.sessionTrustData'; - -/** Trust entries expire after 1 week. */ -const TRUST_DURATION_MS = 7 * 24 * 60 * 60 * 1000; - -/** - * Shape of the JSON blob persisted under {@link STORAGE_KEY}. - * Top-level keys are session ids; each value holds the session's - * trusted certificates. - */ -interface PersistedTrustData { - [sessionId: string]: { - trustedCerts?: { host: string; fingerprint: string; expiresAt: number }[]; - }; -} - -/** - * Public subset of {@link BrowserSessionTrust} exposed to consumers - * (e.g. {@link BrowserView}) that need to trust/untrust certificates - * or query certificate errors. - */ -export interface IBrowserSessionTrust { - trustCertificate(host: string, fingerprint: string): Promise; - untrustCertificate(host: string, fingerprint: string): Promise; - getCertificateError(url: string): IBrowserViewCertificateError | undefined; - installCertErrorHandler(webContents: Electron.WebContents): void; -} - -/** - * Centralises all certificate and trust-related security logic for a - * browser session. Owns the trusted-certificate store, the cert-error - * cache, the `setCertificateVerifyProc` handler on the Electron session, - * and the per-`WebContents` `certificate-error` handler. - */ -export class BrowserSessionTrust implements IBrowserSessionTrust { - - /** - * Trusted certificates stored as host → (fingerprint → expiration epoch ms). - * Entries are time-limited; see {@link TRUST_DURATION_MS}. - */ - private readonly _trustedCertificates = new Map>(); - - /** - * Last known certificate per host (hostname → { fingerprint, error }). - * Populated by `setCertificateVerifyProc` which fires for every TLS - * handshake, not just errors. This lets us look up cert status for a - * URL even after Chromium has cached the allow decision. - */ - private readonly _certErrors = new Map(); - - /** - * Application storage service for persisting trusted certificates - * across restarts. Set via {@link connectStorage}; `undefined` until then. - */ - private _storage: IApplicationStorageMainService | undefined; - - constructor( - private readonly _session: BrowserSession, - ) { - this._installCertVerifyProc(); - } - - /** - * Install the session-level certificate verification callback that records cert errors. - * This does not grant any trust by itself; it just populates the `_certErrors` cache. - */ - private _installCertVerifyProc(): void { - this._session.electronSession.setCertificateVerifyProc((request, callback) => { - const { hostname, errorCode, certificate, verificationResult } = request; - - if (errorCode !== 0) { - this._certErrors.set(hostname, { certificate, error: verificationResult }); - } else { - this._certErrors.delete(hostname); - } - - return callback(-3); // Always use default handling from Chromium - }); - } - - /** - * Install a `certificate-error` handler on a {@link Electron.WebContents} - * so that user-trusted certificates are accepted at the page level. - */ - installCertErrorHandler(webContents: Electron.WebContents): void { - webContents.on('certificate-error', (event, url, _error, certificate, callback) => { - event.preventDefault(); - - const host = URL.parse(url)?.hostname; - if (!host) { - return callback(false); - } - - if (this.isCertificateTrusted(host, certificate.fingerprint)) { - return callback(true); - } - - return callback(false); - }); - } - - /** - * Look up the certificate status for a URL by extracting the host and - * checking whether we have a last-known bad cert that was user-trusted. - * Returns the cert error info if the host has a bad cert that was trusted, - * or `undefined` if the cert is valid or unknown. - */ - getCertificateError(url: string): IBrowserViewCertificateError | undefined { - const parsed = URL.parse(url); - if (!parsed || parsed.protocol !== 'https:') { - return undefined; - } - - const host = parsed.hostname; - if (!host) { - return undefined; - } - - const known = this._certErrors.get(host); - if (!known) { - return undefined; - } - - const cert = known.certificate; - return { - host, - fingerprint: cert.fingerprint, - error: known.error, - url, - hasTrustedException: this.isCertificateTrusted(host, cert.fingerprint), - issuerName: cert.issuerName, - subjectName: cert.subjectName, - validStart: cert.validStart, - validExpiry: cert.validExpiry, - }; - } - - /** - * Trust a certificate identified by host and SHA-256 fingerprint. - */ - async trustCertificate(host: string, fingerprint: string): Promise { - let entries = this._trustedCertificates.get(host); - if (!entries) { - entries = new Map(); - this._trustedCertificates.set(host, entries); - } - entries.set(fingerprint, Date.now() + TRUST_DURATION_MS); - this.writeStorage(); - } - - /** - * Revoke trust for a certificate identified by host and fingerprint. - */ - async untrustCertificate(host: string, fingerprint: string): Promise { - const entries = this._trustedCertificates.get(host); - if (entries && entries.delete(fingerprint)) { - if (entries.size === 0) { - this._trustedCertificates.delete(host); - } - } else { - throw new Error(`Certificate not found: host=${host} fingerprint=${fingerprint}`); - } - this.writeStorage(); - // Important: close all connections since they may be using the now-untrusted cert. - await this._session.electronSession.closeAllConnections(); - } - - /** - * Check whether a certificate is trusted for a given host. - */ - isCertificateTrusted(host: string, fingerprint: string): boolean { - const expiresAt = this._trustedCertificates.get(host)?.get(fingerprint); - if (expiresAt === undefined) { - return false; - } - if (Date.now() > expiresAt) { - return false; - } - return true; - } - - /** - * Connect application storage so that trusted certificates are - * persisted across restarts. Restores any previously-saved data on - * first call; subsequent calls are no-ops. - */ - connectStorage(storage: IApplicationStorageMainService): void { - if (this._storage) { - return; // already connected - } - this._storage = storage; - this.readStorage(); - } - - /** - * Clear all trust state: in-memory certs, cert-error cache, persisted - * data, and close open connections that may be using now-untrusted certs. - */ - async clear(): Promise { - this._trustedCertificates.clear(); - this._certErrors.clear(); - this.writeStorage(); - // Important: close all connections since they may be using now-untrusted certs. - await this._session.electronSession.closeAllConnections(); - } - - // #region Persistence helpers - - /** - * Restore trusted certificates from application storage. - */ - private readStorage(): void { - const storage = this._storage; - if (!storage) { - return; - } - - const raw = storage.get(STORAGE_KEY, StorageScope.APPLICATION); - if (!raw) { - return; - } - - const now = Date.now(); - let pruned = false; - try { - const all: PersistedTrustData = JSON.parse(raw); - const certs = all[this._session.id]?.trustedCerts; - if (certs) { - for (const { host, fingerprint, expiresAt } of certs) { - if (expiresAt > now) { - let entries = this._trustedCertificates.get(host); - if (!entries) { - entries = new Map(); - this._trustedCertificates.set(host, entries); - } - entries.set(fingerprint, expiresAt); - } else { - pruned = true; - } - } - } - } catch { - // Corrupt data — ignore - } - - // Flush expired entries from storage - if (pruned) { - this.writeStorage(); - } - } - - /** - * Write trusted certificates to application storage. - * The single storage key holds **all** sessions' data so that we can - * clean up stale entries atomically. - */ - private writeStorage(): void { - const storage = this._storage; - if (!storage) { - return; - } - - // Read existing blob (other sessions may have data too) - let all: PersistedTrustData = {}; - try { - const raw = storage.get(STORAGE_KEY, StorageScope.APPLICATION); - if (raw) { - all = JSON.parse(raw); - } - } catch { - // Overwrite corrupt data - } - - // Ensure this session's entry exists - if (!all[this._session.id]) { - all[this._session.id] = {}; - } - - // Update the trusted certs slice - if (this._trustedCertificates.size === 0) { - delete all[this._session.id].trustedCerts; - } else { - const certs: { host: string; fingerprint: string; expiresAt: number }[] = []; - for (const [host, entries] of this._trustedCertificates) { - for (const [fingerprint, expiresAt] of entries) { - certs.push({ host, fingerprint, expiresAt }); - } - } - all[this._session.id].trustedCerts = certs; - } - - // Remove empty session entries - if (Object.keys(all[this._session.id]).length === 0) { - delete all[this._session.id]; - } - - // Write back (or remove if empty) - if (Object.keys(all).length === 0) { - storage.remove(STORAGE_KEY, StorageScope.APPLICATION); - } else { - storage.store(STORAGE_KEY, JSON.stringify(all), StorageScope.APPLICATION, StorageTarget.MACHINE); - } - } - - // #endregion -} diff --git a/src/vs/platform/browserView/electron-main/browserView.ts b/src/vs/platform/browserView/electron-main/browserView.ts deleted file mode 100644 index 2b86ca73..00000000 --- a/src/vs/platform/browserView/electron-main/browserView.ts +++ /dev/null @@ -1,770 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { WebContentsView, webContents } from 'electron'; -import { Disposable } from '../../../base/common/lifecycle.js'; -import { Emitter, Event } from '../../../base/common/event.js'; -import { VSBuffer } from '../../../base/common/buffer.js'; -import { CancellationToken } from '../../../base/common/cancellation.js'; -import { IBrowserViewBounds, IBrowserViewDevToolsStateEvent, IBrowserViewFocusEvent, IBrowserViewKeyDownEvent, IBrowserViewState, IBrowserViewNavigationEvent, IBrowserViewLoadingEvent, IBrowserViewLoadError, IBrowserViewTitleChangeEvent, IBrowserViewFaviconChangeEvent, IBrowserViewCaptureScreenshotOptions, IBrowserViewFindInPageOptions, IBrowserViewFindInPageResult, IBrowserViewVisibilityEvent, browserViewIsolatedWorldId, browserZoomFactors, browserZoomDefaultIndex, IElementData, IBrowserViewOwner, IBrowserViewOpenOptions } from '../common/browserView.js'; -import { BrowserViewElementInspector } from './browserViewElementInspector.js'; -import { IWindowsMainService } from '../../windows/electron-main/windows.js'; -import { ICodeWindow, LoadReason } from '../../window/electron-main/window.js'; -import { IAuxiliaryWindowsMainService } from '../../auxiliaryWindow/electron-main/auxiliaryWindows.js'; -import { BrowserViewDebugger } from './browserViewDebugger.js'; -import { ILogService } from '../../log/common/log.js'; -import { BrowserSession } from './browserSession.js'; -import { IAuxiliaryWindow } from '../../auxiliaryWindow/electron-main/auxiliaryWindow.js'; -import { SCAN_CODE_STR_TO_EVENT_KEY_CODE } from '../../../base/common/keyCodes.js'; -import { ITelemetryService } from '../../telemetry/common/telemetry.js'; -import { logBrowserOpen } from '../common/browserViewTelemetry.js'; - -enum NewPageLocation { - Foreground = 'foreground', - Background = 'background', - NewWindow = 'newWindow' -} - -/** - * Represents a single browser view instance with its WebContentsView and all associated logic. - * This class encapsulates all operations and events for a single browser view. - */ -export class BrowserView extends Disposable { - private readonly _view: WebContentsView; - private readonly _faviconRequestCache = new Map>(); - - private _lastScreenshot: VSBuffer | undefined = undefined; - private _lastFavicon: string | undefined = undefined; - private _lastError: IBrowserViewLoadError | undefined = undefined; - private _lastUserGestureTimestamp: number = -Infinity; - private _browserZoomIndex: number = browserZoomDefaultIndex; - - readonly debugger: BrowserViewDebugger; - private readonly _inspector: BrowserViewElementInspector; - - private _ownerWindow: ICodeWindow; - private _currentWindow: ICodeWindow | IAuxiliaryWindow | undefined; - private _isDisposed = false; - - private static readonly MAX_CONSOLE_LOG_ENTRIES = 1000; - private readonly _consoleLogs: string[] = []; - - private readonly _onDidNavigate = this._register(new Emitter()); - readonly onDidNavigate: Event = this._onDidNavigate.event; - - private readonly _onDidChangeLoadingState = this._register(new Emitter()); - readonly onDidChangeLoadingState: Event = this._onDidChangeLoadingState.event; - - private readonly _onDidChangeFocus = this._register(new Emitter()); - readonly onDidChangeFocus: Event = this._onDidChangeFocus.event; - - private readonly _onDidChangeVisibility = this._register(new Emitter()); - readonly onDidChangeVisibility: Event = this._onDidChangeVisibility.event; - - private readonly _onDidChangeDevToolsState = this._register(new Emitter()); - readonly onDidChangeDevToolsState: Event = this._onDidChangeDevToolsState.event; - - private readonly _onDidKeyCommand = this._register(new Emitter()); - readonly onDidKeyCommand: Event = this._onDidKeyCommand.event; - - private readonly _onDidChangeTitle = this._register(new Emitter()); - readonly onDidChangeTitle: Event = this._onDidChangeTitle.event; - - private readonly _onDidChangeFavicon = this._register(new Emitter()); - readonly onDidChangeFavicon: Event = this._onDidChangeFavicon.event; - - private readonly _onDidFindInPage = this._register(new Emitter()); - readonly onDidFindInPage: Event = this._onDidFindInPage.event; - - private readonly _onDidClose = this._register(new Emitter()); - readonly onDidClose: Event = this._onDidClose.event; - - constructor( - public readonly id: string, - public readonly owner: IBrowserViewOwner, - public readonly session: BrowserSession, - createChildView: (url: string, electronOptions: Electron.WebContentsViewConstructorOptions | undefined, openOptions: IBrowserViewOpenOptions) => BrowserView, - openContextMenu: (view: BrowserView, params: Electron.ContextMenuParams) => void, - options: Electron.WebContentsViewConstructorOptions | undefined, - @IWindowsMainService private readonly windowsMainService: IWindowsMainService, - @IAuxiliaryWindowsMainService private readonly auxiliaryWindowsMainService: IAuxiliaryWindowsMainService, - @ILogService private readonly logService: ILogService, - @ITelemetryService private readonly telemetryService: ITelemetryService, - ) { - super(); - - const webPreferences: Electron.WebPreferences & { type: ReturnType } = { - ...options?.webPreferences, - - nodeIntegration: false, - contextIsolation: true, - sandbox: true, - webviewTag: false, - session: this.session.electronSession, - - // TODO@kycutler: Remove this once https://github.com/electron/electron/issues/42578 is fixed - type: 'browserView' - }; - - this._view = new WebContentsView({ - webPreferences, - // Passing an `undefined` webContents triggers an error in Electron. - ...(options?.webContents ? { webContents: options.webContents } : {}) - }); - - // Use a default size of 1024x768. - this._view.setBounds({ x: -10000, y: -10000, width: 1024, height: 768 }); - this._view.setBackgroundColor('#FFFFFF'); - - this._ownerWindow = this.windowsMainService.getWindowById(owner.mainWindowId)!; - if (!this._ownerWindow) { - throw new Error(`Window with ID ${owner.mainWindowId} not found`); - } - this._register(this._ownerWindow.onDidClose(() => this.dispose())); - this._register(this._ownerWindow.onWillLoad((e) => { - if (e.reason === LoadReason.LOAD) { - this.dispose(); // Dispose when switching workspaces. - } else if (e.reason === LoadReason.RELOAD) { - this.setVisible(false); // Hide when reloading. - } - })); - - this._view.setVisible(false); - this._ownerWindow.win?.contentView.addChildView(this._view); - - this._view.webContents.setWindowOpenHandler((details) => { - const location = (() => { - switch (details.disposition) { - case 'background-tab': return NewPageLocation.Background; - case 'foreground-tab': return NewPageLocation.Foreground; - case 'new-window': return NewPageLocation.NewWindow; - default: return undefined; - } - })(); - - if (!location || !this.consumePopupPermission(location)) { - // Eventually we may want to surface this. For now, just silently block it. - return { action: 'deny' }; - } - - return { - action: 'allow', - createWindow: (options) => { - logBrowserOpen(this.telemetryService, (() => { - switch (location) { - case NewPageLocation.NewWindow: return 'browserLinkNewWindow'; - case NewPageLocation.Background: return 'browserLinkBackground'; - case NewPageLocation.Foreground: return 'browserLinkForeground'; - } - })()); - - const childView = createChildView(details.url, options, { - pinned: true, - background: location === NewPageLocation.Background, - parentViewId: id, - auxiliaryWindow: location === NewPageLocation.NewWindow - ? { x: options.x, y: options.y, width: options.width, height: options.height } - : undefined, - }); - - // Return the webContents so Electron can complete the window.open() call - return childView.webContents; - }, - - // We want the standard browser behavior as opposed to Electron's default of closing the new window when the parent is closed - outlivesOpener: true - }; - }); - - this._view.webContents.on('context-menu', (_event, params) => { - openContextMenu(this, params); - }); - - this._view.webContents.on('destroyed', () => { - this.dispose(); - }); - - this.debugger = new BrowserViewDebugger(this, this.logService); - this._inspector = this._register(new BrowserViewElementInspector(this)); - - this.setupEventListeners(); - } - - private setupEventListeners(): void { - const webContents = this._view.webContents; - - // DevTools state events - webContents.on('devtools-opened', () => { - this._onDidChangeDevToolsState.fire({ isDevToolsOpen: true }); - }); - - webContents.on('devtools-closed', () => { - this._onDidChangeDevToolsState.fire({ isDevToolsOpen: false }); - }); - - // Favicon events - webContents.on('page-favicon-updated', async (_event, favicons) => { - // try each url in order until one works - for (const url of favicons) { - if (!this._faviconRequestCache.has(url)) { - this._faviconRequestCache.set(url, (async () => { - if (url.startsWith('data:image/')) { - return url; - } - const response = await webContents.session.fetch(url, { - cache: 'force-cache' - }); - if (!response.ok) { - throw new Error(`Failed to fetch favicon: ${response.status} ${response.statusText}`); - } - const type = await response.headers.get('content-type'); - if (!type?.startsWith('image/')) { - throw new Error(`Favicon is not an image: ${type}`); - } - const buffer = await response.arrayBuffer(); - - return `data:${type};base64,${Buffer.from(buffer).toString('base64')}`; - })()); - } - - try { - this._lastFavicon = await this._faviconRequestCache.get(url)!; - this._onDidChangeFavicon.fire({ favicon: this._lastFavicon }); - // On success, stop searching - return; - } catch (e) { - // On failure, just try the next one - } - } - - // If we searched all favicons and none worked, clear the favicon - if (this._lastFavicon) { - this._lastFavicon = undefined; - this._onDidChangeFavicon.fire({ favicon: this._lastFavicon }); - } - }); - - // Title events - webContents.on('page-title-updated', (_event, title) => { - this._onDidChangeTitle.fire({ title }); - }); - - const fireNavigationEvent = () => { - const url = webContents.getURL(); - this._onDidNavigate.fire({ - url, - title: webContents.getTitle(), - canGoBack: webContents.navigationHistory.canGoBack(), - canGoForward: webContents.navigationHistory.canGoForward(), - certificateError: this.session.trust.getCertificateError(url) - }); - }; - - const fireLoadingEvent = (loading: boolean) => { - this._onDidChangeLoadingState.fire({ loading, error: this._lastError }); - }; - - // Loading state events - webContents.on('did-start-loading', () => { - this._lastError = undefined; - - // Don't fire loading events for e.g. same-document navigations - if (webContents.isLoadingMainFrame()) { - fireLoadingEvent(true); - } - }); - webContents.on('did-stop-loading', () => fireLoadingEvent(false)); - webContents.on('did-fail-load', (e, errorCode, errorDescription, validatedURL, isMainFrame) => { - if (isMainFrame) { - // Ignore ERR_ABORTED (-3) which is the expected error when user stops a page load. - if (errorCode === -3) { - fireLoadingEvent(false); - return; - } - - this._lastError = { - url: validatedURL, - errorCode, - errorDescription, - // -200 - -220 are the range of certificate errors in Chromium. - certificateError: errorCode <= -200 && errorCode >= -220 ? this.session.trust.getCertificateError(validatedURL) : undefined - }; - - fireLoadingEvent(false); - this._onDidNavigate.fire({ - url: validatedURL, - title: '', - canGoBack: webContents.navigationHistory.canGoBack(), - canGoForward: webContents.navigationHistory.canGoForward(), - certificateError: this.session.trust.getCertificateError(validatedURL) - }); - } - }); - webContents.on('did-finish-load', () => fireLoadingEvent(false)); - - this.session.trust.installCertErrorHandler(webContents); - - webContents.on('render-process-gone', (_event, details) => { - this._lastError = { - url: webContents.getURL(), - errorCode: details.exitCode, - errorDescription: `Render process gone: ${details.reason}` - }; - - fireLoadingEvent(false); - }); - - // Navigation events (when URL actually changes) - webContents.on('did-navigate', fireNavigationEvent); - webContents.on('did-navigate-in-page', fireNavigationEvent); - - webContents.on('did-navigate', () => { - // Chromium resets the zoom factor to its per-origin default (100%) when - // navigating to a new document. Re-apply our stored zoom to override it. - this._consoleLogs.length = 0; // Clear console logs on navigation since they are per-page - this._view.webContents.setZoomFactor(browserZoomFactors[this._browserZoomIndex]); - - // Enable pinch-to-zoom - void this._view.webContents.setVisualZoomLevelLimits(1, 3).catch(error => { - this.logService.error('Failed to set visual zoom level limits for browser view webContents.', error); - }); - }); - - // Focus events - webContents.on('focus', () => { - this._onDidChangeFocus.fire({ focused: true }); - }); - - webContents.on('blur', () => { - this._onDidChangeFocus.fire({ focused: false }); - }); - - // Forward key down events that weren't handled by the page to the workbench for shortcut handling. - webContents.ipc.on('vscode:browserView:keydown', (_event, keyEvent: IBrowserViewKeyDownEvent) => { - this._onDidKeyCommand.fire(keyEvent); - }); - // If the page won't be able to handle events, forward key down events directly. - webContents.on('before-input-event', (event, input) => { - if (input.type !== 'keyDown') { - return; - } - - const pageIsAvailable = this._view.getVisible() - && !webContents.isCrashed() - && !this.debugger.isPaused; - if (pageIsAvailable) { - return; - } - - // This logic should mirror that in preload-browserView.ts. - if (!(input.control || input.alt || input.meta) && input.key.length === 1) { - return; - } - - event.preventDefault(); - - const eventKeyCode = SCAN_CODE_STR_TO_EVENT_KEY_CODE[input.code] || 0; - this._onDidKeyCommand.fire({ - key: input.key, - keyCode: eventKeyCode, - code: input.code, - ctrlKey: input.control, - shiftKey: input.shift, - altKey: input.alt, - metaKey: input.meta, - repeat: input.isAutoRepeat - }); - }); - - // Track user gestures for popup blocking logic. - // Roughly based on https://html.spec.whatwg.org/multipage/interaction.html#tracking-user-activation. - webContents.on('input-event', (_event, input) => { - switch (input.type) { - case 'rawKeyDown': - case 'keyDown': - case 'mouseDown': - case 'pointerDown': - case 'pointerUp': - case 'touchEnd': - this._lastUserGestureTimestamp = Date.now(); - } - }); - - // For now, always prevent sites from blocking unload. - // In the future we may want to show a dialog to ask the user, - // with heavy restrictions regarding interaction and repeated prompts. - webContents.on('will-prevent-unload', (e) => { - e.preventDefault(); - }); - - // Find in page events - webContents.on('found-in-page', (_event, result) => { - this._onDidFindInPage.fire({ - activeMatchOrdinal: result.activeMatchOrdinal, - matches: result.matches, - selectionArea: result.selectionArea, - finalUpdate: result.finalUpdate - }); - }); - - // Capture console messages for sharing with chat - this._view.webContents.on('console-message', (event) => { - this._consoleLogs.push(`[${event.level}] ${event.message}`); - if (this._consoleLogs.length > BrowserView.MAX_CONSOLE_LOG_ENTRIES) { - this._consoleLogs.splice(0, this._consoleLogs.length - BrowserView.MAX_CONSOLE_LOG_ENTRIES); - } - }); - } - - private consumePopupPermission(location: NewPageLocation): boolean { - switch (location) { - case NewPageLocation.Foreground: - case NewPageLocation.Background: - return true; - case NewPageLocation.NewWindow: - // Each user gesture allows one popup window within 1 second - if (this._lastUserGestureTimestamp > Date.now() - 1000) { - this._lastUserGestureTimestamp = -Infinity; - return true; - } - - return false; - } - } - - get webContents(): Electron.WebContents { - return this._view.webContents; - } - - /** - * Get the current state of this browser view - */ - getState(): IBrowserViewState { - const webContents = this._view.webContents; - const url = webContents.getURL(); - - return { - url, - title: webContents.getTitle(), - canGoBack: webContents.navigationHistory.canGoBack(), - canGoForward: webContents.navigationHistory.canGoForward(), - loading: webContents.isLoading(), - focused: webContents.isFocused(), - visible: this._view.getVisible(), - isDevToolsOpen: webContents.isDevToolsOpened(), - lastScreenshot: this._lastScreenshot, - lastFavicon: this._lastFavicon, - lastError: this._lastError, - certificateError: this.session.trust.getCertificateError(url), - storageScope: this.session.storageScope, - browserZoomIndex: this._browserZoomIndex - }; - } - - /** - * Toggle developer tools for this browser view. - */ - toggleDevTools(): void { - this._view.webContents.toggleDevTools(); - } - - /** - * Update the layout bounds of this view - */ - layout(bounds: IBrowserViewBounds): void { - if (this._currentWindow?.win?.id !== bounds.windowId) { - const newWindow = this._windowById(bounds.windowId); - if (newWindow) { - this._currentWindow?.win?.contentView.removeChildView(this._view); - this._currentWindow = newWindow; - newWindow.win?.contentView.addChildView(this._view); - } - } - - this._view.setBorderRadius(Math.round(bounds.cornerRadius * bounds.zoomFactor)); - this._view.setBounds({ - x: Math.round(bounds.x * bounds.zoomFactor), - y: Math.round(bounds.y * bounds.zoomFactor), - width: Math.round(bounds.width * bounds.zoomFactor), - height: Math.round(bounds.height * bounds.zoomFactor) - }); - } - - setBrowserZoomIndex(zoomIndex: number): void { - this._browserZoomIndex = Math.max(0, Math.min(zoomIndex, browserZoomFactors.length - 1)); - const browserZoomFactor = browserZoomFactors[this._browserZoomIndex]; - this._view.webContents.setZoomFactor(browserZoomFactor); - } - - /** - * Set the visibility of this view - */ - setVisible(visible: boolean): void { - if (this._view.getVisible() === visible) { - return; - } - - // If the view is focused, pass focus back to the window when hiding - if (!visible && this._view.webContents.isFocused()) { - this._currentWindow?.win?.webContents.focus(); - } - - this._view.setVisible(visible); - this._onDidChangeVisibility.fire({ visible }); - } - - /** - * Get captured console logs. - */ - getConsoleLogs(): string { - return this._consoleLogs.join('\n'); - } - - /** - * Start element inspection mode. Sets up a CDP overlay that highlights elements - * on hover. When the user clicks, the element data is returned and the overlay is removed. - * @param token Cancellation token to abort the inspection. - */ - async getElementData(token: CancellationToken): Promise { - return this._inspector.getElementData(token); - } - - /** - * Get element data for the currently focused element. - */ - async getFocusedElementData(): Promise { - return this._inspector.getFocusedElementData(); - } - - /** - * Load a URL in this view - */ - async loadURL(url: string): Promise { - await this._view.webContents.loadURL(url); - } - - /** - * Get the current URL - */ - getURL(): string { - return this._view.webContents.getURL(); - } - - /** - * Navigate back in history - */ - goBack(): void { - if (this._view.webContents.navigationHistory.canGoBack()) { - this._view.webContents.navigationHistory.goBack(); - } - } - - /** - * Navigate forward in history - */ - goForward(): void { - if (this._view.webContents.navigationHistory.canGoForward()) { - this._view.webContents.navigationHistory.goForward(); - } - } - - /** - * Reload the current page - */ - reload(hard?: boolean): void { - if (hard) { - this._view.webContents.reloadIgnoringCache(); - } else { - this._view.webContents.reload(); - } - } - - /** - * Check if the view can navigate back - */ - canGoBack(): boolean { - return this._view.webContents.navigationHistory.canGoBack(); - } - - /** - * Check if the view can navigate forward - */ - canGoForward(): boolean { - return this._view.webContents.navigationHistory.canGoForward(); - } - - /** - * Capture a screenshot of this view - */ - async captureScreenshot(options?: IBrowserViewCaptureScreenshotOptions): Promise { - if (!this._view.getVisible()) { - // This ensures the webContents rendering pipeline is ready so background tabs can be captured too. - this._view.setVisible(true); - this._view.setVisible(false); - } - - const quality = options?.quality ?? 80; - if (options?.pageRect) { - const zoomFactor = this._view.webContents.getZoomFactor(); - // The visual viewport scale accounts for pinch-to-zoom magnification, which is separate from the regular zoom factor. - const visualViewportScale = await this._inspector.getVisualViewportScale(); - options.screenRect = { - x: options.pageRect.x * visualViewportScale * zoomFactor, - y: options.pageRect.y * visualViewportScale * zoomFactor, - width: options.pageRect.width * visualViewportScale * zoomFactor, - height: options.pageRect.height * visualViewportScale * zoomFactor - }; - } - const image = await this._view.webContents.capturePage(options?.screenRect, { - stayHidden: true - }); - const buffer = image.toJPEG(quality); - const screenshot = VSBuffer.wrap(buffer); - // Only update _lastScreenshot if capturing the full view - if (!options?.screenRect) { - this._lastScreenshot = screenshot; - } - return screenshot; - } - - /** - * Focus this view - */ - async focus(force?: boolean): Promise { - // By default, only focus the view if its window is already focused. - if (!force && !this._currentWindow?.win?.isFocused()) { - return; - } - this._view.webContents.focus(); - } - - /** - * Find text in the page - */ - async findInPage(text: string, options?: IBrowserViewFindInPageOptions): Promise { - this._view.webContents.findInPage(text, { - matchCase: options?.matchCase ?? false, - forward: options?.forward ?? true, - - // `findNext` is not very clearly named. From Electron docs: `Whether to begin a new text finding session with this request`. - // It needs to be set to `true` if we want a new search to be performed, such as when the text changes. - // We name it `recompute` in our internal options to better reflect its purpose / behavior. - findNext: options?.recompute ?? false - }); - } - - /** - * Stop finding in page - */ - async stopFindInPage(keepSelection?: boolean): Promise { - this._view.webContents.stopFindInPage(keepSelection ? 'keepSelection' : 'clearSelection'); - } - - /** - * Get the currently selected text in the browser view. - * Returns immediately with empty string if the page is still loading. - */ - async getSelectedText(): Promise { - // we don't want to wait for the page to finish loading, which executeJavaScript normally does. - if (this._view.webContents.isLoading()) { - return ''; - } - try { - // Uses our preloaded contextBridge-exposed API. - return await this._view.webContents.executeJavaScriptInIsolatedWorld(browserViewIsolatedWorldId, [{ code: 'window.browserViewAPI?.getSelectedText?.() ?? ""' }]); - } catch { - return ''; - } - } - - /** - * Clear all storage data for this browser view's session - */ - async clearStorage(): Promise { - await this.session.clearData(); - } - - /** - * Trust a certificate for a given host and reload the page. - */ - async trustCertificate(host: string, fingerprint: string): Promise { - await this.session.trust.trustCertificate(host, fingerprint); - this._view.webContents.reload(); - } - - /** - * Revoke trust for a previously trusted certificate and close the view. - */ - async untrustCertificate(host: string, fingerprint: string): Promise { - await this.session.trust.untrustCertificate(host, fingerprint); - this.dispose(); - } - - /** - * Get the underlying WebContentsView - */ - getWebContentsView(): WebContentsView { - return this._view; - } - - /** - * Get the hosting Electron window for this view, if any. - * This can be an auxiliary window, depending on where the view is currently hosted. - */ - getElectronWindow(): Electron.BrowserWindow | undefined { - return this._currentWindow?.win ?? undefined; - } - - override dispose(): void { - if (this._isDisposed) { - return; - } - this._isDisposed = true; - - // Dispose debugger. This detaches debug sessions first. - this.debugger.dispose(); - - // Remove from parent window (guard against already-destroyed window) - const currentWin = this._currentWindow?.win; - if (currentWin && !currentWin.isDestroyed()) { - currentWin.contentView.removeChildView(this._view); - } - - // Fire close event BEFORE disposing emitters. This signals the view has been destroyed. - this._onDidClose.fire(); - - // Clean up the view and all its event listeners - if (!this._view.webContents.isDestroyed()) { - this._view.webContents.close({ waitForBeforeUnload: false }); - } - - super.dispose(); - } - - private _windowById(windowId: number | undefined): ICodeWindow | IAuxiliaryWindow | undefined { - return this._codeWindowById(windowId) ?? this._auxiliaryWindowById(windowId); - } - - private _codeWindowById(windowId: number | undefined): ICodeWindow | undefined { - if (typeof windowId !== 'number') { - return undefined; - } - - return this.windowsMainService.getWindowById(windowId); - } - - private _auxiliaryWindowById(windowId: number | undefined): IAuxiliaryWindow | undefined { - if (typeof windowId !== 'number') { - return undefined; - } - - const contents = webContents.fromId(windowId); - if (!contents) { - return undefined; - } - - return this.auxiliaryWindowsMainService.getWindowByWebContents(contents); - } -} diff --git a/src/vs/platform/browserView/electron-main/browserViewCDPTarget.ts b/src/vs/platform/browserView/electron-main/browserViewCDPTarget.ts deleted file mode 100644 index 42653e9a..00000000 --- a/src/vs/platform/browserView/electron-main/browserViewCDPTarget.ts +++ /dev/null @@ -1,108 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { Emitter } from '../../../base/common/event.js'; -import { Disposable } from '../../../base/common/lifecycle.js'; -import { CDPTargetInfo, ICDPConnection, ICDPTarget } from '../common/cdp/types.js'; -import type { BrowserView } from './browserView.js'; - -/** - * Wraps a {@link BrowserViewDebugger} transport as an {@link ICDPTarget}, - * tracking sessions and forwarding target-info changes for a single - * CDP target (page, worker, iframe, etc.). - */ -export class BrowserViewCDPTarget extends Disposable implements ICDPTarget { - protected readonly _sessions = new Map(); - get sessions(): ReadonlyMap { return this._sessions; } - - private readonly _onSessionCreated = this._register(new Emitter<{ session: ICDPConnection; waitingForDebugger: boolean }>()); - readonly onSessionCreated = this._onSessionCreated.event; - - private readonly _onClose = this._register(new Emitter()); - readonly onClose = this._onClose.event; - - private readonly _onTargetInfoChanged = this._register(new Emitter()); - readonly onTargetInfoChanged = this._onTargetInfoChanged.event; - - private _isDisposed = false; - - constructor( - readonly view: BrowserView, - protected readonly _targetInfo: CDPTargetInfo - ) { - super(); - - this._register(this.view.debugger.onTargetInfoChanged(info => { - if (info.targetId !== this._targetInfo.targetId) { - return; - } - - if (info.title !== this._targetInfo.title || info.url !== this._targetInfo.url) { - this._targetInfo.title = info.title; - this._targetInfo.url = info.url; - this._onTargetInfoChanged.fire(this.targetInfo); - } - })); - - this._register(this.view.debugger.onTargetDestroyed(targetId => { - if (targetId === this._targetInfo.targetId) { - this.dispose(); - } - })); - } - - get targetInfo(): CDPTargetInfo { - return { - ...this._targetInfo, - attached: this._sessions.size > 0, - browserContextId: this.view.session.id - }; - } - - async attach(): Promise { - const session = await this.view.debugger.attachToTarget(this.targetInfo.targetId); - this.notifySessionCreated(session, false); - return session; - } - - notifySessionCreated(session: ICDPConnection, waitingForDebugger: boolean): void { - if (this._sessions.has(session.sessionId)) { - return; - } - if (this.sessions.size === 0) { - // First session attached, update target info to reflect attached state. - this._onTargetInfoChanged.fire(this.targetInfo); - } - - this._sessions.set(session.sessionId, session); - session.onClose(() => { - this._sessions.delete(session.sessionId); - if (this.sessions.size === 0) { - // Last session detached, update target info to reflect detached state. - this._onTargetInfoChanged.fire(this.targetInfo); - } - }); - - this._onSessionCreated.fire({ session, waitingForDebugger }); - } - - override dispose(): void { - if (this._isDisposed) { - return; - } - this._isDisposed = true; - - // Dispose owned sessions. - for (const [, session] of this._sessions) { - session.dispose(); - } - this._sessions.clear(); - - // Signal target closure. - this._onClose.fire(); - - super.dispose(); - } -} diff --git a/src/vs/platform/browserView/electron-main/browserViewDebugger.ts b/src/vs/platform/browserView/electron-main/browserViewDebugger.ts deleted file mode 100644 index 413d2eac..00000000 --- a/src/vs/platform/browserView/electron-main/browserViewDebugger.ts +++ /dev/null @@ -1,274 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { Emitter } from '../../../base/common/event.js'; -import { Disposable, DisposableMap } from '../../../base/common/lifecycle.js'; -import { ILogService } from '../../log/common/log.js'; -import { CDPEvent, CDPTargetInfo, ICDPConnection } from '../common/cdp/types.js'; -import { BrowserView } from './browserView.js'; - -/** - * CDP transport for a browser view, backed by the Electron debugger. - * - * Manages: - * - Electron debugger lifecycle (attach/detach) - * - Session registry and event routing - * - Auto-attach via `Target.setAutoAttach` (flatten=true) - * - * All CDP sessions on this WebContents (root + child sessions) live in a - * single flat registry here. Target-level abstractions (sub-target - * discovery, {@link ICDPTarget} contract) are handled by - * {@link BrowserViewCDPTarget} which wraps this class. - */ -export class BrowserViewDebugger extends Disposable { - - private readonly _sessions = this._register(new DisposableMap()); - private readonly _onSessionCreated = this._register(new Emitter<{ session: ICDPConnection; waitingForDebugger: boolean }>()); - readonly onSessionCreated = this._onSessionCreated.event; - - /** - * Target IDs discovered via `Target.attachedToTarget`. Consumed by - * {@link BrowserViewCDPTarget} to create sub-target handles. - */ - private readonly _knownTargets = new Map(); - get knownTargets(): ReadonlyMap { return this._knownTargets; } - - private readonly _onTargetDiscovered = this._register(new Emitter()); - /** Fired when a new targetId is seen in an attachedToTarget event. */ - readonly onTargetDiscovered = this._onTargetDiscovered.event; - - private readonly _onTargetDestroyed = this._register(new Emitter()); - /** Fired when a targetId is removed via a targetDestroyed event. */ - readonly onTargetDestroyed = this._onTargetDestroyed.event; - - private readonly _onTargetInfoChanged = this._register(new Emitter()); - /** Fired when targetInfo for a known target changes (e.g. title/url update). */ - readonly onTargetInfoChanged = this._onTargetInfoChanged.event; - - /** Whether any attached debugger session has paused JavaScript execution. */ - private _isPaused = false; - get isPaused(): boolean { return this._isPaused; } - - private readonly _messageHandler: (event: Electron.Event, method: string, params: unknown, sessionId?: string) => void; - private readonly _electronDebugger: Electron.Debugger; - private _targetId: string | undefined; - - constructor( - private readonly view: BrowserView, - readonly logService: ILogService - ) { - super(); - - this._electronDebugger = view.webContents.debugger; - - this._messageHandler = (_event: Electron.Event, method: string, params: unknown, sessionId?: string) => { - this.routeCDPEvent(method, params, sessionId); - }; - } - - /** - * Attach to this debugger. - * Attach to a target by its targetId, returning the session. - * Works for both the root page and sub-targets. - */ - async attach(): Promise { - if (!this._targetId) { - const targetInfo = await this.getTargetInfo(); - this._targetId = targetInfo.targetId; - } - return this.attachToTarget(this._targetId); - } - - async attachToTarget(targetId: string): Promise { - this.ensureAttached(); - const result = await this._electronDebugger.sendCommand('Target.attachToTarget', { - targetId, - flatten: true - }) as { sessionId: string }; - - if (!this._sessions.has(result.sessionId)) { - throw new Error(`Failed to attach to target ${targetId}`); - } - - return this._sessions.get(result.sessionId)!; - } - - async getTargetInfo(): Promise { - this.ensureAttached(); - const result = await this._electronDebugger.sendCommand('Target.getTargetInfo') as { targetInfo: CDPTargetInfo }; - return result.targetInfo; - } - - /** - * Send a CDP command. Handles Electron-specific workarounds in a single place. - */ - sendCommand(method: string, params?: unknown, sessionId?: string): Promise { - // This crashes Electron. Don't pass it through. - if (method === 'Emulation.setDeviceMetricsOverride') { - return Promise.resolve({}); - } - - this.ensureAttached(); - const resultPromise = this._electronDebugger.sendCommand(method, params, sessionId); - - // Electron overrides dialog behavior — manually dismiss open dialogs. - if (method === 'Page.handleJavaScriptDialog') { - this.view.webContents.emit('-cancel-dialogs'); - } - - return resultPromise; - } - - private ensureAttached(): void { - if (this._electronDebugger.isAttached()) { - return; - } - - this._electronDebugger.on('message', this._messageHandler); - this._electronDebugger.attach('1.3'); - - // We use auto-attach to discover descendent targets. - // Regular target discovery doesn't provide ancestor information for workers, - // And we have to filter to avoid including targets from other pages or VS Code internals. - void this._electronDebugger.sendCommand('Target.setAutoAttach', { - autoAttach: true, - flatten: true, - waitForDebuggerOnStart: false - }); - // We still set discoverTargets so we get target info updates. - void this._electronDebugger.sendCommand('Target.setDiscoverTargets', { - discover: true - }); - } - - private detachElectronDebugger(): void { - try { - if (this.view.webContents.isDestroyed() || !this._electronDebugger.isAttached()) { - return; - } - - this._electronDebugger.removeListener('message', this._messageHandler); - this._electronDebugger.detach(); - } catch { - // WebContents may already be destroyed or in an inconsistent state - } - } - - /** - * Route a CDP event from the Electron debugger. - */ - private routeCDPEvent(method: string, params: unknown, sessionId?: string): void { - if (method === 'Target.attachedToTarget') { - const p = params as { sessionId: string; targetInfo: CDPTargetInfo; waitingForDebugger: boolean }; - this.registerSession(p.sessionId, p.targetInfo, p.waitingForDebugger, sessionId); - } else if (method === 'Target.detachedFromTarget') { - const p = params as { sessionId: string }; - this._sessions.deleteAndDispose(p.sessionId); - } else if (method === 'Target.targetDestroyed') { - const p = params as { targetId: string }; - this.destroyTarget(p.targetId); - } else if (method === 'Target.targetInfoChanged' && !sessionId) { - const p = params as { targetInfo: CDPTargetInfo }; - if (this._knownTargets.has(p.targetInfo.targetId)) { - this._knownTargets.set(p.targetInfo.targetId, p.targetInfo); - this._onTargetInfoChanged.fire(p.targetInfo); - } - } else if (method === 'Debugger.paused') { - this._isPaused = true; - } else if (method === 'Debugger.resumed') { - this._isPaused = false; - } - - const session = sessionId ? this._sessions.get(sessionId) : undefined; - if (session) { - session.emitEvent({ method, params, sessionId }); - } - } - - /** - * A target was destroyed by the Electron debugger. - * Dispose all sessions belonging to that target before firing the - * lifecycle event so that listeners never observe stale sessions. - */ - private destroyTarget(targetId: string): void { - const toDispose: string[] = []; - for (const [sessionId, session] of this._sessions) { - if (session.targetId === targetId) { - toDispose.push(sessionId); - } - } - for (const sessionId of toDispose) { - this._sessions.deleteAndDispose(sessionId); - } - - if (this._knownTargets.delete(targetId)) { - this._onTargetDestroyed.fire(targetId); - } - } - - private registerSession(sessionId: string, targetInfo: CDPTargetInfo, waitingForDebugger: boolean, parentSessionId: string | undefined): DebugSession { - if (!this._knownTargets.has(targetInfo.targetId) && targetInfo.targetId !== this._targetId) { - this._knownTargets.set(targetInfo.targetId, targetInfo); - this._onTargetDiscovered.fire(targetInfo); - } - - if (this._sessions.has(sessionId)) { - return this._sessions.get(sessionId)!; - } - - const session = new DebugSession(parentSessionId, sessionId, targetInfo.targetId, this); - this._sessions.set(sessionId, session); - session.onClose(() => this._sessions.deleteAndDispose(sessionId)); - - this._onSessionCreated.fire({ session, waitingForDebugger }); - - return session; - } - - override dispose(): void { - this.detachElectronDebugger(); - super.dispose(); - } -} - -/** - * A CDP session backed by the Electron debugger. - * - * Pure plumbing — holds a sessionId, emits events, and delegates - * commands to the root {@link BrowserViewDebugger}. - */ -class DebugSession extends Disposable implements ICDPConnection { - private readonly _onEvent = this._register(new Emitter()); - readonly onEvent = this._onEvent.event; - readonly emitEvent = (event: CDPEvent) => this._onEvent.fire(event); - - private readonly _onClose = this._register(new Emitter()); - readonly onClose = this._onClose.event; - - private _isDisposed = false; - - constructor( - public readonly parentSessionId: string | undefined, - public readonly sessionId: string, - public readonly targetId: string, - private readonly _debugger: BrowserViewDebugger, - ) { - super(); - } - - async sendCommand(method: string, params?: unknown): Promise { - return this._debugger.sendCommand(method, params, this.sessionId); - } - - override dispose(): void { - if (this._isDisposed) { - return; - } - this._isDisposed = true; - - this._onClose.fire(); - super.dispose(); - } -} diff --git a/src/vs/platform/browserView/electron-main/browserViewElementInspector.ts b/src/vs/platform/browserView/electron-main/browserViewElementInspector.ts deleted file mode 100644 index 8e8f9e85..00000000 --- a/src/vs/platform/browserView/electron-main/browserViewElementInspector.ts +++ /dev/null @@ -1,423 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { CancellationToken } from '../../../base/common/cancellation.js'; -import { Disposable, DisposableStore } from '../../../base/common/lifecycle.js'; -import { IElementData, IElementAncestor } from '../common/browserView.js'; -import { ICDPConnection } from '../common/cdp/types.js'; -import type { BrowserView } from './browserView.js'; - -type Quad = [number, number, number, number, number, number, number, number]; - -interface IBoxModel { - content: Quad; - padding: Quad; - border: Quad; - margin: Quad; - width: number; - height: number; -} - -interface ICSSStyle { - cssText?: string; - cssProperties: Array<{ name: string; value: string }>; -} - -interface ISelectorList { - selectors: Array<{ text: string }>; -} - -interface ICSSRule { - selectorList: ISelectorList; - origin: string; - style: ICSSStyle; -} - -interface IRuleMatch { - rule: ICSSRule; -} - -interface IInheritedStyleEntry { - inlineStyle?: ICSSStyle; - matchedCSSRules: IRuleMatch[]; -} - -interface IMatchedStyles { - inlineStyle?: ICSSStyle; - matchedCSSRules?: IRuleMatch[]; - inherited?: IInheritedStyleEntry[]; -} - -interface INode { - nodeId: number; - backendNodeId: number; - parentId?: number; - localName: string; - attributes: string[]; - children?: INode[]; - pseudoElements?: INode[]; -} - -interface ILayoutMetricsResult { - cssVisualViewport?: { - scale?: number; - }; -} - -function useScopedDisposal() { - const store = new DisposableStore() as DisposableStore & { [Symbol.dispose](): void }; - store[Symbol.dispose] = () => store.dispose(); - return store; -} - -/** - * Manages element inspection on a browser view. - * - * Attaches a persistent CDP session in the constructor; methods wait for - * it to be ready before issuing commands. - */ -export class BrowserViewElementInspector extends Disposable { - - private readonly _connectionPromise: Promise; - - constructor(private readonly browser: BrowserView) { - super(); - - this._connectionPromise = browser.debugger.attach().then( - async conn => { - try { - // Important: don't use `Runtime.*` commands so we can support inspection during debugging. - // We also initialize here rather than during selection as CSS.enable will hang if debugging is paused, but works if enabled beforehand. - await conn.sendCommand('DOM.enable'); - await conn.sendCommand('Overlay.enable'); - await conn.sendCommand('CSS.enable'); - - if (this._store.isDisposed) { - conn.dispose(); - throw new Error('Inspector disposed before connection was ready'); - } - this._register(conn); - return conn; - } catch (error) { - conn.dispose(); - throw error; - } - } - ); - } - - /** - * Start element inspection mode on the browser view. Sets up an - * overlay that highlights elements on hover. When the user clicks, the - * element data is returned and the overlay is removed. - * - * @param token Cancellation token to abort the inspection. - */ - async getElementData(token: CancellationToken): Promise { - const connection = await this._connectionPromise; - const store = new DisposableStore(); - const result = new Promise((resolve, reject) => { - store.add(token.onCancellationRequested(() => { - resolve(undefined); - })); - - store.add(connection.onEvent(async (event) => { - if (event.method !== 'Overlay.inspectNodeRequested') { - return; - } - - const params = event.params as { backendNodeId: number }; - if (!params?.backendNodeId) { - reject(new Error('Missing backendNodeId in inspectNodeRequested event')); - return; - } - - try { - const nodeData = await extractNodeData(connection, { backendNodeId: params.backendNodeId }); - resolve({ - ...nodeData, - url: this.browser.getURL() - }); - } catch (err) { - reject(err); - } - })); - }); - - try { - await connection.sendCommand('Overlay.setInspectMode', { - mode: 'searchForNode', - highlightConfig: inspectHighlightConfig, - }); - return await result; - } finally { - try { - await connection.sendCommand('Overlay.setInspectMode', { - mode: 'none', - highlightConfig: { showInfo: false, showStyles: false } - }); - await connection.sendCommand('Overlay.hideHighlight'); - } catch { - // Best effort cleanup - } - store.dispose(); - } - } - - /** - * Get element data for the currently focused element. - */ - async getFocusedElementData(): Promise { - const connection = await this._connectionPromise; - - await connection.sendCommand('Runtime.enable'); - const { result } = await connection.sendCommand('Runtime.evaluate', { - expression: 'document.activeElement', - returnByValue: false, - }) as { result: { objectId?: string } }; - - if (!result?.objectId) { - return undefined; - } - - const nodeData = await extractNodeData(connection, { objectId: result.objectId }); - return { - ...nodeData, - url: this.browser.getURL() - }; - } - - async getVisualViewportScale(): Promise { - try { - const connection = await this._connectionPromise; - const result = await connection.sendCommand('Page.getLayoutMetrics') as ILayoutMetricsResult; - if (typeof result.cssVisualViewport?.scale === 'number') { - const scale = Number(result.cssVisualViewport.scale); - if (Number.isFinite(scale) && scale > 0) { - return scale; - } - } - } catch { - // Ignore execution errors while loading and use defaults. - } - - return 1; - } -} - -async function extractNodeData(connection: ICDPConnection, id: { backendNodeId?: number; objectId?: string }): Promise { - using store = useScopedDisposal(); - - const discoveredNodesByNodeId: Record = {}; - store.add(connection.onEvent(event => { - if (event.method === 'DOM.setChildNodes') { - const { nodes } = event.params as { nodes: INode[] }; - for (const node of nodes) { - discoveredNodesByNodeId[node.nodeId] = node; - if (node.children) { - for (const child of node.children) { - discoveredNodesByNodeId[child.nodeId] = { - ...child, - parentId: node.nodeId - }; - } - } - if (node.pseudoElements) { - for (const pseudo of node.pseudoElements) { - discoveredNodesByNodeId[pseudo.nodeId] = { - ...pseudo, - parentId: node.nodeId - }; - } - } - } - } - })); - - await connection.sendCommand('DOM.getDocument'); - - const { node } = await connection.sendCommand('DOM.describeNode', id) as { node: INode }; - if (!node) { - throw new Error('Failed to describe node.'); - } - let nodeId = node.nodeId; - if (!nodeId) { - const { nodeIds } = await connection.sendCommand('DOM.pushNodesByBackendIdsToFrontend', { backendNodeIds: [node.backendNodeId] }) as { nodeIds: number[] }; - if (!nodeIds?.length) { - throw new Error('Failed to get node ID.'); - } - nodeId = nodeIds[0]; - } - - const { model } = await connection.sendCommand('DOM.getBoxModel', { nodeId }) as { model: IBoxModel }; - if (!model) { - throw new Error('Failed to get box model.'); - } - - const content = model.content; - const margin = model.margin; - const x = Math.min(margin[0], content[0]); - const y = Math.min(margin[1], content[1]); - const width = Math.max(margin[2] - margin[0], content[2] - content[0]); - const height = Math.max(margin[5] - margin[1], content[5] - content[1]); - - const matched = await connection.sendCommand('CSS.getMatchedStylesForNode', { nodeId }); - if (!matched) { - throw new Error('Failed to get matched css.'); - } - - const computedStyle = formatMatchedStyles(matched as IMatchedStyles); - const { outerHTML } = await connection.sendCommand('DOM.getOuterHTML', { nodeId }) as { outerHTML: string }; - if (!outerHTML) { - throw new Error('Failed to get outerHTML.'); - } - - const attributes = attributeArrayToRecord(node.attributes); - - const ancestors: IElementAncestor[] = []; - let currentNode: INode | undefined = discoveredNodesByNodeId[nodeId] ?? node; - while (currentNode) { - const attributes = attributeArrayToRecord(currentNode.attributes); - ancestors.unshift({ - tagName: currentNode.localName, - id: attributes.id, - classNames: attributes.class?.trim().split(/\s+/).filter(Boolean) - }); - currentNode = currentNode.parentId ? discoveredNodesByNodeId[currentNode.parentId] : undefined; - } - - let computedStyles: Record | undefined; - try { - const { computedStyle: computedStyleArray } = await connection.sendCommand('CSS.getComputedStyleForNode', { nodeId }) as { computedStyle?: Array<{ name: string; value: string }> }; - if (computedStyleArray) { - computedStyles = {}; - for (const prop of computedStyleArray) { - if (prop.name && typeof prop.value === 'string') { - computedStyles[prop.name] = prop.value; - } - } - } - } catch { } - - return { - outerHTML, - computedStyle, - bounds: { x, y, width, height }, - ancestors, - attributes, - computedStyles, - dimensions: { top: y, left: x, width, height } - }; -} - -function formatMatchedStyles(matched: IMatchedStyles): string { - const lines: string[] = []; - - if (matched.inlineStyle?.cssProperties?.length) { - lines.push('/* Inline style */'); - lines.push('element {'); - for (const prop of matched.inlineStyle.cssProperties) { - if (prop.name && prop.value) { - lines.push(` ${prop.name}: ${prop.value};`); - } - } - lines.push('}\n'); - } - - if (matched.matchedCSSRules?.length) { - for (const ruleEntry of matched.matchedCSSRules) { - const rule = ruleEntry.rule; - const selectors = rule.selectorList.selectors.map((s: { text: string }) => s.text).join(', '); - lines.push(`/* Matched Rule from ${rule.origin} */`); - lines.push(`${selectors} {`); - for (const prop of rule.style.cssProperties) { - if (prop.name && prop.value) { - lines.push(` ${prop.name}: ${prop.value};`); - } - } - lines.push('}\n'); - } - } - - if (matched.inherited?.length) { - let level = 1; - for (const inherited of matched.inherited) { - if (inherited.inlineStyle) { - lines.push(`/* Inherited from ancestor level ${level} (inline) */`); - lines.push('element {'); - lines.push(inherited.inlineStyle.cssText || ''); - lines.push('}\n'); - } - - const rules = inherited.matchedCSSRules || []; - for (const ruleEntry of rules) { - const rule = ruleEntry.rule; - const selectors = rule.selectorList.selectors.map((s: { text: string }) => s.text).join(', '); - lines.push(`/* Inherited from ancestor level ${level} (${rule.origin}) */`); - lines.push(`${selectors} {`); - for (const prop of rule.style.cssProperties) { - if (prop.name && prop.value) { - lines.push(` ${prop.name}: ${prop.value};`); - } - } - lines.push('}\n'); - } - level++; - } - } - - return '\n' + lines.join('\n'); -} - -function attributeArrayToRecord(attributes: string[]): Record { - const record: Record = {}; - for (let i = 0; i < attributes.length; i += 2) { - const name = attributes[i]; - const value = attributes[i + 1]; - record[name] = value; - } - return record; -} - -/** Slightly customised CDP debugger inspect highlight colours. */ -const inspectHighlightConfig = { - showInfo: true, - showRulers: false, - showStyles: true, - showAccessibilityInfo: true, - showExtensionLines: false, - contrastAlgorithm: 'aa', - contentColor: { r: 173, g: 216, b: 255, a: 0.8 }, - paddingColor: { r: 150, g: 200, b: 255, a: 0.5 }, - borderColor: { r: 120, g: 180, b: 255, a: 0.7 }, - marginColor: { r: 200, g: 220, b: 255, a: 0.4 }, - eventTargetColor: { r: 130, g: 160, b: 255, a: 0.8 }, - shapeColor: { r: 130, g: 160, b: 255, a: 0.8 }, - shapeMarginColor: { r: 130, g: 160, b: 255, a: 0.5 }, - gridHighlightConfig: { - rowGapColor: { r: 140, g: 190, b: 255, a: 0.3 }, - rowHatchColor: { r: 140, g: 190, b: 255, a: 0.7 }, - columnGapColor: { r: 140, g: 190, b: 255, a: 0.3 }, - columnHatchColor: { r: 140, g: 190, b: 255, a: 0.7 }, - rowLineColor: { r: 120, g: 180, b: 255 }, - columnLineColor: { r: 120, g: 180, b: 255 }, - rowLineDash: true, - columnLineDash: true - }, - flexContainerHighlightConfig: { - containerBorder: { color: { r: 120, g: 180, b: 255 }, pattern: 'solid' }, - itemSeparator: { color: { r: 140, g: 190, b: 255 }, pattern: 'solid' }, - lineSeparator: { color: { r: 140, g: 190, b: 255 }, pattern: 'solid' }, - mainDistributedSpace: { hatchColor: { r: 140, g: 190, b: 255, a: 0.7 }, fillColor: { r: 140, g: 190, b: 255, a: 0.4 } }, - crossDistributedSpace: { hatchColor: { r: 140, g: 190, b: 255, a: 0.7 }, fillColor: { r: 140, g: 190, b: 255, a: 0.4 } }, - rowGapSpace: { hatchColor: { r: 140, g: 190, b: 255, a: 0.7 }, fillColor: { r: 140, g: 190, b: 255, a: 0.4 } }, - columnGapSpace: { hatchColor: { r: 140, g: 190, b: 255, a: 0.7 }, fillColor: { r: 140, g: 190, b: 255, a: 0.4 } }, - }, - flexItemHighlightConfig: { - baseSizeBox: { hatchColor: { r: 130, g: 170, b: 255, a: 0.6 } }, - baseSizeBorder: { color: { r: 120, g: 180, b: 255 }, pattern: 'solid' }, - flexibilityArrow: { color: { r: 130, g: 190, b: 255 } } - }, -}; diff --git a/src/vs/platform/browserView/electron-main/browserViewGroup.ts b/src/vs/platform/browserView/electron-main/browserViewGroup.ts deleted file mode 100644 index 1f016fee..00000000 --- a/src/vs/platform/browserView/electron-main/browserViewGroup.ts +++ /dev/null @@ -1,274 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { Disposable, DisposableMap, DisposableStore } from '../../../base/common/lifecycle.js'; -import { Emitter, Event } from '../../../base/common/event.js'; -import { BrowserView } from './browserView.js'; -import { ICDPTarget, CDPBrowserVersion, CDPWindowBounds, CDPTargetInfo, ICDPConnection, ICDPBrowserTarget, CDPRequest, CDPResponse, CDPEvent } from '../common/cdp/types.js'; -import { CDPBrowserProxy } from '../common/cdp/proxy.js'; -import { IBrowserViewGroup, IBrowserViewGroupViewEvent } from '../common/browserViewGroup.js'; -import { IBrowserViewOwner } from '../common/browserView.js'; -import { IBrowserViewMainService } from './browserViewMainService.js'; -import { IProductService } from '../../product/common/productService.js'; -import { BrowserSession } from './browserSession.js'; -import { generateUuid } from '../../../base/common/uuid.js'; -import { BrowserViewCDPTarget } from './browserViewCDPTarget.js'; - -/** - * An isolated group of {@link BrowserView} instances exposed as CDP targets. - * - * Each group represents an independent CDP "browser" endpoint - * (`/devtools/browser/{id}`). Different groups can expose different - * subsets of browser views, enabling selective target visibility across - * CDP sessions. - * - * Created via {@link BrowserViewGroupMainService.createGroup}. - */ -export class BrowserViewGroup extends Disposable implements ICDPBrowserTarget, IBrowserViewGroup { - - private readonly views = new Map(); - private readonly viewTargets = this._register(new DisposableMap()); - - /** All context IDs known to this group, including those from views added to it. */ - private readonly knownContextIds = new Set(); - /** Browser context IDs created by this group via {@link createBrowserContext}. */ - private readonly ownedContextIds = new Set(); - - private readonly _onDidAddView = this._register(new Emitter()); - readonly onDidAddView: Event = this._onDidAddView.event; - - private readonly _onDidRemoveView = this._register(new Emitter()); - readonly onDidRemoveView: Event = this._onDidRemoveView.event; - - private readonly _onDidDestroy = this._register(new Emitter()); - readonly onDidDestroy: Event = this._onDidDestroy.event; - - readonly debugger = this._register(new CDPBrowserProxy(this)); - - constructor( - readonly id: string, - readonly owner: IBrowserViewOwner, - @IBrowserViewMainService private readonly browserViewMainService: IBrowserViewMainService, - @IProductService private readonly productService: IProductService - ) { - super(); - } - - get onCDPMessage(): Event { - return this.debugger.onMessage; - } - - sendCDPMessage(msg: CDPRequest): Promise { - return this.debugger.sendMessage(msg); - } - - // #region View management - - /** - * Add a {@link BrowserView} to this group. - * Fires {@link onDidAddView} and registers the view as a CDP target. - * Also subscribes to the view's sub-target events (iframes, workers) - * and bubbles them as group-level target events. - * Automatically removes the view when it closes. - */ - async addView(viewId: string): Promise { - if (this.views.has(viewId)) { - return; - } - const view = this.browserViewMainService.tryGetBrowserView(viewId); - if (!view) { - throw new Error(`Browser view ${viewId} not found`); - } - this.views.set(view.id, view); - this.knownContextIds.add(view.session.id); - this._onDidAddView.fire({ viewId: view.id }); - - // Register the close listener before any async work so we never - // miss a close event that fires during the await. - const closeListener = Event.once(view.onDidClose)(() => { - this.removeView(viewId); - }); - - const info = await view.debugger.getTargetInfo(); - - if (this.views.get(viewId) !== view) { - // View was removed while we were awaiting target info - closeListener.dispose(); - return; - } - - // Create a CDP target wrapping the view's debugger transport - const target = new BrowserViewCDPTarget(view, info); - this.viewTargets.set(view.id, target); - - const store = new DisposableStore(); - store.add(closeListener); - target.onClose(() => store.dispose()); - - this.debugger.registerTarget(target); - - // Register sub-targets of the view - for (const targetInfo of view.debugger.knownTargets.values()) { - this.debugger.registerTarget(new BrowserViewCDPTarget(view, targetInfo)); - } - store.add(view.debugger.onTargetDiscovered(targetInfo => { - this.debugger.registerTarget(new BrowserViewCDPTarget(view, targetInfo)); - })); - - // Some sessions won't go through the proxy -- e.g. when auto-attaching to workers. - // So we let the proxy know that the session exists, and it decides whether it cares about it. - store.add(view.debugger.onSessionCreated(({ session, waitingForDebugger }) => { - this.debugger.notifySessionCreated(session, waitingForDebugger); - })); - } - - /** - * Remove a {@link BrowserView} from this group. - * Disposes the associated {@link BrowserViewCDPTarget}, which cascades - * destruction to sub-targets and sessions via {@link ICDPTarget.onClose}. - */ - async removeView(viewId: string): Promise { - const view = this.views.get(viewId); - if (view && this.views.delete(viewId)) { - // If no remaining views belong to the view's context, and we don't own the context, remove it from known contexts - if (!this.ownedContextIds.has(view.session.id) && ![...this.views.values()].some(v => v.session.id === view.session.id)) { - this.knownContextIds.delete(view.session.id); - } - this._onDidRemoveView.fire({ viewId: view.id }); - this.viewTargets.deleteAndDispose(viewId); - } - } - - // #endregion - - // #region ICDPBrowserTarget implementation - - private readonly _onTargetInfoChanged = this._register(new Emitter()); - readonly onTargetInfoChanged = this._onTargetInfoChanged.event; - - getVersion(): CDPBrowserVersion { - return { - protocolVersion: '1.3', - product: `${this.productService.nameShort}/${this.productService.version}`, - revision: this.productService.commit || 'unknown', - userAgent: 'Electron', - jsVersion: process.versions.v8 - }; - } - - getWindowForTarget(target: ICDPTarget): { windowId: number; bounds: CDPWindowBounds } { - if (!(target instanceof BrowserViewCDPTarget)) { - throw new Error('Can only get window for BrowserView targets'); - } - - const view = target.view.getWebContentsView(); - const viewBounds = view.getBounds(); - return { - windowId: this.owner.mainWindowId, - bounds: { - left: viewBounds.x, - top: viewBounds.y, - width: viewBounds.width, - height: viewBounds.height, - windowState: 'normal' - } - }; - } - - async attach(): Promise { - return new CDPBrowserProxy(this); - } - - /** Browser target sessions are managed by the CDPBrowserProxy, not tracked here. */ - readonly sessions: ReadonlyMap = new Map(); - readonly onSessionCreated = Event.None; - readonly onClose: Event = this._onDidDestroy.event; - notifySessionCreated(): void { } - - get targetInfo(): CDPTargetInfo { - return { - targetId: this.id, - type: 'browser', - title: this.getVersion().product, - url: '', - attached: true, - canAccessOpener: false - }; - } - - async createTarget(url: string, browserContextId?: string): Promise { - if (browserContextId && !this.knownContextIds.has(browserContextId)) { - throw new Error(`Unknown browser context ${browserContextId}`); - } - - const target = await this.browserViewMainService.createTarget(url, this.owner, browserContextId); - if (target instanceof BrowserView) { - await this.addView(target.id); - return this.viewTargets.get(target.id)!; - } - return target; - } - - async activateTarget(target: ICDPTarget): Promise { - if (!(target instanceof BrowserViewCDPTarget)) { - throw new Error('Can only activate BrowserView targets'); - } - // TODO@kycutler - } - - async closeTarget(target: ICDPTarget): Promise { - if (!(target instanceof BrowserViewCDPTarget)) { - throw new Error('Can only close BrowserView targets'); - } - - await this.removeView(target.view.id); - await this.browserViewMainService.destroyBrowserView(target.view.id); - return true; - } - - // Browser context management - - /** - * Returns only the browser context IDs that are visible to this group, - * i.e. contexts used by views currently in the group. - */ - getBrowserContexts(): string[] { - return [...this.knownContextIds]; - } - - async createBrowserContext(): Promise { - const browserSession = BrowserSession.getOrCreateEphemeral(generateUuid(), 'cdp-created'); - const contextId = browserSession.id; - this.knownContextIds.add(contextId); - this.ownedContextIds.add(contextId); - return contextId; - } - - async disposeBrowserContext(browserContextId: string): Promise { - if (!this.ownedContextIds.has(browserContextId)) { - throw new Error('Can only dispose browser contexts created by this group'); - } - - // Snapshot IDs to avoid mutating the map while iterating - const viewIds = [...this.views.entries()] - .filter(([, view]) => view.session.id === browserContextId) - .map(([id]) => id); - - for (const viewId of viewIds) { - await this.removeView(viewId); - await this.browserViewMainService.destroyBrowserView(viewId); - } - - this.knownContextIds.delete(browserContextId); - this.ownedContextIds.delete(browserContextId); - } - - // #endregion - - override dispose(): void { - this._onDidDestroy.fire(); - super.dispose(); - } -} diff --git a/src/vs/platform/browserView/electron-main/browserViewGroupMainService.ts b/src/vs/platform/browserView/electron-main/browserViewGroupMainService.ts deleted file mode 100644 index f849d821..00000000 --- a/src/vs/platform/browserView/electron-main/browserViewGroupMainService.ts +++ /dev/null @@ -1,94 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { Disposable, DisposableMap } from '../../../base/common/lifecycle.js'; -import { Event } from '../../../base/common/event.js'; -import { createDecorator, IInstantiationService } from '../../instantiation/common/instantiation.js'; -import { generateUuid } from '../../../base/common/uuid.js'; -import { IBrowserViewGroupService, IBrowserViewGroupViewEvent } from '../common/browserViewGroup.js'; -import { IBrowserViewOwner } from '../common/browserView.js'; -import { BrowserViewGroup } from './browserViewGroup.js'; -import { CDPEvent, CDPRequest, CDPResponse } from '../common/cdp/types.js'; - -export const IBrowserViewGroupMainService = createDecorator('browserViewGroupMainService'); - -export interface IBrowserViewGroupMainService extends IBrowserViewGroupService { - readonly _serviceBrand: undefined; -} - -/** - * Main-process service that manages {@link BrowserViewGroup} instances. - * - * Implements {@link IBrowserViewGroupService} so it can be surfaced to - * the workbench/shared process via {@link ProxyChannel}. - */ -export class BrowserViewGroupMainService extends Disposable implements IBrowserViewGroupMainService { - declare readonly _serviceBrand: undefined; - - private readonly groups = this._register(new DisposableMap()); - - constructor( - @IInstantiationService private readonly instantiationService: IInstantiationService - ) { - super(); - } - - async createGroup(owner: IBrowserViewOwner): Promise { - const id = generateUuid(); - const group = this.instantiationService.createInstance(BrowserViewGroup, id, owner); - this.groups.set(id, group); - - // Auto-cleanup when the group disposes itself - Event.once(group.onDidDestroy)(() => { - this.groups.deleteAndLeak(id); - }); - - return id; - } - - async destroyGroup(groupId: string): Promise { - this.groups.deleteAndDispose(groupId); - } - - async addViewToGroup(groupId: string, viewId: string): Promise { - return this._getGroup(groupId).addView(viewId); - } - - async removeViewFromGroup(groupId: string, viewId: string): Promise { - return this._getGroup(groupId).removeView(viewId); - } - - async sendCDPMessage(groupId: string, message: CDPRequest): Promise { - return this._getGroup(groupId).debugger.sendMessage(message); - } - - onDynamicDidAddView(groupId: string): Event { - return this._getGroup(groupId).onDidAddView; - } - - onDynamicDidRemoveView(groupId: string): Event { - return this._getGroup(groupId).onDidRemoveView; - } - - onDynamicDidDestroy(groupId: string): Event { - return this._getGroup(groupId).onDidDestroy; - } - - onDynamicCDPMessage(groupId: string): Event { - return this._getGroup(groupId).debugger.onMessage; - } - - /** - * Get a group or throw if not found. - */ - private _getGroup(groupId: string): BrowserViewGroup { - const group = this.groups.get(groupId); - if (!group) { - throw new Error(`Browser view group ${groupId} not found`); - } - return group; - } -} - diff --git a/src/vs/platform/browserView/electron-main/browserViewMainService.ts b/src/vs/platform/browserView/electron-main/browserViewMainService.ts deleted file mode 100644 index 4bebe104..00000000 --- a/src/vs/platform/browserView/electron-main/browserViewMainService.ts +++ /dev/null @@ -1,498 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { Emitter, Event } from '../../../base/common/event.js'; -import { Disposable, DisposableMap } from '../../../base/common/lifecycle.js'; -import { VSBuffer } from '../../../base/common/buffer.js'; -import { CancellationToken, CancellationTokenSource } from '../../../base/common/cancellation.js'; -import { IBrowserViewBounds, IBrowserViewState, IBrowserViewService, IBrowserViewCaptureScreenshotOptions, IBrowserViewFindInPageOptions, BrowserViewCommandId, IElementData, IBrowserViewOwner, IBrowserViewInfo, IBrowserViewCreatedEvent, IBrowserViewOpenOptions, IBrowserViewCreateOptions } from '../common/browserView.js'; -import { clipboard, Menu, MenuItem } from 'electron'; -import { IEnvironmentMainService } from '../../environment/electron-main/environmentMainService.js'; -import { createDecorator, IInstantiationService } from '../../instantiation/common/instantiation.js'; -import { BrowserView } from './browserView.js'; -import { generateUuid } from '../../../base/common/uuid.js'; -import { IWindowsMainService } from '../../windows/electron-main/windows.js'; -import { BrowserSession } from './browserSession.js'; -import { IApplicationStorageMainService } from '../../storage/electron-main/storageMainService.js'; -import { IntegratedBrowserOpenSource, logBrowserOpen } from '../common/browserViewTelemetry.js'; -import { ITelemetryService } from '../../telemetry/common/telemetry.js'; -import { localize } from '../../../nls.js'; -import { INativeHostMainService } from '../../native/electron-main/nativeHostMainService.js'; -import { htmlAttributeEncodeValue } from '../../../base/common/strings.js'; - -export const IBrowserViewMainService = createDecorator('browserViewMainService'); - -export interface IBrowserViewMainService extends IBrowserViewService { - readonly _serviceBrand: undefined; - - tryGetBrowserView(id: string): BrowserView | undefined; - - /** Create a new target and return it. */ - createTarget(url: string, owner: IBrowserViewOwner, browserContextId?: string): Promise; -} - -export class BrowserViewMainService extends Disposable implements IBrowserViewMainService { - declare readonly _serviceBrand: undefined; - - /** - * Check if a webContents belongs to an integrated browser view. - * Delegates to {@link BrowserSession.isBrowserViewWebContents}. - */ - static isBrowserViewWebContents(contents: Electron.WebContents): boolean { - return BrowserSession.isBrowserViewWebContents(contents); - } - - private readonly browserViews = this._register(new DisposableMap()); - private readonly _activeTokens = new Map(); - private _keybindings: { [commandId: string]: string } = Object.create(null); - - private readonly _onDidCreateBrowserView = this._register(new Emitter()); - readonly onDidCreateBrowserView: Event = this._onDidCreateBrowserView.event; - - constructor( - @IEnvironmentMainService private readonly environmentMainService: IEnvironmentMainService, - @IInstantiationService private readonly instantiationService: IInstantiationService, - @IWindowsMainService private readonly windowsMainService: IWindowsMainService, - @ITelemetryService private readonly telemetryService: ITelemetryService, - @INativeHostMainService private readonly nativeHostMainService: INativeHostMainService, - @IApplicationStorageMainService private readonly applicationStorageMainService: IApplicationStorageMainService - ) { - super(); - } - - async getOrCreateBrowserView(id: string, options: IBrowserViewCreateOptions): Promise { - if (this.browserViews.has(id)) { - // Note: options will be ignored if the view already exists. - const view = this.browserViews.get(id)!; - return view.getState(); - } - - const ownerWindow = this.windowsMainService.getWindowById(options.owner.mainWindowId); - if (!ownerWindow) { - throw new Error(`Owner window with ID ${options.owner.mainWindowId} not found`); - } - - const browserSession = BrowserSession.getOrCreate( - id, - options.scope, - this.environmentMainService.workspaceStorageHome, - ownerWindow.openedWorkspace?.id - ); - - const view = this.createBrowserView(id, options.owner, browserSession); - - if (options.initialState?.url) { - void view.loadURL(options.initialState.url); - } - - return { - ...view.getState(), - ...options.initialState - }; - } - - tryGetBrowserView(id: string): BrowserView | undefined { - return this.browserViews.get(id); - } - - async createTarget(url: string, owner: IBrowserViewOwner, browserContextId?: string): Promise { - const browserSession = browserContextId ? BrowserSession.get(browserContextId) : undefined; - - return this.openNew(url, { - owner, - session: browserSession, - openOptions: { preserveFocus: true }, - source: 'cdpCreated' - }); - } - - /** - * Get a browser view or throw if not found - */ - private _getBrowserView(id: string): BrowserView { - const view = this.browserViews.get(id); - if (!view) { - throw new Error(`Browser view ${id} not found`); - } - return view; - } - - private _getViewInfo(view: BrowserView): IBrowserViewInfo { - return { - id: view.id, - owner: view.owner, - state: view.getState() - }; - } - - async getBrowserViews(windowId?: number): Promise { - const result: IBrowserViewInfo[] = []; - for (const [, view] of this.browserViews) { - if (windowId !== undefined && view.owner.mainWindowId !== windowId) { - continue; - } - result.push(this._getViewInfo(view)); - } - return result; - } - - onDynamicDidNavigate(id: string) { - return this._getBrowserView(id).onDidNavigate; - } - - onDynamicDidChangeLoadingState(id: string) { - return this._getBrowserView(id).onDidChangeLoadingState; - } - - onDynamicDidChangeFocus(id: string) { - return this._getBrowserView(id).onDidChangeFocus; - } - - onDynamicDidChangeVisibility(id: string) { - return this._getBrowserView(id).onDidChangeVisibility; - } - - onDynamicDidChangeDevToolsState(id: string) { - return this._getBrowserView(id).onDidChangeDevToolsState; - } - - onDynamicDidKeyCommand(id: string) { - return this._getBrowserView(id).onDidKeyCommand; - } - - onDynamicDidChangeTitle(id: string) { - return this._getBrowserView(id).onDidChangeTitle; - } - - onDynamicDidChangeFavicon(id: string) { - return this._getBrowserView(id).onDidChangeFavicon; - } - - onDynamicDidFindInPage(id: string) { - return this._getBrowserView(id).onDidFindInPage; - } - - onDynamicDidClose(id: string) { - return this._getBrowserView(id).onDidClose; - } - - async getState(id: string): Promise { - return this._getBrowserView(id).getState(); - } - - async destroyBrowserView(id: string): Promise { - return this.browserViews.deleteAndDispose(id); - } - - async layout(id: string, bounds: IBrowserViewBounds): Promise { - return this._getBrowserView(id).layout(bounds); - } - - async setVisible(id: string, visible: boolean): Promise { - return this._getBrowserView(id).setVisible(visible); - } - - async loadURL(id: string, url: string): Promise { - return this._getBrowserView(id).loadURL(url); - } - - async getURL(id: string): Promise { - return this._getBrowserView(id).getURL(); - } - - async goBack(id: string): Promise { - return this._getBrowserView(id).goBack(); - } - - async goForward(id: string): Promise { - return this._getBrowserView(id).goForward(); - } - - async reload(id: string, hard?: boolean): Promise { - return this._getBrowserView(id).reload(hard); - } - - async toggleDevTools(id: string): Promise { - return this._getBrowserView(id).toggleDevTools(); - } - - async canGoBack(id: string): Promise { - return this._getBrowserView(id).canGoBack(); - } - - async canGoForward(id: string): Promise { - return this._getBrowserView(id).canGoForward(); - } - - async captureScreenshot(id: string, options?: IBrowserViewCaptureScreenshotOptions): Promise { - return this._getBrowserView(id).captureScreenshot(options); - } - - async focus(id: string, force?: boolean): Promise { - return this._getBrowserView(id).focus(force); - } - - async findInPage(id: string, text: string, options?: IBrowserViewFindInPageOptions): Promise { - return this._getBrowserView(id).findInPage(text, options); - } - - async stopFindInPage(id: string, keepSelection?: boolean): Promise { - return this._getBrowserView(id).stopFindInPage(keepSelection); - } - - async getSelectedText(id: string): Promise { - return this._getBrowserView(id).getSelectedText(); - } - - async clearStorage(id: string): Promise { - return this._getBrowserView(id).clearStorage(); - } - - async setBrowserZoomIndex(id: string, zoomIndex: number): Promise { - return this._getBrowserView(id).setBrowserZoomIndex(zoomIndex); - } - - async trustCertificate(id: string, host: string, fingerprint: string): Promise { - return this._getBrowserView(id).trustCertificate(host, fingerprint); - } - - async untrustCertificate(id: string, host: string, fingerprint: string): Promise { - return this._getBrowserView(id).untrustCertificate(host, fingerprint); - } - - async clearGlobalStorage(): Promise { - const browserSession = BrowserSession.getOrCreateGlobal(); - browserSession.connectStorage(this.applicationStorageMainService); - await browserSession.clearData(); - } - - async clearWorkspaceStorage(workspaceId: string): Promise { - const browserSession = BrowserSession.getOrCreateWorkspace( - workspaceId, - this.environmentMainService.workspaceStorageHome - ); - browserSession.connectStorage(this.applicationStorageMainService); - await browserSession.clearData(); - } - - async getConsoleLogs(id: string): Promise { - return this._getBrowserView(id).getConsoleLogs(); - } - - async getElementData(id: string, cancellationId: number): Promise { - return this._makeCancellable(cancellationId, (token) => this._getBrowserView(id).getElementData(token)); - } - - async getFocusedElementData(id: string): Promise { - return this._getBrowserView(id).getFocusedElementData(); - } - - async cancel(cancellationId: number): Promise { - this._activeTokens.get(cancellationId)?.cancel(); - } - - async updateKeybindings(keybindings: { [commandId: string]: string }): Promise { - this._keybindings = keybindings; - } - - private async _makeCancellable(cancellationId: number, callback: (token: CancellationToken) => T | Promise): Promise { - const cts: CancellationTokenSource = new CancellationTokenSource(); - this._activeTokens.set(cancellationId, cts); - try { - return await callback(cts.token); - } finally { - this._activeTokens.delete(cancellationId); - cts.dispose(); - } - } - - /** - * Create a browser view backed by the given {@link BrowserSession}. - */ - private createBrowserView(id: string, owner: IBrowserViewOwner, browserSession: BrowserSession, options?: Electron.WebContentsViewConstructorOptions): BrowserView { - if (this.browserViews.has(id)) { - throw new Error(`Browser view with id ${id} already exists`); - } - - browserSession.connectStorage(this.applicationStorageMainService); - - const view = this.instantiationService.createInstance( - BrowserView, - id, - owner, - browserSession, - // Recursive factory for nested windows (child views share the same session and owner). - (url, electronOptions, openOptions) => { - const child = this.createBrowserView(generateUuid(), owner, browserSession, electronOptions); - - if (url) { - void child.loadURL(url).catch(() => { }); - } - - const info = this._getViewInfo(child); - this._onDidCreateBrowserView.fire({ - info: url ? { ...info, state: { ...info.state, url } } : info, - openOptions - }); - - return child; - }, - (v, params) => this.showContextMenu(v, params), - options - ); - this.browserViews.set(id, view); - - Event.once(view.onDidClose)(() => { - this.browserViews.deleteAndDispose(id); - }); - - return view; - } - - private async openNew( - url: string, - { - owner, - session, - openOptions, - source - }: { - owner: IBrowserViewOwner; - session: BrowserSession | undefined; - openOptions: IBrowserViewOpenOptions | undefined; - source: IntegratedBrowserOpenSource; - } - ): Promise { - const targetId = generateUuid(); - const view = this.createBrowserView(targetId, owner, session || BrowserSession.getOrCreateEphemeral(targetId)); - - if (url) { - void view.loadURL(url).catch(() => { }); - } - - logBrowserOpen(this.telemetryService, source); - - // Fire creation event so the workbench can open an editor tab - const info = this._getViewInfo(view); - this._onDidCreateBrowserView.fire({ - info: url ? { ...info, state: { ...info.state, url } } : info, - openOptions - }); - - return view; - } - - private showContextMenu(view: BrowserView, params: Electron.ContextMenuParams): void { - const win = view.getElectronWindow(); - if (!win) { - return; - } - const webContents = view.webContents; - if (webContents.isDestroyed()) { - return; - } - const menu = new Menu(); - - if (params.linkURL) { - menu.append(new MenuItem({ - label: localize('browser.contextMenu.openLinkInNewTab', 'Open Link in New Tab'), - click: () => { - void this.openNew(params.linkURL, { - owner: view.owner, - session: view.session, - openOptions: { preserveFocus: true, background: true }, - source: 'browserLinkBackground' - }); - } - })); - menu.append(new MenuItem({ - label: localize('browser.contextMenu.openLinkInExternalBrowser', 'Open Link in External Browser'), - click: () => { void this.nativeHostMainService.openExternal(undefined, params.linkURL); } - })); - menu.append(new MenuItem({ type: 'separator' })); - menu.append(new MenuItem({ - label: localize('browser.contextMenu.copyLink', 'Copy Link'), - click: () => { - clipboard.write({ - text: params.linkURL, - html: `${htmlAttributeEncodeValue(params.linkText || params.linkURL)}` - }); - } - })); - } - - if (params.hasImageContents && params.srcURL) { - if (menu.items.length > 0) { - menu.append(new MenuItem({ type: 'separator' })); - } - menu.append(new MenuItem({ - label: localize('browser.contextMenu.openImageInNewTab', 'Open Image in New Tab'), - click: () => { - void this.openNew(params.srcURL!, { - owner: view.owner, - session: view.session, - openOptions: { preserveFocus: true, background: true }, - source: 'browserLinkBackground' - }); - } - })); - menu.append(new MenuItem({ - label: localize('browser.contextMenu.copyImage', 'Copy Image'), - click: () => { view.webContents.copyImageAt(params.x, params.y); } - })); - menu.append(new MenuItem({ - label: localize('browser.contextMenu.copyImageUrl', 'Copy Image URL'), - click: () => { clipboard.writeText(params.srcURL!); } - })); - } - - if (params.isEditable) { - menu.append(new MenuItem({ role: 'cut', enabled: params.editFlags.canCut })); - menu.append(new MenuItem({ role: 'copy', enabled: params.editFlags.canCopy })); - menu.append(new MenuItem({ role: 'paste', enabled: params.editFlags.canPaste })); - menu.append(new MenuItem({ role: 'pasteAndMatchStyle', enabled: params.editFlags.canPaste })); - menu.append(new MenuItem({ role: 'selectAll', enabled: params.editFlags.canSelectAll })); - } else if (params.selectionText) { - menu.append(new MenuItem({ role: 'copy' })); - } - - // Add navigation items as defaults - if (menu.items.length === 0) { - if (webContents.navigationHistory.canGoBack()) { - menu.append(new MenuItem({ - label: localize('browser.contextMenu.back', 'Back'), - accelerator: this._keybindings[BrowserViewCommandId.GoBack], - click: () => webContents.navigationHistory.goBack() - })); - } - if (webContents.navigationHistory.canGoForward()) { - menu.append(new MenuItem({ - label: localize('browser.contextMenu.forward', 'Forward'), - accelerator: this._keybindings[BrowserViewCommandId.GoForward], - click: () => webContents.navigationHistory.goForward() - })); - } - menu.append(new MenuItem({ - label: localize('browser.contextMenu.reload', 'Reload'), - accelerator: this._keybindings[BrowserViewCommandId.Reload], - click: () => webContents.reload() - })); - } - - menu.append(new MenuItem({ type: 'separator' })); - menu.append(new MenuItem({ - label: localize('browser.contextMenu.inspect', 'Inspect'), - click: () => webContents.inspectElement(params.x, params.y) - })); - - const viewBounds = view.getWebContentsView().getBounds(); - menu.popup({ - window: win, - x: viewBounds.x + params.x, - y: viewBounds.y + params.y, - sourceType: params.menuSourceType - }); - } -} diff --git a/src/vs/platform/browserView/node/browserViewGroupRemoteService.ts b/src/vs/platform/browserView/node/browserViewGroupRemoteService.ts deleted file mode 100644 index 7a24d995..00000000 --- a/src/vs/platform/browserView/node/browserViewGroupRemoteService.ts +++ /dev/null @@ -1,109 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { Event } from '../../../base/common/event.js'; -import { Disposable } from '../../../base/common/lifecycle.js'; -import { ProxyChannel } from '../../../base/parts/ipc/common/ipc.js'; -import { IMainProcessService } from '../../ipc/common/mainProcessService.js'; -import { IBrowserViewGroup, IBrowserViewGroupService, IBrowserViewGroupViewEvent, ipcBrowserViewGroupChannelName } from '../common/browserViewGroup.js'; -import { IBrowserViewOwner } from '../common/browserView.js'; -import { CDPEvent, CDPRequest, CDPResponse } from '../common/cdp/types.js'; - -/** - * Remote-process service for managing browser view groups. - * - * Connects to the main-process {@link BrowserViewGroupMainService} via - * IPC and provides {@link IBrowserViewGroup} instances for - * interacting with groups. - * - * Usable from the shared process. - */ -export interface IBrowserViewGroupRemoteService { - /** - * Create a new browser view group. - * @param owner The owner of the group's lifecycle. - */ - createGroup(owner: IBrowserViewOwner): Promise; -} - -/** - * Remote proxy for a browser view group living in the main process. - */ -class RemoteBrowserViewGroup extends Disposable implements IBrowserViewGroup { - constructor( - readonly id: string, - private readonly groupService: IBrowserViewGroupService, - ) { - super(); - - this._register(groupService.onDynamicDidDestroy(this.id)(() => { - // Avoid loops - this.dispose(true); - })); - } - - get onDidAddView(): Event { - return this.groupService.onDynamicDidAddView(this.id); - } - - get onDidRemoveView(): Event { - return this.groupService.onDynamicDidRemoveView(this.id); - } - - get onDidDestroy(): Event { - return this.groupService.onDynamicDidDestroy(this.id); - } - - async addView(viewId: string): Promise { - return this.groupService.addViewToGroup(this.id, viewId); - } - - async removeView(viewId: string): Promise { - return this.groupService.removeViewFromGroup(this.id, viewId); - } - - async sendCDPMessage(msg: CDPRequest): Promise { - return this.groupService.sendCDPMessage(this.id, msg); - } - - get onCDPMessage(): Event { - return this.groupService.onDynamicCDPMessage(this.id); - } - - override dispose(fromService = false): void { - if (!fromService) { - this.groupService.destroyGroup(this.id); - } - super.dispose(); - } -} - -export class BrowserViewGroupRemoteService implements IBrowserViewGroupRemoteService { - private readonly _groupService: IBrowserViewGroupService; - private readonly _groups = new Map(); - - constructor( - mainProcessService: IMainProcessService, - ) { - const channel = mainProcessService.getChannel(ipcBrowserViewGroupChannelName); - this._groupService = ProxyChannel.toService(channel); - } - - async createGroup(owner: IBrowserViewOwner): Promise { - const id = await this._groupService.createGroup(owner); - return this._wrap(id); - } - - private _wrap(id: string): IBrowserViewGroup { - const group = new RemoteBrowserViewGroup(id, this._groupService); - this._groups.set(id, group); - - Event.once(group.onDidDestroy)(() => { - this._groups.delete(id); - }); - - return group; - } -} diff --git a/src/vs/platform/browserView/node/playwrightChannel.ts b/src/vs/platform/browserView/node/playwrightChannel.ts deleted file mode 100644 index 08a4882c..00000000 --- a/src/vs/platform/browserView/node/playwrightChannel.ts +++ /dev/null @@ -1,84 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { Event } from '../../../base/common/event.js'; -import { Disposable, DisposableMap } from '../../../base/common/lifecycle.js'; -import { IPCServer, IServerChannel } from '../../../base/parts/ipc/common/ipc.js'; -import { IMainProcessService } from '../../ipc/common/mainProcessService.js'; -import { ILogService } from '../../log/common/log.js'; -import { IAgentNetworkFilterService } from '../../networkFilter/common/networkFilterService.js'; -import { BrowserViewGroupRemoteService } from './browserViewGroupRemoteService.js'; -import { PlaywrightService } from './playwrightService.js'; - -/** - * IPC channel for the Playwright service. - * - * Each connected window gets its own {@link PlaywrightService}, - * keyed by the opaque IPC connection context. The client sends an - * `__initialize` call with its numeric window ID before any other - * method calls, which eagerly creates the instance. When a window - * disconnects the instance is automatically disposed. - */ -export class PlaywrightChannel extends Disposable implements IServerChannel { - - private readonly _instances = this._register(new DisposableMap()); - private readonly browserViewGroupRemoteService: BrowserViewGroupRemoteService; - - constructor( - ipcServer: IPCServer, - mainProcessService: IMainProcessService, - private readonly logService: ILogService, - private readonly agentNetworkFilterService: IAgentNetworkFilterService, - ) { - super(); - this.browserViewGroupRemoteService = new BrowserViewGroupRemoteService(mainProcessService); - this._register(ipcServer.onDidRemoveConnection(c => { - this._instances.deleteAndDispose(c.ctx); - })); - } - - listen(ctx: string, event: string): Event { - const instance = this._instances.get(ctx); - if (!instance) { - throw new Error(`Window not initialized for context: ${ctx}`); - } - const source = (instance as unknown as Record>)[event]; - if (typeof source !== 'function') { - throw new Error(`Event not found: ${event}`); - } - return source as Event; - } - - call(ctx: string, command: string, arg?: unknown): Promise { - // Handle the one-time initialization call that creates the instance - if (command === '__initialize') { - if (typeof arg !== 'number') { - throw new Error(`Invalid argument for __initialize: expected window ID as number, got ${typeof arg}`); - } - if (!this._instances.has(ctx)) { - const windowId = arg as number; - this._instances.set(ctx, new PlaywrightService(windowId, this.browserViewGroupRemoteService, this.logService, this.agentNetworkFilterService)); - } - return Promise.resolve(undefined as T); - } - - const instance = this._instances.get(ctx); - if (!instance) { - throw new Error(`Window not initialized for context: ${ctx}`); - } - - const target = (instance as unknown as Record)[command]; - if (typeof target !== 'function') { - throw new Error(`Method not found: ${command}`); - } - - const methodArgs = Array.isArray(arg) ? arg : []; - let res = target.apply(instance, methodArgs); - if (!(res instanceof Promise)) { - res = Promise.resolve(res); - } - return res; - } -} diff --git a/src/vs/platform/browserView/node/playwrightService.ts b/src/vs/platform/browserView/node/playwrightService.ts deleted file mode 100644 index 4ded5360..00000000 --- a/src/vs/platform/browserView/node/playwrightService.ts +++ /dev/null @@ -1,698 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { Disposable, DisposableMap, DisposableStore, IDisposable } from '../../../base/common/lifecycle.js'; -import { DeferredPromise, disposableTimeout, raceTimeout } from '../../../base/common/async.js'; -import { Emitter, Event } from '../../../base/common/event.js'; -import { ILogService } from '../../log/common/log.js'; -import { IAgentNetworkFilterService } from '../../networkFilter/common/networkFilterService.js'; -import { IInvokeFunctionResult, IPlaywrightService } from '../common/playwrightService.js'; -import { IBrowserViewGroupRemoteService } from '../node/browserViewGroupRemoteService.js'; -import { IBrowserViewGroup } from '../common/browserViewGroup.js'; -import { PlaywrightTab, DialogInterruptedError } from './playwrightTab.js'; -import { CDPEvent, CDPRequest, CDPResponse } from '../common/cdp/types.js'; -import { generateUuid } from '../../../base/common/uuid.js'; - -// eslint-disable-next-line local/code-import-patterns -import type { Browser, BrowserContext, Page } from 'playwright-core'; - -interface PlaywrightTransport { - send(s: CDPRequest): void; - close(): void; // Note: calling close is expected to issue onclose at some point. - onmessage?: (message: CDPResponse | CDPEvent) => void; - onclose?: (reason?: string) => void; -} - -declare module 'playwright-core' { - interface BrowserType { - _connectOverCDPTransport(transport: PlaywrightTransport): Promise; - } -} - -const DEFERRED_RESULT_CLEANUP_MS = 5 * 60_000; // 5 minutes - -/** - * Shared-process implementation of {@link IPlaywrightService}. - * - * Creates a {@link PlaywrightPageManager} eagerly on construction to track - * browser views. The Playwright browser connection is lazily initialised - * only when an operation that requires it is called. - */ -export class PlaywrightService extends Disposable implements IPlaywrightService { - declare readonly _serviceBrand: undefined; - - private readonly _pages: PlaywrightPageManager; - readonly onDidChangeTrackedPages: Event; - - private _browser: Browser | undefined; - private _initPromise: Promise | undefined; - - /** In-flight deferred results keyed by their generated ID. */ - private readonly _deferredResults = this._register(new DisposableMap; - } & IDisposable>()); - - constructor( - private readonly windowId: number, - private readonly browserViewGroupRemoteService: IBrowserViewGroupRemoteService, - private readonly logService: ILogService, - agentNetworkFilterService: IAgentNetworkFilterService, - ) { - super(); - this._pages = this._register(new PlaywrightPageManager(logService, agentNetworkFilterService)); - this.onDidChangeTrackedPages = this._pages.onDidChangeTrackedPages; - } - - // --- Page tracking (delegated to manager) --- - - async startTrackingPage(viewId: string): Promise { - return this._pages.startTrackingPage(viewId); - } - - async stopTrackingPage(viewId: string): Promise { - return this._pages.stopTrackingPage(viewId); - } - - async isPageTracked(viewId: string): Promise { - return this._pages.isPageTracked(viewId); - } - - async getTrackedPages(): Promise { - return this._pages.getTrackedPages(); - } - - // --- Playwright operations (lazy init) --- - - /** - * Ensure the Playwright browser connection is initialized and the page - * manager is wired up to the browser view group. - */ - private async initialize(): Promise { - if (this._browser) { - return; - } - - if (this._initPromise) { - return this._initPromise; - } - - this._initPromise = (async () => { - try { - this.logService.debug('[PlaywrightService] Creating browser view group'); - const group = await this.browserViewGroupRemoteService.createGroup({ mainWindowId: this.windowId }); - - this.logService.debug('[PlaywrightService] Connecting to browser via CDP'); - const playwright = await import('playwright-core'); - const sub = group.onCDPMessage(msg => transport.onmessage?.(msg)); - const transport: PlaywrightTransport = { - close() { - sub.dispose(); - this.onclose?.(); - }, - send(message) { - void group.sendCDPMessage(message); - } - }; - const browser = await playwright.chromium._connectOverCDPTransport(transport); - - this.logService.debug('[PlaywrightService] Connected to browser'); - - // This can happen if the service was disposed while we were waiting for the connection. In that case, clean up immediately. - if (this._initPromise === undefined) { - browser.close().catch(() => { /* ignore */ }); - group.dispose(); - throw new Error('PlaywrightService was disposed during initialization'); - } - - browser.on('disconnected', () => { - this.logService.debug('[PlaywrightService] Browser disconnected'); - if (this._browser === browser) { - this._pages.reset(); - this._browser = undefined; - this._initPromise = undefined; - } - }); - - await this._pages.initialize(browser, group); - this._browser = browser; - } catch (e) { - this._initPromise = undefined; - throw e; - } - })(); - - return this._initPromise; - } - - async openPage(url: string): Promise<{ pageId: string; summary: string }> { - await this.initialize(); - const pageId = await this._pages.newPage(url); - const summary = await this._pages.getSummary(pageId); - return { pageId, summary }; - } - - async getSummary(pageId: string): Promise { - await this.initialize(); - return this._pages.getSummary(pageId, true); - } - - async invokeFunctionRaw(pageId: string, fnDef: string, ...args: unknown[]): Promise { - await this.initialize(); - - const vm = await import('vm'); - const fn = vm.compileFunction(`return (${fnDef})(page, ...args)`, ['page', 'args'], { parsingContext: vm.createContext() }); - - return this._pages.runAgainstPage(pageId, (page) => fn(page, args)); - } - - private async invokeFunctionWithDeferral(pageId: string, fnDef: string, args: unknown[], timeoutMs: number): Promise { - await this.initialize(); - - const vm = await import('vm'); - const fn = vm.compileFunction(`return (${fnDef})(page, ...args)`, ['page', 'args'], { parsingContext: vm.createContext() }); - - return this._runWithDeferral(pageId, (page) => fn(page, args ?? []), timeoutMs); - } - - async invokeFunction(pageId: string, fnDef: string, args: unknown[] = [], timeoutMs?: number): Promise { - this.logService.info(`[PlaywrightService] Invoking function on view ${pageId}`); - - if (timeoutMs !== undefined) { - return this.invokeFunctionWithDeferral(pageId, fnDef, args, timeoutMs); - } - - let result, error; - try { - result = await this.invokeFunctionRaw(pageId, fnDef, ...args); - } catch (err: unknown) { - error = err instanceof Error ? err.message : String(err); - } - - const summary = await this._pages.getSummary(pageId); - - return { result, error, summary }; - } - - async waitForDeferredResult(deferredResultId: string, timeoutMs: number): Promise { - const entry = this._deferredResults.get(deferredResultId); - if (!entry) { - throw new Error(`No deferred result found with ID "${deferredResultId}". It may have been cleaned up or already consumed.`); - } - - const { pageId, promise } = entry; - // Remove eagerly — _runWithDeferral will re-insert if interrupted again. - this._deferredResults.deleteAndDispose(deferredResultId); - - // The callback ignores the page param since execution is already in-flight. - return this._runWithDeferral(pageId, () => promise, timeoutMs, deferredResultId); - } - - /** - * Run a callback against a page with deferred result support. - */ - private async _runWithDeferral(pageId: string, callback: (page: Page) => Promise, timeoutMs: number, existingDeferredId?: string): Promise { - const effectiveTimeout = timeoutMs; - - // Start execution via safeRunAgainstPage, but capture the raw promise - // independently so it can be deferred if a dialog or timeout interrupts. - const deferred = new DeferredPromise(); - const wrappedPromise = this._pages.runAgainstPage(pageId, async (page) => { - const promise = callback(page); - promise.catch(() => { /* prevent unhandled rejection if deferred */ }); - deferred.settleWith(promise); - return promise; - }); - - let result, error; - let interrupted = false; - - try { - result = await raceTimeout(wrappedPromise, effectiveTimeout, () => { interrupted = true; }); - } catch (err: unknown) { - if (err instanceof DialogInterruptedError) { - interrupted = true; - } - error = err instanceof Error ? err.message : String(err); - } - - let deferredResultId: string | undefined; - if (interrupted) { - deferredResultId = existingDeferredId ?? generateUuid(); - const cleanup = disposableTimeout(() => this._deferredResults.deleteAndDispose(deferredResultId!), DEFERRED_RESULT_CLEANUP_MS); - this._deferredResults.set(deferredResultId, { pageId, promise: deferred.p, dispose: () => cleanup.dispose() }); - - this.logService.info(`[PlaywrightService] Execution interrupted, deferred as ${deferredResultId}`); - } - - const summary = await this._pages.getSummary(pageId); - return { result, error, summary, deferredResultId }; - } - - async replyToFileChooser(pageId: string, files: string[]): Promise<{ summary: string }> { - await this.initialize(); - const summary = await this._pages.replyToFileChooser(pageId, files); - return { summary }; - } - - async replyToDialog(pageId: string, accept: boolean, promptText?: string): Promise<{ summary: string }> { - await this.initialize(); - const summary = await this._pages.replyToDialog(pageId, accept, promptText); - return { summary }; - } - - override dispose(): void { - if (this._browser) { - this._browser.close().catch(() => { /* ignore */ }); - this._browser = undefined; - } - this._initPromise = undefined; - super.dispose(); - } -} - -/** - * Manages page tracking and correlates browser view IDs with Playwright - * {@link Page} instances. - * - * Created eagerly by {@link PlaywrightService} and operates in two phases: - * - * 1. **Before initialization** - tracks which pages are added/removed but - * cannot resolve Playwright {@link Page} objects. - * 2. **After {@link initialize}** - proxies add/remove calls to the - * {@link IBrowserViewGroup} and pairs view IDs with Playwright pages - * via FIFO matching of the group's IPC events and Playwright's CDP events. - * - * A periodic scan handles the case where Playwright creates a new - * {@link BrowserContext} for a target whose session was previously unknown. - */ -class PlaywrightPageManager extends Disposable { - - // --- Page tracking --- - - private readonly _trackedPages = new Set(); - - private readonly _onDidChangeTrackedPages = this._register(new Emitter()); - readonly onDidChangeTrackedPages: Event = this._onDidChangeTrackedPages.event; - - // --- Page matching --- - - private readonly _viewIdToPage = new Map(); - private readonly _pageToViewId = new WeakMap(); - private readonly _tabs = new WeakMap(); - - /** View IDs received from the group but not yet matched with a page. */ - private _viewIdQueue: Array<{ - viewId: string; - page: DeferredPromise; - }> = []; - - /** Pages received from Playwright but not yet matched with a view ID. */ - private _pageQueue: Array<{ - page: Page; - viewId: DeferredPromise; - }> = []; - - private readonly _watchedContexts = new WeakSet(); - private _scanTimer: ReturnType | undefined; - - // --- Initialized state --- - - private readonly _initStore = this._register(new DisposableStore()); - private _group: IBrowserViewGroup | undefined; - private _browser: Browser | undefined; - private _openContext: BrowserContext | undefined = undefined; - - constructor( - private readonly logService: ILogService, - private readonly agentNetworkFilterService: IAgentNetworkFilterService, - ) { - super(); - } - - // --- Public: page tracking --- - - isPageTracked(viewId: string): boolean { - return this._trackedPages.has(viewId); - } - - getTrackedPages(): readonly string[] { - return [...this._trackedPages]; - } - - async startTrackingPage(viewId: string): Promise { - if (this._trackedPages.has(viewId)) { - return; - } - - this._trackedPages.add(viewId); - this._fireTrackedPagesChanged(); - - if (this._group) { - await this._addPageToGroup(viewId); - } - } - - async stopTrackingPage(viewId: string): Promise { - if (!this._trackedPages.has(viewId)) { - return; - } - - this._trackedPages.delete(viewId); - this._fireTrackedPagesChanged(); - - if (this._group) { - await this._removePageFromGroup(viewId); - } - } - - // --- Public: Playwright operations (require initialization) --- - - /** - * Create a new page in the browser and return its associated page ID. - * The page is automatically added to the tracked set. - */ - async newPage(url: string): Promise { - if (!this._browser) { - throw new Error('PlaywrightPageManager has not been initialized'); - } - - if (!this._openContext) { - this._openContext = await this._browser.newContext(); - this.onContextAdded(this._openContext); - } - - const page = await this._openContext.newPage(); - - const viewId = await this.onPageAdded(page); - - this._trackedPages.add(viewId); - this._fireTrackedPagesChanged(); - - await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 }); - - return viewId; - } - - async runAgainstPage(pageId: string, callback: (page: Page) => T | Promise): Promise { - const page = await this.getPage(pageId); - const tab = this._tabs.get(page); - if (!tab) { - throw new Error('Failed to execute function against page'); - } - return tab.safeRunAgainstPage(async () => callback(page)); - } - - async getSummary(pageId: string, full = false): Promise { - const page = await this.getPage(pageId); - const tab = this._tabs.get(page); - if (!tab) { - throw new Error('Failed to get page summary'); - } - return tab.getSummary(full); - } - - async replyToDialog(pageId: string, accept: boolean, promptText?: string): Promise { - const page = await this.getPage(pageId); - const tab = this._tabs.get(page); - if (!tab) { - throw new Error('Failed to reply to dialog'); - } - await tab.replyToDialog(accept, promptText); - return tab.getSummary(); - } - - async replyToFileChooser(pageId: string, files: string[]): Promise { - const page = await this.getPage(pageId); - const tab = this._tabs.get(page); - if (!tab) { - throw new Error('Failed to reply to file chooser'); - } - await tab.replyToFileChooser(files); - return tab.getSummary(); - } - - // --- Initialization --- - - /** - * Wire up the manager to a browser and group. Replays any pages that - * were tracked before initialization. - */ - async initialize(browser: Browser, group: IBrowserViewGroup): Promise { - this._initStore.clear(); - - this._browser = browser; - this._group = group; - - this._initStore.add(group); - this._initStore.add(group.onDidAddView(e => this.onViewAdded(e.viewId))); - this._initStore.add(group.onDidRemoveView(e => this.onViewRemoved(e.viewId))); - - this.scanForNewContexts(); - - // Eagerly connect any pages that were tracked before initialization. - await Promise.all( - [...this._trackedPages].map(viewId => this._addPageToGroup(viewId)) - ); - } - - /** - * Clear initialized state but preserve tracked pages so the manager - * can be re-initialized with a new browser and group. - */ - reset(): void { - this._initStore.clear(); - this._browser = undefined; - this._group = undefined; - - this.stopScanning(); - this._viewIdToPage.clear(); - - for (const { page } of this._viewIdQueue) { - page.error(new Error('PlaywrightPageManager reset')); - } - for (const { viewId } of this._pageQueue) { - viewId.error(new Error('PlaywrightPageManager reset')); - } - this._viewIdQueue = []; - this._pageQueue = []; - } - - // --- Private: group proxy --- - - private async _addPageToGroup(viewId: string): Promise { - if (this._viewIdToPage.has(viewId)) { - return; - } - if (this._viewIdQueue.some(item => item.viewId === viewId)) { - return; - } - - // Ensure the viewId is queued so we can immediately fetch the promise via getPage(). - this.onViewAdded(viewId); - - try { - await this._group!.addView(viewId); - } catch (err) { - this.onViewRemoved(viewId); - throw err; - } - } - - private async _removePageFromGroup(viewId: string): Promise { - this.onViewRemoved(viewId); - await this._group!.removeView(viewId); - } - - private _fireTrackedPagesChanged(): void { - this._onDidChangeTrackedPages.fire([...this._trackedPages]); - } - - // --- Page matching (view ↔ page pairing) --- - - /** - * Get the Playwright {@link Page} for a browser view. - * If the view is tracked but not yet connected, it is added to the group - * automatically. Throws if the view has not been added. - */ - private async getPage(viewId: string): Promise { - const resolved = this._viewIdToPage.get(viewId); - if (resolved) { - return resolved; - } - const queued = this._viewIdQueue.find(item => item.viewId === viewId); - if (queued) { - return queued.page.p; - } - - throw new Error(`Page "${viewId}" not found`); - } - - /** - * Called when the group fires onDidAddView. Creates a deferred entry in - * the view ID queue and attempts to match it with a page. - */ - private onViewAdded(viewId: string, timeoutMs = 10000): Promise { - const resolved = this._viewIdToPage.get(viewId); - if (resolved) { - return Promise.resolve(resolved); - } - const queued = this._viewIdQueue.find(item => item.viewId === viewId); - if (queued) { - return queued.page.p; - } - - const deferred = new DeferredPromise(); - const timeout = setTimeout(() => deferred.error(new Error(`Timed out waiting for page`)), timeoutMs); - - deferred.p.finally(() => { - clearTimeout(timeout); - this._viewIdQueue = this._viewIdQueue.filter(item => item.viewId !== viewId); - if (this._viewIdQueue.length === 0) { - this.stopScanning(); - } - }); - - this._viewIdQueue.push({ viewId, page: deferred }); - this.tryMatch(); - this.ensureScanning(); - - return deferred.p; - } - - private onViewRemoved(viewId: string): void { - this._viewIdQueue = this._viewIdQueue.filter(item => item.viewId !== viewId); - const page = this._viewIdToPage.get(viewId); - if (page) { - this._pageToViewId.delete(page); - } - this._viewIdToPage.delete(viewId); - this._trackedPages.delete(viewId); - this._fireTrackedPagesChanged(); - } - - private onPageAdded(page: Page, timeoutMs = 10000): Promise { - const resolved = this._pageToViewId.get(page); - if (resolved) { - return Promise.resolve(resolved); - } - const queued = this._pageQueue.find(item => item.page === page); - if (queued) { - return queued.viewId.p; - } - - this.onContextAdded(page.context()); - page.once('close', () => this.onPageRemoved(page)); - page.setDefaultTimeout(10000); - this._tabs.set(page, new PlaywrightTab(page, this.agentNetworkFilterService)); - - const deferred = new DeferredPromise(); - const timeout = setTimeout(() => deferred.error(new Error(`Timed out waiting for browser view`)), timeoutMs); - deferred.p.finally(() => { - clearTimeout(timeout); - this._pageQueue = this._pageQueue.filter(item => item.page !== page); - }); - - this._pageQueue.push({ page, viewId: deferred }); - this.tryMatch(); - - return deferred.p; - } - - private onPageRemoved(page: Page): void { - this._pageQueue = this._pageQueue.filter(item => item.page !== page); - const viewId = this._pageToViewId.get(page); - if (viewId) { - this._viewIdToPage.delete(viewId); - this._trackedPages.delete(viewId); - this._fireTrackedPagesChanged(); - } - this._pageToViewId.delete(page); - } - - private onContextAdded(context: BrowserContext): void { - if (this._watchedContexts.has(context)) { - return; - } - this._watchedContexts.add(context); - - context.on('page', (page: Page) => this.onPageAdded(page)); - context.on('close', () => this.onContextRemoved(context)); - - for (const page of context.pages()) { - this.onPageAdded(page); - } - } - - private onContextRemoved(context: BrowserContext): void { - this._watchedContexts.delete(context); - } - - // --- Matching --- - - /** - * Pair up queued view IDs with queued pages in FIFO order and resolve - * any callers waiting for the matched view IDs. - */ - private tryMatch(): void { - while (this._viewIdQueue.length > 0 && this._pageQueue.length > 0) { - const viewIdItem = this._viewIdQueue.shift()!; - const pageItem = this._pageQueue.shift()!; - - this._viewIdToPage.set(viewIdItem.viewId, pageItem.page); - this._pageToViewId.set(pageItem.page, viewIdItem.viewId); - - viewIdItem.page.complete(pageItem.page); - pageItem.viewId.complete(viewIdItem.viewId); - - this.logService.debug(`[PlaywrightPageManager] Matched view ${viewIdItem.viewId} → page`); - } - - if (this._viewIdQueue.length === 0) { - this.stopScanning(); - } - } - - // --- Context scanning --- - - /** - * Watch all current {@link BrowserContext BrowserContexts} for new pages. - * Also processes any existing pages in newly discovered contexts. - */ - private scanForNewContexts(): void { - if (!this._browser) { - return; - } - for (const context of this._browser.contexts()) { - this.onContextAdded(context); - } - } - - private ensureScanning(): void { - if (this._scanTimer === undefined) { - this._scanTimer = setInterval(() => this.scanForNewContexts(), 100); - } - } - - private stopScanning(): void { - if (this._scanTimer !== undefined) { - clearInterval(this._scanTimer); - this._scanTimer = undefined; - } - } - - override dispose(): void { - this.stopScanning(); - for (const { page } of this._viewIdQueue) { - page.error(new Error('PlaywrightPageManager disposed')); - } - for (const { viewId } of this._pageQueue) { - viewId.error(new Error('PlaywrightPageManager disposed')); - } - this._viewIdQueue = []; - this._pageQueue = []; - super.dispose(); - } -} diff --git a/src/vs/platform/browserView/node/playwrightTab.ts b/src/vs/platform/browserView/node/playwrightTab.ts deleted file mode 100644 index d3c8a4df..00000000 --- a/src/vs/platform/browserView/node/playwrightTab.ts +++ /dev/null @@ -1,282 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -// eslint-disable-next-line local/code-import-patterns -import type * as playwright from 'playwright-core'; -import { Emitter, Event } from '../../../base/common/event.js'; -import { CancellationToken } from '../../../base/common/cancellation.js'; -import { createCancelablePromise, raceCancellablePromises, timeout } from '../../../base/common/async.js'; -import { URI } from '../../../base/common/uri.js'; -import { IAgentNetworkFilterService } from '../../networkFilter/common/networkFilterService.js'; - -type IAiAriaSnapshotOptions = NonNullable[0]> & { _track?: string }; - -declare module 'playwright-core' { - interface Page { - // We defined this here to be able to use the unofficial `_track` option - ariaSnapshot(options?: IAiAriaSnapshotOptions): Promise; - } -} - -/** - * Thrown when a dialog (alert, confirm, prompt) opens while a page action is - * running. The caller should defer the underlying promise and let the agent - * handle the dialog before retrying. - */ -export class DialogInterruptedError extends Error { - constructor() { - super('Action was interrupted by a dialog'); - this.name = 'DialogInterruptedError'; - } -} - -/** - * Wrapper around a Playwright page that tracks additional state like active dialogs and recent console messages, - * and can produce a summary of the page's current state for use in tools. - * - * Loosely based on https://github.com/microsoft/playwright/blob/main/packages/playwright/src/mcp/browser/tab.ts. - */ -export class PlaywrightTab { - private _onDialogStateChanged = new Emitter(); - - private _dialog: playwright.Dialog | undefined; - private _fileChooser: playwright.FileChooser | undefined; - private _logs: { type: string; time: number; description: string }[] = []; - private _needsFullSnapshot = false; - - private _initialized: Promise; - - constructor( - /** - * @deprecated prefer accessing the page via safeRunAgainstPage. - * Only use this directly if you are sure it cannot be blocked by dialogs. - */ - private readonly page: playwright.Page, - private readonly agentNetworkFilterService: IAgentNetworkFilterService, - ) { - page.on('console', event => this._handleConsoleMessage(event)) - .on('pageerror', error => this._handlePageError(error)) - .on('requestfailed', request => this._handleRequestFailed(request)) - .on('dialog', dialog => this._handleDialog(dialog)) - .on('download', download => this._handleDownload(download)); - - this._initialized = this._initialize(); - } - - private async _initialize() { - const messages = await this.page.consoleMessages().catch(() => []); - for (const message of messages) { this._handleConsoleMessage(message); } - const errors = await this.page.pageErrors().catch(() => []); - for (const error of errors) { this._handlePageError(error); } - } - - private _handleDialog(dialog: playwright.Dialog) { - this._dialog = dialog; - // Playwright doesn't give us an event for when a dialog is closed, so we run a no-op script to know when it closes. - this.page.waitForFunction(() => true, undefined, { timeout: 0 }).then(() => { - if (this._dialog === dialog) { - this._dialog = undefined; - this._onDialogStateChanged.fire(); - } - }); - this._onDialogStateChanged.fire(); - } - - async replyToDialog(accept?: boolean, promptText?: string) { - if (!this._dialog) { - throw new Error('No active modal dialog to respond to'); - } - const dialog = this._dialog; - this._dialog = undefined; - this._onDialogStateChanged.fire(); - await this.safeRunAgainstPage(async () => { - if (accept) { - await dialog.accept(promptText); - } else { - await dialog.dismiss(); - } - }); - } - - private _handleFileChooser(chooser: playwright.FileChooser) { - this._fileChooser = chooser; - } - - async replyToFileChooser(files: string[]) { - if (!this._fileChooser) { - throw new Error('No active file chooser dialog to respond to'); - } - const chooser = this._fileChooser; - this._fileChooser = undefined; - await this.safeRunAgainstPage(() => chooser.setFiles(files)); - } - - private async _handleDownload(download: playwright.Download) { - this._logs.push({ type: 'download', time: Date.now(), description: `${download.suggestedFilename()}` }); - } - - private _handleRequestFailed(request: playwright.Request) { - const timing = request.timing(); - this._logs.push({ type: 'requestFailed', time: timing.responseEnd + timing.startTime, description: `${request.method()} request to ${request.url()} failed: "${request.failure()?.errorText}"` }); - } - - private _handleConsoleMessage(message: playwright.ConsoleMessage) { - if (message.type() === 'error' || message.type() === 'warning') { - this._logs.push({ type: 'console', time: message.timestamp(), description: `[${message.type()}] ${message.text()}` }); - } - } - - private _handlePageError(error: Error) { - this._logs.push({ type: 'pageError', time: Date.now(), description: error.stack ?? error.message }); - } - - /** - * Returns a blocked-by-policy error message if the current page URL is - * denied by the network filter, or `undefined` if the URL is allowed. - */ - private _getBlockedURLErrorMessage(): string | undefined { - const url = this.page.url(); - if (!url || url === 'about:blank') { - return undefined; - } - let uri: URI | undefined; - try { uri = URI.parse(url); } catch { } - if (uri && !this.agentNetworkFilterService.isUriAllowed(uri)) { - return this.agentNetworkFilterService.formatError(uri); - } - return undefined; - } - - /** - * Run a callback against the page and wait for it to complete. - * - * Because dialogs pause the page, execution races against any dialog that opens -- if a dialog - * appears before the callback finishes, the method throws so the caller can surface it to the agent. - * - * Also allows for interactions to be handled differently when triggered by agents. - * E.g. file dialogs should appear when the user triggers one, but not when the agent does. - */ - async safeRunAgainstPage(action: (page: playwright.Page, token: CancellationToken) => Promise): Promise { - if (this._dialog) { - throw new Error(`Cannot perform action while a dialog is open`); - } - - // Block agent actions when the current page URL is on the deny list. - const blockedError = this._getBlockedURLErrorMessage(); - if (blockedError) { - throw new Error(blockedError); - } - - let actionDidComplete = false; - let result: T | void; - const dialogOpened = Event.toPromise(this._onDialogStateChanged.event); - const actionCompleted = createCancelablePromise(async (token) => { - - // Whenever the page has a `filechooser` handler, the default file chooser is disabled. - // We don't want this during normal user interactions, but we do for agentic interactions. - // So we add a handler just during the action, and remove it afterwards. - // This isn't perfect (e.g. the user could trigger it while an action is running), but it's a best effort. - const handleFileChooser = (chooser: playwright.FileChooser) => this._handleFileChooser(chooser); - this.page.on('filechooser', handleFileChooser); - - try { - result = await this.runAndWaitForCompletion((token) => action(this.page, token), token); - actionDidComplete = true; - } finally { - this.page.off('filechooser', handleFileChooser); - } - }); - - return raceCancellablePromises([dialogOpened, actionCompleted]).then(() => { - if (!actionDidComplete) { - // A dialog was opened before the action completed. Note we don't cancel the action, just ignore its result. - throw new DialogInterruptedError(); - } - return result!; - }); - } - - async getSummary(full = this._needsFullSnapshot): Promise { - await this._initialized; - - // When the current page URL is blocked by network policy, return only a - // policy error — do not expose title, URL, console logs, or snapshot to - // avoid prompt-injection via blocked content. - const blockedError = this._getBlockedURLErrorMessage(); - if (blockedError) { - return blockedError; - } - - if (full && this._needsFullSnapshot) { - this._needsFullSnapshot = false; - } - - const snapshotFromPage = await this.safeRunAgainstPage((page) => this.getAiSnapshot(page, full)).catch(() => { - this._needsFullSnapshot = true; - return undefined; - }); - const title = await this.safeRunAgainstPage((page) => page.title()).catch(() => ''); - - const logs = this._logs; - this._logs = []; - - const snapshot = snapshotFromPage?.trim() ?? ''; - - return [ - ...(title ? [`Page Title: ${title}`] : []), - `URL: ${this.page.url()}`, - ...(this._dialog ? [`Active ${this._dialog.type()} dialog: "${this._dialog.message()}"`] : []), - ...(this._fileChooser ? [`Active file chooser dialog`] : []), - ...(logs.length > 0 ? [ - `Recent events:`, - ...logs.map(log => `- [${new Date(log.time).toISOString()}] (${log.type}) ${log.description}`) - ] : []), - `Snapshot: ${snapshotFromPage !== undefined ? snapshot ? `\n${snapshot}` : '' : ''}`, - ].join('\n'); - } - - private getAiSnapshot(page: playwright.Page, full: boolean): Promise { - const options: IAiAriaSnapshotOptions = { mode: 'ai' }; - if (!full) { - options._track = 'response'; - } - return page.ariaSnapshot(options); - } - - private async runAndWaitForCompletion(callback: (token: CancellationToken) => Promise, token = CancellationToken.None): Promise { - const requests: playwright.Request[] = []; - - const requestListener = (request: playwright.Request) => requests.push(request); - const disposeListeners = () => { - this.page.off('request', requestListener); - }; - this.page.on('request', requestListener); - - let result: T; - try { - result = await callback(token); - } finally { - disposeListeners(); - } - - const requestedNavigation = requests.some(request => request.isNavigationRequest()); - if (requestedNavigation) { - await this.page.mainFrame().waitForLoadState('load', { timeout: 10000 }).catch(() => { }); - return result; - } - - const promises: Promise[] = []; - for (const request of requests) { - if (['document', 'stylesheet', 'script', 'xhr', 'fetch'].includes(request.resourceType())) { promises.push(request.response().then(r => r?.finished()).catch(() => { })); } - else { promises.push(request.response().catch(() => { })); } - } - await raceCancellablePromises([ - Promise.all(promises), - timeout(5000) // Don't wait indefinitely for requests to finish - ]); - - return result; - } -} diff --git a/src/vs/platform/browserView/test/electron-main/browserSessionTrust.test.ts b/src/vs/platform/browserView/test/electron-main/browserSessionTrust.test.ts deleted file mode 100644 index 4dc9fab0..00000000 --- a/src/vs/platform/browserView/test/electron-main/browserSessionTrust.test.ts +++ /dev/null @@ -1,340 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import assert from 'assert'; -import * as sinon from 'sinon'; -import { EventEmitter } from 'events'; -import { runWithFakedTimers } from '../../../../base/test/common/timeTravelScheduler.js'; -import { StorageScope, StorageTarget } from '../../../storage/common/storage.js'; -import { IApplicationStorageMainService } from '../../../storage/electron-main/storageMainService.js'; -import { BrowserSessionTrust } from '../../electron-main/browserSessionTrust.js'; -import type { BrowserSession } from '../../electron-main/browserSession.js'; -import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; - -const STORAGE_KEY = 'browserView.sessionTrustData'; -const TRUST_DURATION_MS = 7 * 24 * 60 * 60 * 1000; - -type CertificateVerifyProc = Parameters[0]; -type CertificateVerifyRequest = Parameters>[0]; - -class TestElectronSession { - readonly closeAllConnections = sinon.stub().resolves(); - certificateVerifyProc: CertificateVerifyProc | undefined; - - setCertificateVerifyProc(callback: CertificateVerifyProc): void { - this.certificateVerifyProc = callback; - } - - asSession(): Electron.Session { - return this as unknown as Electron.Session; - } -} - -class TestBrowserSession { - constructor( - readonly id: string, - readonly electronSession: Electron.Session, - ) { } - - asBrowserSession(): BrowserSession { - return this as unknown as BrowserSession; - } -} - -class TestApplicationStorageMainService { - private readonly data = new Map(); - readonly store = sinon.stub<[string, string | number | boolean | object | null | undefined, StorageScope, StorageTarget], void>().callsFake((key, value) => { - this.data.set(key, String(value)); - }); - readonly remove = sinon.stub<[string, StorageScope], void>().callsFake(key => { - this.data.delete(key); - }); - - get(key: string, _scope: StorageScope, fallbackValue?: string): string | undefined { - return this.data.get(key) ?? fallbackValue; - } - - seed(key: string, value: string): void { - this.data.set(key, value); - } - - read(key: string): string | undefined { - return this.data.get(key); - } - - asService(): IApplicationStorageMainService { - return this as unknown as IApplicationStorageMainService; - } -} - -class TestWebContents extends EventEmitter { - asWebContents(): Electron.WebContents { - return this as unknown as Electron.WebContents; - } -} - -function createTrust(sessionId = 'test-session'): { - trust: BrowserSessionTrust; - electronSession: TestElectronSession; - storage: TestApplicationStorageMainService; -} { - const electronSession = new TestElectronSession(); - const browserSession = new TestBrowserSession(sessionId, electronSession.asSession()); - const trust = new BrowserSessionTrust(browserSession.asBrowserSession()); - const storage = new TestApplicationStorageMainService(); - - return { trust, electronSession, storage }; -} - -function createCertificate(fingerprint: string, extra?: Partial): Electron.Certificate { - return { fingerprint, issuerName: 'Test CA', subjectName: 'test.example.com', validStart: 0, validExpiry: 0, ...extra } as Electron.Certificate; -} - -function invokeVerifyProc( - electronSession: TestElectronSession, - request: Partial & { hostname: string; certificate: Electron.Certificate } -): number { - assert.ok(electronSession.certificateVerifyProc); - - let result: number | undefined; - electronSession.certificateVerifyProc!({ - errorCode: 0, - verificationResult: 'OK', - ...request - } as CertificateVerifyRequest, value => { - result = value; - }); - - assert.notStrictEqual(result, undefined); - return result!; -} - -suite('BrowserSessionTrust', () => { - teardown(() => { - sinon.restore(); - }); - - test('installs certificate verify proc and tracks certificate errors', () => { - const { trust, electronSession } = createTrust(); - - const verificationResult = invokeVerifyProc(electronSession, { - hostname: 'example.com', - errorCode: -202, - verificationResult: 'net::ERR_CERT_AUTHORITY_INVALID', - certificate: createCertificate('abc123') - }); - - assert.strictEqual(verificationResult, -3); - assert.deepStrictEqual(trust.getCertificateError('https://example.com/path'), { - host: 'example.com', - fingerprint: 'abc123', - error: 'net::ERR_CERT_AUTHORITY_INVALID', - url: 'https://example.com/path', - hasTrustedException: false, - issuerName: 'Test CA', - subjectName: 'test.example.com', - validStart: 0, - validExpiry: 0, - }); - - invokeVerifyProc(electronSession, { - hostname: 'example.com', - certificate: createCertificate('abc123') - }); - - assert.strictEqual(trust.getCertificateError('https://example.com/path'), undefined); - }); - - test('trustCertificate persists data under the trust storage key', async () => { - const { trust, storage } = createTrust(); - trust.connectStorage(storage.asService()); - - await trust.trustCertificate('example.com', 'abc123'); - - assert.strictEqual(storage.store.calledOnce, true); - assert.deepStrictEqual(storage.store.firstCall.args.slice(0, 4), [STORAGE_KEY, storage.read(STORAGE_KEY), StorageScope.APPLICATION, StorageTarget.MACHINE]); - - const persisted = JSON.parse(storage.read(STORAGE_KEY)!); - assert.deepStrictEqual(persisted['test-session'].trustedCerts.map((entry: { host: string; fingerprint: string }) => ({ host: entry.host, fingerprint: entry.fingerprint })), [{ host: 'example.com', fingerprint: 'abc123' }]); - }); - - test('trustCertificate stores expiresAt relative to current time', async () => { - const clock = sinon.useFakeTimers({ now: Date.parse('2026-03-01T00:00:00.000Z') }); - const { trust, storage } = createTrust(); - trust.connectStorage(storage.asService()); - - await trust.trustCertificate('example.com', 'abc123'); - - const persisted = JSON.parse(storage.read(STORAGE_KEY)!); - const [entry] = persisted['test-session'].trustedCerts as { host: string; fingerprint: string; expiresAt: number }[]; - assert.strictEqual(entry.host, 'example.com'); - assert.strictEqual(entry.fingerprint, 'abc123'); - assert.strictEqual(entry.expiresAt, Date.now() + TRUST_DURATION_MS); - - clock.restore(); - }); - - test('trust is valid at expiration and invalid after expiration', async () => { - const clock = sinon.useFakeTimers({ now: Date.parse('2026-03-01T00:00:00.000Z') }); - const { trust, electronSession, storage } = createTrust(); - const webContents = new TestWebContents(); - trust.installCertErrorHandler(webContents.asWebContents()); - trust.connectStorage(storage.asService()); - await trust.trustCertificate('example.com', 'abc123'); - electronSession.closeAllConnections.resetHistory(); - - // Prior to the expiration boundary, trust should still be valid - clock.tick(TRUST_DURATION_MS - 10); - let callbackResult: boolean | undefined; - const firstEvent = { preventDefault: sinon.spy() }; - webContents.emit('certificate-error', firstEvent, 'https://example.com', 'ERR_CERT', createCertificate('abc123'), (value: boolean) => { - callbackResult = value; - }); - assert.strictEqual(callbackResult, true); - - // After expiration, trust should be revoked - clock.tick(20); - const secondEvent = { preventDefault: sinon.spy() }; - webContents.emit('certificate-error', secondEvent, 'https://example.com', 'ERR_CERT', createCertificate('abc123'), (value: boolean) => { - callbackResult = value; - }); - assert.strictEqual(callbackResult, false); - - clock.restore(); - }); - - test('connectStorage restores valid trust entries and prunes expired ones', () => runWithFakedTimers({ useFakeTimers: true }, async () => { - const { trust, storage } = createTrust(); - const webContents = new TestWebContents(); - trust.installCertErrorHandler(webContents.asWebContents()); - storage.seed(STORAGE_KEY, JSON.stringify({ - 'test-session': { - trustedCerts: [ - { host: 'valid.example.com', fingerprint: 'valid', expiresAt: Date.now() + 1000 }, - { host: 'expired.example.com', fingerprint: 'expired', expiresAt: Date.now() - 1000 } - ] - } - })); - - trust.connectStorage(storage.asService()); - - let callbackResult: boolean | undefined; - const validEvent = { preventDefault: sinon.spy() }; - webContents.emit('certificate-error', validEvent, 'https://valid.example.com', 'ERR_CERT', createCertificate('valid'), (value: boolean) => { - callbackResult = value; - }); - assert.strictEqual(callbackResult, true); - - const expiredEvent = { preventDefault: sinon.spy() }; - webContents.emit('certificate-error', expiredEvent, 'https://expired.example.com', 'ERR_CERT', createCertificate('expired'), (value: boolean) => { - callbackResult = value; - }); - assert.strictEqual(callbackResult, false); - - const persisted = JSON.parse(storage.read(STORAGE_KEY)!); - assert.deepStrictEqual(persisted['test-session'].trustedCerts.map((entry: { host: string; fingerprint: string }) => ({ host: entry.host, fingerprint: entry.fingerprint })), [{ host: 'valid.example.com', fingerprint: 'valid' }]); - })); - - test('stored and reloaded trust expires and is pruned', async () => { - const clock = sinon.useFakeTimers({ now: Date.parse('2026-03-01T00:00:00.000Z') }); - - const storage = new TestApplicationStorageMainService(); - const firstSession = new TestElectronSession(); - const firstBrowserSession = new TestBrowserSession('test-session', firstSession.asSession()); - const firstTrust = new BrowserSessionTrust(firstBrowserSession.asBrowserSession()); - firstTrust.connectStorage(storage.asService()); - await firstTrust.trustCertificate('reload.example.com', 'reload-fingerprint'); - - clock.tick(TRUST_DURATION_MS + 1); - - const secondSession = new TestElectronSession(); - const secondBrowserSession = new TestBrowserSession('test-session', secondSession.asSession()); - const secondTrust = new BrowserSessionTrust(secondBrowserSession.asBrowserSession()); - const webContents = new TestWebContents(); - secondTrust.installCertErrorHandler(webContents.asWebContents()); - secondTrust.connectStorage(storage.asService()); - - let callbackResult: boolean | undefined; - const event = { preventDefault: sinon.spy() }; - webContents.emit('certificate-error', event, 'https://reload.example.com', 'ERR_CERT', createCertificate('reload-fingerprint'), (value: boolean) => { - callbackResult = value; - }); - assert.strictEqual(callbackResult, false); - assert.strictEqual(storage.read(STORAGE_KEY), undefined); - - clock.restore(); - }); - - test('untrustCertificate removes persisted trust and closes connections', async () => { - const { trust, electronSession, storage } = createTrust(); - trust.connectStorage(storage.asService()); - await trust.trustCertificate('example.com', 'abc123'); - electronSession.closeAllConnections.resetHistory(); - storage.store.resetHistory(); - - await trust.untrustCertificate('example.com', 'abc123'); - - assert.strictEqual(electronSession.closeAllConnections.calledOnce, true); - assert.strictEqual(storage.remove.calledOnceWithExactly(STORAGE_KEY, StorageScope.APPLICATION), true); - assert.strictEqual(storage.read(STORAGE_KEY), undefined); - }); - - test('untrustCertificate throws when certificate is not found', async () => { - const { trust, electronSession, storage } = createTrust(); - trust.connectStorage(storage.asService()); - - await assert.rejects( - () => trust.untrustCertificate('missing.example.com', 'missing-fingerprint'), - error => { - assert.ok(error instanceof Error); - assert.strictEqual(error.message, 'Certificate not found: host=missing.example.com fingerprint=missing-fingerprint'); - return true; - } - ); - assert.strictEqual(electronSession.closeAllConnections.called, false); - }); - - test('clear removes trust, clears cert errors, and closes connections', async () => { - const { trust, electronSession, storage } = createTrust(); - trust.connectStorage(storage.asService()); - await trust.trustCertificate('example.com', 'abc123'); - invokeVerifyProc(electronSession, { - hostname: 'example.com', - errorCode: -202, - verificationResult: 'net::ERR_CERT_COMMON_NAME_INVALID', - certificate: createCertificate('abc123') - }); - - await trust.clear(); - - assert.strictEqual(electronSession.closeAllConnections.calledOnce, true); - assert.strictEqual(trust.getCertificateError('https://example.com'), undefined); - assert.strictEqual(storage.read(STORAGE_KEY), undefined); - }); - - test('installCertErrorHandler only allows trusted certificates', async () => { - const { trust } = createTrust(); - const webContents = new TestWebContents(); - trust.installCertErrorHandler(webContents.asWebContents()); - - let callbackResult: boolean | undefined; - const firstEvent = { preventDefault: sinon.spy() }; - webContents.emit('certificate-error', firstEvent, 'https://example.com', 'ERR_CERT', createCertificate('abc123'), (value: boolean) => { - callbackResult = value; - }); - assert.strictEqual(callbackResult, false); - assert.strictEqual(firstEvent.preventDefault.calledOnce, true); - - await trust.trustCertificate('example.com', 'abc123'); - const secondEvent = { preventDefault: sinon.spy() }; - webContents.emit('certificate-error', secondEvent, 'https://example.com', 'ERR_CERT', createCertificate('abc123'), (value: boolean) => { - callbackResult = value; - }); - assert.strictEqual(callbackResult, true); - assert.strictEqual(secondEvent.preventDefault.calledOnce, true); - }); - - ensureNoDisposablesAreLeakedInTestSuite(); -}); diff --git a/src/vs/platform/turbofile/common/turbofile.ts b/src/vs/platform/turbofile/common/turbofile.ts index 666a028c..9ee39746 100644 --- a/src/vs/platform/turbofile/common/turbofile.ts +++ b/src/vs/platform/turbofile/common/turbofile.ts @@ -17,18 +17,67 @@ export interface ITurboFileSshService { detectCapabilities(options: ITurboFileSshConnectionOptions): Promise; installTransferDependencies(options: ITurboFileSshConnectionOptions, packages: readonly TurboFileTransferDependency[]): Promise; listRemoteDirectory(options: ITurboFileSshConnectionOptions, path: string, refresh?: boolean): Promise; + /** + * Lists a remote directory returning only names + type, skipping the per-file stat for size and + * modified time. This loads huge directories instantly (one round trip, no stat); the missing + * size/modified are filled in afterwards for visible rows via {@link statRemoteEntries}. + */ + listRemoteDirectoryNames(options: ITurboFileSshConnectionOptions, path: string, refresh?: boolean): Promise; + /** + * Fetches size + modified time for specific entries of a remote directory in a single round trip. + * Used to lazily enrich the rows currently visible after a names-only listing. + */ + statRemoteEntries(options: ITurboFileSshConnectionOptions, directoryPath: string, names: readonly string[]): Promise; listLocalDirectory(path: string, refresh?: boolean): Promise; + listLocalDirectoryNames(path: string, refresh?: boolean): Promise; + statLocalEntries(directoryPath: string, names: readonly string[]): Promise; + normalizeLocalWindowsPaths(paths: readonly string[]): Promise; + /** + * Resolves the real local Desktop directory, honoring a Desktop relocated to another drive + * (e.g. D:\Desktop) instead of assuming the profile's `%USERPROFILE%\Desktop`. Returns an empty + * string if it cannot be determined. + */ + getLocalDesktopPath(): Promise; transferFile(request: ITurboFileTransferRequest): Promise; transferBatch(request: ITurboFileBatchTransferRequest): Promise; cancelTransfer(progressId: string): Promise; createDirectory(endpoint: ITurboFileTransferEndpoint): Promise; + movePathsToTrash(request: ITurboFileMoveToTrashRequest): Promise; + listTrashItems(device: ITurboFileTrashDevice): Promise; + restoreTrashItems(request: ITurboFileRestoreTrashRequest): Promise; + permanentlyDeleteTrashItems(request: ITurboFileTrashItemsRequest): Promise; + emptyTrash(device: ITurboFileTrashDevice): Promise; + getTrashState(device: ITurboFileTrashDevice): Promise; deletePath(endpoint: ITurboFileTransferEndpoint, recursive?: boolean): Promise; + /** + * Deletes many paths in as few round trips as possible (POSIX remote: a single `xargs -0 rm`). + * All endpoints must target the same device/connection. + */ + deletePaths(endpoints: readonly ITurboFileTransferEndpoint[], recursive?: boolean): Promise; renamePath(endpoint: ITurboFileTransferEndpoint, targetPath: string): Promise; + /** + * Moves many paths into a single target directory in as few round trips as possible (POSIX remote: + * one `xargs -0 mv` — a metadata-only rename, no data copied). All sources must be on the same + * device as the target directory. + */ + movePathsIntoDirectory(sources: readonly ITurboFileTransferEndpoint[], targetDirectory: string): Promise; calculatePathSize(endpoint: ITurboFileTransferEndpoint): Promise; readWindowsClipboardFiles(): Promise; startWindowsFileDrag(request: ITurboFileStartWindowsDragRequest): Promise; readFile(endpoint: ITurboFileTransferEndpoint, options?: ITurboFileReadFileOptions): Promise; readImage(endpoint: ITurboFileTransferEndpoint, options?: ITurboFileReadImageOptions): Promise; + /** + * Reads the raw bytes of many files belonging to the same device in a single round trip (SSH: one + * `cat` stream, no per-file processing). Used to load a whole visible page of image-grid + * thumbnails at once instead of one fetch per card. The endpoint provides the connection; `paths` + * are absolute remote paths. Files larger than the batch limit come back with `oversized: true` so + * the caller can fall back to the per-image path. + */ + readImageBatch(endpoint: ITurboFileTransferEndpoint, paths: readonly string[], maxWidth?: number): Promise; + /** Reads file stat and original image dimensions in bounded batches for stable grid sorting. */ + readImageMetadataBatch(endpoint: ITurboFileTransferEndpoint, paths: readonly string[]): Promise; + createImageResource(endpoint: ITurboFileTransferEndpoint, options?: ITurboFileImageResourceOptions): Promise; + releaseImageResource(url: string): Promise; writeClipboardImage(content: string, mime: string, encoding?: TurboFileContentEncoding): Promise; writeFile(endpoint: ITurboFileTransferEndpoint, content: string, encoding?: TurboFileContentEncoding): Promise; compressPath(endpoint: ITurboFileTransferEndpoint, targetPath?: string): Promise; @@ -77,6 +126,25 @@ export interface ITurboFileDirectoryListing { readonly entries: ITurboFileFileEntry[]; } +export interface ITurboFileEntryStat { + readonly name: string; + readonly size: number; + readonly modified: number; +} + +export interface ITurboFileImageBatchEntry { + readonly path: string; + readonly ok: boolean; + /** Base64-encoded raw file bytes when `ok`. */ + readonly content?: string; + readonly mime?: string; + /** True when the file exceeded the batch size limit; the caller should load it individually. */ + readonly oversized?: boolean; + readonly message?: string; + /** True when the batch transport failed transiently; callers should retry instead of caching a file error. */ + readonly transient?: boolean; +} + export type TurboFileEndpointKind = 'local' | 'ssh'; export type TurboFileTransferProtocol = 'auto-fast' | 'sftp' | 'scp' | 'rsync' | 'tar'; export type TurboFileTransferDependency = 'rsync' | 'sshpass'; @@ -114,6 +182,74 @@ export interface ITurboFileTransferEndpoint { readonly preferredProtocol?: TurboFileTransferProtocol; } +export interface ITurboFileTrashDevice { + readonly kind: TurboFileEndpointKind; + readonly connection?: ITurboFileSshConnectionOptions; + readonly label?: string; + readonly localUserIdentity?: string; + readonly windows?: boolean; +} + +export interface ITurboFileTrashSource { + readonly endpoint: ITurboFileTransferEndpoint; + readonly type: TurboFileFileType; + readonly knownSize?: number; +} + +export interface ITurboFileMoveToTrashRequest { + readonly device: ITurboFileTrashDevice; + readonly sources: readonly ITurboFileTrashSource[]; +} + +export interface ITurboFileTrashItemsRequest { + readonly device: ITurboFileTrashDevice; + readonly items: readonly ITurboFileTrashItem[]; +} + +export interface ITurboFileRestoreTrashRequest extends ITurboFileTrashItemsRequest { + readonly conflictPolicy: 'cancel' | 'keepBoth'; +} + +export interface ITurboFileTrashItem { + readonly id: string; + readonly originalName: string; + readonly originalPath: string; + readonly deletedAt: number; + readonly type: TurboFileFileType; + readonly knownSize?: number; + readonly volumeId: string; + readonly trashRoot: string; + readonly payloadPath: string; + readonly deviceIdentity: string; + readonly damaged?: boolean; + readonly damageMessage?: string; +} + +export interface ITurboFileTrashItemResult { + readonly id?: string; + readonly path: string; + readonly ok: boolean; + readonly message: string; + readonly conflict?: boolean; + readonly restoredPath?: string; +} + +export interface ITurboFileTrashOperationResult extends ITurboFileFileOperationResult { + readonly completed: number; + readonly failed: number; + readonly results: readonly ITurboFileTrashItemResult[]; +} + +export interface ITurboFileTrashListing extends ITurboFileFileOperationResult { + readonly items: readonly ITurboFileTrashItem[]; + readonly inaccessibleVolumes: readonly string[]; +} + +export interface ITurboFileTrashState extends ITurboFileFileOperationResult { + readonly hasItems: boolean; + readonly itemCount: number; +} + export interface ITurboFileTransferRequest { readonly source: ITurboFileTransferEndpoint; readonly target: ITurboFileTransferEndpoint; @@ -164,9 +300,18 @@ export interface ITurboFileWindowsDragItem { export interface ITurboFileStartWindowsDragRequest { readonly source: ITurboFileTransferEndpoint; readonly items: readonly ITurboFileWindowsDragItem[]; + readonly operation?: 'copy' | 'move'; readonly progressId?: string; } +export interface ITurboFileImageMetadataEntry { + readonly path: string; + readonly size: number; + readonly modified: number; + readonly width?: number; + readonly height?: number; +} + export interface ITurboFileStartWindowsDragResult extends ITurboFileFileOperationResult { readonly targetDirectory?: string; readonly canceled?: boolean; @@ -251,6 +396,16 @@ export interface ITurboFileReadImageOptions extends ITurboFileReadFileOptions { readonly sourceModified?: number; } +export interface ITurboFileImageResourceOptions extends Omit { +} + +export interface ITurboFileImageResourceResult { + readonly ok: boolean; + readonly message: string; + readonly url?: string; + readonly mime?: string; +} + export interface ITurboFileReadFileResult { readonly ok: boolean; readonly message: string; @@ -260,4 +415,6 @@ export interface ITurboFileReadFileResult { readonly size: number; readonly width?: number; readonly height?: number; + readonly originalWidth?: number; + readonly originalHeight?: number; } diff --git a/src/vs/platform/turbofile/common/turbofileArchiveExtract.ts b/src/vs/platform/turbofile/common/turbofileArchiveExtract.ts new file mode 100644 index 00000000..007bdfe0 --- /dev/null +++ b/src/vs/platform/turbofile/common/turbofileArchiveExtract.ts @@ -0,0 +1,63 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export function isTurboFileTarFamilyArchivePath(path: string): boolean { + const lower = path.toLowerCase(); + return ['.tar', '.tar.gz', '.tgz', '.tar.bz2', '.tbz2', '.tar.xz', '.txz', '.tar.zst', '.tzst'].some(extension => lower.endsWith(extension)); +} + +export function buildTurboFileRemoteExtractCommand(path: string, quotedArchive: string): string { + const bsdtar = `bsdtar -xf ${quotedArchive} -C "$out"`; + const tar = `tar -xf ${quotedArchive} -C "$out"`; + if (isRarPath(path)) { + return `if command -v unrar >/dev/null 2>&1; then unrar x -o+ ${quotedArchive} "$out"/; elif command -v unar >/dev/null 2>&1; then unar -force-overwrite -output-directory "$out" ${quotedArchive}; elif command -v 7z >/dev/null 2>&1; then 7z x -y -o"$out" ${quotedArchive}; elif command -v bsdtar >/dev/null 2>&1; then ${bsdtar}; else echo 'No rar extractor (need unrar/unar/7z/bsdtar).' >&2; exit 2; fi`; + } + if (is7zPath(path)) { + return `if command -v 7z >/dev/null 2>&1; then 7z x -y -o"$out" ${quotedArchive}; elif command -v 7za >/dev/null 2>&1; then 7za x -y -o"$out" ${quotedArchive}; elif command -v bsdtar >/dev/null 2>&1; then ${bsdtar}; else echo 'No 7z extractor (need 7z/7za/bsdtar).' >&2; exit 2; fi`; + } + if (isZipPath(path)) { + return buildRemoteZipExtractCommand(quotedArchive, bsdtar, tar); + } + return `if command -v tar >/dev/null 2>&1; then ${tar}; elif command -v bsdtar >/dev/null 2>&1; then ${bsdtar}; else echo 'No tar extractor available.' >&2; exit 2; fi`; +} + +function buildRemoteZipExtractCommand(quotedArchive: string, bsdtar: string, tar: string): string { + const pythonScript = [ + 'import os, sys, zipfile', + 'archive, out = sys.argv[1], sys.argv[2]', + 'os.makedirs(out, exist_ok=True)', + 'try:', + ' zf = zipfile.ZipFile(archive, metadata_encoding="gbk")', + 'except TypeError:', + ' zf = zipfile.ZipFile(archive)', + 'with zf:', + ' zf.extractall(out)' + ].join('\n'); + const pythonExtract = `python3 -c ${quotePosixShellArg(pythonScript)} ${quotedArchive} "$out"`; + return [ + `if command -v unzip >/dev/null 2>&1 && unzip -O GBK -o ${quotedArchive} -d "$out"; then :;`, + `elif command -v python3 >/dev/null 2>&1 && ${pythonExtract}; then :;`, + `elif command -v bsdtar >/dev/null 2>&1; then ${bsdtar};`, + `elif command -v unzip >/dev/null 2>&1; then unzip -o ${quotedArchive} -d "$out";`, + `else ${tar};`, + 'fi' + ].join(' '); +} + +function isZipPath(path: string): boolean { + return path.toLowerCase().endsWith('.zip'); +} + +function isRarPath(path: string): boolean { + return path.toLowerCase().endsWith('.rar'); +} + +function is7zPath(path: string): boolean { + return path.toLowerCase().endsWith('.7z'); +} + +function quotePosixShellArg(value: string): string { + return `'${value.replace(/'/g, `'\\''`)}'`; +} diff --git a/src/vs/platform/turbofile/common/turbofileBatchTarPlan.ts b/src/vs/platform/turbofile/common/turbofileBatchTarPlan.ts index 0b38d114..63f88b73 100644 --- a/src/vs/platform/turbofile/common/turbofileBatchTarPlan.ts +++ b/src/vs/platform/turbofile/common/turbofileBatchTarPlan.ts @@ -10,14 +10,30 @@ export interface ITurboFileBatchTarPlan { readonly parentPath: string; readonly names: readonly string[]; readonly targetNames: readonly string[]; + readonly fileNames: readonly string[]; + readonly directoryNames: readonly string[]; + readonly unknownNames: readonly string[]; + readonly requiresSafeParentPath?: boolean; + readonly containsDirectories?: boolean; } +export interface ITurboFileBatchTarSourcePlan { + readonly command: string; + readonly input?: Uint8Array; +} + +/** + * Keep inline commands comfortably below platform ARG_MAX and the smaller + * per-argument limit applied to the shell's `-c` command string. + */ +export const TURBOFILE_BATCH_COMMAND_SAFE_BYTES = 32 * 1024; + export function createLocalBatchTarPlans(items: readonly ITurboFileBatchTransferItem[]): readonly ITurboFileBatchTarPlan[] | undefined { if (!items.length) { return undefined; } - const plans = new Map(); + const plans = new Map(); const seenTargetNames = new Set(); for (const item of items) { @@ -34,20 +50,63 @@ export function createLocalBatchTarPlans(items: readonly ITurboFileBatchTransfer const parentPath = dirname(sourcePath); let plan = plans.get(parentPath); if (!plan) { - plan = { parentPath, names: [], targetNames: [] }; + plan = { + parentPath, + names: [], + targetNames: [], + fileNames: [], + directoryNames: [], + unknownNames: [], + ...(requiresLocalTarSafeRootAlias(parentPath) ? { requiresSafeParentPath: true } : undefined) + }; plans.set(parentPath, plan); } plan.names.push(sourceName); plan.targetNames.push(item.targetName); + if (item.sourceType === 'directory') { + plan.directoryNames.push(sourceName); + plan.containsDirectories = true; + } else if (item.sourceType === 'file') { + plan.fileNames.push(sourceName); + } else { + plan.unknownNames.push(sourceName); + } } return Array.from(plans.values()); } -export function createTarNullListBuffer(entries: readonly string[]): Buffer { - return Buffer.from(`${entries.join('\0')}\0`, 'utf8'); +export function createTarNullListBuffer(entries: readonly string[]): Uint8Array { + if (entries.some(entry => entry.includes('\0'))) { + throw new Error('Tar manifest entries cannot contain NUL characters.'); + } + return new TextEncoder().encode(entries.length ? `${entries.join('\0')}\0` : ''); +} + +export function createRemoteBatchTarSourcePlan(parentPath: string, names: readonly string[]): ITurboFileBatchTarSourcePlan { + const inlineCommand = `command -v tar >/dev/null 2>&1 && tar -cf - -C ${quotePosixShellArg(parentPath)} -- ${names.map(quotePosixShellArg).join(' ')}`; + if (isTurboFileBatchCommandSafe(inlineCommand)) { + return { command: inlineCommand }; + } + + return { + command: `command -v tar >/dev/null 2>&1 && tar -cf - -C ${quotePosixShellArg(parentPath)} --null -T -`, + input: createTarNullListBuffer(names) + }; +} + +export function isTurboFileBatchCommandSafe(command: string): boolean { + return new TextEncoder().encode(command).byteLength <= TURBOFILE_BATCH_COMMAND_SAFE_BYTES; +} + +export function requiresLocalTarSafeRootAlias(path: string): boolean { + return /[^\x20-\x7E]/.test(path); } function trimTrailingPathSeparators(path: string): string { return path.replace(/[\\/]+$/g, ''); } + +function quotePosixShellArg(value: string): string { + return `'${value.replace(/'/g, `'"'"'`)}'`; +} diff --git a/src/vs/platform/turbofile/common/turbofileImageMetadata.ts b/src/vs/platform/turbofile/common/turbofileImageMetadata.ts new file mode 100644 index 00000000..bf256976 --- /dev/null +++ b/src/vs/platform/turbofile/common/turbofileImageMetadata.ts @@ -0,0 +1,285 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export interface ITurboFileImageDimensions { + readonly width: number; + readonly height: number; +} + +export function readTurboFileImageDimensions(name: string, bytes: Uint8Array): ITurboFileImageDimensions | undefined { + const detected = readPngDimensions(bytes) + ?? readJpegDimensions(bytes) + ?? readGifDimensions(bytes) + ?? readBmpDimensions(bytes) + ?? readWebpDimensions(bytes) + ?? readIcoDimensions(bytes) + ?? readTiffDimensions(bytes); + if (detected) { + return detected; + } + const lowerName = name.toLowerCase(); + return lowerName.endsWith('.avif') + ? readAvifDimensions(bytes) + : lowerName.endsWith('.svg') ? readSvgDimensions(bytes) : undefined; +} + +function dimensions(width: number, height: number): ITurboFileImageDimensions | undefined { + if (!Number.isSafeInteger(width) || !Number.isSafeInteger(height) || width <= 0 || height <= 0) { + return undefined; + } + return { width, height }; +} + +function matches(bytes: Uint8Array, offset: number, expected: readonly number[]): boolean { + if (offset < 0 || offset + expected.length > bytes.length) { + return false; + } + for (let index = 0; index < expected.length; index++) { + if (bytes[offset + index] !== expected[index]) { + return false; + } + } + return true; +} + +function matchesAscii(bytes: Uint8Array, offset: number, expected: string): boolean { + if (offset < 0 || offset + expected.length > bytes.length) { + return false; + } + for (let index = 0; index < expected.length; index++) { + if (bytes[offset + index] !== expected.charCodeAt(index)) { + return false; + } + } + return true; +} + +function dataView(bytes: Uint8Array): DataView { + return new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); +} + +function readPngDimensions(bytes: Uint8Array): ITurboFileImageDimensions | undefined { + if (bytes.length < 24 || !matches(bytes, 0, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) || !matchesAscii(bytes, 12, 'IHDR')) { + return undefined; + } + const view = dataView(bytes); + return dimensions(view.getUint32(16), view.getUint32(20)); +} + +function readJpegDimensions(bytes: Uint8Array): ITurboFileImageDimensions | undefined { + if (bytes.length < 4 || bytes[0] !== 0xff || bytes[1] !== 0xd8) { + return undefined; + } + + let offset = 2; + while (offset + 3 < bytes.length) { + while (offset < bytes.length && bytes[offset] !== 0xff) { + offset++; + } + while (offset < bytes.length && bytes[offset] === 0xff) { + offset++; + } + if (offset >= bytes.length) { + return undefined; + } + + const marker = bytes[offset++]; + if (marker === 0xd8 || marker === 0x01) { + continue; + } + if (marker === 0xd9 || marker === 0xda || offset + 2 > bytes.length) { + return undefined; + } + + const segmentLength = (bytes[offset] << 8) | bytes[offset + 1]; + if (segmentLength < 2 || offset + segmentLength > bytes.length) { + return undefined; + } + if (isJpegStartOfFrame(marker)) { + if (segmentLength < 7) { + return undefined; + } + return dimensions( + (bytes[offset + 5] << 8) | bytes[offset + 6], + (bytes[offset + 3] << 8) | bytes[offset + 4] + ); + } + offset += segmentLength; + } + return undefined; +} + +function isJpegStartOfFrame(marker: number): boolean { + return marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc; +} + +function readGifDimensions(bytes: Uint8Array): ITurboFileImageDimensions | undefined { + if (bytes.length < 10 || (!matchesAscii(bytes, 0, 'GIF87a') && !matchesAscii(bytes, 0, 'GIF89a'))) { + return undefined; + } + const view = dataView(bytes); + return dimensions(view.getUint16(6, true), view.getUint16(8, true)); +} + +function readBmpDimensions(bytes: Uint8Array): ITurboFileImageDimensions | undefined { + if (bytes.length < 26 || !matchesAscii(bytes, 0, 'BM')) { + return undefined; + } + const view = dataView(bytes); + const dibSize = view.getUint32(14, true); + if (dibSize === 12) { + return dimensions(view.getUint16(18, true), view.getUint16(20, true)); + } + if (dibSize < 16) { + return undefined; + } + return dimensions(view.getInt32(18, true), Math.abs(view.getInt32(22, true))); +} + +function readWebpDimensions(bytes: Uint8Array): ITurboFileImageDimensions | undefined { + if (bytes.length < 20 || !matchesAscii(bytes, 0, 'RIFF') || !matchesAscii(bytes, 8, 'WEBP')) { + return undefined; + } + + if (matchesAscii(bytes, 12, 'VP8X') && bytes.length >= 30) { + return dimensions(readUint24LE(bytes, 24) + 1, readUint24LE(bytes, 27) + 1); + } + if (matchesAscii(bytes, 12, 'VP8L') && bytes.length >= 25 && bytes[20] === 0x2f) { + const width = 1 + bytes[21] + ((bytes[22] & 0x3f) << 8); + const height = 1 + (bytes[22] >>> 6) + (bytes[23] << 2) + ((bytes[24] & 0x0f) << 10); + return dimensions(width, height); + } + if (matchesAscii(bytes, 12, 'VP8 ') && bytes.length >= 30) { + for (let offset = 20; offset + 6 < bytes.length; offset++) { + if (matches(bytes, offset, [0x9d, 0x01, 0x2a])) { + const view = dataView(bytes); + return dimensions(view.getUint16(offset + 3, true) & 0x3fff, view.getUint16(offset + 5, true) & 0x3fff); + } + } + } + return undefined; +} + +function readUint24LE(bytes: Uint8Array, offset: number): number { + return bytes[offset] | (bytes[offset + 1] << 8) | (bytes[offset + 2] << 16); +} + +function readIcoDimensions(bytes: Uint8Array): ITurboFileImageDimensions | undefined { + if (bytes.length < 22 || !matches(bytes, 0, [0, 0, 1, 0]) || bytes[4] === 0 && bytes[5] === 0) { + return undefined; + } + const count = dataView(bytes).getUint16(4, true); + let best: ITurboFileImageDimensions | undefined; + for (let index = 0; index < count; index++) { + const offset = 6 + index * 16; + if (offset + 16 > bytes.length) { + break; + } + const candidate = dimensions(bytes[offset] || 256, bytes[offset + 1] || 256); + if (candidate && (!best || candidate.width * candidate.height > best.width * best.height)) { + best = candidate; + } + } + return best; +} + +function readTiffDimensions(bytes: Uint8Array): ITurboFileImageDimensions | undefined { + if (bytes.length < 10) { + return undefined; + } + const littleEndian = matchesAscii(bytes, 0, 'II'); + if (!littleEndian && !matchesAscii(bytes, 0, 'MM')) { + return undefined; + } + const view = dataView(bytes); + if (view.getUint16(2, littleEndian) !== 42) { + return undefined; + } + const directoryOffset = view.getUint32(4, littleEndian); + if (directoryOffset + 2 > bytes.length) { + return undefined; + } + const entryCount = view.getUint16(directoryOffset, littleEndian); + let width: number | undefined; + let height: number | undefined; + for (let index = 0; index < entryCount; index++) { + const offset = directoryOffset + 2 + index * 12; + if (offset + 12 > bytes.length) { + break; + } + const tag = view.getUint16(offset, littleEndian); + if (tag !== 256 && tag !== 257) { + continue; + } + const type = view.getUint16(offset + 2, littleEndian); + const count = view.getUint32(offset + 4, littleEndian); + if (count !== 1 || (type !== 3 && type !== 4)) { + continue; + } + const value = type === 3 ? view.getUint16(offset + 8, littleEndian) : view.getUint32(offset + 8, littleEndian); + if (tag === 256) { + width = value; + } else { + height = value; + } + } + return width !== undefined && height !== undefined ? dimensions(width, height) : undefined; +} + +function readSvgDimensions(bytes: Uint8Array): ITurboFileImageDimensions | undefined { + let source: string; + try { + source = new TextDecoder().decode(bytes); + } catch { + return undefined; + } + const match = /]*>/i.exec(source); + if (!match) { + return undefined; + } + const tag = match[0]; + const widthAttribute = readSvgNumberAttribute(tag, 'width'); + const heightAttribute = readSvgNumberAttribute(tag, 'height'); + if ((widthAttribute.value !== undefined && widthAttribute.value <= 0) || (heightAttribute.value !== undefined && heightAttribute.value <= 0)) { + return undefined; + } + if (widthAttribute.value !== undefined && heightAttribute.value !== undefined) { + return dimensions(widthAttribute.value, heightAttribute.value); + } + const viewBox = /\bviewBox\s*=\s*(["'])(.*?)\1/i.exec(tag); + if (!viewBox) { + return undefined; + } + const values = viewBox[2].trim().split(/[\s,]+/).map(value => Number(value)); + return values.length === 4 ? dimensions(values[2], values[3]) : undefined; +} + +function readSvgNumberAttribute(tag: string, name: string): { readonly present: boolean; readonly value: number | undefined } { + const match = new RegExp(`(?:^|[\\s<])${name}\\s*=\\s*(["'])(.*?)\\1`, 'i').exec(tag); + if (!match) { + return { present: false, value: undefined }; + } + const raw = match[2].trim(); + if (!raw || raw.endsWith('%')) { + return { present: true, value: undefined }; + } + const value = Number.parseFloat(raw); + return { present: true, value: Number.isFinite(value) ? value : undefined }; +} + +function readAvifDimensions(bytes: Uint8Array): ITurboFileImageDimensions | undefined { + let best: ITurboFileImageDimensions | undefined; + for (let offset = 4; offset + 16 <= bytes.length; offset++) { + if (!matchesAscii(bytes, offset, 'ispe')) { + continue; + } + const view = dataView(bytes); + const candidate = dimensions(view.getUint32(offset + 8), view.getUint32(offset + 12)); + if (candidate && (!best || candidate.width * candidate.height > best.width * best.height)) { + best = candidate; + } + } + return best; +} diff --git a/src/vs/platform/turbofile/common/turbofileImageMetadataBatch.ts b/src/vs/platform/turbofile/common/turbofileImageMetadataBatch.ts new file mode 100644 index 00000000..1df8e9f3 --- /dev/null +++ b/src/vs/platform/turbofile/common/turbofileImageMetadataBatch.ts @@ -0,0 +1,79 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { readTurboFileImageDimensions } from './turbofileImageMetadata.js'; + +export interface ITurboFileParsedImageMetadata { + readonly path: string; + readonly size: number; + readonly modified: number; + readonly width?: number; + readonly height?: number; +} + +/** + * Parses a sequence of NUL-delimited metadata fields followed by an exact-length binary header. + * Header bytes may contain arbitrary zeros, so only the declared length advances to the next frame. + */ +export function parseTurboFileImageMetadataFrames(bytes: Uint8Array): ITurboFileParsedImageMetadata[] { + const result: ITurboFileParsedImageMetadata[] = []; + let offset = 0; + while (offset < bytes.byteLength) { + const fields: string[] = []; + for (let fieldIndex = 0; fieldIndex < 6; fieldIndex++) { + const field = readNullTerminatedField(bytes, offset); + if (!field) { + return result; + } + fields.push(field.value); + offset = field.nextOffset; + } + + const [path, sizeText, modifiedText, widthText, heightText, headerLengthText] = fields; + const size = Number(sizeText); + const modifiedSeconds = Number(modifiedText); + const suppliedWidth = Number(widthText); + const suppliedHeight = Number(heightText); + const headerLength = Number(headerLengthText); + if (!path || !isNonNegativeSafeInteger(size) || !Number.isFinite(modifiedSeconds) || modifiedSeconds < 0 || !isNonNegativeSafeInteger(headerLength) || offset + headerLength > bytes.byteLength) { + return result; + } + + const header = bytes.subarray(offset, offset + headerLength); + offset += headerLength; + const parsed = isPositiveSafeInteger(suppliedWidth) && isPositiveSafeInteger(suppliedHeight) + ? { width: suppliedWidth, height: suppliedHeight } + : readTurboFileImageDimensions(path, header); + result.push({ + path, + size, + modified: Math.round(modifiedSeconds * 1000), + ...(parsed ? { width: parsed.width, height: parsed.height } : {}) + }); + } + return result; +} + +function readNullTerminatedField(bytes: Uint8Array, offset: number): { readonly value: string; readonly nextOffset: number } | undefined { + let end = offset; + while (end < bytes.byteLength && bytes[end] !== 0) { + end++; + } + if (end >= bytes.byteLength) { + return undefined; + } + return { + value: new TextDecoder().decode(bytes.subarray(offset, end)), + nextOffset: end + 1 + }; +} + +function isPositiveSafeInteger(value: number): boolean { + return Number.isSafeInteger(value) && value > 0; +} + +function isNonNegativeSafeInteger(value: number): boolean { + return Number.isSafeInteger(value) && value >= 0; +} diff --git a/src/vs/platform/turbofile/common/turbofileKeyedGeneration.ts b/src/vs/platform/turbofile/common/turbofileKeyedGeneration.ts new file mode 100644 index 00000000..4e865e65 --- /dev/null +++ b/src/vs/platform/turbofile/common/turbofileKeyedGeneration.ts @@ -0,0 +1,31 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export class TurboFileKeyedGeneration { + private sequence = 0; + private readonly currentByKey = new Map(); + + next(key: string): number { + const generation = ++this.sequence; + this.currentByKey.set(key, generation); + return generation; + } + + current(key: string): number | undefined { + return this.currentByKey.get(key); + } + + isCurrent(key: string, generation: number): boolean { + return this.currentByKey.get(key) === generation; + } + + clearIfCurrent(key: string, generation: number): boolean { + if (!this.isCurrent(key, generation)) { + return false; + } + this.currentByKey.delete(key); + return true; + } +} diff --git a/src/vs/platform/turbofile/common/turbofileOperationQueue.ts b/src/vs/platform/turbofile/common/turbofileOperationQueue.ts new file mode 100644 index 00000000..0923091a --- /dev/null +++ b/src/vs/platform/turbofile/common/turbofileOperationQueue.ts @@ -0,0 +1,179 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export interface ITurboFileOperationQueueOptions { + readonly id?: string; + readonly onQueued?: () => void; + readonly onStarted?: () => void; +} + +export interface ITurboFileOperationAccess { + readonly read?: readonly string[]; + readonly write?: readonly string[]; +} + +interface ITurboFileQueuedOperation { + readonly readKeys: readonly string[]; + readonly writeKeys: readonly string[]; + readonly keys: ReadonlySet; + readonly operation: () => Promise; + readonly options: ITurboFileOperationQueueOptions; + readonly resolve: (value: T | PromiseLike) => void; + readonly reject: (reason?: unknown) => void; + started: boolean; +} + +interface ITurboFileActiveAccess { + readers: number; + writer: boolean; +} + +export class TurboFileOperationCanceledError extends Error { + constructor() { + super('Operation canceled while waiting for the device.'); + this.name = 'TurboFileOperationCanceledError'; + } +} + +/** + * Coordinates shared reads and exclusive writes without imposing a global + * lock. Operations acquire all sorted device keys atomically, so opposite + * transfer directions cannot deadlock. Disjoint devices remain concurrent. + */ +export class TurboFileOperationQueue { + private readonly pending: ITurboFileQueuedOperation[] = []; + private readonly activeAccess = new Map(); + + run(keys: readonly string[], operation: () => Promise, options: ITurboFileOperationQueueOptions = {}): Promise { + return this.runWithAccess({ write: keys }, operation, options); + } + + /** Runs an operation with shared access to each device key. */ + runShared(keys: readonly string[], operation: () => Promise, options: ITurboFileOperationQueueOptions = {}): Promise { + return this.runWithAccess({ read: keys }, operation, options); + } + + /** + * Acquires all requested device keys atomically. Read access may overlap other + * readers, while write access is exclusive. A key requested for both modes is + * normalized to write access. + */ + runWithAccess(access: ITurboFileOperationAccess, operation: () => Promise, options: ITurboFileOperationQueueOptions = {}): Promise { + const writeKeys = [...new Set((access.write ?? []).filter(Boolean))].sort(); + const writeKeySet = new Set(writeKeys); + const readKeys = [...new Set((access.read ?? []).filter(key => Boolean(key) && !writeKeySet.has(key)))].sort(); + return new Promise((resolve, reject) => { + const entry: ITurboFileQueuedOperation = { + readKeys, + writeKeys, + keys: new Set([...readKeys, ...writeKeys]), + operation, + options, + resolve, + reject, + started: false + }; + this.pending.push(entry as ITurboFileQueuedOperation); + this.drain(); + if (!entry.started) { + this.notify(options.onQueued); + } + }); + } + + cancel(id: string): boolean { + const index = this.pending.findIndex(entry => !entry.started && entry.options.id === id); + if (index < 0) { + return false; + } + const [entry] = this.pending.splice(index, 1); + entry.reject(new TurboFileOperationCanceledError()); + this.drain(); + return true; + } + + private drain(): void { + for (let index = 0; index < this.pending.length;) { + const entry = this.pending[index]; + if (!this.canStart(entry, index)) { + index++; + continue; + } + + this.pending.splice(index, 1); + entry.started = true; + this.acquire(entry); + this.notify(entry.options.onStarted); + void Promise.resolve() + .then(entry.operation) + .finally(() => { + this.release(entry); + this.drain(); + }) + .then(entry.resolve, entry.reject); + } + } + + private canStart(entry: ITurboFileQueuedOperation, index: number): boolean { + for (const key of entry.writeKeys) { + if (this.activeAccess.has(key)) { + return false; + } + } + for (const key of entry.readKeys) { + if (this.activeAccess.get(key)?.writer) { + return false; + } + } + for (let earlierIndex = 0; earlierIndex < index; earlierIndex++) { + const earlier = this.pending[earlierIndex]; + if (this.conflicts(entry, earlier)) { + return false; + } + } + return true; + } + + private conflicts(entry: ITurboFileQueuedOperation, earlier: ITurboFileQueuedOperation): boolean { + return entry.writeKeys.some(key => earlier.keys.has(key)) + || entry.readKeys.some(key => earlier.writeKeys.includes(key)); + } + + private acquire(entry: ITurboFileQueuedOperation): void { + for (const key of entry.writeKeys) { + this.activeAccess.set(key, { readers: 0, writer: true }); + } + for (const key of entry.readKeys) { + const active = this.activeAccess.get(key); + if (active) { + active.readers++; + } else { + this.activeAccess.set(key, { readers: 1, writer: false }); + } + } + } + + private release(entry: ITurboFileQueuedOperation): void { + for (const key of entry.writeKeys) { + this.activeAccess.delete(key); + } + for (const key of entry.readKeys) { + const active = this.activeAccess.get(key); + if (!active || active.readers <= 1) { + this.activeAccess.delete(key); + } else { + active.readers--; + } + } + } + + private notify(callback: (() => void) | undefined): void { + try { + callback?.(); + } catch { + // Observers only update UI state and must never strand acquired device keys. + } + } +} diff --git a/src/vs/platform/turbofile/common/turbofilePosixHeartbeat.ts b/src/vs/platform/turbofile/common/turbofilePosixHeartbeat.ts new file mode 100644 index 00000000..da31c34a --- /dev/null +++ b/src/vs/platform/turbofile/common/turbofilePosixHeartbeat.ts @@ -0,0 +1,13 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Returns a Bash 4.0-compatible helper that forwards command output while reporting a dot on + * stderr every 30 seconds. The command status travels through a coprocess pipe, so both fast + * success and fast failure wake immediately without a background sleep holding the SSH channel. + */ +export function buildPosixHeartbeatFunction(): string { + return 'run_with_heartbeat() { exec 8<&0 9>&1; coproc TURBOFILE_HEARTBEAT_COMMAND { set +e; "$@" <&8 >&9; command_status=$?; printf "%s\\n" "$command_status"; exit "$command_status"; }; command_pid=$TURBOFILE_HEARTBEAT_COMMAND_PID; status_fd=${TURBOFILE_HEARTBEAT_COMMAND[0]}; exec 8<&- 9>&-; is_command_running() { while IFS= read -r running_pid; do if [ "$running_pid" = "$command_pid" ]; then return 0; fi; done < <(jobs -pr); return 1; }; while true; do command_status=; if IFS= read -r -t 30 command_status <&"$status_fd"; then wait "$command_pid" 2>/dev/null || true; eval "exec ${status_fd}<&-"; return "$command_status"; fi; if is_command_running; then printf "." >&2; continue; fi; command_status=0; wait "$command_pid" || command_status=$?; eval "exec ${status_fd}<&-"; return "$command_status"; done; }'; +} diff --git a/src/vs/platform/turbofile/common/turbofileRemoteDirectoryRetry.ts b/src/vs/platform/turbofile/common/turbofileRemoteDirectoryRetry.ts new file mode 100644 index 00000000..5ca2bae1 --- /dev/null +++ b/src/vs/platform/turbofile/common/turbofileRemoteDirectoryRetry.ts @@ -0,0 +1,24 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +const nonRetryableRemoteDirectoryErrors = /no such file|no such file or directory|file not found|path not found|enoent|permission denied|not a directory/i; + +const retryableRemoteDirectoryErrors = /no response from server|not connected|connection (?:lost|closed|reset|timed out)|econnreset|socket.*closed|channel.*(?:closed|not open|not available)|sftp.*(?:closed|ended|not connected)|remote directory read timed out|remote command (?:timed out|produced no progress)/i; + +const remoteDirectoryReadRetryDelays = [100, 250] as const; + +export const REMOTE_DIRECTORY_READ_MAX_ATTEMPTS = remoteDirectoryReadRetryDelays.length + 1; + +export function getRemoteDirectoryReadRetryDelay(attempt: number): number | undefined { + return remoteDirectoryReadRetryDelays[attempt]; +} + +export function shouldRetryRemoteDirectoryRead(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + if (nonRetryableRemoteDirectoryErrors.test(message)) { + return false; + } + return retryableRemoteDirectoryErrors.test(message); +} diff --git a/src/vs/platform/turbofile/common/turbofileRemoteNativeBatchPlan.ts b/src/vs/platform/turbofile/common/turbofileRemoteNativeBatchPlan.ts new file mode 100644 index 00000000..20d81207 --- /dev/null +++ b/src/vs/platform/turbofile/common/turbofileRemoteNativeBatchPlan.ts @@ -0,0 +1,73 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { ITurboFileBatchTransferItem } from './turbofile.js'; +import { buildPosixHeartbeatFunction } from './turbofilePosixHeartbeat.js'; + +export interface ITurboFilePosixNativeBatchTransferPlan { + readonly command: string; + readonly input: Uint8Array; +} + +export function createPosixNativeBatchTransferPlan(items: readonly ITurboFileBatchTransferItem[], targetDirectory: string, operation: 'copy' | 'move'): ITurboFilePosixNativeBatchTransferPlan { + const validItems = items.filter(item => !!item.sourcePath && !!item.targetName); + const targetNames = validItems.map(item => item.targetName); + if (new Set(targetNames).size !== targetNames.length) { + throw new Error('Duplicate target names are not allowed in a native batch transfer.'); + } + if ([targetDirectory, ...validItems.flatMap(item => [item.sourcePath, item.targetName])].some(value => value.includes('\0'))) { + throw new Error('Native batch transfer paths cannot contain NUL characters.'); + } + const target = quotePosixShellArg(targetDirectory); + const entries = validItems.flatMap(item => [item.sourcePath, item.targetName, item.sourceType ?? 'unknown']); + const script = [ + 'set -e', + 'target=$1', + 'operation=$2', + 'mkdir -p -- "$target"', + 'sources=()', + 'names=()', + 'types=()', + 'basename_batch=1', + "while IFS= read -r -d '' source && IFS= read -r -d '' name && IFS= read -r -d '' type; do", + ' trimmed=${source%/}; base=${trimmed##*/}; if [ "$base" != "$name" ]; then basename_batch=0; fi', + ' sources+=("$source"); names+=("$name"); types+=("$type")', + 'done', + 'if [ "$basename_batch" -eq 0 ]; then for name in "${names[@]}"; do dst="$target/$name"; if [ -e "$dst" ] || [ -L "$dst" ]; then printf \'%s\\n\' "Target already exists: $dst" >&2; exit 73; fi; done; fi', + 'chunk_size=128', + 'copy_args=(-a -n)', + "if cp --help 2>/dev/null | grep -q -- '--reflink'; then copy_args+=(--reflink=auto --sparse=always); fi", + buildPosixHeartbeatFunction(), + 'copy_all() {', + ' local total=${#sources[@]} start index dst', + ' if [ "$basename_batch" -eq 1 ]; then', + ' for ((start=0; start&2; done', + ' else', + ' for ((index=0; index&2; fi; done', + ' fi', + '}', + 'move_all() {', + ' local total=${#sources[@]} start index source dst', + ' if [ "$basename_batch" -eq 1 ]; then', + ' for ((start=0; start&2; done', + ' else', + ' for ((index=0; index&2; fi; done', + ' fi', + '}', + 'if [ "$operation" = move ]; then run_with_heartbeat move_all; else run_with_heartbeat copy_all; fi' + ].join('\n'); + return { + command: `bash -lc ${quotePosixShellArg(script)} _ ${target} ${quotePosixShellArg(operation)}`, + input: createNullDelimitedBuffer(entries) + }; +} + +function createNullDelimitedBuffer(entries: readonly string[]): Uint8Array { + return new TextEncoder().encode(entries.length ? `${entries.join('\0')}\0` : ''); +} + +function quotePosixShellArg(value: string): string { + return "'" + value.replace(/'/g, "'\\''") + "'"; +} diff --git a/src/vs/platform/turbofile/common/turbofileRemoteStat.ts b/src/vs/platform/turbofile/common/turbofileRemoteStat.ts new file mode 100644 index 00000000..093c3cbc --- /dev/null +++ b/src/vs/platform/turbofile/common/turbofileRemoteStat.ts @@ -0,0 +1,45 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { ITurboFileEntryStat } from './turbofile.js'; + +export function buildPosixStatEntriesCommand(directoryPath: string, names: readonly string[]): string { + const args = names.map(name => quotePosixShellArg(`./${normalizeRemoteEntryName(name)}`)).join(' '); + return `cd ${quotePosixShellArg(directoryPath)} && { LC_ALL=C stat --printf='%n\\0%s\\0%Y\\0' -- ${args} || true; }`; +} + +export function parseRemoteStatEntries(text: string): ITurboFileEntryStat[] { + if (!text) { + return []; + } + const parts = text.split('\0'); + const stats: ITurboFileEntryStat[] = []; + for (let index = 0; index + 2 < parts.length; index += 3) { + const rawName = parts[index]; + if (!rawName) { + continue; + } + const name = rawName.replace(/^\.\//, '').replace(/\r?\n$/, ''); + if (!name) { + continue; + } + const size = Number(parts[index + 1]); + const modifiedSeconds = Number(parts[index + 2]); + stats.push({ + name, + size: Number.isFinite(size) ? size : 0, + modified: Number.isFinite(modifiedSeconds) ? Math.floor(modifiedSeconds) * 1000 : 0 + }); + } + return stats; +} + +function normalizeRemoteEntryName(path: string): string { + return path.replace(/\\/g, '/').replace(/^\/+/, '').replace(/\/+$/, ''); +} + +function quotePosixShellArg(value: string): string { + return "'" + value.replace(/'/g, "'\\''") + "'"; +} diff --git a/src/vs/platform/turbofile/common/turbofileRsyncBatchPlan.ts b/src/vs/platform/turbofile/common/turbofileRsyncBatchPlan.ts new file mode 100644 index 00000000..c246d2f4 --- /dev/null +++ b/src/vs/platform/turbofile/common/turbofileRsyncBatchPlan.ts @@ -0,0 +1,80 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export interface ITurboFileRsyncSourceGroup { + readonly sourceBase: string; + readonly relativePaths: readonly string[]; +} + +export interface ITurboFileRsyncMappedSource { + readonly sourcePath: string; + readonly targetPath: string; +} + +export interface ITurboFileRsyncMappedGroup extends ITurboFileRsyncSourceGroup { + readonly targetBase: string; +} + +/** Groups explicit rsync sources under stable roots so paths can be streamed with --files-from. */ +export function createTurboFileRsyncSourceGroups(sourcePaths: readonly string[], flavor: 'posix' | 'windows'): ITurboFileRsyncSourceGroup[] { + const groups = new Map(); + for (const sourcePath of sourcePaths) { + if (!sourcePath || sourcePath.includes('\0')) { + throw new Error('Invalid rsync source path.'); + } + const { parent: sourceBase, name: relativePath } = splitTurboFileRsyncPath(sourcePath, flavor); + if (!sourceBase || sourceBase === sourcePath || !relativePath) { + throw new Error('A filesystem root cannot be an explicit rsync source.'); + } + const key = flavor === 'windows' ? sourceBase.toLowerCase() : sourceBase; + let group = groups.get(key); + if (!group) { + group = { sourceBase, relativePaths: [] }; + groups.set(key, group); + } + group.relativePaths.push(relativePath); + } + return [...groups.values()]; +} + +/** Combines basename-preserving source/target mappings for reverse rsync worker manifests. */ +export function createTurboFileRsyncMappedGroups(mappings: readonly ITurboFileRsyncMappedSource[]): { readonly groups: ITurboFileRsyncMappedGroup[]; readonly direct: ITurboFileRsyncMappedSource[] } { + const groups = new Map(); + const direct: ITurboFileRsyncMappedSource[] = []; + for (const mapping of mappings) { + if (!mapping.sourcePath || !mapping.targetPath || mapping.sourcePath.includes('\0') || mapping.targetPath.includes('\0')) { + throw new Error('Invalid rsync source mapping.'); + } + const { parent: sourceBase, name: sourceName } = splitTurboFileRsyncPath(mapping.sourcePath, 'posix'); + const { parent: targetBase, name: targetName } = splitTurboFileRsyncPath(mapping.targetPath, 'posix'); + if (!sourceName || !targetName || sourceBase === mapping.sourcePath || targetBase === mapping.targetPath || sourceName !== targetName) { + direct.push(mapping); + continue; + } + const key = `${sourceBase}\0${targetBase}`; + let group = groups.get(key); + if (!group) { + group = { sourceBase, targetBase, relativePaths: [] }; + groups.set(key, group); + } + group.relativePaths.push(sourceName); + } + return { groups: [...groups.values()], direct }; +} + +function splitTurboFileRsyncPath(path: string, flavor: 'posix' | 'windows'): { readonly parent: string; readonly name: string } { + const slashIndex = path.lastIndexOf('/'); + const separatorIndex = flavor === 'windows' ? Math.max(slashIndex, path.lastIndexOf('\\')) : slashIndex; + if (separatorIndex < 0) { + return { parent: '', name: path }; + } + let parent = path.slice(0, separatorIndex); + if (!parent && separatorIndex === 0) { + parent = '/'; + } else if (flavor === 'windows' && /^[A-Za-z]:$/.test(parent)) { + parent += path.charAt(separatorIndex); + } + return { parent, name: path.slice(separatorIndex + 1) }; +} diff --git a/src/vs/platform/turbofile/common/turbofileTarProgress.ts b/src/vs/platform/turbofile/common/turbofileTarProgress.ts new file mode 100644 index 00000000..61555b28 --- /dev/null +++ b/src/vs/platform/turbofile/common/turbofileTarProgress.ts @@ -0,0 +1,23 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +const DIRECT_TAR_BYTE_PROGRESS_PATTERN = /(?:^|[\r\n])\s*([0-9]+)\s+bytes(?:\s|\()/g; + +/** + * Parses the byte counter emitted by GNU coreutils `dd status=progress`. + * The parser accepts a rolling stderr buffer because SSH may split a progress line across chunks. + */ +export function parseDirectTarProgressBytes(output: string): number | undefined { + let maximum: number | undefined; + let match: RegExpExecArray | null; + DIRECT_TAR_BYTE_PROGRESS_PATTERN.lastIndex = 0; + while ((match = DIRECT_TAR_BYTE_PROGRESS_PATTERN.exec(output)) !== null) { + const bytes = Number(match[1]); + if (Number.isSafeInteger(bytes) && bytes >= 0) { + maximum = Math.max(maximum ?? 0, bytes); + } + } + return maximum; +} diff --git a/src/vs/platform/turbofile/common/turbofileTimeout.ts b/src/vs/platform/turbofile/common/turbofileTimeout.ts new file mode 100644 index 00000000..39e9132a --- /dev/null +++ b/src/vs/platform/turbofile/common/turbofileTimeout.ts @@ -0,0 +1,34 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export function withTurboFileTimeout(operation: Promise, timeoutMs: number, message: string): Promise { + if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) { + return operation; + } + + return new Promise((resolve, reject) => { + let settled = false; + let timer: ReturnType | undefined; + const finish = (callback: () => void): void => { + if (settled) { + return; + } + settled = true; + if (timer) { + clearTimeout(timer); + } + callback(); + }; + + timer = setTimeout(() => { + finish(() => reject(new Error(message))); + }, timeoutMs); + + operation.then( + value => finish(() => resolve(value)), + error => finish(() => reject(error)) + ); + }); +} diff --git a/src/vs/platform/turbofile/common/turbofileTrash.ts b/src/vs/platform/turbofile/common/turbofileTrash.ts new file mode 100644 index 00000000..d9f0a75a --- /dev/null +++ b/src/vs/platform/turbofile/common/turbofileTrash.ts @@ -0,0 +1,85 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { generateUuid } from '../../../base/common/uuid.js'; +import { ITurboFileTrashDevice, ITurboFileTrashItem, TurboFileFileType } from './turbofile.js'; + +interface ITurboFileTrashMetadataCandidate { + readonly id?: unknown; + readonly originalName?: unknown; + readonly originalPath?: unknown; + readonly deletedAt?: unknown; + readonly type?: unknown; + readonly knownSize?: unknown; + readonly volumeId?: unknown; + readonly trashRoot?: unknown; + readonly payloadPath?: unknown; + readonly deviceIdentity?: unknown; +} + +const turboFileFileTypes = new Set(['file', 'directory', 'symlink', 'unknown']); + +export function getTurboFileTrashDeviceIdentity(device: ITurboFileTrashDevice): string { + if (device.kind === 'local') { + return `local:${(device.localUserIdentity ?? 'current-user').trim().toLowerCase()}`; + } + const connection = device.connection; + if (!connection) { + throw new Error('SSH recycle-bin operations require a connection.'); + } + return `ssh:${connection.host.trim().toLowerCase()}:${connection.port}:${connection.username.trim().toLowerCase()}`; +} + +export function createTurboFileTrashItemId(): string { + return generateUuid(); +} + +export function createTurboFileKeepBothName(name: string, ordinal: number): string { + const extensionIndex = name.lastIndexOf('.'); + if (extensionIndex <= 0) { + return `${name} (${ordinal})`; + } + return `${name.slice(0, extensionIndex)} (${ordinal})${name.slice(extensionIndex)}`; +} + +export function sortTurboFileTrashItems(items: readonly ITurboFileTrashItem[]): ITurboFileTrashItem[] { + return [...items].sort((left, right) => right.deletedAt - left.deletedAt || left.originalName.localeCompare(right.originalName)); +} + +export function isTurboFileProtectedTrashSource(path: string, windowsPath: boolean): boolean { + const trimmed = path.trim(); + if (!trimmed) { + return true; + } + if (windowsPath) { + const normalized = trimmed.replace(/\//g, '\\').replace(/\\+$/, ''); + if (/^[A-Za-z]:$/.test(normalized) || normalized === '') { + return true; + } + return /(?:^|\\)(?:\.turbofile-trash-[^\\]+|RecycleBin)(?:\\|$)/i.test(normalized); + } + const normalized = trimmed.replace(/\/+$/, '') || '/'; + if (normalized === '/') { + return true; + } + return /(?:^|\/)(?:\.Trash(?:-[^/]+)?|Trash)\/(?:turbofile)(?:\/|$)/.test(normalized); +} + +export function isTurboFileTrashMetadata(value: unknown): value is ITurboFileTrashItem { + if (!value || typeof value !== 'object') { + return false; + } + const candidate = value as ITurboFileTrashMetadataCandidate; + return typeof candidate.id === 'string' + && typeof candidate.originalName === 'string' + && typeof candidate.originalPath === 'string' + && typeof candidate.deletedAt === 'number' + && turboFileFileTypes.has(candidate.type as TurboFileFileType) + && (candidate.knownSize === undefined || typeof candidate.knownSize === 'number') + && typeof candidate.volumeId === 'string' + && typeof candidate.trashRoot === 'string' + && typeof candidate.payloadPath === 'string' + && typeof candidate.deviceIdentity === 'string'; +} diff --git a/src/vs/platform/turbofile/common/turbofileWindowsPath.ts b/src/vs/platform/turbofile/common/turbofileWindowsPath.ts new file mode 100644 index 00000000..b25978ed --- /dev/null +++ b/src/vs/platform/turbofile/common/turbofileWindowsPath.ts @@ -0,0 +1,61 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export type WindowsPathResolver = (path: string) => Promise; + +const WINDOWS_PATH_RESOLVE_CONCURRENCY = 16; + +export async function normalizeWindowsTransferPaths(paths: readonly string[], resolvePath: WindowsPathResolver): Promise { + const candidates: string[] = []; + const seenCandidates = new Set(); + for (const rawPath of paths) { + const candidate = normalizeWindowsTransferPath(rawPath); + if (!candidate) { + continue; + } + const candidateKey = getWindowsTransferPathKey(candidate); + if (seenCandidates.has(candidateKey)) { + continue; + } + seenCandidates.add(candidateKey); + candidates.push(candidate); + } + + // Explorer may hand us tens of thousands of paths. Resolve a bounded number in parallel so + // normalisation does not become a long serial pre-transfer phase or flood libuv's worker pool. + const resolvedPaths = new Array(candidates.length); + let cursor = 0; + const worker = async (): Promise => { + while (cursor < candidates.length) { + const index = cursor++; + const candidate = candidates[index]; + resolvedPaths[index] = normalizeWindowsTransferPath(await resolvePath(candidate).catch(() => candidate)) ?? candidate; + } + }; + await Promise.all(Array.from({ length: Math.min(WINDOWS_PATH_RESOLVE_CONCURRENCY, candidates.length) }, () => worker())); + + const result: string[] = []; + const seen = new Set(); + for (const resolved of resolvedPaths) { + const key = getWindowsTransferPathKey(resolved); + if (seen.has(key)) { + continue; + } + + seen.add(key); + result.push(resolved); + } + + return result; +} + +export function getWindowsTransferPathKey(path: string): string { + return path.replace(/\//g, '\\').replace(/\\+$/, '').toLowerCase(); +} + +function normalizeWindowsTransferPath(path: string): string | undefined { + const trimmed = path.trim().replace(/^"|"$/g, '').replace(/^file:\/\/\/?/i, ''); + return trimmed || undefined; +} diff --git a/src/vs/platform/turbofile/electron-main/turbofileSshMainService.ts b/src/vs/platform/turbofile/electron-main/turbofileSshMainService.ts index 50480c11..51a89e20 100644 --- a/src/vs/platform/turbofile/electron-main/turbofileSshMainService.ts +++ b/src/vs/platform/turbofile/electron-main/turbofileSshMainService.ts @@ -6,32 +6,72 @@ import type { ConnectConfig } from 'ssh2'; import { createReadStream, createWriteStream, existsSync, promises as fsp } from 'fs'; import { basename, dirname, join } from 'node:path'; -import { networkInterfaces, platform, tmpdir } from 'node:os'; +import { networkInterfaces, platform, tmpdir, userInfo } from 'node:os'; import { createHash, randomUUID } from 'node:crypto'; import { execFile, spawn, type ChildProcess } from 'node:child_process'; import { PassThrough } from 'node:stream'; import { pipeline } from 'node:stream/promises'; import { connect as connectTcp, Socket } from 'node:net'; import { deflateSync } from 'node:zlib'; -import { app, BrowserWindow, clipboard, nativeImage } from 'electron'; +import { app, BrowserWindow, clipboard, nativeImage, protocol } from 'electron'; import { Emitter, Event } from '../../../base/common/event.js'; -import { ITurboFileBatchTransferFailure, ITurboFileBatchTransferItem, ITurboFileBatchTransferRequest, ITurboFileBatchTransferResult, ITurboFileDependencyInstallResult, ITurboFileDirectoryListing, ITurboFileFileEntry, ITurboFileFileOperationResult, ITurboFilePathSizeResult, ITurboFileReadFileOptions, ITurboFileReadFileResult, ITurboFileReadImageOptions, ITurboFileRemoteCapabilities, ITurboFileRemoteCapabilitiesResult, ITurboFileSshConnectionOptions, ITurboFileSshConnectionResult, ITurboFileSshService, ITurboFileSshTerminalSession, ITurboFileStartWindowsDragRequest, ITurboFileStartWindowsDragResult, ITurboFileTransferAttempt, ITurboFileTransferEndpoint, ITurboFileTransferProgressEvent, ITurboFileTransferRequest, ITurboFileTransferResult, ITurboFileWindowsClipboardFilesResult, TurboFileContentEncoding, TurboFileFileType, TurboFileTransferAttemptProtocol, TurboFileTransferDependency, TurboFileTransferProtocol } from '../common/turbofile.js'; -import { createLocalBatchTarPlans, createTarNullListBuffer } from '../common/turbofileBatchTarPlan.js'; +import { ITurboFileBatchTransferFailure, ITurboFileBatchTransferItem, ITurboFileBatchTransferRequest, ITurboFileBatchTransferResult, ITurboFileDependencyInstallResult, ITurboFileDirectoryListing, ITurboFileEntryStat, ITurboFileFileEntry, ITurboFileFileOperationResult, ITurboFileImageBatchEntry, ITurboFileImageMetadataEntry, ITurboFileImageResourceOptions, ITurboFileImageResourceResult, ITurboFileMoveToTrashRequest, ITurboFilePathSizeResult, ITurboFileReadFileOptions, ITurboFileReadFileResult, ITurboFileReadImageOptions, ITurboFileRemoteCapabilities, ITurboFileRemoteCapabilitiesResult, ITurboFileRestoreTrashRequest, ITurboFileSshConnectionOptions, ITurboFileSshConnectionResult, ITurboFileSshService, ITurboFileSshTerminalSession, ITurboFileStartWindowsDragRequest, ITurboFileStartWindowsDragResult, ITurboFileTransferAttempt, ITurboFileTransferEndpoint, ITurboFileTransferProgressEvent, ITurboFileTransferRequest, ITurboFileTransferResult, ITurboFileTrashDevice, ITurboFileTrashItem, ITurboFileTrashItemResult, ITurboFileTrashItemsRequest, ITurboFileTrashListing, ITurboFileTrashOperationResult, ITurboFileTrashSource, ITurboFileTrashState, ITurboFileWindowsClipboardFilesResult, TurboFileContentEncoding, TurboFileFileType, TurboFileTransferAttemptProtocol, TurboFileTransferDependency, TurboFileTransferProtocol } from '../common/turbofile.js'; +import { resolveTerminalEncoding } from '../../../base/node/terminalEncoding.js'; +import { buildTurboFileRemoteExtractCommand, isTurboFileTarFamilyArchivePath } from '../common/turbofileArchiveExtract.js'; +import { createLocalBatchTarPlans, createRemoteBatchTarSourcePlan, createTarNullListBuffer, isTurboFileBatchCommandSafe, requiresLocalTarSafeRootAlias } from '../common/turbofileBatchTarPlan.js'; +import { TurboFileKeyedGeneration } from '../common/turbofileKeyedGeneration.js'; +import { readTurboFileImageDimensions } from '../common/turbofileImageMetadata.js'; +import { parseTurboFileImageMetadataFrames } from '../common/turbofileImageMetadataBatch.js'; +import { TurboFileOperationCanceledError, TurboFileOperationQueue } from '../common/turbofileOperationQueue.js'; +import { createTurboFileRsyncMappedGroups, createTurboFileRsyncSourceGroups } from '../common/turbofileRsyncBatchPlan.js'; +import { getRemoteDirectoryReadRetryDelay, REMOTE_DIRECTORY_READ_MAX_ATTEMPTS, shouldRetryRemoteDirectoryRead } from '../common/turbofileRemoteDirectoryRetry.js'; +import { createPosixNativeBatchTransferPlan } from '../common/turbofileRemoteNativeBatchPlan.js'; +import { buildPosixHeartbeatFunction } from '../common/turbofilePosixHeartbeat.js'; +import { buildPosixStatEntriesCommand as buildRemotePosixStatEntriesCommand } from '../common/turbofileRemoteStat.js'; +import { withTurboFileTimeout } from '../common/turbofileTimeout.js'; +import { parseDirectTarProgressBytes } from '../common/turbofileTarProgress.js'; import { resolveTransferPlan } from '../common/turbofileTransferPlan.js'; +import { createTurboFileKeepBothName, createTurboFileTrashItemId, getTurboFileTrashDeviceIdentity, isTurboFileProtectedTrashSource, isTurboFileTrashMetadata, sortTurboFileTrashItems } from '../common/turbofileTrash.js'; import { createWindowsDragFakeDirectoryName, createWindowsShellAssociationChangedScript, normalizeWindowsCopyHookDestination, TURBOFILE_WINDOWS_DRAG_ACTIVE_DIR, TURBOFILE_WINDOWS_DRAG_APP_DIR, TURBOFILE_WINDOWS_DRAG_ROOT_DIR, TURBOFILE_WINDOWS_DRAG_TARGETS_DIR } from '../common/turbofileWindowsDrag.js'; +import { normalizeWindowsTransferPaths } from '../common/turbofileWindowsPath.js'; const DEFAULT_READ_LIMIT = 10 * 1024 * 1024; const DEFAULT_IMAGE_SOURCE_LIMIT = 128 * 1024 * 1024; const REMOTE_IMAGE_TOOL_CACHE_TTL_MS = 5 * 60 * 1000; -const REMOTE_IMAGE_THUMBNAIL_TIMEOUT_MS = 4000; +// Large source images can need 10-20s for ImageMagick to decode and downscale over SSH; a short +// budget aborts them before any bytes flow. The common case still returns in well under a second. +const REMOTE_IMAGE_THUMBNAIL_TIMEOUT_MS = 25000; +// Max concurrent SSH image reads (remote thumbnail transforms + downloads). The image grid renders +// cards as , and Chromium fetches every visible card at once. Dozens +// of simultaneous `client.exec` calls on one SSH connection blow past the server's MaxSessions +// limit and every command fails with "Unable to exec", so the whole grid stays on generic icons. +// A small global cap keeps thumbnail generation under that ceiling (leaving channels free for +// directory/SFTP work) while still loading several images in parallel. +const SSH_IMAGE_READ_MAX_CONCURRENCY = 6; +const REMOTE_DIRECTORY_READ_TIMEOUT_MS = 8000; +// Fast `find`-based listing is bounded by silence (no output) rather than total duration so large +// directories that legitimately take many seconds to enumerate still load instead of being killed. +const REMOTE_DIRECTORY_READ_INACTIVITY_TIMEOUT_MS = 12000; +const REMOTE_DIRECTORY_READ_MAX_TIMEOUT_MS = 120000; const REMOTE_DIRECT_READ_MAX_BYTES = 2 * 1024 * 1024; const REMOTE_DIRECT_READ_CHUNK_BYTES = 256 * 1024; +// Per-file cap for the one-shot image batch read. Files above this stream their bytes individually +// (and may be server-downscaled) so a single huge image cannot blow up the batched buffer. +const IMAGE_BATCH_MAX_FILE_BYTES = 24 * 1024 * 1024; +const IMAGE_METADATA_HEADER_BYTES = 64 * 1024; +const IMAGE_METADATA_LOCAL_CONCURRENCY = 16; +const IMAGE_METADATA_REMOTE_CONCURRENCY = 3; +const IMAGE_METADATA_PATHS_PER_CHUNK = 96; +const IMAGE_METADATA_COMMAND_MAX_CHARS = 7000; const SMALL_IMAGE_DIM_LIMIT = 100; const WINDOWS_DRAG_ICON_DATA_URL = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='; const RAW_BMP_FAST_PATH_BYTES = 2 * 1024 * 1024; const IMAGE_THUMBNAIL_CACHE_VERSION = 1; const IMAGE_THUMBNAIL_CACHE_MAX_BYTES = 512 * 1024 * 1024; const IMAGE_THUMBNAIL_CACHE_MAX_ENTRY_BYTES = 16 * 1024 * 1024; +const TURBOFILE_IMAGE_RESOURCE_SCHEME = 'turbofile-image'; +const IMAGE_RESOURCE_TTL_MS = 10 * 60 * 1000; +const IMAGE_RESOURCE_CLEANUP_INTERVAL_MS = 60 * 1000; const WINDOWS_DRAG_COPY_HOOK_TIMEOUT_MS = 45000; const WINDOWS_DRAG_COPY_HOOK_POLL_MS = 50; const TURBOFILE_WINDOWS_DRAG_COPY_HOOK_CLSID = '{D650A1FA-43C6-4B8F-8C83-80B028353F64}'; @@ -64,10 +104,16 @@ const DIRECT_SSH_CONNECT_TIMEOUT_MS = 5 * 1000; const DIRECT_SSH_ROUTE_FAILURE_COOLDOWN_MS = 10 * 60 * 1000; const DIRECT_SSH_ROUTE_CACHE_TTL_MS = 10 * 60 * 1000; const DIRECT_TRANSFER_STALL_TIMEOUT_MS = 10 * 1000; +const DIRECT_TAR_STALL_TIMEOUT_MS = 30 * 60 * 1000; +const DIRECT_RSYNC_STALL_TIMEOUT_MS = 10 * 60 * 1000; const DIRECT_TRANSFER_STALL_CHECK_INTERVAL_MS = 1000; +const REMOTE_EXEC_CHANNEL_OPEN_TIMEOUT_MS = 30 * 1000; const LOCAL_RSYNC_STALL_TIMEOUT_MS = 10 * 60 * 1000; const TRANSFER_PROGRESS_EVENT_INTERVAL_MS = 500; const LOCAL_WINDOWS_REVERSE_RSYNC_MIN_SPLIT_ENTRIES = 6; +const TURBOFILE_REVERSE_RSYNC_WORKERS = 4; +// Keep the established high-throughput ceiling. Stability is enforced by the device scheduler, +// bounded manifests, channel timeouts, and per-chunk retries rather than throttling the data path. const LOCAL_PARALLEL_TAR_CHUNK_COUNT = 12; const LOCAL_PARALLEL_TAR_MIN_ENTRIES = 8; const LOCAL_PARALLEL_TAR_CHUNK_RETRIES = 2; @@ -111,7 +157,7 @@ interface ISSHExecStream { off(event: 'drain', listener: () => void): ISSHExecStream; off(event: 'error', listener: (err: Error) => void): ISSHExecStream; write(data: Buffer | string): boolean; - end(data?: Buffer | string): void; + end(data?: Buffer | string, callback?: () => void): void; close?(): void; destroy?(): void; stderr: { @@ -186,11 +232,11 @@ interface ITurboFileSshTerminalMainSession { bootstrapping: boolean; hasDataListener: boolean; closed: boolean; - sharedDirectorySessionKey?: string; } interface IRemoteDirectorySftpSession { key: string; + generation: number; client: ISSHClient; sftp: ISFTPWrapper; closed: boolean; @@ -229,6 +275,11 @@ interface ITarBatchPlan { readonly parentPath: string; readonly names: readonly string[]; readonly targetNames: readonly string[]; + readonly fileNames?: readonly string[]; + readonly directoryNames?: readonly string[]; + readonly unknownNames?: readonly string[]; + readonly requiresSafeParentPath?: boolean; + readonly containsDirectories?: boolean; } interface ITransferProgressSnapshot { @@ -246,12 +297,19 @@ interface IRsyncTimingState { } interface IRemoteExecOptions { + readonly input?: Uint8Array; readonly onStdout?: (chunk: string) => void; readonly onStderr?: (chunk: string) => void; readonly timeoutMs?: number; readonly inactivityTimeoutMs?: number; readonly inactivityMessage?: string; readonly getActivityTime?: () => number | undefined; + /** + * When set, cancelling this transfer forcibly closes the exec channel. Closing the channel makes + * the remote sshd send SIGHUP to the command, so a direct SSH→SSH `tar | ssh …` pipeline is truly + * killed on the servers instead of being orphaned and left running after the user cancels. + */ + readonly progressId?: string; } interface IImageSize { @@ -264,6 +322,8 @@ interface IImageBufferResult { readonly mime: string; readonly width?: number; readonly height?: number; + readonly originalWidth?: number; + readonly originalHeight?: number; } interface IImageSourceFingerprint { @@ -277,6 +337,8 @@ interface IImageThumbnailCacheMetadata { readonly size: number; readonly width?: number; readonly height?: number; + readonly originalWidth?: number; + readonly originalHeight?: number; readonly createdAt: number; } @@ -310,6 +372,12 @@ interface IRsyncProgressReporter { readonly reportProgress: (transferredBytes: number, force?: boolean, toCheck?: number, totalChecks?: number, transferIndex?: number, percent?: number, speedBytesPerSecond?: number) => void; readonly getLastBytes: () => number; readonly getActivityTime: () => number | undefined; + /** + * Publish the known byte total so the UI shows a size (and a real percentage) while rsync is + * still building its file list and has not emitted any transferred bytes yet. The total is + * computed concurrently on the Node side, mirroring how the tar path fills its denominator. + */ + readonly setTotalBytes: (totalBytes: number) => void; } interface ILocalSshTool { @@ -334,9 +402,62 @@ interface ILocalTarChunk { readonly entryCount: number; } +interface ITurboFileImageResourceDescriptor { + readonly endpoint: ITurboFileTransferEndpoint; + readonly options: ITurboFileImageResourceOptions; + readonly createdAt: number; + expiresAt: number; +} + +interface IPreparedRemoteTrashItem { + readonly source: ITurboFileTrashSource; + readonly item: ITurboFileTrashItem; + readonly pendingPath: string; + readonly metadataPath: string; +} + +interface IRemoteTrashMoveResult { + readonly id: string; + readonly ok: boolean; + readonly message?: string; +} + +interface IRemoteTrashMetadataRecord { + readonly metadataPath: string; + readonly metadata: string; +} + +type TurboFileOperationDevice = Pick; + +function getFileOperationDeviceKey(device: TurboFileOperationDevice): string { + if (device.kind === 'local') { + return 'local'; + } + const connection = device.connection; + if (connection) { + return `ssh:${connection.username.trim()}@${connection.host.trim().toLowerCase()}:${connection.port || 22}`; + } + return `ssh:${device.label?.trim().toLowerCase() || 'unknown'}`; +} + +function getFileOperationDeviceKeys(...devices: TurboFileOperationDevice[]): string[] { + return [...new Set(devices.map(getFileOperationDeviceKey))]; +} + +function getTransferOperationAccess(source: TurboFileOperationDevice, target: TurboFileOperationDevice, operation: 'copy' | 'move'): { readonly read: readonly string[]; readonly write: readonly string[] } { + const sourceKey = getFileOperationDeviceKey(source); + const targetKey = getFileOperationDeviceKey(target); + return operation === 'move' + ? { read: [], write: [sourceKey, targetKey] } + : { read: [sourceKey], write: [targetKey] }; +} + export class TurboFileSshMainService implements ITurboFileSshService { declare readonly _serviceBrand: undefined; + private static imageProtocolRegistered = false; + private static imageProtocolService: TurboFileSshMainService | undefined; + private nativeRequire: NodeRequire | undefined; private terminalSessionSequence = 1; private readonly terminalSessions = new Map(); @@ -348,19 +469,33 @@ export class TurboFileSshMainService implements ITurboFileSshService { private readonly rsyncTimingStates = new Map(); private readonly transferCancellationRequests = new Set(); private readonly transferCancelHandlers = new Map void>>(); + private readonly fileOperationQueue = new TurboFileOperationQueue(); private readonly directorySftpSessions = new Map>(); + private readonly directorySftpSessionGenerations = new TurboFileKeyedGeneration(); private readonly directorySftpIdleTimers = new Map>(); private readonly remoteDirectoryListingCache = new Map(); + private readonly remoteDirectoryListingGenerations = new TurboFileKeyedGeneration(); private readonly remoteImageToolCache = new Map(); private readonly remoteImageToolPending = new Map>(); private readonly directSshRouteCache = new Map(); private readonly directSshRouteFailureCooldowns = new Map(); private readonly remoteRsyncSshBinaryCache = new Map(); + private readonly trashRootsByDeviceIdentity = new Map>(); private localWindowsRsyncSourceConnectionPromise: Promise | undefined; private imageThumbnailCacheTrimPromise: Promise | undefined; + private readonly imageResourceDescriptors = new Map(); + private imageResourceCleanupTimer: ReturnType | undefined; private windowsDragCopyHookRegistrationPromise: Promise | undefined; + // Caps concurrent SSH image reads. The image grid renders cards as , + // and Chromium fetches every visible card at once — dozens of simultaneous `client.exec` calls on + // one SSH connection blow past the server's MaxSessions limit and every command fails with + // "Unable to exec". A small global limit keeps thumbnail generation under that ceiling (leaving + // channels free for directory/SFTP work) while still loading several images in parallel. + private imageReadActiveCount = 0; + private readonly imageReadWaiters: Array<() => void> = []; constructor() { + this.ensureTurboFileImageProtocolRegistered(); // A freshly-registered CopyHook handler is not picked up by the already-running Explorer // until Explorer reloads (WinSCP documents the same: its dragext needs a logoff/restart to // load). Prepare the handler eagerly at startup so the very first drag-to-Windows works, @@ -404,6 +539,7 @@ export class TurboFileSshMainService implements ITurboFileSshService { } this.transferCancellationRequests.add(progressId); + this.fileOperationQueue.cancel(progressId); const handlers = this.transferCancelHandlers.get(progressId); if (handlers) { for (const handler of [...handlers]) { @@ -517,13 +653,87 @@ export class TurboFileSshMainService implements ITurboFileSshService { } async listRemoteDirectory(options: ITurboFileSshConnectionOptions, path: string, refresh = false): Promise { + return this.listRemoteDirectoryCore(options, path, refresh, false); + } + + async listRemoteDirectoryNames(options: ITurboFileSshConnectionOptions, path: string, refresh = false): Promise { + return this.listRemoteDirectoryCore(options, path, refresh, true); + } + + private async listRemoteDirectoryCore(options: ITurboFileSshConnectionOptions, path: string, refresh: boolean, namesOnly: boolean): Promise { const cacheKey = this.getRemoteDirectoryListingCacheKey(options, path); if (!refresh) { + // A full (size+date) cached listing satisfies a names-only request too, but not vice versa: + // only reuse a cached entry that has real attributes when the caller asked names-only. const cached = this.getRemoteDirectoryListingCache(cacheKey); - if (cached) { + if (cached && (namesOnly || this.remoteListingHasAttributes(cached))) { return cached; } } + const listingGeneration = this.remoteDirectoryListingGenerations.next(cacheKey); + + const configResult = await this.createConnectConfig(options); + if (!configResult.ok) { + throw new Error(configResult.message); + } + + let lastError: unknown; + for (let attempt = 0; attempt < REMOTE_DIRECTORY_READ_MAX_ATTEMPTS; attempt++) { + let session: IRemoteDirectorySftpSession | undefined; + try { + session = await this.getDirectorySftpSession(options, configResult.config); + const listing = await this.readRemoteDirectoryWithFallback(session.sftp, session.client, options, path, namesOnly); + this.touchDirectorySftpSession(session.key, session.generation); + return this.createRemoteDirectoryListingResult(cacheKey, listingGeneration, namesOnly, listing); + } catch (error) { + lastError = error; + if (this.remoteDirectoryListingGenerations.isCurrent(cacheKey, listingGeneration)) { + this.remoteDirectoryListingCache.delete(cacheKey); + } + if (session) { + this.disposeDirectorySftpSession(session.key, session.generation); + } + const retryDelayMs = getRemoteDirectoryReadRetryDelay(attempt); + if (!shouldRetryRemoteDirectoryRead(error) || retryDelayMs === undefined) { + throw error; + } + await delay(retryDelayMs); + } + } + throw lastError; + } + + private createRemoteDirectoryListingResult(requestedCacheKey: string, listingGeneration: number, namesOnly: boolean, listing: IRemoteDirectoryReadResult): ITurboFileDirectoryListing { + const result: ITurboFileDirectoryListing = { + path: listing.path, + entries: listing.entries + .filter(entry => entry.filename !== '.' && entry.filename !== '..') + .map(entry => this.toRemoteEntry(listing.path, entry)) + .sort(compareFileEntries) + }; + // Only cache full listings: a names-only listing has zeroed size/date and must not shadow a + // later full listing (it is cheap to recompute and gets enriched lazily on the client side). + if (!namesOnly && this.remoteDirectoryListingGenerations.isCurrent(requestedCacheKey, listingGeneration)) { + this.setRemoteDirectoryListingCache(requestedCacheKey, result); + } + return result; + } + + private remoteListingHasAttributes(listing: ITurboFileDirectoryListing): boolean { + // A names-only listing zeroes every size and modified time; a real listing almost always has at + // least one non-zero. Treat an all-zero listing as attribute-less so a names-only result that + // somehow reached the cache never satisfies a request that needs sizes. + return listing.entries.some(entry => entry.size > 0 || entry.modified > 0); + } + + async statRemoteEntries(options: ITurboFileSshConnectionOptions, directoryPath: string, names: readonly string[]): Promise { + const uniqueNames = [...new Set(names.filter(name => !!name && name !== '.' && name !== '..'))]; + if (!uniqueNames.length) { + return []; + } + if (looksLikeWindowsPath(directoryPath)) { + return []; + } const configResult = await this.createConnectConfig(options); if (!configResult.ok) { @@ -532,27 +742,34 @@ export class TurboFileSshMainService implements ITurboFileSshService { const session = await this.getDirectorySftpSession(options, configResult.config); try { - const listing = await this.readRemoteDirectoryWithFallback(session.sftp, session.client, options, path); - this.touchDirectorySftpSession(session.key); - const result = { - path: listing.path, - entries: listing.entries - .filter(entry => entry.filename !== '.' && entry.filename !== '..') - .map(entry => this.toRemoteEntry(listing.path, entry)) - .sort(compareFileEntries) - }; - this.setRemoteDirectoryListingCache(this.getRemoteDirectoryListingCacheKey(options, listing.path), result); - if (listing.path === path) { - this.setRemoteDirectoryListingCache(cacheKey, result); - } - return result; - } catch (error) { - this.remoteDirectoryListingCache.delete(cacheKey); - this.disposeDirectorySftpSession(session.key); - throw error; + const output = await this.execRemoteBytes(session.client, buildPosixStatEntriesCommand(directoryPath, uniqueNames), { + inactivityTimeoutMs: REMOTE_DIRECTORY_READ_INACTIVITY_TIMEOUT_MS, + timeoutMs: REMOTE_DIRECTORY_READ_MAX_TIMEOUT_MS + }); + this.touchDirectorySftpSession(session.key, session.generation); + const stats = parseRemoteStatEntries(output.toString('utf8')); + return stats.length ? stats : await this.statRemoteEntriesWithSftpFallback(session.sftp, directoryPath, uniqueNames); + } catch { + return this.statRemoteEntriesWithSftpFallback(session.sftp, directoryPath, uniqueNames); } } + private async statRemoteEntriesWithSftpFallback(sftp: ISFTPWrapper, directoryPath: string, names: readonly string[]): Promise { + const stats = await mapLimit(names, SFTP_DIRECTORY_ENTRY_CONCURRENCY, async name => { + try { + const stat = await this.remoteStat(sftp, joinRemotePath(directoryPath, name)); + return { + name, + size: stat.size, + modified: stat.mtime * 1000 + }; + } catch { + return undefined; + } + }); + return stats.filter((stat): stat is ITurboFileEntryStat => !!stat); + } + async listLocalDirectory(path: string, _refresh = false): Promise { const dirents = await fsp.readdir(path, { withFileTypes: true }); const visibleNames = await getWindowsExplorerVisibleLocalNames(path); @@ -579,28 +796,108 @@ export class TurboFileSshMainService implements ITurboFileSshService { }; } + async listLocalDirectoryNames(path: string, _refresh = false): Promise { + const dirents = await fsp.readdir(path, { withFileTypes: true }); + const visibleNames = await getWindowsExplorerVisibleLocalNames(path); + const entries = dirents + .filter(dirent => !visibleNames || visibleNames.has(dirent.name)) + .map(dirent => { + const type: TurboFileFileType = dirent.isDirectory() ? 'directory' : dirent.isFile() ? 'file' : dirent.isSymbolicLink() ? 'symlink' : 'unknown'; + return { + name: dirent.name, + path: join(path, dirent.name), + type, + size: 0, + modified: 0 + }; + }); + + return { + path, + entries: entries.sort(compareFileEntries) + }; + } + + async statLocalEntries(directoryPath: string, names: readonly string[]): Promise { + const uniqueNames = Array.from(new Set(names.filter(name => !!name))); + const stats = await mapLimit(uniqueNames, LOCAL_DIRECTORY_STAT_CONCURRENCY, async name => { + try { + const stat = await fsp.lstat(join(directoryPath, name)); + return { + name, + size: stat.size, + modified: stat.mtimeMs + }; + } catch { + return undefined; + } + }); + return stats.filter((stat): stat is ITurboFileEntryStat => !!stat); + } + + async normalizeLocalWindowsPaths(paths: readonly string[]): Promise { + return normalizeWindowsTransferPaths(paths, resolveLocalWindowsRealPath); + } + async transferFile(request: ITurboFileTransferRequest): Promise { const strategy = getTransferStrategy(request.source, request.target); - const startedAt = Date.now(); + const queuedAt = Date.now(); if (request.progressId) { this.transferCancellationRequests.delete(request.progressId); } + try { + return await this.fileOperationQueue.runWithAccess( + getTransferOperationAccess(request.source, request.target, 'copy'), + () => this.transferFileCore(request), + { + id: request.progressId, + onQueued: () => this.fireTransferProgress(request, 0, Math.max(0, request.expectedBytes ?? 0), 'prepare', 'Waiting for device operation queue.', true, { + currentName: getTransferName(request.source.path) + }) + } + ); + } catch (error) { + const canceled = error instanceof TurboFileOperationCanceledError || this.isTransferCanceled(request.progressId); + if (request.progressId) { + this.transferCancellationRequests.delete(request.progressId); + this.transferCancelHandlers.delete(request.progressId); + } + this.scheduleTransferProgressCleanup(request.progressId); + return { + ok: false, + strategy, + bytes: 0, + message: canceled ? TRANSFER_CANCELED_MESSAGE : error instanceof Error ? error.message : String(error), + canceled, + durationMs: Date.now() - queuedAt + }; + } + } + + private async transferFileCore(request: ITurboFileTransferRequest): Promise { + const strategy = getTransferStrategy(request.source, request.target); + const startedAt = Date.now(); try { this.throwIfTransferCanceled(request.progressId); if (request.source.kind === 'local' && request.target.kind === 'local') { - this.fireTransferProgress(request, 0, Math.max(0, request.expectedBytes ?? 0), 'prepare', 'Preparing local copy.', true); - const totalBytes = await getLocalPathTransferSize(request.source.path, request.expectedBytes); + const expectedBytes = Math.max(0, request.expectedBytes ?? 0); + this.fireTransferProgress(request, 0, expectedBytes, 'prepare', 'Preparing local copy.', true); + const needsSourceStat = !request.sourceType || (request.sourceType === 'file' && expectedBytes <= 0); + const sourceStat = needsSourceStat ? await fsp.stat(request.source.path) : undefined; + const sourceType = request.sourceType ?? (sourceStat?.isDirectory() ? 'directory' : 'file'); + const totalBytes = sourceType === 'directory' ? 0 : Math.max(expectedBytes, sourceStat?.size ?? 0); this.fireTransferProgress(request, 0, totalBytes, 'transfer', 'Copying locally.', true); let transferredBytes = 0; const onDelta = (delta: number): void => { this.throwIfTransferCanceled(request.progressId); transferredBytes += delta; - this.fireTransferProgress(request, Math.min(transferredBytes, totalBytes), totalBytes, 'transfer', 'Copying locally.'); + const progressBytes = totalBytes > 0 ? Math.min(transferredBytes, totalBytes) : transferredBytes; + this.fireTransferProgress(request, progressBytes, totalBytes, 'transfer', 'Copying locally.'); }; await this.copyLocalPath(request.source.path, request.target.path, onDelta, request.progressId); this.throwIfTransferCanceled(request.progressId); const bytes = Math.max(totalBytes, transferredBytes); - this.fireTransferProgress(request, bytes, bytes, 'complete', 'Local copy completed.', true); + this.fireTransferProgress(request, bytes, totalBytes, 'complete', 'Local copy completed.', true); return this.createTransferResult(true, strategy, bytes, 'Local copy completed.', startedAt, request.progressId); } @@ -916,14 +1213,17 @@ export class TurboFileSshMainService implements ITurboFileSshService { const sshBinary = await this.resolveRemoteRsyncSshBinary(client, request.target.connection); const sshTransport = buildOptimizedRsyncSshCommand(sourceConnection.port || 22, !!sourceConnection.password, sshBinary); - const command = buildLocalWindowsReverseParallelRsyncCommand(sourceConnection, items, sshTransport, [remoteTargetDirectory]); + const command = buildLocalWindowsReverseParallelRsyncCommand(sourceConnection, sshTransport); + const input = buildLocalWindowsReverseParallelRsyncInput(items, [remoteTargetDirectory]); this.throwIfTransferCanceled(request.progressId); await this.execRemote(client, command, { + input, onStdout: handleProgressOutput, onStderr: handleProgressOutput, - inactivityTimeoutMs: DIRECT_TRANSFER_STALL_TIMEOUT_MS, - inactivityMessage: `Parallel rsync target pull produced no transfer progress for ${DIRECT_TRANSFER_STALL_TIMEOUT_MS} ms.`, - getActivityTime: () => progress.getActivityTime() ?? lastActivityAt + inactivityTimeoutMs: DIRECT_RSYNC_STALL_TIMEOUT_MS, + inactivityMessage: `Parallel rsync target pull produced no transfer progress for ${DIRECT_RSYNC_STALL_TIMEOUT_MS} ms.`, + getActivityTime: () => progress.getActivityTime() ?? lastActivityAt, + progressId: request.progressId }); this.throwIfTransferCanceled(request.progressId); @@ -989,8 +1289,12 @@ export class TurboFileSshMainService implements ITurboFileSshService { private async readLocalRsyncSplitEntries(localDirectory: string): Promise> { const entries = await fsp.readdir(localDirectory, { withFileTypes: true }); + if (entries.some(entry => !entry.isDirectory() && !entry.isFile() && !entry.isSymbolicLink())) { + // Keep special/unknown entries on the unsplit rsync path. A split manifest must never + // silently omit an entry that rsync itself could have preserved. + return []; + } return entries - .filter(entry => entry.isDirectory() || entry.isFile()) .map(entry => ({ name: entry.name, directory: entry.isDirectory() })) .sort((a, b) => FILE_ENTRY_NAME_COLLATOR.compare(a.name, b.name)); } @@ -1022,15 +1326,22 @@ export class TurboFileSshMainService implements ITurboFileSshService { const cleanupTargets = new Set(); const rsyncItems: ILocalWindowsReverseRsyncItem[] = []; - let hasDirectory = false; - for (const item of batchItems) { - const stat = await fsp.stat(item.sourcePath).catch(() => undefined); - if (!stat) { - return undefined; + const itemTypes = await mapLimit(batchItems, LOCAL_DIRECTORY_STAT_CONCURRENCY, async item => { + if (item.sourceType === 'file' || item.sourceType === 'directory') { + return item.sourceType; } + const stat = await fsp.stat(item.sourcePath).catch(() => undefined); + return stat?.isDirectory() ? 'directory' : stat?.isFile() ? 'file' : undefined; + }); + if (itemTypes.some(type => !type)) { + return undefined; + } + let hasDirectory = false; + for (const [index, item] of batchItems.entries()) { + const sourceType = itemTypes[index]; const targetPath = joinRemotePath(remoteTargetDirectory, item.targetName); cleanupTargets.add(targetPath); - if (stat.isDirectory()) { + if (sourceType === 'directory') { hasDirectory = true; const splitItems = await this.createLocalWindowsReverseRsyncItems(item.sourcePath, targetPath); if (splitItems.length) { @@ -1043,7 +1354,7 @@ export class TurboFileSshMainService implements ITurboFileSshService { targetPath }); } - } else if (stat.isFile()) { + } else if (sourceType === 'file') { rsyncItems.push({ name: item.displayName ?? item.targetName, directory: false, @@ -1088,18 +1399,21 @@ export class TurboFileSshMainService implements ITurboFileSshService { const sshBinary = await this.resolveRemoteRsyncSshBinary(client, request.targetDirectory.connection); const sshTransport = buildOptimizedRsyncSshCommand(sourceConnection.port || 22, !!sourceConnection.password, sshBinary); - const command = buildLocalWindowsReverseParallelRsyncCommand(sourceConnection, rsyncItems, sshTransport, [...cleanupTargets]); + const command = buildLocalWindowsReverseParallelRsyncCommand(sourceConnection, sshTransport); + const input = buildLocalWindowsReverseParallelRsyncInput(rsyncItems, [...cleanupTargets]); this.throwIfTransferCanceled(request.progressId); await this.execRemote(client, command, { + input, onStdout: handleProgressOutput, onStderr: handleProgressOutput, - inactivityTimeoutMs: DIRECT_TRANSFER_STALL_TIMEOUT_MS, - inactivityMessage: `Parallel rsync target pull produced no transfer progress for ${DIRECT_TRANSFER_STALL_TIMEOUT_MS} ms.`, - getActivityTime: () => progress.getActivityTime() ?? lastActivityAt + inactivityTimeoutMs: DIRECT_RSYNC_STALL_TIMEOUT_MS, + inactivityMessage: `Parallel rsync target pull produced no transfer progress for ${DIRECT_RSYNC_STALL_TIMEOUT_MS} ms.`, + getActivityTime: () => progress.getActivityTime() ?? lastActivityAt, + progressId: request.progressId }); this.throwIfTransferCanceled(request.progressId); - const cleanupFailures = operation === 'move' ? await this.deleteBatchSources(request.source, batchItems) : []; + const cleanupFailures = operation === 'move' ? await this.deleteBatchSources(request.source, batchItems, request.progressId) : []; const resultBytes = progress.getLastBytes() || bytes; this.fireTransferProgress(request, resultBytes, 0, 'complete', 'Parallel rsync target pull completed.', true, { strategy: 'rsync-direct-parallel', @@ -1178,8 +1492,9 @@ export class TurboFileSshMainService implements ITurboFileSshService { this.throwIfTransferCanceled(request.progressId); try { if (protocol === 'rsync') { - this.fireTransferProgress(request, 0, 0, 'transfer', 'Uploading over local rsync.', true, { strategy: 'rsync-upload' }); - const progress = this.createRsyncProgressReporter(request, 0, 'transfer', 'Uploading over local rsync.', { strategy: 'rsync-upload' }); + const initialTotalBytes = Math.max(0, bytes); + this.fireTransferProgress(request, 0, initialTotalBytes, 'transfer', 'Uploading over local rsync.', true, { strategy: 'rsync-upload' }); + const progress = this.createRsyncProgressReporter(request, initialTotalBytes, 'transfer', 'Uploading over local rsync.', { strategy: 'rsync-upload' }); await this.runLocalRsyncUpload(request, bytes, progress.handleOutput); const resultBytes = progress.getLastBytes() || bytes; await this.verifySshUploadSize(request.target, 0, 'Local rsync upload'); @@ -1215,8 +1530,9 @@ export class TurboFileSshMainService implements ITurboFileSshService { this.throwIfTransferCanceled(request.progressId); try { if (protocol === 'rsync') { - this.fireTransferProgress(request, 0, 0, 'transfer', 'Downloading over local rsync.', true, { strategy: 'rsync-download' }); - const progress = this.createRsyncProgressReporter(request, 0, 'transfer', 'Downloading over local rsync.', { strategy: 'rsync-download' }); + const initialTotalBytes = Math.max(0, bytes); + this.fireTransferProgress(request, 0, initialTotalBytes, 'transfer', 'Downloading over local rsync.', true, { strategy: 'rsync-download' }); + const progress = this.createRsyncProgressReporter(request, initialTotalBytes, 'transfer', 'Downloading over local rsync.', { strategy: 'rsync-download' }); await this.runLocalRsyncDownload(request, bytes, progress.handleOutput); const resultBytes = progress.getLastBytes() || bytes; this.fireTransferProgress(request, resultBytes, 0, 'complete', 'Local rsync download completed.', true, { strategy: 'rsync-download' }); @@ -1249,7 +1565,7 @@ export class TurboFileSshMainService implements ITurboFileSshService { if (actualBytes === expectedBytes) { return; } - await this.deletePath(target, true).catch(() => undefined); + await this.deletePathCore(target, true).catch(() => undefined); throw new Error(`${label} size mismatch: expected ${expectedBytes}, got ${actualBytes}.`); } @@ -1269,11 +1585,7 @@ export class TurboFileSshMainService implements ITurboFileSshService { if (!stat.isDirectory()) { return undefined; } - let bytes = Math.max(0, expectedBytes); - // Compute the byte total concurrently with the remote prep + transfer instead of blocking - // the start on a full directory scan. The progress denominator fills in as soon as the - // scan resolves (well before the first byte flows), so the display stays scp-like. - const sizePromise = bytes <= 0 ? getLocalPathSize(request.source.path).catch(() => 0) : undefined; + const bytes = Math.max(0, expectedBytes); this.fireTransferProgress(request, 0, bytes, 'prepare', 'Preparing tar upload.', true, { strategy: 'tar-upload' }); let archiveBytes = 0; @@ -1282,26 +1594,11 @@ export class TurboFileSshMainService implements ITurboFileSshService { archiveBytes += Math.max(0, delta); this.fireTransferProgress(request, bytes > 0 ? Math.min(archiveBytes, bytes) : archiveBytes, bytes, 'transfer', 'Tar upload in progress.', false, { strategy: 'tar-upload' }); }; - if (sizePromise) { - void sizePromise.then(total => { - if (total > bytes) { - bytes = total; - this.fireTransferProgress(request, Math.min(archiveBytes, bytes), bytes, 'transfer', 'Tar upload in progress.', true, { strategy: 'tar-upload' }); - } - }); - } this.markTransferDataStarted(request.progressId); await this.runLocalDirectoryTarUpload(request.source.path, request.target, request.progressId, onArchiveDelta); this.throwIfTransferCanceled(request.progressId); - if (sizePromise) { - bytes = Math.max(bytes, await sizePromise); - } - const targetSize = await this.getRemotePathContentSizeByCommand(request.target).catch(() => bytes); - if (bytes > 0 && targetSize !== bytes) { - throw new Error(`Tar upload size mismatch: expected ${bytes}, got ${targetSize}.`); - } - const resultBytes = bytes > 0 ? bytes : targetSize; - this.fireTransferProgress(request, resultBytes, resultBytes, 'complete', 'Tar upload completed.', true, { strategy: 'tar-upload' }); + const resultBytes = Math.max(bytes, archiveBytes); + this.fireTransferProgress(request, resultBytes, bytes, 'complete', 'Tar upload completed.', true, { strategy: 'tar-upload' }); return this.createTransferResult(true, 'tar-upload', resultBytes, 'Tar upload completed.', startedAt, request.progressId); } catch (error) { if (error instanceof TurboFileTransferCanceledError || this.isTransferCanceled(request.progressId)) { @@ -1328,11 +1625,7 @@ export class TurboFileSshMainService implements ITurboFileSshService { if (sourceType !== 'directory') { return undefined; } - let bytes = Math.max(0, expectedBytes); - // Run the remote `du` concurrently with the local target prep + transfer rather than - // blocking the start on it (a remote scan of many files on a loaded host can take - // seconds). The denominator fills in as soon as it resolves, keeping the scp-like display. - const sizePromise = bytes <= 0 ? this.getRemotePathContentSizeByCommand(request.source).catch(() => 0) : undefined; + const bytes = Math.max(0, expectedBytes); this.fireTransferProgress(request, 0, bytes, 'prepare', 'Preparing tar download.', true, { strategy: 'tar-download' }); await fsp.rm(request.target.path, { recursive: true, force: true }).catch(() => undefined); @@ -1343,26 +1636,11 @@ export class TurboFileSshMainService implements ITurboFileSshService { archiveBytes += Math.max(0, delta); this.fireTransferProgress(request, bytes > 0 ? Math.min(archiveBytes, bytes) : archiveBytes, bytes, 'transfer', 'Tar download in progress.', false, { strategy: 'tar-download' }); }; - if (sizePromise) { - void sizePromise.then(total => { - if (total > bytes) { - bytes = total; - this.fireTransferProgress(request, Math.min(archiveBytes, bytes), bytes, 'transfer', 'Tar download in progress.', true, { strategy: 'tar-download' }); - } - }); - } this.markTransferDataStarted(request.progressId); await this.runRemoteDirectoryTarDownload(request.source, request.target.path, request.progressId, onArchiveDelta); this.throwIfTransferCanceled(request.progressId); - if (sizePromise) { - bytes = Math.max(bytes, await sizePromise); - } - const targetSize = await getLocalPathSize(request.target.path).catch(() => bytes); - if (bytes > 0 && targetSize !== bytes) { - throw new Error(`Tar download size mismatch: expected ${bytes}, got ${targetSize}.`); - } - const resultBytes = bytes > 0 ? bytes : targetSize; - this.fireTransferProgress(request, resultBytes, resultBytes, 'complete', 'Tar download completed.', true, { strategy: 'tar-download' }); + const resultBytes = Math.max(bytes, archiveBytes); + this.fireTransferProgress(request, resultBytes, bytes, 'complete', 'Tar download completed.', true, { strategy: 'tar-download' }); return this.createTransferResult(true, 'tar-download', resultBytes, 'Tar download completed.', startedAt, request.progressId); } catch (error) { if (error instanceof TurboFileTransferCanceledError || this.isTransferCanceled(request.progressId)) { @@ -1377,32 +1655,37 @@ export class TurboFileSshMainService implements ITurboFileSshService { if (target.kind !== 'ssh' || !target.connection) { throw new Error('Missing SSH target.'); } + const localTarRoot = await this.createLocalTarRootAliasIfNeeded(localDirectory); try { - await this.runParallelLocalDirectoryTarUpload(localDirectory, target.connection, buildPrepareTarExtractToExactDirectoryCommand(target.path), buildTarExtractStreamToDirectoryCommand(target.path), progressId, onArchiveDelta); - return; - } catch (error) { - if (error instanceof TurboFileTransferCanceledError || this.isTransferCanceled(progressId)) { - throw new TurboFileTransferCanceledError(); + try { + await this.runParallelLocalDirectoryTarUpload(localTarRoot.path, target.connection, buildPrepareTarExtractToExactDirectoryCommand(target.path), buildTarExtractStreamToDirectoryCommand(target.path), progressId, onArchiveDelta); + return; + } catch (error) { + if (error instanceof TurboFileTransferCanceledError || this.isTransferCanceled(progressId)) { + throw new TurboFileTransferCanceledError(); + } + // Parallel raw tar|ssh has no remote zstd dependency and is the stable default fast path. } - // Parallel raw tar|ssh has no remote zstd dependency and is the stable default fast path. - } - try { - await this.runParallelLocalDirectoryTarZstdUpload(localDirectory, target.connection, buildPrepareTarZstdExtractToExactDirectoryCommand(target.path), buildTarZstdExtractToDirectoryCommand(target.path), progressId, onArchiveDelta); - return; - } catch (error) { - if (error instanceof TurboFileTransferCanceledError || this.isTransferCanceled(progressId)) { - throw new TurboFileTransferCanceledError(); + try { + await this.runParallelLocalDirectoryTarZstdUpload(localTarRoot.path, target.connection, buildPrepareTarZstdExtractToExactDirectoryCommand(target.path), buildTarZstdExtractToDirectoryCommand(target.path), progressId, onArchiveDelta); + return; + } catch (error) { + if (error instanceof TurboFileTransferCanceledError || this.isTransferCanceled(progressId)) { + throw new TurboFileTransferCanceledError(); + } + // Parallel tar+zstd+ssh is the measured fast path. If unavailable, try single native tar|ssh. } - // Parallel tar+zstd+ssh is the measured fast path. If unavailable, try single native tar|ssh. - } - try { - await this.runNativeLocalTarUpload(['-cf', '-', '-C', localDirectory, '.'], target.connection, buildTarExtractToExactDirectoryCommand(target.path), progressId, onArchiveDelta); - return; - } catch (error) { - if (error instanceof TurboFileTransferCanceledError || this.isTransferCanceled(progressId)) { - throw new TurboFileTransferCanceledError(); + try { + await this.runNativeLocalTarUpload(['-cf', '-', '-C', localTarRoot.path, '.'], target.connection, buildTarExtractToExactDirectoryCommand(target.path), progressId, onArchiveDelta); + return; + } catch (error) { + if (error instanceof TurboFileTransferCanceledError || this.isTransferCanceled(progressId)) { + throw new TurboFileTransferCanceledError(); + } + throw new Error(`Native tar upload failed: ${formatTransferError(error)}`); } - throw new Error(`Native tar upload failed: ${formatTransferError(error)}`); + } finally { + await localTarRoot.dispose(); } } @@ -1433,7 +1716,7 @@ export class TurboFileSshMainService implements ITurboFileSshService { try { client = await this.connect(configResult.config); const sourceStream = await this.openRemoteCommandStream(client, buildTarCreateFromDirectoryCommand(source.path), 'tar download create'); - tarProcess = spawn('tar.exe', ['-xf', '-', '-C', localDirectory], { windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] }); + tarProcess = spawn('tar.exe', [...await this.getLocalTarCharsetArgs(), '-xf', '-', '-C', localDirectory], { windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] }); if (!tarProcess.stdin || !tarProcess.stdout) { throw new Error('Local tar download did not provide pipes.'); } @@ -1448,6 +1731,34 @@ export class TurboFileSshMainService implements ITurboFileSshService { } } + private async createLocalTarRootAliasIfNeeded(localRoot: string, force = false): Promise<{ readonly path: string; readonly dispose: () => Promise }> { + if (platform() !== 'win32' || !(force || requiresLocalTarSafeRootAlias(localRoot))) { + return { + path: localRoot, + dispose: async () => undefined + }; + } + + const tempRoot = join(tmpdir(), `turbofile-tar-root-${randomUUID()}`); + const aliasPath = join(tempRoot, 'root'); + try { + await fsp.mkdir(tempRoot, { recursive: true }); + await fsp.symlink(localRoot, aliasPath, 'junction'); + } catch (error) { + await fsp.rmdir(aliasPath).catch(() => undefined); + await fsp.rmdir(tempRoot).catch(() => undefined); + throw error; + } + + return { + path: aliasPath, + dispose: async () => { + await fsp.rmdir(aliasPath).catch(() => undefined); + await fsp.rmdir(tempRoot).catch(() => undefined); + } + }; + } + private async runNativeLocalTarUpload(tarArgs: string[], connection: ITurboFileSshConnectionOptions, remoteCommand: string, progressId: string | undefined, onArchiveDelta?: (delta: number) => void): Promise { const tool = await resolveLocalSshTool(); await withNativeOpenSshArgs(connection, tool, remoteCommand, async (sshArgs, env) => { @@ -1462,7 +1773,7 @@ export class TurboFileSshMainService implements ITurboFileSshService { } }); try { - tarProcess = spawn('tar.exe', tarArgs, { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] }); + tarProcess = spawn('tar.exe', [...await this.getLocalTarCharsetArgs(), ...tarArgs], { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] }); sshProcess = spawn(tool.executable, sshArgs, { windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'], env }); sshProcess.stdout?.resume(); if (!tarProcess.stdout || !sshProcess.stdin) { @@ -1479,7 +1790,7 @@ export class TurboFileSshMainService implements ITurboFileSshService { }); } - private async runNativeLocalTarDownload(connection: ITurboFileSshConnectionOptions, remoteCommand: string, tarArgs: string[], progressId: string | undefined, onArchiveDelta?: (delta: number) => void): Promise { + private async runNativeLocalTarDownload(connection: ITurboFileSshConnectionOptions, remoteCommand: string, tarArgs: string[], progressId: string | undefined, onArchiveDelta?: (delta: number) => void, remoteInput?: Uint8Array): Promise { const tool = await resolveLocalSshTool(); await withNativeOpenSshArgs(connection, tool, remoteCommand, async (sshArgs, env) => { let sshProcess: ReturnType | undefined; @@ -1493,17 +1804,22 @@ export class TurboFileSshMainService implements ITurboFileSshService { } }); try { - sshProcess = spawn(tool.executable, sshArgs, { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'], env }); - tarProcess = spawn('tar.exe', tarArgs, { windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] }); + sshProcess = spawn(tool.executable, sshArgs, { windowsHide: true, stdio: [remoteInput ? 'pipe' : 'ignore', 'pipe', 'pipe'], env }); + tarProcess = spawn('tar.exe', [...await this.getLocalTarCharsetArgs(), ...tarArgs], { windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] }); tarProcess.stdout?.resume(); if (!sshProcess.stdout || !tarProcess.stdin) { throw new Error('Native tar download did not provide required pipes.'); } const sshClose = waitForLocalProcessClose(sshProcess, 'native tar download create'); const tarClose = waitForLocalProcessClose(tarProcess, 'native tar download extract'); - await pipeline(sshProcess.stdout, createProgressPassThrough(onArchiveDelta), tarProcess.stdin); - await sshClose; - await tarClose; + const archivePipeline = pipeline(sshProcess.stdout, createProgressPassThrough(onArchiveDelta), tarProcess.stdin); + const inputWrite = remoteInput ? (() => { + if (!sshProcess.stdin) { + throw new Error('Native tar download did not provide stdin for the remote path list.'); + } + return writeInputToWritableStream(sshProcess.stdin, remoteInput, 'native tar download path list'); + })() : Promise.resolve(); + await Promise.all([archivePipeline, inputWrite, sshClose, tarClose]); } finally { unregisterCancel(); } @@ -1553,7 +1869,11 @@ export class TurboFileSshMainService implements ITurboFileSshService { const preparePromise = this.prepareRemoteBatchTarTarget(connection, targetDirectory, plan.targetNames, progressId, false); let entries: readonly string[]; try { - entries = await this.collectLocalTarRelativeEntries(plan.parentPath, plan.names); + entries = plan.fileNames && plan.directoryNames && plan.unknownNames + ? await this.collectLocalTarRelativeEntries(plan.parentPath, plan.unknownNames, plan.directoryNames, plan.fileNames) + : plan.containsDirectories + ? await this.collectLocalTarRelativeEntries(plan.parentPath, plan.names) + : plan.names.map(normalizeTarListPath); } catch (error) { await preparePromise.catch(() => undefined); throw error; @@ -1578,7 +1898,11 @@ export class TurboFileSshMainService implements ITurboFileSshService { const preparePromise = this.prepareRemoteBatchTarTarget(connection, targetDirectory, plan.targetNames, progressId, true); let entries: readonly string[]; try { - entries = await this.collectLocalTarRelativeEntries(plan.parentPath, plan.names); + entries = plan.fileNames && plan.directoryNames && plan.unknownNames + ? await this.collectLocalTarRelativeEntries(plan.parentPath, plan.unknownNames, plan.directoryNames, plan.fileNames) + : plan.containsDirectories + ? await this.collectLocalTarRelativeEntries(plan.parentPath, plan.names) + : plan.names.map(normalizeTarListPath); } catch (error) { await preparePromise.catch(() => undefined); throw error; @@ -1647,46 +1971,174 @@ export class TurboFileSshMainService implements ITurboFileSshService { } } - private async collectLocalTarRelativeEntries(localRoot: string, names?: readonly string[]): Promise { - // Walk concurrently and trust the Dirent type from readdir instead of an extra lstat per - // entry: the parent readdir already tells us file/dir/symlink, so a 30k-file tree no longer - // pays 30k serial lstat round trips before the transfer can start. + private async collectLocalTarRelativeEntries(localRoot: string, unknownNames?: readonly string[], knownDirectoryNames: readonly string[] = [], knownFileNames: readonly string[] = []): Promise { const entries: string[] = []; - const walkDirectory = async (relativeDir: string): Promise => { + const directoryQueue = knownDirectoryNames.map(normalizeTarListPath); + const inspectDirectory = async (relativeDir: string): Promise => { const fullDir = relativeDir ? join(localRoot, ...relativeDir.split('/')) : localRoot; const children = await fsp.readdir(fullDir, { withFileTypes: true }); if (!children.length && relativeDir) { entries.push(relativeDir); - return; + return []; } const subDirectories: string[] = []; for (const child of children) { const childRelativePath = normalizeTarListPath(relativeDir ? `${relativeDir}/${child.name}` : child.name); if (child.isDirectory()) { subDirectories.push(childRelativePath); - } else if (child.isFile() || child.isSymbolicLink()) { + } else { + // Tar can preserve symlinks, FIFOs and device nodes. Treat every non-directory + // Dirent as a leaf so a fast pre-walk can never silently drop a selected source. Unix + // sockets are the exception: tar formats cannot preserve them, so fail before a move can + // ever clean the source tree. + if (child.isSocket()) { + throw new Error(`Cannot archive Unix socket: ${childRelativePath}`); + } + if (!child.isFile() && !child.isSymbolicLink() && !child.isBlockDevice() && !child.isCharacterDevice() && !child.isFIFO()) { + const stat = await fsp.lstat(join(fullDir, child.name)); + if (stat.isSocket()) { + throw new Error(`Cannot archive Unix socket: ${childRelativePath}`); + } + } entries.push(childRelativePath); } } - await mapLimit(subDirectories, LOCAL_DIRECTORY_STAT_CONCURRENCY, walkDirectory); + return subDirectories; }; - if (names?.length) { - await mapLimit(names, LOCAL_DIRECTORY_STAT_CONCURRENCY, async name => { + entries.push(...knownFileNames.map(normalizeTarListPath)); + const hasExplicitRoots = unknownNames !== undefined || knownDirectoryNames.length > 0 || knownFileNames.length > 0; + if (unknownNames?.length) { + const unknownEntries = await mapLimit(unknownNames, LOCAL_DIRECTORY_STAT_CONCURRENCY, async name => { const relativePath = normalizeTarListPath(name); const stat = await fsp.lstat(join(localRoot, ...relativePath.split('/'))); - if (stat.isDirectory()) { - await walkDirectory(relativePath); - } else if (stat.isFile() || stat.isSymbolicLink()) { - entries.push(relativePath); + if (stat.isSocket()) { + throw new Error(`Cannot archive Unix socket: ${relativePath}`); } + return { relativePath, directory: stat.isDirectory() }; }); - } else { - await walkDirectory(''); + for (const entry of unknownEntries) { + if (entry.directory) { + directoryQueue.push(entry.relativePath); + } else { + entries.push(entry.relativePath); + } + } + } else if (!hasExplicitRoots) { + directoryQueue.push(''); } + + await new Promise((resolve, reject) => { + let nextIndex = 0; + let active = 0; + let settled = false; + const pump = (): void => { + if (settled) { + return; + } + while (active < LOCAL_DIRECTORY_STAT_CONCURRENCY && nextIndex < directoryQueue.length) { + const relativeDir = directoryQueue[nextIndex++]; + active++; + void inspectDirectory(relativeDir).then(subDirectories => { + directoryQueue.push(...subDirectories); + }, error => { + if (!settled) { + settled = true; + reject(error); + } + }).finally(() => { + active--; + pump(); + }); + } + if (active === 0 && nextIndex >= directoryQueue.length) { + settled = true; + resolve(); + } + }; + pump(); + }); return entries.sort((a, b) => FILE_ENTRY_NAME_COLLATOR.compare(a, b)); } + /** + * Pre-walk the local source tree on the Node side and write a null-separated file list so the + * bundled cygwin `rsync.exe` can skip its own recursive scan via `--files-from`. cygwin's + * per-entry `stat()` is ~10-50× slower than Node's `readdir(... withFileTypes)`, so a directory + * of 30k tiny files (the `1024x40` test set) keeps rsync silent for 20+ seconds before the + * first byte is sent. Building the list ourselves drops that to well under a second. + * + * Only runs on Windows (where cygwin's stat is the bottleneck) and only when every item shares + * the same parent directory so a single rsync invocation with one source root suffices. + * Returns `undefined` to indicate the caller should fall back to the per-item-argument form. + */ + private localTarListEncodingPromise: Promise | undefined; + + /** + * The bundled Windows `tar.exe` (libarchive bsdtar) reads `-T` list files using the process + * ANSI code page, not UTF-8. Writing the list as UTF-8 makes non-ASCII names (e.g. Chinese) + * unreadable and aborts the transfer ("Couldn't visit directory: No such file or directory"). + * Resolve the system code page once so list files are encoded the way bsdtar expects; falls + * back to UTF-8 on non-Windows or when detection fails. + */ + private async getLocalTarListEncoding(): Promise { + if (!this.localTarListEncodingPromise) { + this.localTarListEncodingPromise = (async () => { + if (platform() !== 'win32') { + return 'utf8'; + } + try { + const encoding = await resolveTerminalEncoding(); + const iconv = await import('@vscode/iconv-lite-umd'); + return iconv.default.encodingExists(encoding) ? encoding : 'utf8'; + } catch { + return 'utf8'; + } + })(); + } + return this.localTarListEncodingPromise; + } + + private localTarCharsetArgsPromise: Promise | undefined; + + /** + * Windows `tar.exe` is libarchive `bsdtar`, which interprets archive member names in the process + * ANSI code page (e.g. GBK) unless told otherwise. The peer on a transfer is a UTF-8 Linux host + * (GNU tar), so without this option non-ASCII names (e.g. Chinese) garble in both directions: + * on extract bsdtar reads the remote's raw UTF-8 bytes as GBK, and on create it would emit GBK. + * `--options hdrcharset=UTF-8` makes bsdtar read/write archive names as UTF-8 so both ends agree. + * The flag is only valid for bsdtar (GNU tar rejects --options and is already UTF-8 native), and + * only matters on Windows, so it is emitted just for a detected bsdtar. SSH↔SSH transfers never + * touch local tar, which is why they were never affected. + */ + private getLocalTarCharsetArgs(): Promise { + if (!this.localTarCharsetArgsPromise) { + this.localTarCharsetArgsPromise = (async () => { + if (platform() !== 'win32') { + return []; + } + try { + const version = await new Promise((resolve, reject) => { + execFile('tar.exe', ['--version'], { windowsHide: true }, (error, stdout) => error ? reject(error) : resolve(stdout)); + }); + return /bsdtar|libarchive/i.test(version) ? ['--options', 'hdrcharset=UTF-8'] : []; + } catch { + return []; + } + })(); + } + return this.localTarCharsetArgsPromise; + } + + private async createLocalTarListBuffer(entries: readonly string[]): Promise { + const encoding = await this.getLocalTarListEncoding(); + if (encoding === 'utf8') { + return createTarNullListBuffer(entries); + } + const iconv = await import('@vscode/iconv-lite-umd'); + return Buffer.from(iconv.default.encode(`${entries.join('\0')}\0`, encoding)); + } + private async writeLocalTarChunkLists(tempRoot: string, entries: readonly string[]): Promise { const chunkCount = Math.min(LOCAL_PARALLEL_TAR_CHUNK_COUNT, entries.length); const chunks = Array.from({ length: chunkCount }, () => [] as string[]); @@ -1697,14 +2149,14 @@ export class TurboFileSshMainService implements ITurboFileSshService { .filter(chunk => chunk.length > 0) .map(async (chunk, index) => { const listPath = join(tempRoot, `chunk-${String(index).padStart(2, '0')}.lst`); - await fsp.writeFile(listPath, createTarNullListBuffer(chunk)); + await fsp.writeFile(listPath, await this.createLocalTarListBuffer(chunk)); return { listPath, entryCount: chunk.length }; })); } private async writeLocalTarListFile(tempRoot: string, names: readonly string[]): Promise { const listPath = join(tempRoot, 'items.lst'); - await fsp.writeFile(listPath, createTarNullListBuffer(names.map(name => normalizeTarListPath(name)))); + await fsp.writeFile(listPath, await this.createLocalTarListBuffer(names.map(name => normalizeTarListPath(name)))); return listPath; } @@ -1714,7 +2166,7 @@ export class TurboFileSshMainService implements ITurboFileSshService { }); } - private async runNativeSshCommandWithInput(connection: ITurboFileSshConnectionOptions, tool: ILocalSshTool, remoteCommand: string, input: Buffer, progressId: string | undefined): Promise { + private async runNativeSshCommandWithInput(connection: ITurboFileSshConnectionOptions, tool: ILocalSshTool, remoteCommand: string, input: Uint8Array, progressId: string | undefined): Promise { await withNativeOpenSshArgs(connection, tool, remoteCommand, async (sshArgs, env) => { let sshProcess: ReturnType | undefined; const unregisterCancel = this.registerTransferCancelHandler(progressId, () => { @@ -1729,8 +2181,10 @@ export class TurboFileSshMainService implements ITurboFileSshService { throw new Error('Native ssh command did not provide stdin.'); } const sshClose = waitForLocalProcessClose(sshProcess, 'native ssh command'); - sshProcess.stdin.end(input); - await sshClose; + await Promise.all([ + writeInputToWritableStream(sshProcess.stdin, input, 'native ssh command input'), + sshClose + ]); } finally { unregisterCancel(); } @@ -1740,7 +2194,8 @@ export class TurboFileSshMainService implements ITurboFileSshService { private async prepareRemoteBatchTarTarget(connection: ITurboFileSshConnectionOptions, targetDirectory: string, targetNames: readonly string[], progressId: string | undefined, requireZstd: boolean): Promise { const sshTool = await resolveLocalSshTool(); const command = buildPrepareTarExtractToDirectoryFromListCommand(targetDirectory, requireZstd); - await this.runNativeSshCommandWithInput(connection, sshTool, command, createTarNullListBuffer(targetNames), progressId); + const targetPaths = targetNames.map(name => joinRemotePath(targetDirectory, name)); + await this.runNativeSshCommandWithInput(connection, sshTool, command, createTarNullListBuffer(targetPaths), progressId); } private async runNativeLocalTarUploadChunk(localRoot: string, chunk: ILocalTarChunk, connection: ITurboFileSshConnectionOptions, sshTool: ILocalSshTool, remoteCommand: string, progressId: string | undefined, onArchiveDelta?: (delta: number) => void): Promise { @@ -1756,7 +2211,7 @@ export class TurboFileSshMainService implements ITurboFileSshService { } }); try { - tarProcess = spawn('tar.exe', ['-cf', '-', '-C', localRoot, '--null', '-T', chunk.listPath], { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] }); + tarProcess = spawn('tar.exe', [...await this.getLocalTarCharsetArgs(), '-cf', '-', '-C', localRoot, '--null', '-T', chunk.listPath], { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] }); sshProcess = spawn(sshTool.executable, sshArgs, { windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'], env: sshEnv }); sshProcess.stdout?.resume(); if (!tarProcess.stdout || !sshProcess.stdin) { @@ -1811,7 +2266,7 @@ export class TurboFileSshMainService implements ITurboFileSshService { } }); try { - tarProcess = spawn('tar.exe', ['-cf', '-', '-C', localRoot, '--null', '-T', chunk.listPath], { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] }); + tarProcess = spawn('tar.exe', [...await this.getLocalTarCharsetArgs(), '-cf', '-', '-C', localRoot, '--null', '-T', chunk.listPath], { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] }); zstdProcess = spawn(zstdTool.executable, [`-${LOCAL_TAR_ZSTD_LEVEL}`, '-T1', '-c'], { windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'], env: zstdTool.env }); sshProcess = spawn(sshTool.executable, sshArgs, { windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'], env: sshEnv }); sshProcess.stdout?.resume(); @@ -2110,12 +2565,45 @@ export class TurboFileSshMainService implements ITurboFileSshService { if (request.progressId) { this.transferCancellationRequests.delete(request.progressId); } - if (!items.length) { - return this.createBatchTransferResult(false, strategy, 0, 'No transfer items.', 0, 0, [], startedAt, request.progressId); - } - - const expectedBytes = this.getBatchExpectedBytes(request, items); - const initialAttempt = this.getInitialBatchTransferAttempt(request, items); + try { + return await this.fileOperationQueue.runWithAccess( + getTransferOperationAccess(request.source, request.targetDirectory, operation), + () => this.transferBatchCore(request), + { + id: request.progressId, + onQueued: () => this.fireTransferProgress(request, 0, Math.max(0, request.expectedBytes ?? 0), 'prepare', 'Waiting for device operation queue.', true, { + completedItems: 0, + totalItems: items.length, + currentName: items[0]?.displayName ?? items[0]?.targetName + }) + } + ); + } catch (error) { + const canceled = error instanceof TurboFileOperationCanceledError || this.isTransferCanceled(request.progressId); + if (request.progressId) { + this.transferCancellationRequests.delete(request.progressId); + this.transferCancelHandlers.delete(request.progressId); + } + this.scheduleTransferProgressCleanup(request.progressId); + return this.createBatchTransferResult(false, strategy, 0, canceled ? TRANSFER_CANCELED_MESSAGE : error instanceof Error ? error.message : String(error), 0, items.length, canceled || !items.length ? [] : [{ + name: items[0].displayName ?? items[0].targetName, + path: items[0].sourcePath, + message: error instanceof Error ? error.message : String(error) + }], startedAt, request.progressId, canceled); + } + } + + private async transferBatchCore(request: ITurboFileBatchTransferRequest): Promise { + const startedAt = Date.now(); + const strategy = getTransferStrategy(request.source, request.targetDirectory); + const items = request.items.filter(item => !!item.sourcePath && !!item.targetName); + const operation = request.operation === 'move' ? 'move' : 'copy'; + if (!items.length) { + return this.createBatchTransferResult(false, strategy, 0, 'No transfer items.', 0, 0, [], startedAt, request.progressId); + } + + const expectedBytes = this.getBatchExpectedBytes(request, items); + const initialAttempt = this.getInitialBatchTransferAttempt(request, items); const initialStrategy = transferAttemptToDirectionalStrategy(request.source, request.targetDirectory, initialAttempt); const initialIsRsync = initialAttempt?.protocol === 'rsync'; try { @@ -2262,7 +2750,7 @@ export class TurboFileSshMainService implements ITurboFileSshService { ? await this.tryTunneledBatchRsyncRelayTransfer(request, items, expectedBytes, startedAt, true) : undefined; if (result?.ok && operation === 'move') { - const cleanupFailures = await this.deleteBatchSources(request.source, items); + const cleanupFailures = await this.deleteBatchSources(request.source, items, request.progressId); if (cleanupFailures.length) { return this.createBatchTransferResult(false, result.strategy, result.bytes, `Batch transfer completed, but source cleanup failed for ${cleanupFailures.length} item(s).`, items.length, cleanupFailures.length, cleanupFailures, startedAt, request.progressId); } @@ -2282,20 +2770,21 @@ export class TurboFileSshMainService implements ITurboFileSshService { } private async runLocalNativeBatchTransfer(request: ITurboFileBatchTransferRequest, items: readonly ITurboFileBatchTransferItem[], operation: 'copy' | 'move', expectedBytes: number, startedAt: number): Promise { - const totalBytes = operation === 'copy' ? await this.getLocalBatchTransferSize(items, expectedBytes) : expectedBytes; + const totalBytes = Math.max(0, expectedBytes); this.fireTransferProgress(request, 0, totalBytes, 'transfer', operation === 'move' ? 'Moving locally.' : 'Copying locally.', true, { completedItems: 0, totalItems: items.length, currentName: items[0]?.displayName ?? items[0]?.targetName }); const sources = items.map(item => item.sourcePath); + let transferredBytes = 0; if (operation === 'copy') { await ensureLocalDirectory(request.targetDirectory.path); - let transferredBytes = 0; for (const [index, item] of items.entries()) { this.throwIfTransferCanceled(request.progressId); const currentName = item.displayName ?? item.targetName; - this.fireTransferProgress(request, Math.min(transferredBytes, totalBytes), totalBytes, 'transfer', 'Copying locally.', true, { + const progressBytes = totalBytes > 0 ? Math.min(transferredBytes, totalBytes) : transferredBytes; + this.fireTransferProgress(request, progressBytes, totalBytes, 'transfer', 'Copying locally.', index === 0, { completedItems: index, totalItems: items.length, currentName @@ -2303,13 +2792,15 @@ export class TurboFileSshMainService implements ITurboFileSshService { await this.copyLocalPath(item.sourcePath, this.joinBatchTargetPath(request.targetDirectory, item.targetName), delta => { this.throwIfTransferCanceled(request.progressId); transferredBytes += delta; - this.fireTransferProgress(request, Math.min(transferredBytes, totalBytes), totalBytes, 'transfer', 'Copying locally.', false, { + const progressBytes = totalBytes > 0 ? Math.min(transferredBytes, totalBytes) : transferredBytes; + this.fireTransferProgress(request, progressBytes, totalBytes, 'transfer', 'Copying locally.', false, { completedItems: index, totalItems: items.length, currentName }); }, request.progressId); - this.fireTransferProgress(request, Math.min(transferredBytes, totalBytes), totalBytes, 'transfer', 'Copying locally.', true, { + const completedBytes = totalBytes > 0 ? Math.min(transferredBytes, totalBytes) : transferredBytes; + this.fireTransferProgress(request, completedBytes, totalBytes, 'transfer', 'Copying locally.', false, { completedItems: index + 1, totalItems: items.length, currentName @@ -2317,28 +2808,37 @@ export class TurboFileSshMainService implements ITurboFileSshService { } } else if (platform() === 'win32' || looksLikeWindowsPath(request.targetDirectory.path) || sources.some(looksLikeWindowsPath)) { await ensureLocalParentDirectory(this.joinBatchTargetPath(request.targetDirectory, items[0].targetName)); + const mappings = items.map(item => ({ + sourcePath: item.sourcePath, + targetPath: this.joinBatchTargetPath(request.targetDirectory, item.targetName), + sourceType: item.sourceType ?? 'unknown' + })); this.markTransferDataStarted(request.progressId); - await runLocalPowerShellScript(buildLocalWindowsBatchScript(sources, request.targetDirectory.path, operation)); + await this.runCancelableLocalCommand('powershell.exe', [ + '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-EncodedCommand', encodePowerShellCommand(buildWindowsMappedBatchFromInputScript(operation)) + ], { + input: Buffer.from(JSON.stringify(mappings), 'utf8'), + inactivityTimeoutMs: REMOTE_DIRECTORY_READ_MAX_TIMEOUT_MS, + inactivityMessage: 'Local Windows batch move produced no progress.' + }, request.progressId); } else { await ensureLocalParentDirectory(this.joinBatchTargetPath(request.targetDirectory, items[0].targetName)); await ensureLocalDirectory(request.targetDirectory.path); + const plan = createPosixNativeBatchTransferPlan(items, request.targetDirectory.path, operation); this.markTransferDataStarted(request.progressId); - await runLocalCommand('mv', ['-f', ...sources, request.targetDirectory.path]); + await this.runCancelableLocalCommand('sh', ['-c', plan.command], { + input: plan.input, + inactivityTimeoutMs: REMOTE_DIRECTORY_READ_MAX_TIMEOUT_MS, + inactivityMessage: 'Local POSIX batch move produced no progress.' + }, request.progressId); } this.throwIfTransferCanceled(request.progressId); - this.fireTransferProgress(request, totalBytes, totalBytes, 'complete', 'Batch local transfer completed.', true, { + const resultBytes = operation === 'copy' ? transferredBytes : totalBytes; + this.fireTransferProgress(request, resultBytes, totalBytes, 'complete', 'Batch local transfer completed.', true, { completedItems: items.length, totalItems: items.length }); - return this.createBatchTransferResult(true, operation === 'move' ? 'local-copy' : 'local-copy', totalBytes, 'Batch local transfer completed.', items.length, 0, [], startedAt, request.progressId); - } - - private async getLocalBatchTransferSize(items: readonly ITurboFileBatchTransferItem[], expectedBytes: number): Promise { - if (expectedBytes > 0) { - return expectedBytes; - } - const sizes = await mapLimit(items, BATCH_TRANSFER_DIRECTORY_WORKERS, item => getLocalPathTransferSize(item.sourcePath, item.expectedBytes)); - return sizes.reduce((total, size) => total + size, 0); + return this.createBatchTransferResult(true, operation === 'move' ? 'local-copy' : 'local-copy', resultBytes, 'Batch local transfer completed.', items.length, 0, [], startedAt, request.progressId); } private async runSameRemoteNativeBatchTransfer(request: ITurboFileBatchTransferRequest, items: readonly ITurboFileBatchTransferItem[], operation: 'copy' | 'move', expectedBytes: number, startedAt: number): Promise { @@ -2348,11 +2848,20 @@ export class TurboFileSshMainService implements ITurboFileSshService { currentName: items[0]?.displayName ?? items[0]?.targetName }); const sources = items.map(item => item.sourcePath); - const command = looksLikeWindowsPath(request.targetDirectory.path) || sources.some(looksLikeWindowsPath) - ? buildWindowsBatchCommand(sources, request.targetDirectory.path, operation) - : buildPosixBatchCommand(sources, request.targetDirectory.path, operation); this.markTransferDataStarted(request.progressId); - await this.withSshClient(request.source, client => this.execRemote(client, command)); + if (looksLikeWindowsPath(request.targetDirectory.path) || sources.some(looksLikeWindowsPath)) { + const input = Buffer.from(JSON.stringify(items.map(item => ({ + sourcePath: item.sourcePath, + targetPath: this.joinBatchTargetPath(request.targetDirectory, item.targetName), + sourceType: item.sourceType ?? 'unknown' + }))), 'utf8'); + await this.withSshClient(request.source, client => this.execRemoteWithInput(client, buildWindowsMappedBatchFromInputCommand(operation), input, { + progressId: request.progressId + })); + } else { + const plan = createPosixNativeBatchTransferPlan(items, request.targetDirectory.path, operation); + await this.withSshClient(request.source, client => this.execRemoteWithInput(client, plan.command, plan.input, { progressId: request.progressId })); + } this.throwIfTransferCanceled(request.progressId); this.fireTransferProgress(request, expectedBytes, expectedBytes, 'complete', 'Batch remote native transfer completed.', true, { completedItems: items.length, @@ -2389,61 +2898,65 @@ export class TurboFileSshMainService implements ITurboFileSshService { const connection = isUpload ? request.targetDirectory.connection! : request.source.connection!; try { const tool = await resolveLocalRsyncTool(); - const progress = this.createRsyncProgressReporter(request, 0, 'transfer', isUpload ? 'Batch rsync upload in progress.' : 'Batch rsync download in progress.', { - strategy: isUpload ? 'rsync-upload' : 'rsync-download', - completedItems: 0, - totalItems: items.length - }); + const initialTotalBytes = Math.max(0, expectedBytes); + let transferredBytes = 0; // Fire 'transfer' (not 'prepare') right away: rsync starts immediately and may stay quiet // while scanning its file list, and the last phase label should not read as "preparing". - this.fireTransferProgress(request, 0, 0, 'transfer', isUpload ? 'Starting batch rsync upload.' : 'Starting batch rsync download.', true, { + this.fireTransferProgress(request, 0, initialTotalBytes, 'transfer', isUpload ? 'Starting batch rsync upload.' : 'Starting batch rsync download.', true, { strategy: isUpload ? 'rsync-upload' : 'rsync-download', completedItems: 0, totalItems: items.length, currentName: items[0]?.displayName ?? items[0]?.targetName }); - // Progress is driven by rsync's own --info=progress2 transferred byte count. - if (isUpload) { - const sourceArgs = items.map(item => toRsyncLocalPath(item.sourcePath, tool.pathFlavor)); - await withLocalRsyncSshEnvironment(connection, tool, (env, identityPath) => { - return this.runCancelableLocalCommand(tool.executable, [ - ...buildLocalRsyncOptionArgs(true), - buildRsyncPathWithMkdirOption(request.targetDirectory.path), - '-e', - buildLocalRsyncSshTransport(connection, tool.sshExecutable, identityPath), - ...sourceArgs, - formatRsyncRemoteSpec(connection, ensureTrailingRemoteSlash(request.targetDirectory.path)) - ], { - env, - onStdout: progress.handleOutput, - onStderr: progress.handleOutput, - inactivityTimeoutMs: LOCAL_RSYNC_STALL_TIMEOUT_MS, - inactivityMessage: `Batch rsync upload produced no output for ${LOCAL_RSYNC_STALL_TIMEOUT_MS} ms.` - }, request.progressId); - }); - } else { + // Explicit paths are always supplied as a NUL-delimited list. Grouping by source parent + // preserves the historical flattened-basename behavior without exposing argv limits. + const sourceGroups = createTurboFileRsyncSourceGroups( + items.map(item => item.sourcePath), + isUpload && platform() === 'win32' ? 'windows' : 'posix' + ); + if (!isUpload) { await ensureLocalDirectory(request.targetDirectory.path); - const sourceArgs = items.map(item => formatRsyncRemoteSpec(connection, item.sourcePath)); - await withLocalRsyncSshEnvironment(connection, tool, (env, identityPath) => { - return this.runCancelableLocalCommand(tool.executable, [ + } + await withLocalRsyncSshEnvironment(connection, tool, async (env, identityPath) => { + for (const group of sourceGroups) { + this.throwIfTransferCanceled(request.progressId); + const progress = this.createRsyncProgressReporter(request, initialTotalBytes, 'transfer', isUpload ? 'Batch rsync upload in progress.' : 'Batch rsync download in progress.', { + strategy: isUpload ? 'rsync-upload' : 'rsync-download', + completedItems: 0, + totalItems: items.length + }, undefined, transferredBytes); + const sourceBase = isUpload + ? ensureTrailingRemoteSlash(toRsyncLocalPath(group.sourceBase, tool.pathFlavor)) + : formatRsyncRemoteSpec(connection, ensureTrailingRemoteSlash(group.sourceBase)); + const target = isUpload + ? formatRsyncRemoteSpec(connection, ensureTrailingRemoteSlash(request.targetDirectory.path)) + : toRsyncLocalPath(request.targetDirectory.path, tool.pathFlavor); + const args = [ ...buildLocalRsyncOptionArgs(true), + '--files-from=-', + '--from0', + '-r', + ...(isUpload ? [buildRsyncPathWithMkdirOption(request.targetDirectory.path)] : []), '-e', buildLocalRsyncSshTransport(connection, tool.sshExecutable, identityPath), - ...sourceArgs, - toRsyncLocalPath(request.targetDirectory.path, tool.pathFlavor) - ], { + sourceBase, + target + ]; + await this.runCancelableLocalCommand(tool.executable, args, { env, + input: createTarNullListBuffer(group.relativePaths), onStdout: progress.handleOutput, onStderr: progress.handleOutput, inactivityTimeoutMs: LOCAL_RSYNC_STALL_TIMEOUT_MS, - inactivityMessage: `Batch rsync download produced no output for ${LOCAL_RSYNC_STALL_TIMEOUT_MS} ms.` + inactivityMessage: `Batch rsync ${isUpload ? 'upload' : 'download'} produced no output for ${LOCAL_RSYNC_STALL_TIMEOUT_MS} ms.` }, request.progressId); - }); - } + transferredBytes = progress.getLastBytes(); + } + }); - const cleanupFailures = operation === 'move' ? await this.deleteBatchSources(request.source, items) : []; - const bytes = progress.getLastBytes() || expectedBytes; + const cleanupFailures = operation === 'move' ? await this.deleteBatchSources(request.source, items, request.progressId) : []; + const bytes = transferredBytes || expectedBytes; this.fireTransferProgress(request, bytes, 0, 'complete', isUpload ? 'Batch rsync upload completed.' : 'Batch rsync download completed.', true, { strategy: isUpload ? 'rsync-upload' : 'rsync-download', completedItems: items.length, @@ -2530,32 +3043,40 @@ export class TurboFileSshMainService implements ITurboFileSshService { const sshTransport = buildOptimizedRsyncSshCommand(remote.port || 22, !!remote.password, sshBinary); const sshpassPrefix = buildSshpassPrefix(remote.password); const rsyncOptions = buildRsyncOptions(true); + const listOptions = ['--files-from=-', '--from0', '-r'].join(' '); const rsyncPathOption = route === 'source-push' ? ` ${buildRsyncPathWithMkdirShellOption(request.targetDirectory.path)}` : ''; - const sourceArgs = route === 'source-push' - ? items.map(item => quotePosixShellArg(item.sourcePath)).join(' ') - : items.map(item => quotePosixShellArg(`${remoteUserHost}:${item.sourcePath}`)).join(' '); const targetSpec = route === 'source-push' ? `${remoteUserHost}:${ensureTrailingRemoteSlash(request.targetDirectory.path)}` : ensureTrailingRemoteSlash(request.targetDirectory.path); - const rsyncCommandCore = `${sshpassPrefix}rsync ${rsyncOptions}${rsyncPathOption} -e ${quotePosixShellArg(sshTransport)} ${sourceArgs} ${quotePosixShellArg(targetSpec)}`; - const rsyncCommand = route === 'source-push' ? rsyncCommandCore : `mkdir -p ${quotePosixShellArg(request.targetDirectory.path)} && ${rsyncCommandCore}`; - const progress = this.createRsyncProgressReporter(request, 0, 'rsync-direct', 'Batch direct rsync transfer in progress.', { - strategy: 'rsync-direct', - route, - completedItems: 0, - totalItems: items.length - }, markActivity); - this.throwIfTransferCanceled(request.progressId); - markActivity(); - await this.execRemote(client, rsyncCommand, { - onStdout: progress.handleOutput, - onStderr: progress.handleOutput, - inactivityTimeoutMs: DIRECT_TRANSFER_STALL_TIMEOUT_MS, - inactivityMessage: `Batch direct rsync ${route} produced no transfer progress for ${DIRECT_TRANSFER_STALL_TIMEOUT_MS} ms.`, - getActivityTime: () => lastActivityAt - }); + const sourceGroups = createTurboFileRsyncSourceGroups(items.map(item => item.sourcePath), 'posix'); + let transferredBytes = 0; + for (const group of sourceGroups) { + const sourceSpec = route === 'source-push' + ? ensureTrailingRemoteSlash(group.sourceBase) + : `${remoteUserHost}:${ensureTrailingRemoteSlash(group.sourceBase)}`; + const rsyncCommandCore = `${sshpassPrefix}rsync ${rsyncOptions} ${listOptions}${rsyncPathOption} -e ${quotePosixShellArg(sshTransport)} ${quotePosixShellArg(sourceSpec)} ${quotePosixShellArg(targetSpec)}`; + const rsyncCommand = route === 'source-push' ? rsyncCommandCore : `mkdir -p ${quotePosixShellArg(request.targetDirectory.path)} && ${rsyncCommandCore}`; + const progress = this.createRsyncProgressReporter(request, 0, 'rsync-direct', 'Batch direct rsync transfer in progress.', { + strategy: 'rsync-direct', + route, + completedItems: 0, + totalItems: items.length + }, markActivity, transferredBytes); + this.throwIfTransferCanceled(request.progressId); + markActivity(); + await this.execRemote(client, rsyncCommand, { + input: createTarNullListBuffer(group.relativePaths), + onStdout: progress.handleOutput, + onStderr: progress.handleOutput, + inactivityTimeoutMs: DIRECT_RSYNC_STALL_TIMEOUT_MS, + inactivityMessage: `Batch direct rsync ${route} produced no transfer progress for ${DIRECT_RSYNC_STALL_TIMEOUT_MS} ms.`, + getActivityTime: () => lastActivityAt, + progressId: request.progressId + }); + transferredBytes = progress.getLastBytes(); + } this.throwIfTransferCanceled(request.progressId); - const bytes = progress.getLastBytes() || expectedBytes; + const bytes = transferredBytes || expectedBytes; this.fireTransferProgress(request, bytes, 0, 'complete', 'Batch direct rsync completed.', true, { strategy: 'rsync-direct', route, @@ -2644,23 +3165,30 @@ export class TurboFileSshMainService implements ITurboFileSshService { return undefined; } const rsyncOptions = buildRsyncOptions(true); - const sourceArgs = items.map(item => quotePosixShellArg(item.sourcePath)).join(' '); + const listOptions = ['--files-from=-', '--from0', '-r'].join(' '); const targetSpec = `${targetUserHost}:${ensureTrailingRemoteSlash(request.targetDirectory.path)}`; - const rsyncCommand = `${sshpassPrefix}rsync ${rsyncOptions} ${buildRsyncPathWithMkdirShellOption(request.targetDirectory.path)} -e ${quotePosixShellArg(sshTransport)} ${sourceArgs} ${quotePosixShellArg(targetSpec)}`; - const progress = this.createRsyncProgressReporter(request, 0, 'relay-upload', 'Batch rsync relay transfer in progress.', { - strategy: 'rsync-relay', - completedItems: 0, - totalItems: items.length - }); - await this.execRemote(client, rsyncCommand, { - onStdout: progress.handleOutput, - onStderr: progress.handleOutput, - inactivityTimeoutMs: DIRECT_TRANSFER_STALL_TIMEOUT_MS, - inactivityMessage: `Batch rsync relay produced no transfer progress for ${DIRECT_TRANSFER_STALL_TIMEOUT_MS} ms.`, - getActivityTime: progress.getActivityTime - }); + const sourceGroups = createTurboFileRsyncSourceGroups(items.map(item => item.sourcePath), 'posix'); + let transferredBytes = 0; + for (const group of sourceGroups) { + const rsyncCommand = `${sshpassPrefix}rsync ${rsyncOptions} ${listOptions} ${buildRsyncPathWithMkdirShellOption(request.targetDirectory.path)} -e ${quotePosixShellArg(sshTransport)} ${quotePosixShellArg(ensureTrailingRemoteSlash(group.sourceBase))} ${quotePosixShellArg(targetSpec)}`; + const progress = this.createRsyncProgressReporter(request, 0, 'relay-upload', 'Batch rsync relay transfer in progress.', { + strategy: 'rsync-relay', + completedItems: 0, + totalItems: items.length + }, undefined, transferredBytes); + await this.execRemote(client, rsyncCommand, { + input: createTarNullListBuffer(group.relativePaths), + onStdout: progress.handleOutput, + onStderr: progress.handleOutput, + inactivityTimeoutMs: DIRECT_RSYNC_STALL_TIMEOUT_MS, + inactivityMessage: `Batch rsync relay produced no transfer progress for ${DIRECT_RSYNC_STALL_TIMEOUT_MS} ms.`, + getActivityTime: progress.getActivityTime, + progressId: request.progressId + }); + transferredBytes = progress.getLastBytes(); + } this.throwIfTransferCanceled(request.progressId); - const bytes = progress.getLastBytes() || expectedBytes; + const bytes = transferredBytes || expectedBytes; this.fireTransferProgress(request, bytes, 0, 'complete', 'Batch rsync relay completed.', true, { strategy: 'rsync-relay', completedItems: items.length, @@ -2697,9 +3225,7 @@ export class TurboFileSshMainService implements ITurboFileSshService { if (!plans?.length) { return undefined; } - const targetNames = collectBatchTarTargetNames(plans); let bytes = Math.max(0, expectedBytes); - const sizePromise = bytes <= 0 ? this.getLocalBatchTarBytes(items).catch(() => 0) : undefined; this.fireTransferProgress(request, 0, bytes, 'prepare', 'Preparing batch tar upload.', true, { strategy: 'tar-upload', completedItems: 0, totalItems: items.length }); let archiveBytes = 0; const onArchiveDelta = (delta: number): void => { @@ -2707,20 +3233,9 @@ export class TurboFileSshMainService implements ITurboFileSshService { archiveBytes += Math.max(0, delta); this.fireTransferProgress(request, bytes > 0 ? Math.min(archiveBytes, bytes) : archiveBytes, bytes, 'transfer', 'Batch tar upload in progress.', false, { strategy: 'tar-upload', completedItems: 0, totalItems: items.length }); }; - if (sizePromise) { - void sizePromise.then(total => { - if (total > bytes) { - bytes = total; - this.fireTransferProgress(request, Math.min(archiveBytes, bytes), bytes, 'transfer', 'Batch tar upload in progress.', true, { strategy: 'tar-upload', completedItems: 0, totalItems: items.length }); - } - }); - } this.markTransferDataStarted(request.progressId); await this.runLocalBatchTarUploads(plans, request.targetDirectory, request.progressId, onArchiveDelta); - if (sizePromise) { - bytes = Math.max(bytes, await sizePromise); - } - const resultBytes = bytes > 0 ? bytes : await this.getRemoteBatchTargetContentSize(request.targetDirectory, targetNames).catch(() => 0); + const resultBytes = Math.max(bytes, archiveBytes); await this.cleanupBatchSourcesAfterTar(request, items, operation); this.fireTransferProgress(request, resultBytes, resultBytes, 'complete', 'Batch tar upload completed.', true, { strategy: 'tar-upload', completedItems: items.length, totalItems: items.length }); return this.createBatchTransferResult(true, 'tar-upload', resultBytes, 'Batch tar upload completed.', items.length, 0, [], startedAt, request.progressId); @@ -2738,7 +3253,6 @@ export class TurboFileSshMainService implements ITurboFileSshService { return undefined; } let bytes = Math.max(0, expectedBytes); - const sizePromise = bytes <= 0 ? this.getRemoteBatchSourceContentSize(request.source, items).catch(() => 0) : undefined; this.fireTransferProgress(request, 0, bytes, 'prepare', 'Preparing batch tar download.', true, { strategy: 'tar-download', completedItems: 0, totalItems: items.length }); await this.prepareLocalBatchTarTarget(request.targetDirectory.path, plan.targetNames); let archiveBytes = 0; @@ -2747,20 +3261,9 @@ export class TurboFileSshMainService implements ITurboFileSshService { archiveBytes += Math.max(0, delta); this.fireTransferProgress(request, bytes > 0 ? Math.min(archiveBytes, bytes) : archiveBytes, bytes, 'transfer', 'Batch tar download in progress.', false, { strategy: 'tar-download', completedItems: 0, totalItems: items.length }); }; - if (sizePromise) { - void sizePromise.then(total => { - if (total > bytes) { - bytes = total; - this.fireTransferProgress(request, Math.min(archiveBytes, bytes), bytes, 'transfer', 'Batch tar download in progress.', true, { strategy: 'tar-download', completedItems: 0, totalItems: items.length }); - } - }); - } this.markTransferDataStarted(request.progressId); await this.runRemoteBatchTarDownload(request.source, plan, request.targetDirectory.path, request.progressId, onArchiveDelta); - if (sizePromise) { - bytes = Math.max(bytes, await sizePromise); - } - const resultBytes = bytes > 0 ? bytes : await getLocalBatchTargetSize(request.targetDirectory.path, plan.targetNames).catch(() => 0); + const resultBytes = Math.max(bytes, archiveBytes); await this.cleanupBatchSourcesAfterTar(request, items, operation); this.fireTransferProgress(request, resultBytes, resultBytes, 'complete', 'Batch tar download completed.', true, { strategy: 'tar-download', completedItems: items.length, totalItems: items.length }); return this.createBatchTransferResult(true, 'tar-download', resultBytes, 'Batch tar download completed.', items.length, 0, [], startedAt, request.progressId); @@ -2777,7 +3280,7 @@ export class TurboFileSshMainService implements ITurboFileSshService { if (!plan) { return undefined; } - const bytes = expectedBytes > 0 ? expectedBytes : await this.getRemoteBatchSourceContentSize(request.source, items); + const bytes = Math.max(0, expectedBytes); let directFailure: unknown; if (request.strategyHint !== 'relay') { try { @@ -2822,44 +3325,16 @@ export class TurboFileSshMainService implements ITurboFileSshService { return { parentPath, names, targetNames }; } - private async getLocalBatchTarBytes(items: readonly ITurboFileBatchTransferItem[]): Promise { - let total = 0; - for (const item of items) { - total += await getLocalPathSize(item.sourcePath).catch(() => Math.max(0, item.expectedBytes ?? 0)); - } - return total; - } - - private async getRemoteBatchSourceContentSize(source: ITurboFileTransferEndpoint, items: readonly ITurboFileBatchTransferItem[]): Promise { - let total = 0; - for (const item of items) { - total += await this.getRemotePathContentSizeByCommand({ ...source, path: item.sourcePath }).catch(() => Math.max(0, item.expectedBytes ?? 0)); - } - return total; - } - - private async getRemoteBatchTargetContentSize(targetDirectory: ITurboFileTransferEndpoint, targetNames: readonly string[]): Promise { - let total = 0; - for (const targetName of targetNames) { - total += await this.getRemotePathContentSizeByCommand({ ...targetDirectory, path: joinRemotePath(targetDirectory.path, targetName) }).catch(() => 0); - } - return total; - } - - private beginRemoteBatchTargetGrowthTiming(progressId: string | undefined, targetDirectory: ITurboFileTransferEndpoint, targetNames: readonly string[], baselineSize = 0, expectedBytes = 0, onProgress?: (transferredBytes: number) => void, pollIntervalMs = TRANSFER_PROGRESS_EVENT_INTERVAL_MS, initialDelayMs = 0): () => void { - return this.beginTransferGrowthTiming(progressId, () => this.getRemoteBatchTargetContentSize(targetDirectory, targetNames), baselineSize, expectedBytes, onProgress, pollIntervalMs, initialDelayMs); - } - private async prepareLocalBatchTarTarget(targetDirectory: string, targetNames: readonly string[]): Promise { await ensureLocalDirectory(targetDirectory); - await Promise.all(targetNames.map(targetName => fsp.rm(join(targetDirectory, targetName), { recursive: true, force: true }).catch(() => undefined))); + await mapLimit(targetNames, BATCH_TRANSFER_WORKERS, targetName => fsp.rm(join(targetDirectory, targetName), { recursive: true, force: true }).catch(() => undefined)); } private async cleanupBatchSourcesAfterTar(request: ITurboFileBatchTransferRequest, items: readonly ITurboFileBatchTransferItem[], operation: 'copy' | 'move'): Promise { if (operation !== 'move') { return; } - const cleanupFailures = await this.deleteBatchSources(request.source, items); + const cleanupFailures = await this.deleteBatchSources(request.source, items, request.progressId); if (cleanupFailures.length) { throw new Error(`Batch transfer completed, but source cleanup failed for ${cleanupFailures.length} item(s).`); } @@ -2876,33 +3351,41 @@ export class TurboFileSshMainService implements ITurboFileSshService { if (targetDirectory.kind !== 'ssh' || !targetDirectory.connection) { throw new Error('Missing SSH target.'); } + const localTarRoot = await this.createLocalTarRootAliasIfNeeded(plan.parentPath, plan.requiresSafeParentPath === true); + const tarPlan = localTarRoot.path === plan.parentPath + ? plan + : { ...plan, parentPath: localTarRoot.path }; try { - await this.runParallelLocalBatchTarUpload(plan, targetDirectory.connection, targetDirectory.path, progressId, onArchiveDelta); - return; - } catch (error) { - if (error instanceof TurboFileTransferCanceledError || this.isTransferCanceled(progressId)) { - throw new TurboFileTransferCanceledError(); + try { + await this.runParallelLocalBatchTarUpload(tarPlan, targetDirectory.connection, targetDirectory.path, progressId, onArchiveDelta); + return; + } catch (error) { + if (error instanceof TurboFileTransferCanceledError || this.isTransferCanceled(progressId)) { + throw new TurboFileTransferCanceledError(); + } + // Parallel raw tar|ssh is the stable default for local multi-file/folder uploads. } - // Parallel raw tar|ssh is the stable default for local multi-file/folder uploads. - } - try { - await this.runParallelLocalBatchTarZstdUpload(plan, targetDirectory.connection, targetDirectory.path, progressId, onArchiveDelta); - return; - } catch (error) { - if (error instanceof TurboFileTransferCanceledError || this.isTransferCanceled(progressId)) { - throw new TurboFileTransferCanceledError(); + try { + await this.runParallelLocalBatchTarZstdUpload(tarPlan, targetDirectory.connection, targetDirectory.path, progressId, onArchiveDelta); + return; + } catch (error) { + if (error instanceof TurboFileTransferCanceledError || this.isTransferCanceled(progressId)) { + throw new TurboFileTransferCanceledError(); + } + // Parallel tar+zstd+ssh is the measured fast path. If unavailable, try single native tar|ssh. } - // Parallel tar+zstd+ssh is the measured fast path. If unavailable, try single native tar|ssh. - } - try { - await this.prepareRemoteBatchTarTarget(targetDirectory.connection, targetDirectory.path, plan.targetNames, progressId, false); - await this.runNativeLocalBatchTarUploadFromList(plan, targetDirectory.connection, buildTarExtractStreamToDirectoryCommand(targetDirectory.path), progressId, onArchiveDelta); - return; - } catch (error) { - if (error instanceof TurboFileTransferCanceledError || this.isTransferCanceled(progressId)) { - throw new TurboFileTransferCanceledError(); + try { + await this.prepareRemoteBatchTarTarget(targetDirectory.connection, targetDirectory.path, tarPlan.targetNames, progressId, false); + await this.runNativeLocalBatchTarUploadFromList(tarPlan, targetDirectory.connection, buildTarExtractStreamToDirectoryCommand(targetDirectory.path), progressId, onArchiveDelta); + return; + } catch (error) { + if (error instanceof TurboFileTransferCanceledError || this.isTransferCanceled(progressId)) { + throw new TurboFileTransferCanceledError(); + } + throw new Error(`Native batch tar upload failed: ${formatTransferError(error)}`); } - throw new Error(`Native batch tar upload failed: ${formatTransferError(error)}`); + } finally { + await localTarRoot.dispose(); } } @@ -2921,8 +3404,9 @@ export class TurboFileSshMainService implements ITurboFileSshService { if (source.kind !== 'ssh' || !source.connection) { throw new Error('Missing SSH source.'); } + const sourcePlan = createRemoteBatchTarSourcePlan(plan.parentPath, plan.names); try { - await this.runNativeLocalTarDownload(source.connection, buildTarCreateFromItemsCommand(plan.parentPath, plan.names), ['-xf', '-', '-C', targetDirectory], progressId, onArchiveDelta); + await this.runNativeLocalTarDownload(source.connection, sourcePlan.command, ['-xf', '-', '-C', targetDirectory], progressId, onArchiveDelta, sourcePlan.input); return; } catch (error) { if (error instanceof TurboFileTransferCanceledError || this.isTransferCanceled(progressId)) { @@ -2942,16 +3426,18 @@ export class TurboFileSshMainService implements ITurboFileSshService { }); try { client = await this.connect(configResult.config); - const sourceStream = await this.openRemoteCommandStream(client, buildTarCreateFromItemsCommand(plan.parentPath, plan.names), 'batch tar download create'); - tarProcess = spawn('tar.exe', ['-xf', '-', '-C', targetDirectory], { windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] }); + const sourceStream = await this.openRemoteCommandStream(client, sourcePlan.command, 'batch tar download create'); + tarProcess = spawn('tar.exe', [...await this.getLocalTarCharsetArgs(), '-xf', '-', '-C', targetDirectory], { windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] }); if (!tarProcess.stdin || !tarProcess.stdout) { throw new Error('Local batch tar download did not provide pipes.'); } tarProcess.stdout.resume(); const tarClose = waitForLocalProcessClose(tarProcess, 'local batch tar download'); - await pipeline(sourceStream.stream as unknown as NodeJS.ReadableStream, createProgressPassThrough(onArchiveDelta), tarProcess.stdin); - await sourceStream.close; - await tarClose; + const archivePipeline = pipeline(sourceStream.stream as unknown as NodeJS.ReadableStream, createProgressPassThrough(onArchiveDelta), tarProcess.stdin); + const inputWrite = sourcePlan.input + ? writeInputToWritableStream(sourceStream.stream, sourcePlan.input, 'batch tar download path list') + : Promise.resolve(); + await Promise.all([archivePipeline, inputWrite, sourceStream.close, tarClose]); } finally { unregisterCancel(); client?.end(); @@ -2989,7 +3475,7 @@ export class TurboFileSshMainService implements ITurboFileSshService { private async tryDirectBatchTarTransferRoute(request: ITurboFileBatchTransferRequest, items: readonly ITurboFileBatchTransferItem[], plan: ITarBatchPlan, operation: 'copy' | 'move', expectedBytes: number, startedAt: number, route: TurboFileDirectSshRoute): Promise { const executorEndpoint = route === 'source-push' ? request.source : request.targetDirectory; const remoteEndpoint = route === 'source-push' ? request.targetDirectory : request.source; - if (!executorEndpoint.connection || !remoteEndpoint.connection) { + if (!executorEndpoint.connection || !remoteEndpoint.connection || !request.targetDirectory.connection) { throw new Error('Direct batch tar route is missing SSH connection details.'); } const executorConfigResult = await this.createConnectConfig(executorEndpoint.connection); @@ -3000,29 +3486,44 @@ export class TurboFileSshMainService implements ITurboFileSshService { const client = await this.connectDirectSsh(executorConfigResult.config); const unregisterCancel = this.registerTransferCancelHandler(request.progressId, () => client.end()); let credential: IRemoteScpCredential | undefined; - let stopGrowthTiming: () => void = () => undefined; + let lastActivityAt = Date.now(); try { credential = await this.prepareRemoteScpCredential(client, remoteEndpoint.connection); const remote = remoteEndpoint.connection; const remoteUserHost = `${remote.username}@${remote.host}`; const sshpassPrefix = buildSshpassPrefix(remote.password); const sshCommand = buildDirectScpSshCommand(remote.port || 22, !!remote.password, credential.identityPath); - const command = route === 'source-push' + const sourcePlan = createRemoteBatchTarSourcePlan(plan.parentPath, plan.names); + const inlineCommand = route === 'source-push' ? buildTarItemsSourcePushCommand(plan.parentPath, plan.names, request.targetDirectory.path, plan.targetNames, remoteUserHost, sshCommand, sshpassPrefix) : buildTarItemsTargetPullCommand(plan.parentPath, plan.names, request.targetDirectory.path, plan.targetNames, remoteUserHost, sshCommand, sshpassPrefix); + let command = inlineCommand; + if (!isTurboFileBatchCommandSafe(inlineCommand)) { + await this.prepareRemoteBatchTarTarget(request.targetDirectory.connection, request.targetDirectory.path, plan.targetNames, request.progressId, false); + command = route === 'source-push' + ? buildPreparedTarItemsSourcePushCommand(sourcePlan.command, request.targetDirectory.path, remoteUserHost, sshCommand, sshpassPrefix) + : buildPreparedTarItemsTargetPullCommand(sourcePlan.command, request.targetDirectory.path, remoteUserHost, sshCommand, sshpassPrefix); + } + const tarProgress = this.createDirectTarProgressReporter(request, expectedBytes, 'Batch direct tar transfer in progress.', { + strategy: 'tar-direct', route, completedItems: 0, totalItems: items.length + }, () => lastActivityAt = Date.now()); + const reportTarProgress = tarProgress.reportTarProgress; this.fireTransferProgress(request, 0, expectedBytes, 'prepare', 'Preparing batch direct tar transfer.', true, { strategy: 'tar-direct', route, completedItems: 0, totalItems: items.length }); - stopGrowthTiming = this.beginRemoteBatchTargetGrowthTiming(request.progressId, request.targetDirectory, plan.targetNames, 0, expectedBytes, transferredBytes => { - this.fireTransferProgress(request, transferredBytes, expectedBytes, 'transfer', 'Batch direct tar transfer in progress.', false, { strategy: 'tar-direct', route, completedItems: 0, totalItems: items.length }); - }); this.markTransferDataStarted(request.progressId); - await this.execRemote(client, command, { inactivityTimeoutMs: DIRECT_TRANSFER_STALL_TIMEOUT_MS }); + await this.execRemote(client, command, { + input: sourcePlan.input, + onStderr: reportTarProgress, + inactivityTimeoutMs: DIRECT_TAR_STALL_TIMEOUT_MS, + inactivityMessage: `Direct batch tar ${route} produced no stream progress for ${DIRECT_TAR_STALL_TIMEOUT_MS} ms.`, + getActivityTime: () => lastActivityAt, + progressId: request.progressId + }); this.throwIfTransferCanceled(request.progressId); - const bytes = expectedBytes > 0 ? expectedBytes : await this.getRemoteBatchTargetContentSize(request.targetDirectory, plan.targetNames).catch(() => 0); + const bytes = Math.max(expectedBytes, tarProgress.getTransferredBytes()); await this.cleanupBatchSourcesAfterTar(request, items, operation); - this.fireTransferProgress(request, bytes, bytes, 'complete', 'Batch direct tar completed.', true, { strategy: 'tar-direct', route, completedItems: items.length, totalItems: items.length }); + this.fireTransferProgress(request, bytes, expectedBytes, 'complete', 'Batch direct tar completed.', true, { strategy: 'tar-direct', route, completedItems: items.length, totalItems: items.length }); return this.createBatchTransferResult(true, 'tar-direct', bytes, 'Batch direct tar completed.', items.length, 0, [], startedAt, request.progressId); } finally { - stopGrowthTiming(); if (credential?.cleanupCommand) { await this.execRemote(client, credential.cleanupCommand).catch(() => undefined); } @@ -3043,7 +3544,7 @@ export class TurboFileSshMainService implements ITurboFileSshService { let sourceClient: ISSHClient | undefined; let targetClient: ISSHClient | undefined; - let stopGrowthTiming: () => void = () => undefined; + let transferredBytes = 0; const unregisterCancel = this.registerTransferCancelHandler(request.progressId, () => { sourceClient?.end(); targetClient?.end(); @@ -3052,22 +3553,39 @@ export class TurboFileSshMainService implements ITurboFileSshService { sourceClient = await this.connect(sourceConfigResult.config); targetClient = await this.connect(targetConfigResult.config); this.fireTransferProgress(request, 0, expectedBytes, 'prepare', 'Preparing batch tar relay.', true, { strategy: 'tar-relay', route: 'relay', completedItems: 0, totalItems: items.length }); - stopGrowthTiming = this.beginRemoteBatchTargetGrowthTiming(request.progressId, request.targetDirectory, plan.targetNames, 0, expectedBytes, transferredBytes => { - this.fireTransferProgress(request, transferredBytes, expectedBytes, 'relay-upload', 'Batch tar relay in progress.', false, { strategy: 'tar-relay', route: 'relay', completedItems: 0, totalItems: items.length }); - }); + const sourcePlan = createRemoteBatchTarSourcePlan(plan.parentPath, plan.names); + let targetCommand = buildTarExtractToDirectoryCommand(request.targetDirectory.path, plan.targetNames); + if (!isTurboFileBatchCommandSafe(targetCommand)) { + await this.execRemoteWithInput( + targetClient, + buildPrepareTarExtractToDirectoryFromListCommand(request.targetDirectory.path, false), + createTarNullListBuffer(plan.targetNames.map(name => joinRemotePath(request.targetDirectory.path, name))), + { + inactivityTimeoutMs: REMOTE_DIRECTORY_READ_MAX_TIMEOUT_MS, + inactivityMessage: 'Batch tar target preparation produced no progress.', + progressId: request.progressId + } + ); + targetCommand = buildTarExtractStreamToDirectoryCommand(request.targetDirectory.path); + } + const onArchiveDelta = (delta: number): void => { + transferredBytes += Math.max(0, delta); + this.fireTransferProgress(request, expectedBytes > 0 ? Math.min(transferredBytes, expectedBytes) : transferredBytes, expectedBytes, 'relay-upload', 'Batch tar relay in progress.', false, { strategy: 'tar-relay', route: 'relay', completedItems: 0, totalItems: items.length }); + }; this.markTransferDataStarted(request.progressId); - const sourceStream = await this.openRemoteCommandStream(sourceClient, buildTarCreateFromItemsCommand(plan.parentPath, plan.names), 'batch tar relay create'); - const targetStream = await this.openRemoteCommandStream(targetClient, buildTarExtractToDirectoryCommand(request.targetDirectory.path, plan.targetNames), 'batch tar relay extract', true); - await pipeline(sourceStream.stream as unknown as NodeJS.ReadableStream, targetStream.stream as unknown as NodeJS.WritableStream); - await sourceStream.close; - await targetStream.close; + const sourceStream = await this.openRemoteCommandStream(sourceClient, sourcePlan.command, 'batch tar relay create'); + const targetStream = await this.openRemoteCommandStream(targetClient, targetCommand, 'batch tar relay extract', true); + const archivePipeline = pipeline(sourceStream.stream as unknown as NodeJS.ReadableStream, createProgressPassThrough(onArchiveDelta), targetStream.stream as unknown as NodeJS.WritableStream); + const inputWrite = sourcePlan.input + ? writeInputToWritableStream(sourceStream.stream, sourcePlan.input, 'batch tar relay path list') + : Promise.resolve(); + await Promise.all([archivePipeline, inputWrite, sourceStream.close, targetStream.close]); this.throwIfTransferCanceled(request.progressId); - const bytes = expectedBytes > 0 ? expectedBytes : await this.getRemoteBatchTargetContentSize(request.targetDirectory, plan.targetNames).catch(() => 0); + const bytes = Math.max(expectedBytes, transferredBytes); await this.cleanupBatchSourcesAfterTar(request, items, operation); - this.fireTransferProgress(request, bytes, bytes, 'complete', 'Batch tar relay completed.', true, { strategy: 'tar-relay', route: 'relay', completedItems: items.length, totalItems: items.length }); + this.fireTransferProgress(request, bytes, expectedBytes, 'complete', 'Batch tar relay completed.', true, { strategy: 'tar-relay', route: 'relay', completedItems: items.length, totalItems: items.length }); return this.createBatchTransferResult(true, 'tar-relay', bytes, 'Batch tar relay completed.', items.length, 0, [], startedAt, request.progressId); } finally { - stopGrowthTiming(); unregisterCancel(); sourceClient?.end(); targetClient?.end(); @@ -3077,6 +3595,8 @@ export class TurboFileSshMainService implements ITurboFileSshService { private async runConcurrentBatchTransfer(request: ITurboFileBatchTransferRequest, items: readonly ITurboFileBatchTransferItem[], operation: 'copy' | 'move', expectedBytes: number, startedAt: number, disableRsync: boolean): Promise { const transferredByItem = new Array(items.length).fill(0); const totalByItem = items.map(item => Math.max(0, item.expectedBytes ?? 0)); + let aggregateTransferredBytes = 0; + let aggregateTotalBytes = totalByItem.reduce((sum, value) => sum + value, 0); const successfulItems: ITurboFileBatchTransferItem[] = []; const failures: ITurboFileBatchTransferFailure[] = []; const strategies = new Set(); @@ -3098,12 +3618,16 @@ export class TurboFileSshMainService implements ITurboFileSshService { const childSource = disableRsync ? { ...request.source, path: item.sourcePath, supportsRsync: false } : { ...request.source, path: item.sourcePath }; const childTarget = disableRsync ? { ...request.targetDirectory, path: this.joinBatchTargetPath(request.targetDirectory, item.targetName), supportsRsync: false } : { ...request.targetDirectory, path: this.joinBatchTargetPath(request.targetDirectory, item.targetName) }; const disposable = this.onDynamicTransferProgress(childProgressId)(event => { - transferredByItem[index] = event.transferredBytes; - totalByItem[index] = Math.max(totalByItem[index], event.totalBytes); - this.fireBatchAggregateProgress(request, transferredByItem, totalByItem, event.phase, event.detail ?? 'Batch transfer in progress.', completedItems, items.length, item.displayName ?? item.targetName, false, event.strategy); + const nextTransferredBytes = Math.max(0, event.transferredBytes); + aggregateTransferredBytes += nextTransferredBytes - transferredByItem[index]; + transferredByItem[index] = nextTransferredBytes; + const nextTotalBytes = Math.max(totalByItem[index], event.totalBytes); + aggregateTotalBytes += nextTotalBytes - totalByItem[index]; + totalByItem[index] = nextTotalBytes; + this.fireBatchAggregateProgress(request, aggregateTransferredBytes, Math.max(expectedBytes, aggregateTotalBytes), event.phase, event.detail ?? 'Batch transfer in progress.', completedItems, items.length, item.displayName ?? item.targetName, false, event.strategy); }); try { - const result = await this.transferFile({ + const result = await this.transferFileCore({ source: childSource, target: childTarget, sourceType: item.sourceType, @@ -3115,8 +3639,12 @@ export class TurboFileSshMainService implements ITurboFileSshService { successfulItems.push(item); strategies.add(result.strategy); completedBytes += result.bytes; - transferredByItem[index] = Math.max(transferredByItem[index], result.bytes); - totalByItem[index] = Math.max(totalByItem[index], result.bytes); + const nextTransferredBytes = Math.max(transferredByItem[index], result.bytes); + aggregateTransferredBytes += nextTransferredBytes - transferredByItem[index]; + transferredByItem[index] = nextTransferredBytes; + const nextTotalBytes = Math.max(totalByItem[index], result.bytes); + aggregateTotalBytes += nextTotalBytes - totalByItem[index]; + totalByItem[index] = nextTotalBytes; } else if (result.canceled) { throw new TurboFileTransferCanceledError(); } else { @@ -3126,15 +3654,15 @@ export class TurboFileSshMainService implements ITurboFileSshService { completedItems++; childProgressIds.delete(childProgressId); disposable.dispose(); - this.fireBatchAggregateProgress(request, transferredByItem, totalByItem, 'transfer', 'Batch transfer in progress.', completedItems, items.length, item.displayName ?? item.targetName, true); + this.fireBatchAggregateProgress(request, aggregateTransferredBytes, Math.max(expectedBytes, aggregateTotalBytes), 'transfer', 'Batch transfer in progress.', completedItems, items.length, item.displayName ?? item.targetName, false); } }); if (operation === 'move' && successfulItems.length) { - const cleanupFailures = await this.deleteBatchSources(request.source, successfulItems); + const cleanupFailures = await this.deleteBatchSources(request.source, successfulItems, request.progressId); failures.push(...cleanupFailures); } - const bytes = Math.max(completedBytes, transferredByItem.reduce((sum, value) => sum + value, 0), expectedBytes > 0 && failures.length === 0 ? expectedBytes : 0); + const bytes = Math.max(completedBytes, aggregateTransferredBytes, expectedBytes > 0 && failures.length === 0 ? expectedBytes : 0); this.fireTransferProgress(request, bytes, Math.max(bytes, expectedBytes), 'complete', 'Batch transfer completed.', true, { completedItems: successfulItems.length, totalItems: items.length @@ -3143,7 +3671,7 @@ export class TurboFileSshMainService implements ITurboFileSshService { return this.createBatchTransferResult(failed === 0, [...strategies][0] ?? getTransferStrategy(request.source, request.targetDirectory), bytes, failed === 0 ? 'Batch transfer completed.' : `Batch transfer completed with ${failed} failure(s).`, successfulItems.length, failed, failures, startedAt, request.progressId); } catch (error) { if (error instanceof TurboFileTransferCanceledError || this.isTransferCanceled(request.progressId)) { - return this.createBatchTransferResult(false, [...strategies][0] ?? getTransferStrategy(request.source, request.targetDirectory), completedBytes, TRANSFER_CANCELED_MESSAGE, successfulItems.length, failures.length, failures, startedAt, request.progressId, true); + return this.createBatchTransferResult(false, [...strategies][0] ?? getTransferStrategy(request.source, request.targetDirectory), Math.max(completedBytes, aggregateTransferredBytes), TRANSFER_CANCELED_MESSAGE, successfulItems.length, failures.length, failures, startedAt, request.progressId, true); } throw error; } finally { @@ -3151,9 +3679,7 @@ export class TurboFileSshMainService implements ITurboFileSshService { } } - private fireBatchAggregateProgress(request: ITurboFileBatchTransferRequest, transferredByItem: readonly number[], totalByItem: readonly number[], phase: ITurboFileTransferProgressEvent['phase'], detail: string, completedItems: number, totalItems: number, currentName?: string, force = false, strategy?: ITurboFileTransferResult['strategy']): void { - const transferredBytes = transferredByItem.reduce((sum, value) => sum + Math.max(0, value), 0); - const totalBytes = Math.max(this.getBatchExpectedBytes(request, request.items), totalByItem.reduce((sum, value) => sum + Math.max(0, value), 0)); + private fireBatchAggregateProgress(request: ITurboFileBatchTransferRequest, transferredBytes: number, totalBytes: number, phase: ITurboFileTransferProgressEvent['phase'], detail: string, completedItems: number, totalItems: number, currentName?: string, force = false, strategy?: ITurboFileTransferResult['strategy']): void { this.fireTransferProgress(request, transferredBytes, totalBytes, phase, detail, force, { strategy, completedItems, @@ -3162,7 +3688,7 @@ export class TurboFileSshMainService implements ITurboFileSshService { }); } - private async deleteBatchSources(source: ITurboFileTransferEndpoint, items: readonly ITurboFileBatchTransferItem[]): Promise { + private async deleteBatchSources(source: ITurboFileTransferEndpoint, items: readonly ITurboFileBatchTransferItem[], progressId?: string): Promise { const failures: ITurboFileBatchTransferFailure[] = []; if (source.kind === 'local') { await mapLimit(items, BATCH_TRANSFER_WORKERS, async item => { @@ -3176,10 +3702,18 @@ export class TurboFileSshMainService implements ITurboFileSshService { } try { const paths = items.map(item => item.sourcePath); - const command = paths.some(looksLikeWindowsPath) - ? buildWindowsBatchDeleteCommand(paths) - : `rm -rf -- ${paths.map(path => quotePosixShellArg(path)).join(' ')}`; - await this.withSshClient(source, client => this.execRemote(client, command)); + if (paths.some(looksLikeWindowsPath)) { + const input = Buffer.from(JSON.stringify(paths), 'utf8'); + await this.withSshClient(source, client => this.execRemoteWithInput(client, buildWindowsDeleteManyFromInputCommand(true), input, { + progressId + })); + } else { + await this.withSshClient(source, client => this.execRemoteWithInput(client, buildPosixDeleteManyFromInputCommand(true), createTarNullListBuffer(paths), { + inactivityTimeoutMs: REMOTE_DIRECTORY_READ_MAX_TIMEOUT_MS, + inactivityMessage: 'Remote POSIX batch cleanup produced no progress.', + progressId + })); + } } catch (error) { for (const item of items) { failures.push({ name: item.displayName ?? item.targetName, path: item.sourcePath, message: error instanceof Error ? error.message : String(error) }); @@ -3209,95 +3743,904 @@ export class TurboFileSshMainService implements ITurboFileSshService { } async createDirectory(endpoint: ITurboFileTransferEndpoint): Promise { - try { - if (endpoint.kind === 'local') { - await fsp.mkdir(endpoint.path, { recursive: true }); - } else { - await this.withSftp(endpoint, sftp => this.ensureRemoteDirectory(sftp, endpoint.path)); - } - this.clearRemoteDirectoryListingCache(endpoint); - return { ok: true, message: 'Directory created.' }; - } catch (error) { - return { ok: false, message: error instanceof Error ? error.message : String(error) }; - } + return this.fileOperationQueue.run( + getFileOperationDeviceKeys(endpoint), + () => this.createDirectoryCore(endpoint) + ); } - async deletePath(endpoint: ITurboFileTransferEndpoint, recursive = true): Promise { + private async createDirectoryCore(endpoint: ITurboFileTransferEndpoint): Promise { try { if (endpoint.kind === 'local') { - await fsp.rm(endpoint.path, { recursive, force: true }); + await fsp.mkdir(endpoint.path, { recursive: true }); } else { - try { - await this.deleteRemotePathNative(endpoint, recursive); - } catch (error) { - if (error instanceof Error && error.message === 'Refusing to delete a filesystem root.') { - throw error; - } - await this.withSftp(endpoint, async sftp => { - const stats = await this.remoteStat(sftp, endpoint.path); - if (stats.isDirectory()) { - await this.deleteRemoteDirectory(sftp, endpoint.path); - } else { - await this.unlinkRemote(sftp, endpoint.path); - } - }); - } + // Reuse the already-open directory session instead of withSftp, which opens a fresh SSH + // connection every call — that handshake is the bulk of "new folder is slow", especially + // on a high-latency remote host. + await this.withDirectorySftp(endpoint, sftp => this.ensureRemoteDirectory(sftp, endpoint.path)); } this.clearRemoteDirectoryListingCache(endpoint); - return { ok: true, message: 'Path deleted.' }; + return { ok: true, message: 'Directory created.' }; } catch (error) { return { ok: false, message: error instanceof Error ? error.message : String(error) }; } } - private async deleteRemotePathNative(endpoint: ITurboFileTransferEndpoint, recursive: boolean): Promise { - this.assertSafeDeletePath(endpoint.path); - const command = looksLikeWindowsPath(endpoint.path) - ? buildWindowsDeleteCommand(endpoint.path, recursive) - : buildPosixDeleteCommand(endpoint.path, recursive); - await this.withSshClient(endpoint, client => this.execRemote(client, command)); - } - - private assertSafeDeletePath(path: string): void { - const trimmed = path.trim(); - const normalized = trimmed.replace(/[\\/]+$/, ''); - if (!trimmed || trimmed === '/' || trimmed === '\\' || normalized === '' || /^[A-Za-z]:$/.test(normalized)) { - throw new Error('Refusing to delete a filesystem root.'); - } + async movePathsToTrash(request: ITurboFileMoveToTrashRequest): Promise { + const deviceKeys = [ + getFileOperationDeviceKey(request.device), + ...request.sources.map(source => getFileOperationDeviceKey(source.endpoint)) + ]; + return this.fileOperationQueue.run(deviceKeys, () => this.movePathsToTrashCore(request)); } - async renamePath(endpoint: ITurboFileTransferEndpoint, targetPath: string): Promise { - try { - if (endpoint.kind === 'local') { - await this.moveLocalPathNative(endpoint.path, targetPath); - } else { - await this.moveRemotePathNative(endpoint, targetPath); + private async movePathsToTrashCore(request: ITurboFileMoveToTrashRequest): Promise { + const deviceIdentity = getTurboFileTrashDeviceIdentity(this.withLocalTrashIdentity(request.device)); + const results: ITurboFileTrashItemResult[] = []; + const validSources: ITurboFileTrashSource[] = []; + for (const source of request.sources) { + try { + if (source.endpoint.kind !== request.device.kind) { + throw new Error('Recycle-bin source does not belong to the selected device.'); + } + if (getFileOperationDeviceKey(source.endpoint) !== getFileOperationDeviceKey(request.device)) { + throw new Error('Recycle-bin source belongs to a different device connection.'); + } + if (isTurboFileProtectedTrashSource(source.endpoint.path, looksLikeWindowsPath(source.endpoint.path))) { + throw new Error('Refusing to move a filesystem root or recycle-bin path to the recycle bin.'); + } + validSources.push(source); + } catch (error) { + results.push({ path: source.endpoint.path, ok: false, message: error instanceof Error ? error.message : String(error) }); } - this.clearRemoteDirectoryListingCache(endpoint); - this.clearRemoteDirectoryListingCache(endpoint, targetPath); - return { ok: true, message: 'Path renamed.' }; - } catch (error) { - return { ok: false, message: error instanceof Error ? error.message : String(error) }; } + if (request.device.kind === 'ssh' && validSources.length) { + results.push(...await this.moveRemotePathsToTrash(validSources, deviceIdentity)); + } else { + const workerLimit = validSources.some(source => source.type === 'directory') ? BATCH_TRANSFER_DIRECTORY_WORKERS : BATCH_TRANSFER_WORKERS; + const localResults = await mapLimit(validSources, workerLimit, async source => { + try { + const item = await this.moveLocalPathToTrash(source, deviceIdentity); + this.clearRemoteDirectoryListingCache(source.endpoint); + return { id: item.id, path: source.endpoint.path, ok: true, message: 'Moved to recycle bin.' } satisfies ITurboFileTrashItemResult; + } catch (error) { + return { path: source.endpoint.path, ok: false, message: error instanceof Error ? error.message : String(error) } satisfies ITurboFileTrashItemResult; + } + }); + results.push(...localResults); + } + return this.createTrashOperationResult(results, 'Moved to recycle bin.'); } - async calculatePathSize(endpoint: ITurboFileTransferEndpoint): Promise { + async listTrashItems(device: ITurboFileTrashDevice): Promise { try { - const size = endpoint.kind === 'local' - ? await getLocalPathSizeByCommand(endpoint.path) - : await this.getRemotePathSizeByCommand(endpoint); - const humanSize = await this.getPathHumanSize(endpoint, size); - return { ok: true, message: 'Path size calculated.', size, humanSize }; + const normalizedDevice = this.withLocalTrashIdentity(device); + const items = normalizedDevice.kind === 'local' + ? await this.listLocalTrashItems(normalizedDevice) + : await this.listRemoteTrashItems(normalizedDevice); + return { + ok: true, + message: 'Recycle bin loaded.', + items: sortTurboFileTrashItems(items), + inaccessibleVolumes: [] + }; } catch (error) { return { ok: false, message: error instanceof Error ? error.message : String(error), - size: 0 + items: [], + inaccessibleVolumes: [] }; } } - async readWindowsClipboardFiles(): Promise { + async restoreTrashItems(request: ITurboFileRestoreTrashRequest): Promise { + return this.fileOperationQueue.run( + [getFileOperationDeviceKey(request.device)], + () => this.restoreTrashItemsCore(request) + ); + } + + private async restoreTrashItemsCore(request: ITurboFileRestoreTrashRequest): Promise { + const device = this.withLocalTrashIdentity(request.device); + const expectedIdentity = getTurboFileTrashDeviceIdentity(device); + const results: ITurboFileTrashItemResult[] = []; + for (const item of request.items) { + try { + if (item.deviceIdentity !== expectedIdentity) { + throw new Error('Recycle-bin item belongs to a different device.'); + } + const result = device.kind === 'local' + ? await this.restoreLocalTrashItem(item, request.conflictPolicy) + : await this.restoreRemoteTrashItem(device, item, request.conflictPolicy); + results.push(result); + } catch (error) { + results.push({ id: item.id, path: item.originalPath, ok: false, message: error instanceof Error ? error.message : String(error) }); + } + } + return this.createTrashOperationResult(results, 'Recycle-bin items restored.'); + } + + async permanentlyDeleteTrashItems(request: ITurboFileTrashItemsRequest): Promise { + return this.fileOperationQueue.run( + [getFileOperationDeviceKey(request.device)], + () => this.permanentlyDeleteTrashItemsCore(request) + ); + } + + private async permanentlyDeleteTrashItemsCore(request: ITurboFileTrashItemsRequest): Promise { + const device = this.withLocalTrashIdentity(request.device); + const expectedIdentity = getTurboFileTrashDeviceIdentity(device); + const validItems = request.items.filter(item => item.deviceIdentity === expectedIdentity && this.isSafeTrashItem(item)); + if (!validItems.length) { + return this.createTrashOperationResult(request.items.map(item => ({ + id: item.id, + path: item.originalPath, + ok: false, + message: 'Recycle-bin item is invalid or belongs to a different device.' + })), 'No recycle-bin items were deleted.'); + } + const results = await this.deleteTrashItemsNative(device, validItems); + return this.createTrashOperationResult(results, 'Recycle-bin items permanently deleted.'); + } + + async emptyTrash(device: ITurboFileTrashDevice): Promise { + return this.fileOperationQueue.run( + [getFileOperationDeviceKey(device)], + () => this.emptyTrashCore(device) + ); + } + + private async emptyTrashCore(device: ITurboFileTrashDevice): Promise { + return this.emptyTrashNative(this.withLocalTrashIdentity(device)); + } + + async getTrashState(device: ITurboFileTrashDevice): Promise { + try { + const hasItems = await this.hasTrashItemsNative(this.withLocalTrashIdentity(device)); + return { ok: true, message: hasItems ? 'Recycle bin contains items.' : 'Recycle bin is empty.', hasItems, itemCount: hasItems ? 1 : 0 }; + } catch (error) { + return { ok: false, message: error instanceof Error ? error.message : String(error), hasItems: false, itemCount: 0 }; + } + } + + private withLocalTrashIdentity(device: ITurboFileTrashDevice): ITurboFileTrashDevice { + if (device.kind !== 'local' || device.localUserIdentity) { + return device; + } + const info = userInfo(); + const domain = process.env.USERDOMAIN?.trim(); + return { + ...device, + localUserIdentity: domain ? `${domain}\\${info.username}` : info.username + }; + } + + private createTrashOperationResult(results: readonly ITurboFileTrashItemResult[], successMessage: string): ITurboFileTrashOperationResult { + const completed = results.filter(result => result.ok).length; + const failed = results.length - completed; + return { + ok: failed === 0, + message: failed === 0 ? successMessage : `${completed} succeeded, ${failed} failed.`, + completed, + failed, + results + }; + } + + private async moveLocalPathToTrash(source: ITurboFileTrashSource, deviceIdentity: string): Promise { + const { trashRoot, volumeId } = this.resolveLocalTrashRoot(source.endpoint.path, deviceIdentity); + const filesDirectory = join(trashRoot, 'files'); + const infoDirectory = join(trashRoot, 'info'); + const id = createTurboFileTrashItemId(); + const payloadPath = join(filesDirectory, id); + const pendingPath = join(infoDirectory, `${id}.pending.json`); + const metadataPath = join(infoDirectory, `${id}.json`); + const item: ITurboFileTrashItem = { + id, + originalName: basename(source.endpoint.path), + originalPath: source.endpoint.path, + deletedAt: Date.now(), + type: source.type, + knownSize: source.type === 'file' ? source.knownSize : undefined, + volumeId, + trashRoot, + payloadPath, + deviceIdentity + }; + await fsp.mkdir(filesDirectory, { recursive: true }); + await fsp.mkdir(infoDirectory, { recursive: true }); + await fsp.writeFile(pendingPath, JSON.stringify(item), { encoding: 'utf8', flag: 'wx' }); + try { + await fsp.rename(source.endpoint.path, payloadPath); + await fsp.rename(pendingPath, metadataPath); + } catch (error) { + const sourceStillExists = await this.localPathExists(source.endpoint.path); + const payloadExists = await this.localPathExists(payloadPath); + if (sourceStillExists && !payloadExists) { + await fsp.rm(pendingPath, { force: true }).catch(() => undefined); + } + throw error; + } + this.rememberTrashRoot(deviceIdentity, trashRoot); + return item; + } + + private async listLocalTrashItems(device: ITurboFileTrashDevice): Promise { + const deviceIdentity = getTurboFileTrashDeviceIdentity(device); + const roots = await this.getLocalTrashRoots(deviceIdentity); + const itemsByRoot = await mapLimit([...roots], BATCH_TRANSFER_DIRECTORY_WORKERS, async trashRoot => { + const infoDirectory = join(trashRoot, 'info'); + let entries: string[]; + try { + entries = await fsp.readdir(infoDirectory); + } catch { + return []; + } + const values = await mapLimit(entries, BATCH_TRANSFER_WORKERS, async entry => { + if (!entry.endsWith('.json')) { + return undefined; + } + const metadataPath = join(infoDirectory, entry); + try { + const parsed = JSON.parse(await fsp.readFile(metadataPath, 'utf8')); + if (!isTurboFileTrashMetadata(parsed) || parsed.deviceIdentity !== deviceIdentity) { + return undefined; + } + if (entry.endsWith('.pending.json')) { + await this.recoverLocalPendingTrashItem(parsed, metadataPath); + } + if (await this.localPathExists(parsed.payloadPath)) { + return parsed; + } + } catch { + // Corrupt metadata remains on disk for diagnosis and is not exposed as restorable data. + } + return undefined; + }); + return values.filter((item): item is ITurboFileTrashItem => !!item); + }); + return itemsByRoot.flat(); + } + + private async recoverLocalPendingTrashItem(item: ITurboFileTrashItem, pendingPath: string): Promise { + const sourceExists = await this.localPathExists(item.originalPath); + const payloadExists = await this.localPathExists(item.payloadPath); + if (payloadExists && !sourceExists) { + await fsp.rename(pendingPath, join(item.trashRoot, 'info', `${item.id}.json`)); + } else if (sourceExists && !payloadExists) { + await fsp.rm(pendingPath, { force: true }); + } + } + + private async restoreLocalTrashItem(item: ITurboFileTrashItem, conflictPolicy: ITurboFileRestoreTrashRequest['conflictPolicy']): Promise { + let targetPath = item.originalPath; + if (await this.localPathExists(targetPath)) { + if (conflictPolicy === 'cancel') { + return { id: item.id, path: item.originalPath, ok: false, conflict: true, message: 'An item with the same name already exists.' }; + } + targetPath = await this.findLocalKeepBothPath(targetPath); + } + await fsp.mkdir(dirname(targetPath), { recursive: true }); + await fsp.rename(item.payloadPath, targetPath); + await fsp.rm(join(item.trashRoot, 'info', `${item.id}.json`), { force: true }); + return { id: item.id, path: item.originalPath, restoredPath: targetPath, ok: true, message: 'Restored.' }; + } + + private async findLocalKeepBothPath(originalPath: string): Promise { + const parent = dirname(originalPath); + const name = basename(originalPath); + for (let ordinal = 1; ordinal < 10000; ordinal++) { + const candidate = join(parent, createTurboFileKeepBothName(name, ordinal)); + if (!await this.localPathExists(candidate)) { + return candidate; + } + } + throw new Error('Unable to create a unique restore name.'); + } + + private resolveLocalTrashRoot(sourcePath: string, deviceIdentity: string): { readonly trashRoot: string; readonly volumeId: string } { + const uncMatch = /^(\\\\[^\\]+\\[^\\]+)(?:\\|$)/.exec(sourcePath); + if (uncMatch) { + const volumeId = uncMatch[1].toLowerCase(); + const userKey = createHash('sha256').update(deviceIdentity).digest('hex').slice(0, 12); + return { trashRoot: `${uncMatch[1]}\\.turbofile-trash-${userKey}\\turbofile`, volumeId }; + } + const match = /^([A-Za-z]):[\\/]/.exec(sourcePath); + if (!match) { + throw new Error('Local recycle-bin deletion requires an absolute Windows path.'); + } + const drive = match[1].toUpperCase(); + const volumeId = drive; + const localAppDataDrive = /^([A-Za-z]):[\\/]/.exec(process.env.LOCALAPPDATA ?? '')?.[1]?.toUpperCase(); + if (drive === localAppDataDrive && process.env.LOCALAPPDATA) { + return { trashRoot: join(process.env.LOCALAPPDATA, 'TurboFile', 'RecycleBin', volumeId), volumeId }; + } + const userKey = createHash('sha256').update(deviceIdentity).digest('hex').slice(0, 12); + return { trashRoot: `${drive}:\\.turbofile-trash-${userKey}\\turbofile`, volumeId }; + } + + private async getLocalTrashRoots(deviceIdentity: string): Promise> { + const roots = new Set(this.trashRootsByDeviceIdentity.get(deviceIdentity)); + const localAppDataDrive = /^([A-Za-z]):[\\/]/.exec(process.env.LOCALAPPDATA ?? '')?.[1]?.toUpperCase(); + await Promise.all(Array.from({ length: 26 }, async (_, index) => { + const code = 65 + index; + const drive = String.fromCharCode(code); + const { trashRoot } = this.resolveLocalTrashRoot(`${drive}:\\`, deviceIdentity); + if (drive === localAppDataDrive || await this.localPathExists(dirname(trashRoot))) { + roots.add(trashRoot); + } + })); + return roots; + } + + private localPathExists(path: string): Promise { + return fsp.stat(path).then(() => true, () => false); + } + + private async moveRemotePathsToTrash(sources: readonly ITurboFileTrashSource[], deviceIdentity: string): Promise { + const first = sources[0]; + const results: ITurboFileTrashItemResult[] = []; + const preparedItems: IPreparedRemoteTrashItem[] = []; + const rootsByParent = new Map(); + const windowsPath = looksLikeWindowsPath(first.endpoint.path); + if (!windowsPath) { + const probePaths = Array.from(new Set(sources.map(source => source.type === 'directory' ? source.endpoint.path : getRemoteParentPath(source.endpoint.path)))); + if (probePaths.length > 1) { + const batchRoots = await this.resolveRemoteTrashRoots(first.endpoint, probePaths).catch(() => new Map()); + for (const [probePath, root] of batchRoots) { + rootsByParent.set(probePath, root); + } + } + } + for (const source of sources) { + try { + const parentKey = source.type === 'directory' ? source.endpoint.path : getRemoteParentPath(source.endpoint.path); + let root = rootsByParent.get(parentKey); + if (!root) { + root = await this.resolveRemoteTrashRoot(source.endpoint, deviceIdentity); + rootsByParent.set(parentKey, root); + } + const filesDirectory = joinRemotePath(root.trashRoot, 'files'); + const infoDirectory = joinRemotePath(root.trashRoot, 'info'); + const id = createTurboFileTrashItemId(); + const item: ITurboFileTrashItem = { + id, + originalName: getTransferName(source.endpoint.path), + originalPath: source.endpoint.path, + deletedAt: Date.now(), + type: source.type, + knownSize: source.type === 'file' ? source.knownSize : undefined, + volumeId: root.volumeId, + trashRoot: root.trashRoot, + payloadPath: joinRemotePath(filesDirectory, id), + deviceIdentity + }; + preparedItems.push({ + source, + item, + pendingPath: joinRemotePath(infoDirectory, `${id}.pending.json`), + metadataPath: joinRemotePath(infoDirectory, `${id}.json`) + }); + } catch (error) { + results.push({ path: source.endpoint.path, ok: false, message: error instanceof Error ? error.message : String(error) }); + } + } + if (!preparedItems.length) { + return results; + } + + let moveResults: readonly IRemoteTrashMoveResult[]; + try { + const command = windowsPath ? buildWindowsTrashMoveBatchCommand() : buildPosixTrashMoveBatchCommand(); + const input = windowsPath + ? Buffer.from(JSON.stringify(preparedItems.map(prepared => ({ + id: prepared.item.id, + sourcePath: prepared.source.endpoint.path, + payloadPath: prepared.item.payloadPath, + pendingPath: prepared.pendingPath, + metadataPath: prepared.metadataPath, + metadata: JSON.stringify(prepared.item) + }))), 'utf8') + : Buffer.from(`${preparedItems.flatMap(prepared => [ + prepared.item.id, prepared.source.endpoint.path, prepared.item.payloadPath, prepared.pendingPath, prepared.metadataPath, JSON.stringify(prepared.item) + ]).join('\0')}\0`, 'utf8'); + const output = await this.withSshClient(first.endpoint, client => this.execRemoteWithInput(client, command, input, windowsPath + ? {} + : { inactivityTimeoutMs: REMOTE_DIRECTORY_READ_MAX_TIMEOUT_MS, inactivityMessage: 'Recycle-bin batch move produced no progress.' })); + moveResults = parseRemoteTrashMoveResults(output, windowsPath); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + moveResults = preparedItems.map(prepared => ({ id: prepared.item.id, ok: false, message })); + } + const moveResultById = new Map(moveResults.map(result => [result.id, result])); + for (const prepared of preparedItems) { + const moveResult = moveResultById.get(prepared.item.id) ?? { id: prepared.item.id, ok: false, message: 'Remote move returned no result.' }; + if (!moveResult.ok) { + results.push({ id: prepared.item.id, path: prepared.source.endpoint.path, ok: false, message: moveResult.message ?? 'Remote move failed.' }); + continue; + } + this.rememberTrashRoot(deviceIdentity, prepared.item.trashRoot); + this.clearRemoteDirectoryListingCache(prepared.source.endpoint); + results.push({ id: prepared.item.id, path: prepared.source.endpoint.path, ok: true, message: 'Moved to recycle bin.' }); + } + return results; + } + + private async listRemoteTrashItems(device: ITurboFileTrashDevice): Promise { + const connection = device.connection; + if (!connection) { + throw new Error('SSH recycle-bin operations require a connection.'); + } + const deviceIdentity = getTurboFileTrashDeviceIdentity(device); + const endpoint: ITurboFileTransferEndpoint = { kind: 'ssh', path: device.windows ? 'C:\\' : '/', connection, label: device.label }; + const rememberedRoots = [...(this.trashRootsByDeviceIdentity.get(deviceIdentity) ?? [])]; + const windowsPath = !!device.windows; + const command = windowsPath + ? buildWindowsTrashListCommand(deviceIdentity, rememberedRoots) + : buildPosixTrashListCommand(rememberedRoots); + const output = await this.withDirectorySshClient(endpoint, client => this.execRemoteBytes(client, command, { + inactivityTimeoutMs: REMOTE_DIRECTORY_READ_INACTIVITY_TIMEOUT_MS, + inactivityMessage: 'Recycle-bin metadata listing produced no output.' + })); + const records = parseRemoteTrashMetadataRecords(output, windowsPath); + const items: ITurboFileTrashItem[] = []; + const pending: Array<{ readonly item: ITurboFileTrashItem; readonly metadataPath: string }> = []; + const seenIds = new Set(); + for (const record of records) { + try { + const parsed = JSON.parse(record.metadata); + if (!isTurboFileTrashMetadata(parsed) || parsed.deviceIdentity !== deviceIdentity || !this.isSafeTrashItem(parsed)) { + continue; + } + const isPending = record.metadataPath.endsWith('.pending.json'); + const expectedMetadataPath = joinRemotePath(joinRemotePath(parsed.trashRoot, 'info'), `${parsed.id}${isPending ? '.pending' : ''}.json`); + if (!trashPathsEqual(record.metadataPath, expectedMetadataPath, windowsPath) || seenIds.has(parsed.id)) { + continue; + } + this.rememberTrashRoot(deviceIdentity, parsed.trashRoot); + seenIds.add(parsed.id); + if (isPending) { + pending.push({ item: parsed, metadataPath: record.metadataPath }); + } else { + items.push(parsed); + } + } catch { + // Keep corrupt remote metadata untouched for diagnosis. + } + } + if (pending.length) { + const recoveredIds = await this.recoverRemotePendingTrashItemsNative(endpoint, pending, windowsPath); + for (const value of pending) { + if (recoveredIds.has(value.item.id)) { + items.push(value.item); + } + } + } + return items; + } + + private async recoverRemotePendingTrashItemsNative(endpoint: ITurboFileTransferEndpoint, pending: readonly { readonly item: ITurboFileTrashItem; readonly metadataPath: string }[], windowsPath: boolean): Promise> { + const command = windowsPath ? buildWindowsTrashPendingRecoveryCommand() : buildPosixTrashPendingRecoveryCommand(); + const input = windowsPath + ? Buffer.from(JSON.stringify(pending.map(value => ({ id: value.item.id, sourcePath: value.item.originalPath, payloadPath: value.item.payloadPath, pendingPath: value.metadataPath, metadataPath: joinRemotePath(joinRemotePath(value.item.trashRoot, 'info'), `${value.item.id}.json`) }))), 'utf8') + : Buffer.from(`${pending.flatMap(value => [value.item.id, value.item.originalPath, value.item.payloadPath, value.metadataPath, joinRemotePath(joinRemotePath(value.item.trashRoot, 'info'), `${value.item.id}.json`)]).join('\0')}\0`, 'utf8'); + const output = await this.withDirectorySshClient(endpoint, client => this.execRemoteWithInput(client, command, input, { timeoutMs: REMOTE_DIRECTORY_READ_MAX_TIMEOUT_MS })); + return new Set(parseRemoteTrashMoveResults(output, windowsPath).filter(result => result.ok).map(result => result.id)); + } + + private async restoreRemoteTrashItem(device: ITurboFileTrashDevice, item: ITurboFileTrashItem, conflictPolicy: ITurboFileRestoreTrashRequest['conflictPolicy']): Promise { + const connection = device.connection; + if (!connection) { + throw new Error('SSH recycle-bin operations require a connection.'); + } + const endpoint: ITurboFileTransferEndpoint = { kind: 'ssh', path: item.payloadPath, connection, label: device.label }; + let targetPath = item.originalPath; + await this.withDirectorySftp(endpoint, async sftp => { + if (await this.remotePathExists(sftp, targetPath)) { + if (conflictPolicy === 'cancel') { + return; + } + targetPath = await this.findRemoteKeepBothPath(sftp, targetPath); + } + await this.ensureRemoteDirectory(sftp, getRemoteParentPath(targetPath)); + }); + if (targetPath === item.originalPath) { + const conflict = await this.withDirectorySftp(endpoint, sftp => this.remotePathExists(sftp, targetPath)); + if (conflict) { + return { id: item.id, path: item.originalPath, ok: false, conflict: true, message: 'An item with the same name already exists.' }; + } + } + await this.moveRemotePathNative(endpoint, targetPath); + await this.withDirectorySftp(endpoint, sftp => this.unlinkRemote(sftp, joinRemotePath(joinRemotePath(item.trashRoot, 'info'), `${item.id}.json`))); + return { id: item.id, path: item.originalPath, restoredPath: targetPath, ok: true, message: 'Restored.' }; + } + + private async findRemoteKeepBothPath(sftp: ISFTPWrapper, originalPath: string): Promise { + const parent = getRemoteParentPath(originalPath); + const name = getTransferName(originalPath); + for (let ordinal = 1; ordinal < 10000; ordinal++) { + const candidate = joinRemotePath(parent, createTurboFileKeepBothName(name, ordinal)); + if (!await this.remotePathExists(sftp, candidate)) { + return candidate; + } + } + throw new Error('Unable to create a unique restore name.'); + } + + private async resolveRemoteTrashRoots(endpoint: ITurboFileTransferEndpoint, probePaths: readonly string[]): Promise> { + const input = createTarNullListBuffer(probePaths); + const output = await this.withSshClient(endpoint, client => this.execRemoteWithInput(client, buildPosixTrashRootBatchCommand(), input, { + inactivityTimeoutMs: REMOTE_DIRECTORY_READ_MAX_TIMEOUT_MS, + inactivityMessage: 'Recycle-bin filesystem detection produced no progress.' + })); + const fields = output.toString('utf8').split('\0'); + const roots = new Map(); + for (let index = 0; index + 2 < fields.length; index += 3) { + const probePath = fields[index]; + const volumeId = fields[index + 1]; + const trashRoot = fields[index + 2]; + if (probePath && volumeId && trashRoot) { + roots.set(probePath, { trashRoot, volumeId }); + } + } + return roots; + } + + private async resolveRemoteTrashRoot(endpoint: ITurboFileTransferEndpoint, deviceIdentity: string): Promise<{ readonly trashRoot: string; readonly volumeId: string }> { + if (looksLikeWindowsPath(endpoint.path)) { + const uncMatch = /^(\\\\[^\\]+\\[^\\]+)(?:\\|$)/.exec(endpoint.path); + if (uncMatch) { + const userKey = createHash('sha256').update(deviceIdentity).digest('hex').slice(0, 12); + return { + trashRoot: `${uncMatch[1]}\\.turbofile-trash-${userKey}\\turbofile`, + volumeId: uncMatch[1].toLowerCase() + }; + } + const drive = endpoint.path.slice(0, 1).toUpperCase(); + const userKey = createHash('sha256').update(deviceIdentity).digest('hex').slice(0, 12); + return { trashRoot: `${drive}:\\.turbofile-trash-${userKey}\\turbofile`, volumeId: drive }; + } + const details = await this.withSshClient(endpoint, async client => { + const command = [ + 'set -eu', + `p=${quotePosixShellArg(endpoint.path)}`, + 'mount_point="$(findmnt -T "$p" -n -o TARGET 2>/dev/null || df -P "$p" | awk \'NR==2 { print $6 }\')"', + 'home_point="$(findmnt -T "$HOME" -n -o TARGET 2>/dev/null || df -P "$HOME" | awk \'NR==2 { print $6 }\')"', + 'printf "%s\\n%s\\n%s\\n%s\\n" "$mount_point" "$home_point" "$(id -u)" "$HOME"' + ].join('; '); + return (await this.execRemoteBytes(client, command)).toString('utf8').split(/\r?\n/); + }); + const mountPoint = details[0]?.trim(); + const homeMountPoint = details[1]?.trim(); + const uid = details[2]?.trim(); + const homePath = details[3]?.trim(); + if (!mountPoint || !uid || (mountPoint === homeMountPoint && !homePath)) { + throw new Error('Unable to resolve the source filesystem for the recycle bin.'); + } + const trashRoot = mountPoint === homeMountPoint + ? joinRemotePath(joinRemotePath(homePath, '.local/share/Trash'), 'turbofile') + : joinRemotePath(joinRemotePath(mountPoint, `.Trash-${uid}`), 'turbofile'); + return { trashRoot, volumeId: mountPoint }; + } + + private rememberTrashRoot(deviceIdentity: string, trashRoot: string): void { + let roots = this.trashRootsByDeviceIdentity.get(deviceIdentity); + if (!roots) { + roots = new Set(); + this.trashRootsByDeviceIdentity.set(deviceIdentity, roots); + } + roots.add(trashRoot); + } + + private isSafeTrashItem(item: ITurboFileTrashItem): boolean { + if (!item.id || !item.trashRoot || !item.payloadPath) { + return false; + } + const windowsPath = looksLikeWindowsPath(item.trashRoot); + const expectedPayloadPath = joinRemotePath(joinRemotePath(item.trashRoot, 'files'), item.id); + return trashPathsEqual(item.payloadPath, expectedPayloadPath, windowsPath); + } + + private async deleteTrashItemsNative(device: ITurboFileTrashDevice, items: readonly ITurboFileTrashItem[]): Promise { + if (device.kind === 'local') { + const workerLimit = items.some(item => item.type === 'directory') ? BATCH_TRANSFER_DIRECTORY_WORKERS : BATCH_TRANSFER_WORKERS; + return mapLimit(items, workerLimit, async item => { + try { + await fsp.rm(item.payloadPath, { recursive: true, force: true }); + await fsp.rm(join(item.trashRoot, 'info', `${item.id}.json`), { force: true }); + return { id: item.id, path: item.originalPath, ok: true, message: 'Permanently deleted.' }; + } catch (error) { + return { id: item.id, path: item.originalPath, ok: false, message: error instanceof Error ? error.message : String(error) }; + } + }); + } + + if (!device.connection) { + return items.map(item => ({ id: item.id, path: item.originalPath, ok: false, message: 'SSH recycle-bin operations require a connection.' })); + } + const windowsPath = !!device.windows || looksLikeWindowsPath(items[0].payloadPath); + const endpoint: ITurboFileTransferEndpoint = { kind: 'ssh', path: items[0].payloadPath, connection: device.connection, label: device.label }; + try { + const command = windowsPath ? buildWindowsTrashDeleteBatchCommand() : buildPosixTrashDeleteBatchCommand(); + const input = windowsPath + ? Buffer.from(JSON.stringify(items.map(item => ({ id: item.id, payloadPath: item.payloadPath, metadataPath: joinRemotePath(joinRemotePath(item.trashRoot, 'info'), `${item.id}.json`) }))), 'utf8') + : Buffer.from(`${items.flatMap(item => [item.id, item.payloadPath, joinRemotePath(joinRemotePath(item.trashRoot, 'info'), `${item.id}.json`)]).join('\0')}\0`, 'utf8'); + const output = await this.withDirectorySshClient(endpoint, client => this.execRemoteWithInput(client, command, input, windowsPath + ? {} + : { inactivityTimeoutMs: REMOTE_DIRECTORY_READ_MAX_TIMEOUT_MS, inactivityMessage: 'Recycle-bin deletion produced no progress.' })); + const remoteResults = new Map(parseRemoteTrashMoveResults(output, windowsPath).map(result => [result.id, result])); + return items.map(item => { + const result = remoteResults.get(item.id); + return { + id: item.id, + path: item.originalPath, + ok: result?.ok === true, + message: result?.ok ? 'Permanently deleted.' : result?.message ?? 'Remote delete returned no result.' + }; + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return items.map(item => ({ id: item.id, path: item.originalPath, ok: false, message })); + } + } + + private async emptyTrashNative(device: ITurboFileTrashDevice): Promise { + if (device.kind === 'local') { + try { + const deviceIdentity = getTurboFileTrashDeviceIdentity(device); + const roots = await this.getLocalTrashRoots(deviceIdentity); + const counts = await Promise.all([...roots].map(async trashRoot => { + const infoDirectory = join(trashRoot, 'info'); + const count = await fsp.readdir(infoDirectory).then(entries => entries.filter(entry => entry.endsWith('.json')).length, () => 0); + await Promise.all([ + fsp.rm(join(trashRoot, 'files'), { recursive: true, force: true }), + fsp.rm(infoDirectory, { recursive: true, force: true }) + ]); + return count; + })); + const completed = counts.reduce((total, count) => total + count, 0); + return { ok: true, message: completed ? 'Recycle bin emptied.' : 'Recycle bin is empty.', completed, failed: 0, results: [] }; + } catch (error) { + return { ok: false, message: error instanceof Error ? error.message : String(error), completed: 0, failed: 1, results: [] }; + } + } + + if (!device.connection) { + return { ok: false, message: 'SSH recycle-bin operations require a connection.', completed: 0, failed: 1, results: [] }; + } + const deviceIdentity = getTurboFileTrashDeviceIdentity(device); + const rememberedRoots = [...(this.trashRootsByDeviceIdentity.get(deviceIdentity) ?? [])]; + const windowsPath = !!device.windows; + const endpoint: ITurboFileTransferEndpoint = { kind: 'ssh', path: windowsPath ? 'C:\\' : '/', connection: device.connection, label: device.label }; + try { + const command = windowsPath + ? buildWindowsTrashEmptyCommand(deviceIdentity, rememberedRoots) + : buildPosixTrashEmptyCommand(rememberedRoots); + const output = await this.withDirectorySshClient(endpoint, client => this.execRemoteBytes(client, command, windowsPath + ? {} + : { inactivityTimeoutMs: REMOTE_DIRECTORY_READ_MAX_TIMEOUT_MS, inactivityMessage: 'Remote recycle-bin emptying produced no progress.' })); + const completed = parseSizeCommandOutput(output.toString()); + return { ok: true, message: completed ? 'Recycle bin emptied.' : 'Recycle bin is empty.', completed, failed: 0, results: [] }; + } catch (error) { + return { ok: false, message: error instanceof Error ? error.message : String(error), completed: 0, failed: 1, results: [] }; + } + } + + private async hasTrashItemsNative(device: ITurboFileTrashDevice): Promise { + const deviceIdentity = getTurboFileTrashDeviceIdentity(device); + if (device.kind === 'local') { + const roots = await this.getLocalTrashRoots(deviceIdentity); + const states = await Promise.all([...roots].map(trashRoot => fsp.readdir(join(trashRoot, 'info')).then(entries => entries.some(entry => entry.endsWith('.json')), () => false))); + return states.some(Boolean); + } + if (!device.connection) { + throw new Error('SSH recycle-bin operations require a connection.'); + } + const rememberedRoots = [...(this.trashRootsByDeviceIdentity.get(deviceIdentity) ?? [])]; + const windowsPath = !!device.windows; + const endpoint: ITurboFileTransferEndpoint = { kind: 'ssh', path: windowsPath ? 'C:\\' : '/', connection: device.connection, label: device.label }; + const command = windowsPath + ? buildWindowsTrashHasItemsCommand(deviceIdentity, rememberedRoots) + : buildPosixTrashHasItemsCommand(rememberedRoots); + const output = await this.withDirectorySshClient(endpoint, client => this.execRemoteBytes(client, command, { timeoutMs: REMOTE_DIRECTORY_READ_MAX_TIMEOUT_MS })); + return output.toString('utf8').trim() === '1'; + } + + private async remotePathExists(sftp: ISFTPWrapper, path: string): Promise { + try { + await this.remoteStat(sftp, path); + return true; + } catch { + return false; + } + } + + async deletePath(endpoint: ITurboFileTransferEndpoint, recursive = true): Promise { + return this.fileOperationQueue.run( + [getFileOperationDeviceKey(endpoint)], + () => this.deletePathCore(endpoint, recursive) + ); + } + + private async deletePathCore(endpoint: ITurboFileTransferEndpoint, recursive = true): Promise { + try { + if (endpoint.kind === 'local') { + await fsp.rm(endpoint.path, { recursive, force: true }); + } else { + try { + await this.deleteRemotePathNative(endpoint, recursive); + } catch (error) { + if (error instanceof Error && error.message === 'Refusing to delete a filesystem root.') { + throw error; + } + await this.withSftp(endpoint, async sftp => { + const stats = await this.remoteStat(sftp, endpoint.path); + if (stats.isDirectory()) { + await this.deleteRemoteDirectory(sftp, endpoint.path); + } else { + await this.unlinkRemote(sftp, endpoint.path); + } + }); + } + } + this.clearRemoteDirectoryListingCache(endpoint); + return { ok: true, message: 'Path deleted.' }; + } catch (error) { + return { ok: false, message: error instanceof Error ? error.message : String(error) }; + } + } + + async deletePaths(endpoints: readonly ITurboFileTransferEndpoint[], recursive = true): Promise { + return this.fileOperationQueue.run( + getFileOperationDeviceKeys(...endpoints), + () => this.deletePathsCore(endpoints, recursive) + ); + } + + private async deletePathsCore(endpoints: readonly ITurboFileTransferEndpoint[], recursive = true): Promise { + const targets = endpoints.filter(endpoint => !!endpoint.path); + if (!targets.length) { + return { ok: true, message: 'Nothing to delete.' }; + } + if (targets.length === 1) { + return this.deletePathCore(targets[0], recursive); + } + + try { + const first = targets[0]; + if (first.kind === 'local') { + const workerLimit = recursive ? BATCH_TRANSFER_DIRECTORY_WORKERS : BATCH_TRANSFER_WORKERS; + await mapLimit(targets, workerLimit, endpoint => fsp.rm(endpoint.path, { recursive, force: true })); + for (const endpoint of targets) { + this.clearRemoteDirectoryListingCache(endpoint); + } + return { ok: true, message: 'Paths deleted.' }; + } + + for (const endpoint of targets) { + this.assertSafeDeletePath(endpoint.path); + } + + if (looksLikeWindowsPath(first.path)) { + const input = Buffer.from(JSON.stringify(targets.map(endpoint => endpoint.path)), 'utf8'); + await this.withSshClient(first, client => this.execRemoteWithInput(client, buildWindowsDeleteManyFromInputCommand(recursive), input)); + } else { + // POSIX remote: feed the NUL-delimited paths to `xargs -0 rm` in a single round trip; + // xargs batches the actual rm invocations server-side so this scales to any selection + // size without ever hitting the shell's argument-length limit. + const input = createTarNullListBuffer(targets.map(endpoint => endpoint.path)); + await this.withSshClient(first, client => this.execRemoteWithInput(client, buildPosixDeleteManyFromInputCommand(recursive), input, { + inactivityTimeoutMs: REMOTE_DIRECTORY_READ_MAX_TIMEOUT_MS, + inactivityMessage: 'Remote POSIX batch delete produced no progress.' + })); + } + this.clearRemoteDirectoryListingCache(first); + return { ok: true, message: 'Paths deleted.' }; + } catch (error) { + return { ok: false, message: error instanceof Error ? error.message : String(error) }; + } + } + + private async deleteRemotePathNative(endpoint: ITurboFileTransferEndpoint, recursive: boolean): Promise { + this.assertSafeDeletePath(endpoint.path); + const command = looksLikeWindowsPath(endpoint.path) + ? buildWindowsDeleteCommand(endpoint.path, recursive) + : buildPosixDeleteCommand(endpoint.path, recursive); + await this.withSshClient(endpoint, client => this.execRemote(client, command)); + } + + private assertSafeDeletePath(path: string): void { + const trimmed = path.trim(); + const normalized = trimmed.replace(/[\\/]+$/, ''); + if (!trimmed || trimmed === '/' || trimmed === '\\' || normalized === '' || /^[A-Za-z]:$/.test(normalized)) { + throw new Error('Refusing to delete a filesystem root.'); + } + } + + async renamePath(endpoint: ITurboFileTransferEndpoint, targetPath: string): Promise { + return this.fileOperationQueue.run( + getFileOperationDeviceKeys(endpoint), + () => this.renamePathCore(endpoint, targetPath) + ); + } + + private async renamePathCore(endpoint: ITurboFileTransferEndpoint, targetPath: string): Promise { + try { + if (endpoint.kind === 'local') { + await this.moveLocalPathNative(endpoint.path, targetPath); + } else { + await this.moveRemotePathNative(endpoint, targetPath); + } + this.clearRemoteDirectoryListingCache(endpoint); + this.clearRemoteDirectoryListingCache(endpoint, targetPath); + return { ok: true, message: 'Path renamed.' }; + } catch (error) { + return { ok: false, message: error instanceof Error ? error.message : String(error) }; + } + } + + async movePathsIntoDirectory(sources: readonly ITurboFileTransferEndpoint[], targetDirectory: string): Promise { + return this.fileOperationQueue.run( + getFileOperationDeviceKeys(...sources), + () => this.movePathsIntoDirectoryCore(sources, targetDirectory) + ); + } + + private async movePathsIntoDirectoryCore(sources: readonly ITurboFileTransferEndpoint[], targetDirectory: string): Promise { + const targets = sources.filter(endpoint => !!endpoint.path); + if (!targets.length) { + return { ok: true, message: 'Nothing to move.' }; + } + const first = targets[0]; + const windowsPath = looksLikeWindowsPath(targetDirectory) || looksLikeWindowsPath(first.path); + + try { + if (first.kind === 'local') { + await mapLimit(targets, BATCH_TRANSFER_WORKERS, endpoint => this.moveLocalPathNative(endpoint.path, joinRemotePath(targetDirectory, getTransferName(endpoint.path)))); + } else if (windowsPath) { + // Windows remote: move each into the directory. Rare path, kept simple for correctness. + for (const endpoint of targets) { + await this.moveRemotePathNative(endpoint, joinRemotePath(targetDirectory, getTransferName(endpoint.path))); + } + } else { + // POSIX remote: a single round trip moves the whole selection. The NUL-delimited sources + // are fed to `xargs -0`, which runs `mv -- ` server-side (batching as needed + // to stay under the argument-length limit), so a 1000-file move is one near-instant rename + // instead of 1000 separate SSH round trips. `mv` drops each file into under its own + // name, so no per-file target path needs computing. + const command = buildPosixMoveManyIntoDirectoryFromInputCommand(targetDirectory); + const input = createTarNullListBuffer(targets.map(endpoint => endpoint.path)); + await this.withSshClient(first, client => this.execRemoteWithInput(client, command, input, { + inactivityTimeoutMs: REMOTE_DIRECTORY_READ_MAX_TIMEOUT_MS, + inactivityMessage: 'Remote POSIX batch move produced no progress.' + })); + } + this.clearRemoteDirectoryListingCache(first); + this.clearRemoteDirectoryListingCache(first, targetDirectory); + return { ok: true, message: 'Paths moved.' }; + } catch (error) { + return { ok: false, message: error instanceof Error ? error.message : String(error) }; + } + } + + async calculatePathSize(endpoint: ITurboFileTransferEndpoint): Promise { + try { + const size = endpoint.kind === 'local' + ? await getLocalPathSizeByCommand(endpoint.path) + : await this.getRemotePathSizeByCommand(endpoint); + const humanSize = await this.getPathHumanSize(endpoint, size); + return { ok: true, message: 'Path size calculated.', size, humanSize }; + } catch (error) { + return { + ok: false, + message: error instanceof Error ? error.message : String(error), + size: 0 + }; + } + } + + async readWindowsClipboardFiles(): Promise { try { const paths = await readWindowsClipboardFilePaths(); return { @@ -3326,13 +4669,14 @@ export class TurboFileSshMainService implements ITurboFileSshService { return { ok: false, message: 'No TurboFile window is available for Windows drag.' }; } - if (request.source.kind === 'ssh') { - return this.startRemoteWindowsFileDrag(request, window); - } - - if (request.source.kind !== 'local') { + const operation = request.operation ?? 'copy'; + if (request.source.kind !== 'local' && request.source.kind !== 'ssh') { return { ok: false, message: 'Unsupported Windows drag source.' }; } + const useDirectLocalDrag = request.source.kind === 'local' && operation === 'copy'; + if (!useDirectLocalDrag) { + return this.startCapturedWindowsFileDrag(request, window); + } const files = request.items .map(item => item.sourcePath) @@ -3356,8 +4700,8 @@ export class TurboFileSshMainService implements ITurboFileSshService { } } - private async startRemoteWindowsFileDrag(request: ITurboFileStartWindowsDragRequest, window: BrowserWindow): Promise { - if (!request.source.connection) { + private async startCapturedWindowsFileDrag(request: ITurboFileStartWindowsDragRequest, _window: BrowserWindow): Promise { + if (request.source.kind === 'ssh' && !request.source.connection) { return { ok: false, message: 'Missing SSH source connection for Windows drag.' }; } if (!request.items.length) { @@ -3367,33 +4711,62 @@ export class TurboFileSshMainService implements ITurboFileSshService { const token = randomUUID(); const fakeDirectoryName = createWindowsDragFakeDirectoryName(token); const paths = this.getWindowsDragCopyHookPaths(token, fakeDirectoryName); + let dragSource: ChildProcess | undefined; try { await this.ensureWindowsDragCopyHookRegistered(); await fsp.rm(paths.activeRoot, { recursive: true, force: true }).catch(() => undefined); await fsp.rm(paths.targetFile, { force: true }).catch(() => undefined); await fsp.mkdir(paths.fakeDirectory, { recursive: true }); await fsp.mkdir(dirname(paths.targetFile), { recursive: true }); - window.webContents.startDrag({ - file: paths.fakeDirectory, - files: [paths.fakeDirectory], - icon: createWindowsDragIcon() - }); + dragSource = this.startWindowsDragSourceHelper(paths.fakeDirectory); - const targetDirectory = await this.waitForWindowsDragCopyHookTarget(paths.targetFile, fakeDirectoryName); + const targetDirectory = await Promise.race([ + this.waitForWindowsDragCopyHookTarget(paths.targetFile, fakeDirectoryName), + this.waitForWindowsDragSourceHelperFailure(dragSource) + ]); return { ok: true, message: 'Windows drop target captured.', targetDirectory }; } catch (error) { const message = error instanceof Error ? error.message : String(error); return { ok: false, message, - canceled: message.includes('timed out') + canceled: message.includes('timed out') || message.includes('canceled') }; } finally { + if (dragSource && dragSource.exitCode === null && !dragSource.killed) { + dragSource.kill(); + } await fsp.rm(paths.activeRoot, { recursive: true, force: true }).catch(() => undefined); await fsp.rm(paths.targetFile, { force: true }).catch(() => undefined); } } + private startWindowsDragSourceHelper(fakeDirectory: string): ChildProcess { + return spawn(this.resolveWindowsDragSourceHelperPath(), [fakeDirectory], { + stdio: 'ignore', + windowsHide: true + }); + } + + private waitForWindowsDragSourceHelperFailure(child: ChildProcess): Promise { + return new Promise((_, reject) => { + const onError = (error: Error): void => { + child.removeListener('exit', onExit); + reject(error); + }; + const onExit = (code: number | null): void => { + child.removeListener('error', onError); + if (code === 2) { + reject(new Error('Windows drag was canceled.')); + } else if (code !== 0) { + reject(new Error(`Windows drag helper failed with exit code ${code ?? 'unknown'}.`)); + } + }; + child.once('error', onError); + child.once('exit', onExit); + }); + } + private getWindowsDragCopyHookPaths(token: string, fakeDirectoryName: string): { readonly activeRoot: string; readonly fakeDirectory: string; readonly targetFile: string } { const root = this.getWindowsDragCopyHookRoot(); const activeRoot = join(root, TURBOFILE_WINDOWS_DRAG_ACTIVE_DIR, token); @@ -3414,7 +4787,10 @@ export class TurboFileSshMainService implements ITurboFileSshService { private async waitForWindowsDragCopyHookTarget(targetFile: string, fakeDirectoryName: string): Promise { const deadline = Date.now() + WINDOWS_DRAG_COPY_HOOK_TIMEOUT_MS; + const desktopFallbackDirs = this.getWindowsDragDesktopFallbackDirs(); while (Date.now() < deadline) { + // 1) The CopyHook captured the destination. This covers drops onto ordinary directories and + // open Explorer windows (the hook is registered for the file-system Directory class). try { const raw = await fsp.readFile(targetFile, 'utf8'); const captured = raw.replace(/^\uFEFF/, ''); @@ -3430,12 +4806,73 @@ export class TurboFileSshMainService implements ITurboFileSshService { throw error; } } + + // 2) Fallback for drops onto the live Desktop. The desktop background is a special shell + // folder, so its drop is handled by the desktop's own drop target and never consults the + // Directory CopyHook (confirmed via the hook log: ordinary folders on any drive fire the + // hook, the relocated D:\Desktop drop does not). In that case Explorer physically copies + // the empty fake folder onto the desktop, so detect it there and use the desktop \u2014 resolved + // through Electron so it follows a relocated (e.g. D:\Desktop) shell folder \u2014 as the target. + for (const desktopDir of desktopFallbackDirs) { + const landedFolder = join(desktopDir, fakeDirectoryName); + try { + if ((await fsp.stat(landedFolder)).isDirectory()) { + await fsp.rm(landedFolder, { recursive: true, force: true }).catch(() => undefined); + return desktopDir; + } + } catch { + // The fake folder has not landed in this candidate yet; keep polling. + } + } await delay(WINDOWS_DRAG_COPY_HOOK_POLL_MS); } throw new Error('Windows drop target capture timed out.'); } + /** + * Resolves the real Desktop directory for the "桌面" quick-nav shortcut. `app.getPath('desktop')` + * follows a Desktop relocated to another drive (e.g. D:\Desktop), so clicking 桌面 lands on the + * folder the OS actually uses — which is also where Explorer drops dragged files. + */ + async getLocalDesktopPath(): Promise { + try { + const desktop = app.getPath('desktop'); + if (desktop) { + return desktop; + } + } catch { + // Electron could not resolve the desktop; fall back to the profile path below. + } + if (process.env.USERPROFILE) { + return join(process.env.USERPROFILE, 'Desktop'); + } + return ''; + } + + /** + * Candidate Desktop directories used to recover a drop the Directory CopyHook never saw (see + * {@link waitForWindowsDragCopyHookTarget}). `app.getPath('desktop')` honors a relocated Desktop + * (e.g. moved to D:\), and the raw profile Desktop is added as a defensive secondary. + */ + private getWindowsDragDesktopFallbackDirs(): string[] { + const dirs: string[] = []; + const add = (dir: string | undefined): void => { + if (dir && !dirs.some(existing => existing.toLowerCase() === dir.toLowerCase())) { + dirs.push(dir); + } + }; + try { + add(app.getPath('desktop')); + } catch { + // Electron could not resolve the desktop; fall back to the profile path below. + } + if (process.env.USERPROFILE) { + add(join(process.env.USERPROFILE, 'Desktop')); + } + return dirs; + } + private async ensureWindowsDragCopyHookRegistered(): Promise { if (!this.windowsDragCopyHookRegistrationPromise) { this.windowsDragCopyHookRegistrationPromise = this.registerWindowsDragCopyHook().catch(error => { @@ -3504,60 +4941,308 @@ export class TurboFileSshMainService implements ITurboFileSshService { return found; } - async readFile(endpoint: ITurboFileTransferEndpoint, options: ITurboFileReadFileOptions = {}): Promise { - const encoding = options.encoding ?? 'utf8'; - const maxBytes = options.maxBytes ?? DEFAULT_READ_LIMIT; - const mime = getMimeType(endpoint.path); + private resolveWindowsDragSourceHelperPath(): string { + const resourcesPath = (process as NodeJS.Process & { resourcesPath?: string }).resourcesPath; + const candidates = [ + join(process.cwd(), 'resources', 'turbofile', 'drag-ext', 'x64', 'TurboFileDragSource.exe'), + join(app.getAppPath(), 'resources', 'turbofile', 'drag-ext', 'x64', 'TurboFileDragSource.exe'), + resourcesPath ? join(resourcesPath, 'app', 'resources', 'turbofile', 'drag-ext', 'x64', 'TurboFileDragSource.exe') : undefined, + resourcesPath ? join(resourcesPath, 'resources', 'turbofile', 'drag-ext', 'x64', 'TurboFileDragSource.exe') : undefined + ].filter((candidate): candidate is string => !!candidate); + const found = candidates.find(candidate => existsSync(candidate)); + if (!found) { + throw new Error(`TurboFile Windows drag source helper was not found. Run scripts\\build-turbofile-drag-ext.ps1. Checked: ${candidates.join(', ')}`); + } + return found; + } + + async readFile(endpoint: ITurboFileTransferEndpoint, options: ITurboFileReadFileOptions = {}): Promise { + const encoding = options.encoding ?? 'utf8'; + const maxBytes = options.maxBytes ?? DEFAULT_READ_LIMIT; + const mime = getMimeType(endpoint.path); + try { + const buffer = endpoint.kind === 'local' + ? await this.readLocalFileBuffer(endpoint.path, maxBytes) + : await this.readRemoteFileBuffer(endpoint, maxBytes); + const imageSize = mime.startsWith('image/') ? getImageSize(buffer) : undefined; + return { + ok: true, + message: 'File loaded.', + content: encoding === 'base64' ? buffer.toString('base64') : buffer.toString('utf8'), + encoding, + mime, + size: buffer.byteLength, + width: imageSize?.width, + height: imageSize?.height, + originalWidth: imageSize?.width, + originalHeight: imageSize?.height + }; + } catch (error) { + return { + ok: false, + message: error instanceof Error ? error.message : String(error), + content: '', + encoding, + mime, + size: 0 + }; + } + } + + async readImage(endpoint: ITurboFileTransferEndpoint, options: ITurboFileReadImageOptions = {}): Promise { + const encoding = options.encoding ?? 'base64'; + try { + const result = await this.readImageBuffer(endpoint, options); + return { + ok: true, + message: 'Image loaded.', + content: encoding === 'base64' ? result.buffer.toString('base64') : result.buffer.toString('utf8'), + encoding, + mime: result.mime, + size: result.buffer.byteLength, + width: result.width, + height: result.height, + originalWidth: result.originalWidth, + originalHeight: result.originalHeight + }; + } catch (error) { + return { + ok: false, + message: error instanceof Error ? error.message : String(error), + content: '', + encoding, + mime: getMimeType(endpoint.path), + size: 0 + }; + } + } + + async readImageBatch(endpoint: ITurboFileTransferEndpoint, paths: readonly string[], maxWidth = 0): Promise { + const targets = [...new Set(paths.filter(path => !!path))]; + if (!targets.length) { + return []; + } + + if (endpoint.kind !== 'ssh' || !endpoint.connection) { + return Promise.all(targets.map(async path => { + try { + const stat = await fsp.stat(path); + if (stat.size > IMAGE_BATCH_MAX_FILE_BYTES) { + return { path, ok: false, oversized: true }; + } + const buffer = await fsp.readFile(path); + return { path, ok: true, content: buffer.toString('base64'), mime: getMimeType(path) }; + } catch (error) { + return { path, ok: false, message: error instanceof Error ? error.message : String(error) }; + } + })); + } + + // The batch command is POSIX shell; a Windows SSH host can't run it. Report oversized so the + // caller transparently falls back to the per-image resource path that handles Windows remotes. + if (looksLikeWindowsPath(targets[0])) { + return targets.map(path => ({ path, ok: false, oversized: true })); + } + + // One SSH round trip streams the whole page back, length-framed. Small originals stream raw + // (instant, zero processing — works on any weak board). Larger originals are downscaled to a + // small JPEG by a remote tool when one is available (vipsthumbnail preferred, then ImageMagick), + // so the transfer shrinks by ~10-50x and big images load nearly as fast as small ones; boards + // with no image tool simply stream the original bytes. The client does no re-encoding. + await this.acquireImageReadSlot(); + try { + const command = buildPosixReadImagesBatchCommand(targets, IMAGE_BATCH_MAX_FILE_BYTES, maxWidth); + const output = await this.withDirectorySshClient(endpoint, client => this.execRemoteBytes(client, command, { + inactivityTimeoutMs: REMOTE_DIRECTORY_READ_INACTIVITY_TIMEOUT_MS, + inactivityMessage: 'Image batch read produced no output.', + timeoutMs: REMOTE_DIRECTORY_READ_MAX_TIMEOUT_MS + })); + return parseImageBatchOutput(output); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return targets.map(path => ({ path, ok: false, message, transient: true })); + } finally { + this.releaseImageReadSlot(); + } + } + + async readImageMetadataBatch(endpoint: ITurboFileTransferEndpoint, paths: readonly string[]): Promise { + const targets = [...new Set(paths.filter(path => !!path))]; + if (!targets.length) { + return []; + } + + if (endpoint.kind !== 'ssh' || !endpoint.connection) { + const entries = await mapLimit(targets, IMAGE_METADATA_LOCAL_CONCURRENCY, async path => { + try { + const stat = await fsp.stat(path); + const headerLength = Math.min(stat.size, IMAGE_METADATA_HEADER_BYTES); + const bytes = Buffer.alloc(headerLength); + let bytesRead = 0; + if (headerLength > 0) { + const handle = await fsp.open(path, 'r'); + try { + while (bytesRead < headerLength) { + const chunk = await handle.read(bytes, bytesRead, headerLength - bytesRead, bytesRead); + if (chunk.bytesRead <= 0) { + break; + } + bytesRead += chunk.bytesRead; + } + } finally { + await handle.close(); + } + } + const parsed = readTurboFileImageDimensions(path, bytes.subarray(0, bytesRead)); + return { path, size: stat.size, modified: stat.mtimeMs, ...(parsed ? { width: parsed.width, height: parsed.height } : {}) }; + } catch { + return undefined; + } + }); + return entries.filter((entry): entry is ITurboFileImageMetadataEntry => !!entry); + } + + const chunks = chunkImageMetadataPaths(targets); + return this.withDirectorySshClient(endpoint, async client => { + const chunkEntries = await mapLimit(chunks, IMAGE_METADATA_REMOTE_CONCURRENCY, async chunk => { + let lastError: unknown; + for (let attempt = 0; attempt < 2; attempt++) { + try { + if (looksLikeWindowsPath(chunk[0])) { + const output = await this.execRemoteBytes(client, buildWindowsReadImageMetadataBatchCommand(chunk, IMAGE_METADATA_HEADER_BYTES), { timeoutMs: REMOTE_DIRECTORY_READ_MAX_TIMEOUT_MS }); + return parseWindowsImageMetadataBatchOutput(output); + } + const output = await this.execRemoteBytes(client, buildPosixReadImageMetadataBatchCommand(chunk, IMAGE_METADATA_HEADER_BYTES), { + inactivityTimeoutMs: REMOTE_DIRECTORY_READ_INACTIVITY_TIMEOUT_MS, + inactivityMessage: 'Image metadata batch read produced no output.', + timeoutMs: REMOTE_DIRECTORY_READ_MAX_TIMEOUT_MS + }); + return parseTurboFileImageMetadataFrames(output); + } catch (error) { + lastError = error; + } + } + throw lastError instanceof Error ? lastError : new Error('Image metadata batch read failed.'); + }); + return chunkEntries.flat(); + }); + } + + async createImageResource(endpoint: ITurboFileTransferEndpoint, options: ITurboFileImageResourceOptions = {}): Promise { try { - const buffer = endpoint.kind === 'local' - ? await this.readLocalFileBuffer(endpoint.path, maxBytes) - : await this.readRemoteFileBuffer(endpoint, maxBytes); - const imageSize = mime.startsWith('image/') ? getImageSize(buffer) : undefined; + const now = Date.now(); + this.trimExpiredImageResources(now); + this.ensureImageResourceCleanupTimer(); + + const token = randomUUID(); + this.imageResourceDescriptors.set(token, { + endpoint, + options: { ...options }, + createdAt: now, + expiresAt: now + IMAGE_RESOURCE_TTL_MS + }); + return { ok: true, - message: 'File loaded.', - content: encoding === 'base64' ? buffer.toString('base64') : buffer.toString('utf8'), - encoding, - mime, - size: buffer.byteLength, - width: imageSize?.width, - height: imageSize?.height + message: 'Image resource created.', + url: `${TURBOFILE_IMAGE_RESOURCE_SCHEME}://${token}`, + mime: getMimeType(endpoint.path) }; } catch (error) { return { ok: false, message: error instanceof Error ? error.message : String(error), - content: '', - encoding, - mime, - size: 0 + mime: getMimeType(endpoint.path) }; } } - async readImage(endpoint: ITurboFileTransferEndpoint, options: ITurboFileReadImageOptions = {}): Promise { - const encoding = options.encoding ?? 'base64'; + async releaseImageResource(url: string): Promise { + const token = this.getImageResourceToken(url); + if (token) { + this.imageResourceDescriptors.delete(token); + } + } + + private ensureTurboFileImageProtocolRegistered(): void { + TurboFileSshMainService.imageProtocolService = this; + if (TurboFileSshMainService.imageProtocolRegistered) { + return; + } + + protocol.handle(TURBOFILE_IMAGE_RESOURCE_SCHEME, request => { + const service = TurboFileSshMainService.imageProtocolService; + if (!service) { + return new Response('TurboFile image service is unavailable.', { status: 503 }); + } + return service.handleTurboFileImageProtocolRequest(request); + }); + TurboFileSshMainService.imageProtocolRegistered = true; + } + + private async handleTurboFileImageProtocolRequest(request: GlobalRequest): Promise { try { - const result = await this.readImageBuffer(endpoint, options); - return { - ok: true, - message: 'Image loaded.', - content: encoding === 'base64' ? result.buffer.toString('base64') : result.buffer.toString('utf8'), - encoding, - mime: result.mime, - size: result.buffer.byteLength, - width: result.width, - height: result.height - }; + const token = this.getImageResourceToken(request.url); + if (!token) { + return new Response('Invalid TurboFile image resource.', { status: 400 }); + } + + const descriptor = this.imageResourceDescriptors.get(token); + if (!descriptor) { + return new Response('TurboFile image resource was not found.', { status: 404 }); + } + + const now = Date.now(); + if (descriptor.expiresAt <= now) { + this.imageResourceDescriptors.delete(token); + return new Response('TurboFile image resource expired.', { status: 410 }); + } + + descriptor.expiresAt = now + IMAGE_RESOURCE_TTL_MS; + const result = await this.readImageBuffer(descriptor.endpoint, descriptor.options); + const body = result.buffer.buffer.slice(result.buffer.byteOffset, result.buffer.byteOffset + result.buffer.byteLength) as ArrayBuffer; + return new Response(body, { + status: 200, + headers: { + 'Content-Type': result.mime || getMimeType(descriptor.endpoint.path), + 'Cache-Control': 'no-store' + } + }); } catch (error) { - return { - ok: false, - message: error instanceof Error ? error.message : String(error), - content: '', - encoding, - mime: getMimeType(endpoint.path), - size: 0 - }; + return new Response(error instanceof Error ? error.message : String(error), { status: 500 }); + } + } + + private getImageResourceToken(url: string): string | undefined { + try { + const parsed = new URL(url); + if (parsed.protocol !== `${TURBOFILE_IMAGE_RESOURCE_SCHEME}:`) { + return undefined; + } + const token = parsed.hostname || parsed.pathname.replace(/^\/+/, '').split('/')[0]; + return token ? decodeURIComponent(token) : undefined; + } catch { + return undefined; + } + } + + private ensureImageResourceCleanupTimer(): void { + if (this.imageResourceCleanupTimer) { + return; + } + this.imageResourceCleanupTimer = setInterval(() => this.trimExpiredImageResources(Date.now()), IMAGE_RESOURCE_CLEANUP_INTERVAL_MS); + (this.imageResourceCleanupTimer as unknown as { unref?: () => void }).unref?.(); + } + + private trimExpiredImageResources(now: number): void { + for (const [token, descriptor] of this.imageResourceDescriptors) { + if (descriptor.expiresAt <= now) { + this.imageResourceDescriptors.delete(token); + } + } + if (this.imageResourceDescriptors.size === 0 && this.imageResourceCleanupTimer) { + clearInterval(this.imageResourceCleanupTimer); + this.imageResourceCleanupTimer = undefined; } } @@ -3579,6 +5264,13 @@ export class TurboFileSshMainService implements ITurboFileSshService { } async writeFile(endpoint: ITurboFileTransferEndpoint, content: string, encoding: TurboFileContentEncoding = 'utf8'): Promise { + return this.fileOperationQueue.run( + getFileOperationDeviceKeys(endpoint), + () => this.writeFileCore(endpoint, content, encoding) + ); + } + + private async writeFileCore(endpoint: ITurboFileTransferEndpoint, content: string, encoding: TurboFileContentEncoding): Promise { const buffer = encoding === 'base64' ? Buffer.from(content, 'base64') : Buffer.from(content, 'utf8'); try { if (endpoint.kind === 'local') { @@ -3590,7 +5282,9 @@ export class TurboFileSshMainService implements ITurboFileSshService { try { await fsp.mkdir(tempRoot, { recursive: true }); await fsp.writeFile(tempPath, buffer); - await this.withSftp(endpoint, async sftp => { + // Reuse the open directory session rather than opening a fresh SSH connection per call, + // so creating/saving a file on a high-latency remote host is not gated on a handshake. + await this.withDirectorySftp(endpoint, async sftp => { await this.ensureRemoteDirectory(sftp, getRemoteParentPath(endpoint.path)); await this.fastPut(sftp, tempPath, endpoint.path, this.createSftpTransferOptions()); }); @@ -3606,10 +5300,17 @@ export class TurboFileSshMainService implements ITurboFileSshService { } async compressPath(endpoint: ITurboFileTransferEndpoint, targetPath?: string): Promise { + return this.fileOperationQueue.run( + getFileOperationDeviceKeys(endpoint), + () => this.compressPathCore(endpoint, targetPath) + ); + } + + private async compressPathCore(endpoint: ITurboFileTransferEndpoint, targetPath?: string): Promise { try { const archivePath = targetPath ?? getDefaultArchivePath(endpoint.path, endpoint.kind === 'local' ? 'zip' : 'tar.gz'); if (endpoint.kind === 'local') { - await runLocalCommand('tar.exe', ['-a', '-cf', archivePath, '-C', dirname(endpoint.path), basename(endpoint.path)]); + await runLocalCommand('tar.exe', [...await this.getLocalTarCharsetArgs(), '-a', '-cf', archivePath, '-C', dirname(endpoint.path), basename(endpoint.path)]); } else { await this.withSshClient(endpoint, client => this.execRemote(client, `mkdir -p ${quotePosixShellArg(getRemoteParentPath(archivePath))} && tar -czf ${quotePosixShellArg(archivePath)} -C ${quotePosixShellArg(getRemoteParentPath(endpoint.path))} ${quotePosixShellArg(getTransferName(endpoint.path))}`)); } @@ -3621,17 +5322,27 @@ export class TurboFileSshMainService implements ITurboFileSshService { } async extractPath(endpoint: ITurboFileTransferEndpoint, targetDirectory?: string): Promise { + return this.fileOperationQueue.run( + getFileOperationDeviceKeys(endpoint), + () => this.extractPathCore(endpoint, targetDirectory) + ); + } + + private async extractPathCore(endpoint: ITurboFileTransferEndpoint, targetDirectory?: string): Promise { try { let outputDirectory = targetDirectory ?? getDefaultExtractDirectory(endpoint.path); if (endpoint.kind === 'local') { outputDirectory = await getAvailableLocalDirectory(outputDirectory); await fsp.mkdir(outputDirectory, { recursive: true }); - await runLocalCommand('tar.exe', ['-xf', endpoint.path, '-C', outputDirectory]); + // bsdtar (Windows tar.exe / libarchive) extracts tar*, zip, 7z and rar. Force UTF-8 only + // for tar-family archives; zip/rar/7z carry their own encoding (zip UTF-8 flag, otherwise + // the creator's code page) and libarchive must auto-detect it to avoid garbling names. + const charsetArgs = isTurboFileTarFamilyArchivePath(endpoint.path) ? await this.getLocalTarCharsetArgs() : []; + await runLocalCommand('tar.exe', [...charsetArgs, '-xf', endpoint.path, '-C', outputDirectory]); } else { + const quotedArchive = quotePosixShellArg(endpoint.path); const outputSetup = `out=${quotePosixShellArg(outputDirectory)}; if [ -e "$out" ] && [ ! -d "$out" ]; then out="$out"_extracted; fi; mkdir -p "$out"`; - const command = isZipPath(endpoint.path) - ? `${outputSetup} && unzip -o ${quotePosixShellArg(endpoint.path)} -d "$out"` - : `${outputSetup} && tar -xf ${quotePosixShellArg(endpoint.path)} -C "$out"`; + const command = `${outputSetup} && ${buildTurboFileRemoteExtractCommand(endpoint.path, quotedArchive)}`; await this.withSshClient(endpoint, client => this.execRemote(client, command)); } this.clearRemoteDirectoryListingCache(endpoint, outputDirectory); @@ -3647,8 +5358,11 @@ export class TurboFileSshMainService implements ITurboFileSshService { throw new Error(configResult.message); } - const reusableDirectorySession = await this.getReusableDirectorySftpSession(options); - const client = reusableDirectorySession?.client ?? await this.connect(configResult.config); + // Use a dedicated connection per terminal instead of reusing the directory-browsing client. + // Sharing coupled the terminal's lifetime to the directory session's idle timer, so an + // unrelated idle eviction could close the client mid-task and kill the terminal. A dedicated + // connection lives until the user closes the terminal (or a real network failure). + const client = await this.connect(configResult.config); let stream: ISSHShellStream | undefined; try { stream = await this.openShell(client, cols, rows); @@ -3662,8 +5376,7 @@ export class TurboFileSshMainService implements ITurboFileSshService { bootstrapBuffer: '', bootstrapping: true, hasDataListener: false, - closed: false, - sharedDirectorySessionKey: reusableDirectorySession?.key + closed: false }; this.terminalSessions.set(sessionId, session); @@ -3712,11 +5425,7 @@ export class TurboFileSshMainService implements ITurboFileSshService { }; } catch (error) { stream?.end(); - if (!reusableDirectorySession) { - client.end(); - } else { - this.touchDirectorySftpSession(reusableDirectorySession.key); - } + client.end(); throw error; } } @@ -3751,11 +5460,9 @@ export class TurboFileSshMainService implements ITurboFileSshService { this.throwIfTransferCanceled(request.progressId); const sourceType = request.sourceType ?? await this.withDirectorySftp(request.source, async sftp => getFileTypeFromStats(await this.remoteStat(sftp, request.source.path))).catch(() => 'unknown' as TurboFileFileType); - const expectedBytes = Math.max(0, request.expectedBytes ?? 0); + const expectedBytes = sourceType === 'directory' ? 0 : Math.max(0, request.expectedBytes ?? 0); let bytes = expectedBytes; - if (sourceType === 'directory') { - bytes = bytes > 64 * 1024 ? bytes : await this.getRemotePathSizeByCommand(request.source).catch(() => expectedBytes); - } else if (bytes <= 0) { + if (sourceType === 'file' && bytes <= 0) { bytes = await this.getRemotePathSizeByCommand(request.source).catch(() => expectedBytes); } this.fireTransferProgress(request, 0, bytes, 'prepare', 'Preparing remote copy.', true); @@ -3767,9 +5474,6 @@ export class TurboFileSshMainService implements ITurboFileSshService { this.fireTransferProgress(request, 0, bytes, 'transfer', 'Remote copy in progress.', true); await this.execRemoteCopyWithProgress(request, command, bytes, sourceType); this.throwIfTransferCanceled(request.progressId); - if (bytes === 0 && expectedBytes === 0) { - bytes = await this.getRemotePathSizeByCommand(request.target).catch(() => 0); - } return this.createTransferResult(true, 'remote-copy', bytes, 'Remote copy completed.', startedAt, request.progressId); } @@ -3792,10 +5496,7 @@ export class TurboFileSshMainService implements ITurboFileSshService { return undefined; } - let bytes = Math.max(0, request.expectedBytes ?? 0); - if (bytes <= 0) { - bytes = await this.getRemotePathContentSizeByCommand(request.source).catch(() => 0); - } + const bytes = Math.max(0, request.expectedBytes ?? 0); const routes = await this.resolveDirectSshRoutes(request.source, request.target, request.progressId); if (!routes.length) { @@ -3836,7 +5537,6 @@ export class TurboFileSshMainService implements ITurboFileSshService { const client = await this.connectDirectSsh(executorConfigResult.config); const unregisterCancel = this.registerTransferCancelHandler(request.progressId, () => client.end()); let credential: IRemoteScpCredential | undefined; - let stopGrowthTiming: () => void = () => undefined; let lastActivityAt = Date.now(); const markActivity = (): void => { lastActivityAt = Date.now(); @@ -3851,25 +5551,27 @@ export class TurboFileSshMainService implements ITurboFileSshService { const command = route === 'source-push' ? buildTarSourcePushCommand(request.source.path, request.target.path, remoteUserHost, sshCommand, sshpassPrefix) : buildTarTargetPullCommand(request.source.path, request.target.path, remoteUserHost, sshCommand, sshpassPrefix); + const tarProgress = this.createDirectTarProgressReporter(request, expectedBytes, 'Direct tar transfer in progress.', { strategy: 'tar-direct', route }, markActivity); + const reportTarProgress = tarProgress.reportTarProgress; this.fireTransferProgress(request, 0, expectedBytes, 'prepare', 'Preparing direct tar transfer.', true, { strategy: 'tar-direct', route }); - stopGrowthTiming = this.beginRemotePathGrowthTiming(request.progressId, request.target, 0, expectedBytes, transferredBytes => { - markActivity(); - this.fireTransferProgress(request, transferredBytes, expectedBytes, 'transfer', 'Direct tar transfer in progress.', false, { strategy: 'tar-direct', route }); - }); this.markTransferDataStarted(request.progressId); await this.execRemote(client, command, { - inactivityTimeoutMs: DIRECT_TRANSFER_STALL_TIMEOUT_MS, - inactivityMessage: `Direct tar ${route} produced no target growth for ${DIRECT_TRANSFER_STALL_TIMEOUT_MS} ms.`, - getActivityTime: () => lastActivityAt + onStderr: reportTarProgress, + inactivityTimeoutMs: DIRECT_TAR_STALL_TIMEOUT_MS, + inactivityMessage: `Direct tar ${route} produced no stream progress for ${DIRECT_TAR_STALL_TIMEOUT_MS} ms.`, + getActivityTime: () => lastActivityAt, + progressId: request.progressId }); this.throwIfTransferCanceled(request.progressId); - const targetSize = await this.getRemotePathContentSizeByCommand(request.target).catch(() => expectedBytes); - if (expectedBytes > 0 && targetSize !== expectedBytes) { - throw new Error(`Direct tar size mismatch: expected ${expectedBytes}, got ${targetSize}.`); + if (expectedBytes > 0) { + const targetSize = await this.getRemotePathContentSizeByCommand(request.target).catch(() => expectedBytes); + if (targetSize !== expectedBytes) { + throw new Error(`Direct tar size mismatch: expected ${expectedBytes}, got ${targetSize}.`); + } } - const bytes = expectedBytes > 0 ? expectedBytes : targetSize; - this.fireTransferProgress(request, bytes, bytes, 'complete', 'Direct tar completed.', true, { strategy: 'tar-direct', route }); + const bytes = Math.max(expectedBytes, tarProgress.getTransferredBytes()); + this.fireTransferProgress(request, bytes, expectedBytes, 'complete', 'Direct tar completed.', true, { strategy: 'tar-direct', route }); return this.createTransferResult(true, 'tar-direct', bytes, 'Direct tar completed.', startedAt, request.progressId); } catch (error) { if (this.isTransferCanceled(request.progressId)) { @@ -3877,7 +5579,6 @@ export class TurboFileSshMainService implements ITurboFileSshService { } throw error; } finally { - stopGrowthTiming(); if (credential?.cleanupCommand) { await this.execRemote(client, credential.cleanupCommand).catch(() => undefined); } @@ -3975,9 +5676,10 @@ export class TurboFileSshMainService implements ITurboFileSshService { await this.execRemote(client, rsyncCommand, { onStdout: progress.handleOutput, onStderr: progress.handleOutput, - inactivityTimeoutMs: DIRECT_TRANSFER_STALL_TIMEOUT_MS, - inactivityMessage: `Direct rsync ${route} produced no transfer progress for ${DIRECT_TRANSFER_STALL_TIMEOUT_MS} ms.`, - getActivityTime: () => lastActivityAt + inactivityTimeoutMs: DIRECT_RSYNC_STALL_TIMEOUT_MS, + inactivityMessage: `Direct rsync ${route} produced no transfer progress for ${DIRECT_RSYNC_STALL_TIMEOUT_MS} ms.`, + getActivityTime: () => lastActivityAt, + progressId: request.progressId }); this.throwIfTransferCanceled(request.progressId); @@ -4106,8 +5808,11 @@ export class TurboFileSshMainService implements ITurboFileSshService { } const sourceIsDirectory = sourceType === 'directory'; - const bytes = Math.max(0, request.expectedBytes ?? 0) || await this.getRemotePathSizeByCommand(request.source).catch(() => 0); - if (bytes <= 0) { + let bytes = Math.max(0, request.expectedBytes ?? 0); + if (!sourceIsDirectory && bytes <= 0) { + bytes = await this.getRemotePathSizeByCommand(request.source).catch(() => 0); + } + if (!sourceIsDirectory && bytes <= 0) { return undefined; } @@ -4152,6 +5857,7 @@ export class TurboFileSshMainService implements ITurboFileSshService { let credential: IRemoteScpCredential | undefined; let stopGrowthTiming: () => void = () => undefined; let lastActivityAt = Date.now(); + let transferredBytes = 0; const markActivity = (): void => { lastActivityAt = Date.now(); }; @@ -4176,13 +5882,15 @@ export class TurboFileSshMainService implements ITurboFileSshService { this.throwIfTransferCanceled(request.progressId); markActivity(); stopGrowthTiming = sourceIsDirectory - ? this.beginRemotePathGrowthTiming(request.progressId, request.target, 0, bytes, transferredBytes => { + ? this.beginRemotePathGrowthTiming(request.progressId, request.target, 0, bytes, currentBytes => { + transferredBytes = Math.max(transferredBytes, currentBytes); markActivity(); - this.fireTransferProgress(request, transferredBytes, bytes, 'transfer', 'Direct SCP transfer in progress.', false, { strategy: 'scp-direct', route }); + this.fireTransferProgress(request, currentBytes, bytes, 'transfer', 'Direct SCP transfer in progress.', false, { strategy: 'scp-direct', route }); }) - : this.beginRemoteFileGrowthTiming(request.progressId, request.target.connection!, request.target.path, 0, bytes, transferredBytes => { + : this.beginRemoteFileGrowthTiming(request.progressId, request.target.connection!, request.target.path, 0, bytes, currentBytes => { + transferredBytes = Math.max(transferredBytes, currentBytes); markActivity(); - this.fireTransferProgress(request, transferredBytes, bytes, 'transfer', 'Direct SCP transfer in progress.', false, { strategy: 'scp-direct', route }); + this.fireTransferProgress(request, currentBytes, bytes, 'transfer', 'Direct SCP transfer in progress.', false, { strategy: 'scp-direct', route }); }); this.markTransferDataStarted(request.progressId); await this.execRemote(client, scpCommand, { @@ -4191,15 +5899,16 @@ export class TurboFileSshMainService implements ITurboFileSshService { getActivityTime: () => lastActivityAt }); this.throwIfTransferCanceled(request.progressId); - const targetSize = sourceIsDirectory - ? await this.getRemotePathSizeByCommand(request.target).catch(() => 0) - : await this.withDirectorySftp(request.target, async sftp => (await this.remoteStat(sftp, request.target.path)).size).catch(() => 0); - if (targetSize !== bytes) { - await this.withDirectorySftp(request.target, sftp => sourceIsDirectory ? this.rmdirRemote(sftp, request.target.path) : this.unlinkRemote(sftp, request.target.path)).catch(() => undefined); - throw new Error(`Direct SCP size mismatch: expected ${bytes}, got ${targetSize}.`); - } - this.fireTransferProgress(request, bytes, bytes, 'transfer', 'Direct SCP transfer completed.', true, { strategy: 'scp-direct', route }); - return this.createTransferResult(true, 'scp-direct', bytes, 'Direct SCP completed.', startedAt, request.progressId); + if (!sourceIsDirectory) { + const targetSize = await this.withDirectorySftp(request.target, async sftp => (await this.remoteStat(sftp, request.target.path)).size).catch(() => 0); + if (targetSize !== bytes) { + await this.withDirectorySftp(request.target, sftp => this.unlinkRemote(sftp, request.target.path)).catch(() => undefined); + throw new Error(`Direct SCP size mismatch: expected ${bytes}, got ${targetSize}.`); + } + } + const resultBytes = Math.max(bytes, transferredBytes); + this.fireTransferProgress(request, resultBytes, bytes, 'transfer', 'Direct SCP transfer completed.', true, { strategy: 'scp-direct', route }); + return this.createTransferResult(true, 'scp-direct', resultBytes, 'Direct SCP completed.', startedAt, request.progressId); } catch (error) { if (this.isTransferCanceled(request.progressId)) { throw new TurboFileTransferCanceledError(); @@ -4295,6 +6004,7 @@ export class TurboFileSshMainService implements ITurboFileSshService { const unregisterCancel = this.registerTransferCancelHandler(request.progressId, () => client.end()); let stopGrowthTiming: () => void = () => undefined; let lastActivityAt = Date.now(); + let transferredBytes = 0; const markActivity = (): void => { lastActivityAt = Date.now(); }; @@ -4306,7 +6016,7 @@ export class TurboFileSshMainService implements ITurboFileSshService { } const expectedBytes = Math.max(0, request.expectedBytes ?? 0); let bytes = expectedBytes; - if (bytes === 0) { + if (sourceType === 'file' && bytes === 0) { bytes = await this.getRemotePathSizeByCommand(request.source).catch(() => expectedBytes); } @@ -4329,13 +6039,15 @@ export class TurboFileSshMainService implements ITurboFileSshService { this.throwIfTransferCanceled(request.progressId); markActivity(); stopGrowthTiming = sourceType === 'directory' - ? this.beginRemotePathGrowthTiming(request.progressId, request.target, 0, bytes, transferredBytes => { + ? this.beginRemotePathGrowthTiming(request.progressId, request.target, 0, bytes, currentBytes => { + transferredBytes = Math.max(transferredBytes, currentBytes); markActivity(); - this.fireTransferProgress(request, transferredBytes, bytes, 'transfer', 'Direct SFTP transfer in progress.', false, { strategy: 'sftp-direct', route }); + this.fireTransferProgress(request, currentBytes, bytes, 'transfer', 'Direct SFTP transfer in progress.', false, { strategy: 'sftp-direct', route }); }) - : this.beginRemoteFileGrowthTiming(request.progressId, request.target.connection!, request.target.path, 0, bytes, transferredBytes => { + : this.beginRemoteFileGrowthTiming(request.progressId, request.target.connection!, request.target.path, 0, bytes, currentBytes => { + transferredBytes = Math.max(transferredBytes, currentBytes); markActivity(); - this.fireTransferProgress(request, transferredBytes, bytes, 'transfer', 'Direct SFTP transfer in progress.', false, { strategy: 'sftp-direct', route }); + this.fireTransferProgress(request, currentBytes, bytes, 'transfer', 'Direct SFTP transfer in progress.', false, { strategy: 'sftp-direct', route }); }); this.markTransferDataStarted(request.progressId); await this.execRemote(client, transferCommand, { @@ -4344,13 +6056,16 @@ export class TurboFileSshMainService implements ITurboFileSshService { getActivityTime: () => lastActivityAt }); this.throwIfTransferCanceled(request.progressId); - const targetSize = await this.getRemotePathSizeByCommand(request.target).catch(() => bytes); - if (bytes > 0 && targetSize !== bytes) { - throw new Error(`Direct SFTP size mismatch: expected ${bytes}, got ${targetSize}.`); + if (sourceType === 'file' && bytes > 0) { + const targetSize = await this.getRemotePathSizeByCommand(request.target).catch(() => bytes); + if (targetSize !== bytes) { + throw new Error(`Direct SFTP size mismatch: expected ${bytes}, got ${targetSize}.`); + } } - this.fireTransferProgress(request, bytes, bytes, 'transfer', 'Direct SFTP transfer completed.', true, { strategy: 'sftp-direct', route }); - return this.createTransferResult(true, 'sftp-direct', bytes, 'Direct SFTP completed.', startedAt, request.progressId); + const resultBytes = Math.max(bytes, transferredBytes); + this.fireTransferProgress(request, resultBytes, bytes, 'transfer', 'Direct SFTP transfer completed.', true, { strategy: 'sftp-direct', route }); + return this.createTransferResult(true, 'sftp-direct', resultBytes, 'Direct SFTP completed.', startedAt, request.progressId); } catch (error) { if (error instanceof TurboFileTransferCanceledError || this.isTransferCanceled(request.progressId)) { throw new TurboFileTransferCanceledError(); @@ -4442,9 +6157,10 @@ export class TurboFileSshMainService implements ITurboFileSshService { await this.execRemote(client, rsyncCommand, { onStdout: progress.handleOutput, onStderr: progress.handleOutput, - inactivityTimeoutMs: DIRECT_TRANSFER_STALL_TIMEOUT_MS, - inactivityMessage: `Rsync relay produced no transfer progress for ${DIRECT_TRANSFER_STALL_TIMEOUT_MS} ms.`, - getActivityTime: progress.getActivityTime + inactivityTimeoutMs: DIRECT_RSYNC_STALL_TIMEOUT_MS, + inactivityMessage: `Rsync relay produced no transfer progress for ${DIRECT_RSYNC_STALL_TIMEOUT_MS} ms.`, + getActivityTime: progress.getActivityTime, + progressId: request.progressId }); this.throwIfTransferCanceled(request.progressId); bytes = progress.getLastBytes() || Math.max(0, request.expectedBytes ?? 0); @@ -4500,10 +6216,7 @@ export class TurboFileSshMainService implements ITurboFileSshService { return undefined; } - let bytes = Math.max(0, request.expectedBytes ?? 0); - if (bytes <= 0) { - bytes = await this.getRemotePathContentSizeByCommand(request.source).catch(() => 0); - } + const bytes = Math.max(0, request.expectedBytes ?? 0); const sourceConfigResult = await this.createConnectConfig(request.source.connection); const targetConfigResult = await this.createConnectConfig(request.target.connection); @@ -4513,7 +6226,7 @@ export class TurboFileSshMainService implements ITurboFileSshService { let sourceClient: ISSHClient | undefined; let targetClient: ISSHClient | undefined; - let stopGrowthTiming: () => void = () => undefined; + let transferredBytes = 0; const unregisterCancel = this.registerTransferCancelHandler(request.progressId, () => { sourceClient?.end(); targetClient?.end(); @@ -4522,22 +6235,25 @@ export class TurboFileSshMainService implements ITurboFileSshService { sourceClient = await this.connect(sourceConfigResult.config); targetClient = await this.connect(targetConfigResult.config); this.fireTransferProgress(request, 0, bytes, 'prepare', 'Preparing tar relay.', true, { strategy: 'tar-relay', route: 'relay' }); - stopGrowthTiming = this.beginRemotePathGrowthTiming(request.progressId, request.target, 0, bytes, transferredBytes => { - this.fireTransferProgress(request, transferredBytes, bytes, 'relay-upload', 'Tar relay transfer in progress.', false, { strategy: 'tar-relay', route: 'relay' }); - }); + const onArchiveDelta = (delta: number): void => { + transferredBytes += Math.max(0, delta); + this.fireTransferProgress(request, bytes > 0 ? Math.min(transferredBytes, bytes) : transferredBytes, bytes, 'relay-upload', 'Tar relay transfer in progress.', false, { strategy: 'tar-relay', route: 'relay' }); + }; this.markTransferDataStarted(request.progressId); const sourceStream = await this.openRemoteCommandStream(sourceClient, buildTarCreateFromDirectoryCommand(request.source.path), 'tar relay create'); const targetStream = await this.openRemoteCommandStream(targetClient, buildTarExtractToExactDirectoryCommand(request.target.path), 'tar relay extract', true); - await pipeline(sourceStream.stream as unknown as NodeJS.ReadableStream, targetStream.stream as unknown as NodeJS.WritableStream); + await pipeline(sourceStream.stream as unknown as NodeJS.ReadableStream, createProgressPassThrough(onArchiveDelta), targetStream.stream as unknown as NodeJS.WritableStream); await sourceStream.close; await targetStream.close; this.throwIfTransferCanceled(request.progressId); - const targetSize = await this.getRemotePathContentSizeByCommand(request.target).catch(() => bytes); - if (bytes > 0 && targetSize !== bytes) { - throw new Error(`Tar relay size mismatch: expected ${bytes}, got ${targetSize}.`); + if (bytes > 0) { + const targetSize = await this.getRemotePathContentSizeByCommand(request.target).catch(() => bytes); + if (targetSize !== bytes) { + throw new Error(`Tar relay size mismatch: expected ${bytes}, got ${targetSize}.`); + } } - const resultBytes = bytes > 0 ? bytes : targetSize; - this.fireTransferProgress(request, resultBytes, resultBytes, 'complete', 'Tar relay completed.', true, { strategy: 'tar-relay', route: 'relay' }); + const resultBytes = Math.max(bytes, transferredBytes); + this.fireTransferProgress(request, resultBytes, bytes, 'complete', 'Tar relay completed.', true, { strategy: 'tar-relay', route: 'relay' }); this.clearRemoteDirectoryListingCache(request.target); return this.createTransferResult(true, 'tar-relay', resultBytes, 'Tar relay completed.', startedAt, request.progressId); } catch (error) { @@ -4546,7 +6262,6 @@ export class TurboFileSshMainService implements ITurboFileSshService { } throw new Error(`Tar relay failed: ${formatTransferError(error)}`); } finally { - stopGrowthTiming(); unregisterCancel(); sourceClient?.end(); targetClient?.end(); @@ -4636,8 +6351,12 @@ export class TurboFileSshMainService implements ITurboFileSshService { throw new Error(`Unsupported SCP relay source type: ${sourceType}.`); } - const bytes = Math.max(0, request.expectedBytes ?? 0) || await this.getRemotePathSizeByCommand(request.source).catch(() => 0); - if (bytes <= 0) { + const sourceIsDirectory = sourceType === 'directory'; + let bytes = Math.max(0, request.expectedBytes ?? 0); + if (!sourceIsDirectory && bytes <= 0) { + bytes = await this.getRemotePathSizeByCommand(request.source).catch(() => 0); + } + if (!sourceIsDirectory && bytes <= 0) { return undefined; } @@ -4654,10 +6373,11 @@ export class TurboFileSshMainService implements ITurboFileSshService { expectedBytes: bytes }, bytes, transferredBytes => { downloadedBytes = Math.max(downloadedBytes, transferredBytes); - this.fireTransferProgress(request, Math.min(downloadedBytes, relayTotalBytes), relayTotalBytes, 'relay-download', 'Downloading to relay cache over SCP.', false, { strategy: 'scp-relay' }); + const progressBytes = relayTotalBytes > 0 ? Math.min(downloadedBytes, relayTotalBytes) : downloadedBytes; + this.fireTransferProgress(request, progressBytes, relayTotalBytes, 'relay-download', 'Downloading to relay cache over SCP.', false, { strategy: 'scp-relay' }); }); this.throwIfTransferCanceled(request.progressId); - this.fireTransferProgress(request, bytes, relayTotalBytes, 'relay-download', 'SCP relay download completed.', true, { strategy: 'scp-relay' }); + this.fireTransferProgress(request, bytes > 0 ? bytes : downloadedBytes, relayTotalBytes, 'relay-download', 'SCP relay download completed.', true, { strategy: 'scp-relay' }); let uploadedBytes = 0; await this.runLocalScpUpload({ @@ -4666,15 +6386,19 @@ export class TurboFileSshMainService implements ITurboFileSshService { expectedBytes: bytes }, bytes, transferredBytes => { uploadedBytes = Math.max(uploadedBytes, transferredBytes); - this.fireTransferProgress(request, Math.min(bytes + uploadedBytes, relayTotalBytes), relayTotalBytes, 'relay-upload', 'Uploading from relay cache over SCP.', false, { strategy: 'scp-relay' }); + const progressBytes = relayTotalBytes > 0 ? Math.min(bytes + uploadedBytes, relayTotalBytes) : downloadedBytes + uploadedBytes; + this.fireTransferProgress(request, progressBytes, relayTotalBytes, 'relay-upload', 'Uploading from relay cache over SCP.', false, { strategy: 'scp-relay' }); }); this.throwIfTransferCanceled(request.progressId); - const targetSize = await this.getRemotePathSizeByCommand(request.target).catch(() => 0); - if (targetSize !== bytes) { - throw new Error(`SCP relay size mismatch: expected ${bytes}, got ${targetSize}.`); + if (!sourceIsDirectory) { + const targetSize = await this.getRemotePathSizeByCommand(request.target).catch(() => 0); + if (targetSize !== bytes) { + throw new Error(`SCP relay size mismatch: expected ${bytes}, got ${targetSize}.`); + } } - this.fireTransferProgress(request, relayTotalBytes, relayTotalBytes, 'relay-upload', 'SCP relay completed.', true, { strategy: 'scp-relay' }); - return this.createTransferResult(true, 'scp-relay', bytes, 'SCP relay completed.', startedAt, request.progressId); + const resultBytes = bytes > 0 ? bytes : Math.max(downloadedBytes, uploadedBytes); + this.fireTransferProgress(request, relayTotalBytes > 0 ? relayTotalBytes : downloadedBytes + uploadedBytes, relayTotalBytes, 'relay-upload', 'SCP relay completed.', true, { strategy: 'scp-relay' }); + return this.createTransferResult(true, 'scp-relay', resultBytes, 'SCP relay completed.', startedAt, request.progressId); } finally { await fsp.rm(relayRoot, { recursive: true, force: true }).catch(() => undefined); } @@ -4693,14 +6417,16 @@ export class TurboFileSshMainService implements ITurboFileSshService { try { this.throwIfTransferCanceled(request.progressId); await fsp.mkdir(relayRoot, { recursive: true }); - let bytes = 0; const sourceType = request.sourceType ?? await this.withSftp(request.source, async sftp => getFileTypeFromStats(await this.remoteStat(sftp, request.source.path)), request.progressId).catch(() => 'unknown' as TurboFileFileType); - bytes = await this.getRemotePathSizeByCommand(request.source).catch(() => 0); + let bytes = sourceType === 'directory' ? 0 : Math.max(0, request.expectedBytes ?? 0); + if (sourceType === 'file' && bytes <= 0) { + bytes = await this.getRemotePathSizeByCommand(request.source).catch(() => 0); + } + let downloadedBytes = 0; await this.withSftp(request.source, async sftp => { this.throwIfTransferCanceled(request.progressId); const relayTotalBytes = bytes * 2; this.fireTransferProgress(request, 0, relayTotalBytes, 'prepare', 'Preparing SFTP relay.', true, { strategy: 'sftp-relay' }); - let downloadedBytes = 0; const onDownloadDelta = (delta: number): void => { this.throwIfTransferCanceled(request.progressId); downloadedBytes += delta; @@ -4712,15 +6438,16 @@ export class TurboFileSshMainService implements ITurboFileSshService { await this.fastGetWithFallback(sftp, request.source.path, relayPath, bytes, onDownloadDelta, request.progressId); } }, request.progressId); + let uploadedBytes = 0; await this.withSftp(request.target, async sftp => { this.throwIfTransferCanceled(request.progressId); const relayStat = await fsp.stat(relayPath); const relayTotalBytes = bytes * 2; - let uploadedBytes = 0; const onUploadDelta = (delta: number): void => { this.throwIfTransferCanceled(request.progressId); uploadedBytes += delta; - this.fireTransferProgress(request, bytes + uploadedBytes, relayTotalBytes, 'relay-upload', 'Uploading from relay cache.', false, { strategy: 'sftp-relay' }); + const progressBytes = relayTotalBytes > 0 ? bytes + uploadedBytes : downloadedBytes + uploadedBytes; + this.fireTransferProgress(request, progressBytes, relayTotalBytes, 'relay-upload', 'Uploading from relay cache.', false, { strategy: 'sftp-relay' }); }; if (relayStat.isDirectory()) { await this.uploadDirectory(sftp, relayPath, request.target.path, onUploadDelta, request.progressId); @@ -4730,9 +6457,11 @@ export class TurboFileSshMainService implements ITurboFileSshService { } }, request.progressId); this.throwIfTransferCanceled(request.progressId); - this.fireTransferProgress(request, bytes * 2, bytes * 2, 'complete', 'SFTP relay completed.', true, { strategy: 'sftp-relay' }); + const relayTotalBytes = bytes * 2; + const resultBytes = bytes > 0 ? bytes : Math.max(downloadedBytes, uploadedBytes); + this.fireTransferProgress(request, relayTotalBytes > 0 ? relayTotalBytes : downloadedBytes + uploadedBytes, relayTotalBytes, 'complete', 'SFTP relay completed.', true, { strategy: 'sftp-relay' }); this.clearRemoteDirectoryListingCache(request.target); - return this.createTransferResult(true, 'sftp-relay', bytes, 'SFTP relay completed.', startedAt, request.progressId); + return this.createTransferResult(true, 'sftp-relay', resultBytes, 'SFTP relay completed.', startedAt, request.progressId); } finally { await fsp.rm(relayRoot, { recursive: true, force: true }).catch(() => undefined); } @@ -4750,7 +6479,12 @@ export class TurboFileSshMainService implements ITurboFileSshService { port: options.port || 22, username: options.username, readyTimeout: options.timeout ?? 10000, - tryKeyboard: false + tryKeyboard: false, + // Keep idle connections alive so long-running terminal tasks (and slow transfers) are not + // dropped by NAT/firewall/server idle timeouts. ~15s pings, tolerate ~3min of unanswered + // pings before treating the link as dead. + keepaliveInterval: 15000, + keepaliveCountMax: 12 }; if (options.password !== undefined) { @@ -4837,51 +6571,84 @@ export class TurboFileSshMainService implements ITurboFileSshService { private async getDirectorySftpSession(options: ITurboFileSshConnectionOptions, config: ConnectConfig): Promise { const key = this.getDirectorySftpSessionKey(options); - const existing = this.directorySftpSessions.get(key); - if (existing) { - const session = await existing; - if (!session.closed) { - this.touchDirectorySftpSession(key); - return session; + while (true) { + const existing = this.directorySftpSessions.get(key); + if (existing) { + let session: IRemoteDirectorySftpSession; + try { + session = await existing; + } catch (error) { + if (this.directorySftpSessions.get(key) !== existing) { + continue; + } + throw error; + } + if (this.directorySftpSessions.get(key) !== existing || !this.directorySftpSessionGenerations.isCurrent(key, session.generation)) { + continue; + } + if (!session.closed) { + this.touchDirectorySftpSession(key, session.generation); + return session; + } + if (this.directorySftpSessionGenerations.clearIfCurrent(key, session.generation)) { + this.directorySftpSessions.delete(key); + this.clearDirectorySftpIdleTimer(key); + } + continue; } - this.directorySftpSessions.delete(key); - } - const sessionPromise = this.createDirectorySftpSession(key, config).catch(error => { - this.directorySftpSessions.delete(key); - this.clearDirectorySftpIdleTimer(key); - throw error; - }); - this.directorySftpSessions.set(key, sessionPromise); - const session = await sessionPromise; - this.touchDirectorySftpSession(key); - return session; + const generation = this.directorySftpSessionGenerations.next(key); + let sessionPromise: Promise; + sessionPromise = this.createDirectorySftpSession(key, generation, config).catch(error => { + if (this.directorySftpSessions.get(key) === sessionPromise && this.directorySftpSessionGenerations.clearIfCurrent(key, generation)) { + this.directorySftpSessions.delete(key); + this.clearDirectorySftpIdleTimer(key); + } + throw error; + }); + this.directorySftpSessions.set(key, sessionPromise); + const session = await sessionPromise; + if (session.closed || this.directorySftpSessions.get(key) !== sessionPromise || !this.directorySftpSessionGenerations.isCurrent(key, generation)) { + continue; + } + this.touchDirectorySftpSession(key, session.generation); + return session; + } } private async getReusableDirectorySftpSession(options: ITurboFileSshConnectionOptions): Promise { const key = this.getDirectorySftpSessionKey(options); - const existing = this.directorySftpSessions.get(key); - if (!existing) { - return undefined; - } - - try { - const session = await existing; - if (session.closed) { - this.directorySftpSessions.delete(key); + while (true) { + const existing = this.directorySftpSessions.get(key); + if (!existing) { return undefined; } - // Keep the shared SSH client alive while the interactive terminal owns a shell channel. - this.clearDirectorySftpIdleTimer(key); - return session; - } catch { - this.directorySftpSessions.delete(key); - return undefined; + try { + const session = await existing; + if (this.directorySftpSessions.get(key) !== existing || !this.directorySftpSessionGenerations.isCurrent(key, session.generation)) { + continue; + } + if (session.closed) { + this.directorySftpSessionGenerations.clearIfCurrent(key, session.generation); + this.directorySftpSessions.delete(key); + this.clearDirectorySftpIdleTimer(key); + return undefined; + } + + // Keep the shared SSH client alive while the interactive terminal owns a shell channel. + this.clearDirectorySftpIdleTimer(key); + return session; + } catch { + if (this.directorySftpSessions.get(key) !== existing) { + continue; + } + return undefined; + } } } - private async createDirectorySftpSession(key: string, config: ConnectConfig): Promise { + private async createDirectorySftpSession(key: string, generation: number, config: ConnectConfig): Promise { const client = await this.connect(config); let sftp: ISFTPWrapper | undefined; try { @@ -4891,27 +6658,35 @@ export class TurboFileSshMainService implements ITurboFileSshService { throw error; } - const session: IRemoteDirectorySftpSession = { key, client, sftp, closed: false }; + const session: IRemoteDirectorySftpSession = { key, generation, client, sftp, closed: false }; const markClosed = (): void => { if (session.closed) { return; } session.closed = true; - this.directorySftpSessions.delete(key); - this.clearDirectorySftpIdleTimer(key); + if (this.directorySftpSessionGenerations.clearIfCurrent(key, generation)) { + this.directorySftpSessions.delete(key); + this.clearDirectorySftpIdleTimer(key); + } }; client.on('close', markClosed); client.on('error', markClosed); return session; } - private touchDirectorySftpSession(key: string): void { + private touchDirectorySftpSession(key: string, generation: number): void { + if (!this.directorySftpSessionGenerations.isCurrent(key, generation)) { + return; + } this.clearDirectorySftpIdleTimer(key); - const timer = setTimeout(() => this.disposeDirectorySftpSession(key), REMOTE_DIRECTORY_SFTP_IDLE_MS); + const timer = setTimeout(() => this.disposeDirectorySftpSession(key, generation), REMOTE_DIRECTORY_SFTP_IDLE_MS); this.directorySftpIdleTimers.set(key, timer); } - private disposeDirectorySftpSession(key: string): void { + private disposeDirectorySftpSession(key: string, generation: number): void { + if (!this.directorySftpSessionGenerations.clearIfCurrent(key, generation)) { + return; + } const sessionPromise = this.directorySftpSessions.get(key); this.directorySftpSessions.delete(key); this.clearDirectorySftpIdleTimer(key); @@ -4995,7 +6770,9 @@ export class TurboFileSshMainService implements ITurboFileSshService { paths.add(path); paths.add(getRemoteParentPath(path)); for (const candidate of paths) { - this.remoteDirectoryListingCache.delete(this.getRemoteDirectoryListingCacheKey(endpoint.connection, candidate)); + const cacheKey = this.getRemoteDirectoryListingCacheKey(endpoint.connection, candidate); + this.remoteDirectoryListingGenerations.next(cacheKey); + this.remoteDirectoryListingCache.delete(cacheKey); } } @@ -5156,14 +6933,10 @@ export class TurboFileSshMainService implements ITurboFileSshService { } catch { // Ignore cleanup errors from already closed SSH channels. } - if (session.sharedDirectorySessionKey) { - this.touchDirectorySftpSession(session.sharedDirectorySessionKey); - } else { - try { - session.client.end(); - } catch { - // Ignore cleanup errors from already closed SSH clients. - } + try { + session.client.end(); + } catch { + // Ignore cleanup errors from already closed SSH clients. } session.exitEmitter.fire(exitCode); session.dataEmitter.dispose(); @@ -5213,10 +6986,10 @@ export class TurboFileSshMainService implements ITurboFileSshService { const session = await this.getDirectorySftpSession(endpoint.connection, configResult.config); try { const result = await task(session.sftp); - this.touchDirectorySftpSession(session.key); + this.touchDirectorySftpSession(session.key, session.generation); return result; } catch (error) { - this.disposeDirectorySftpSession(session.key); + this.disposeDirectorySftpSession(session.key, session.generation); throw error; } } @@ -5234,10 +7007,10 @@ export class TurboFileSshMainService implements ITurboFileSshService { const session = await this.getDirectorySftpSession(endpoint.connection, configResult.config); try { const result = await task(session.client); - this.touchDirectorySftpSession(session.key); + this.touchDirectorySftpSession(session.key, session.generation); return result; } catch (error) { - this.disposeDirectorySftpSession(session.key); + this.disposeDirectorySftpSession(session.key, session.generation); throw error; } } @@ -5283,7 +7056,6 @@ export class TurboFileSshMainService implements ITurboFileSshService { private async copyLocalDirectoryNativeWithProgress(sourcePath: string, targetPath: string, onDelta?: (delta: number) => void, progressId?: string): Promise { await ensureLocalDirectory(targetPath); - let previousBytes = await getLocalPathSize(targetPath).catch(() => 0); let completed = false; const copy = runLocalRobocopyCommand([ sourcePath, @@ -5302,6 +7074,7 @@ export class TurboFileSshMainService implements ITurboFileSshService { ]).finally(() => { completed = true; }); + let previousBytes = await getLocalPathSize(targetPath).catch(() => 0); try { while (!completed) { @@ -5449,6 +7222,31 @@ export class TurboFileSshMainService implements ITurboFileSshService { }; } + private createDirectTarProgressReporter( + request: { readonly progressId?: string; readonly clientStartedAt?: number }, + expectedBytes: number, + detail: string, + extra: Partial>, + onActivity?: () => void + ): { readonly reportTarProgress: (chunk: string) => void; readonly getTransferredBytes: () => number } { + let stderrBuffer = ''; + let transferredBytes = 0; + return { + reportTarProgress: chunk => { + stderrBuffer = `${stderrBuffer}${chunk}`.slice(-8192); + const parsedBytes = parseDirectTarProgressBytes(stderrBuffer); + if (parsedBytes === undefined || parsedBytes <= transferredBytes) { + return; + } + transferredBytes = parsedBytes; + onActivity?.(); + const visibleBytes = expectedBytes > 0 ? Math.min(transferredBytes, expectedBytes) : transferredBytes; + this.fireTransferProgress(request, visibleBytes, expectedBytes, 'transfer', detail, false, extra); + }, + getTransferredBytes: () => transferredBytes + }; + } + private markTransferDataStarted(progressId: string | undefined): void { if (!progressId || this.transferDataStartTimes.has(progressId)) { return; @@ -5510,9 +7308,13 @@ export class TurboFileSshMainService implements ITurboFileSshService { .catch(() => undefined); } - private createRsyncProgressReporter(request: { readonly progressId?: string; readonly clientStartedAt?: number }, totalBytes: number, phase: ITurboFileTransferProgressEvent['phase'], detail: string, extra: ITransferProgressEventExtra = {}, onAcceptedProgress?: () => void): IRsyncProgressReporter { - void totalBytes; - let lastBytes = 0; + private createRsyncProgressReporter(request: { readonly progressId?: string; readonly clientStartedAt?: number }, totalBytes: number, phase: ITurboFileTransferProgressEvent['phase'], detail: string, extra: ITransferProgressEventExtra = {}, onAcceptedProgress?: () => void, transferredByteOffset = 0): IRsyncProgressReporter { + // `knownTotalBytes` is the denominator the UI shows. rsync's --info=progress2 only emits a + // transferred count once data starts flowing, which on a large tree can be 10s+ after the + // process starts; publishing a concurrently-computed total here keeps the display showing the + // size (and a real percentage) from the first second instead of a blank "preparing" state. + let knownTotalBytes = Math.max(0, totalBytes); + let lastBytes = Math.max(0, transferredByteOffset); let rsyncProgressBuffer = ''; let lastActivityAt: number | undefined; let accepted = false; @@ -5525,7 +7327,7 @@ export class TurboFileSshMainService implements ITurboFileSshService { accepted = true; onAcceptedProgress?.(); } - const normalizedTransferred = Math.max(0, transferredBytes); + const normalizedTransferred = Math.max(0, transferredByteOffset + transferredBytes); const hasMetricChange = (toCheck !== undefined && toCheck !== lastToCheck) || (totalChecks !== undefined && totalChecks !== lastTotalChecks) @@ -5548,7 +7350,10 @@ export class TurboFileSshMainService implements ITurboFileSshService { lastSpeedBytesPerSecond = speedBytesPerSecond; } lastActivityAt = Date.now(); - this.fireTransferProgress(request, normalizedTransferred, 0, phase, detail, force, { + // Never let an estimated total round down below what rsync has already moved, otherwise the + // progress bar would jump backwards when the transferred count overtakes the estimate. + const emittedTotal = knownTotalBytes > 0 ? Math.max(knownTotalBytes, normalizedTransferred) : 0; + this.fireTransferProgress(request, normalizedTransferred, emittedTotal, phase, detail, force, { ...extra, ...(toCheck !== undefined ? { toCheck } : {}), ...(totalChecks !== undefined ? { totalChecks } : {}), @@ -5567,18 +7372,29 @@ export class TurboFileSshMainService implements ITurboFileSshService { } return; } - reportProgress(snapshot.bytes ?? lastBytes, false, snapshot.toCheck, snapshot.totalChecks, snapshot.transferIndex, snapshot.percent, snapshot.speedBytesPerSecond); + reportProgress(snapshot.bytes ?? Math.max(0, lastBytes - transferredByteOffset), false, snapshot.toCheck, snapshot.totalChecks, snapshot.transferIndex, snapshot.percent, snapshot.speedBytesPerSecond); }, reportProgress, getLastBytes: () => lastBytes, - getActivityTime: () => lastActivityAt + getActivityTime: () => lastActivityAt, + setTotalBytes: total => { + const normalizedTotal = Math.max(0, total); + if (normalizedTotal <= knownTotalBytes) { + return; + } + knownTotalBytes = normalizedTotal; + // Force one event so the size appears immediately, even before rsync moves a byte. + reportProgress(lastBytes, true); + } }; } private createParallelRsyncProgressReporter(request: { readonly progressId?: string; readonly clientStartedAt?: number }, totalBytes: number, phase: ITurboFileTransferProgressEvent['phase'], detail: string, extra: ITransferProgressEventExtra = {}, onAcceptedProgress?: () => void): IRsyncProgressReporter { - void totalBytes; + let knownTotalBytes = Math.max(0, totalBytes); const transferredByIndex = new Map(); const speedByIndex = new Map(); + let aggregateTransferredBytes = 0; + let aggregateSpeedBytesPerSecond = 0; let outputBuffer = ''; let lastBytes = 0; let lastSpeedBytesPerSecond = 0; @@ -5589,8 +7405,8 @@ export class TurboFileSshMainService implements ITurboFileSshService { accepted = true; onAcceptedProgress?.(); } - const transferredBytes = Array.from(transferredByIndex.values()).reduce((sum, value) => sum + value, 0); - const speedBytesPerSecond = Array.from(speedByIndex.values()).reduce((sum, value) => sum + value, 0); + const transferredBytes = aggregateTransferredBytes; + const speedBytesPerSecond = aggregateSpeedBytesPerSecond; const previousSpeedBytesPerSecond = lastSpeedBytesPerSecond; if (!force && transferredBytes <= lastBytes && speedBytesPerSecond === lastSpeedBytesPerSecond) { return; @@ -5598,7 +7414,8 @@ export class TurboFileSshMainService implements ITurboFileSshService { lastBytes = transferredBytes; lastSpeedBytesPerSecond = speedBytesPerSecond; lastActivityAt = Date.now(); - this.fireTransferProgress(request, transferredBytes, 0, phase, detail, force, { + const emittedTotal = knownTotalBytes > 0 ? Math.max(knownTotalBytes, transferredBytes) : 0; + this.fireTransferProgress(request, transferredBytes, emittedTotal, phase, detail, force, { ...extra, ...(speedBytesPerSecond > 0 || previousSpeedBytesPerSecond > 0 ? { speedBytesPerSecond } : {}) }); @@ -5620,13 +7437,21 @@ export class TurboFileSshMainService implements ITurboFileSshService { return; } if (snapshot.bytes !== undefined) { - transferredByIndex.set(index, Math.max(transferredByIndex.get(index) ?? 0, snapshot.bytes)); + const previousBytes = transferredByIndex.get(index) ?? 0; + const nextBytes = Math.max(previousBytes, snapshot.bytes); + if (nextBytes > previousBytes) { + transferredByIndex.set(index, nextBytes); + aggregateTransferredBytes += nextBytes - previousBytes; + } } if (snapshot.speedBytesPerSecond !== undefined) { + const previousSpeed = speedByIndex.get(index) ?? 0; if (snapshot.percent !== undefined && snapshot.percent >= 100) { speedByIndex.delete(index); + aggregateSpeedBytesPerSecond -= previousSpeed; } else { speedByIndex.set(index, snapshot.speedBytesPerSecond); + aggregateSpeedBytesPerSecond += snapshot.speedBytesPerSecond - previousSpeed; } } report(false); @@ -5641,11 +7466,24 @@ export class TurboFileSshMainService implements ITurboFileSshService { } }, reportProgress: transferredBytes => { - transferredByIndex.set(0, Math.max(transferredByIndex.get(0) ?? 0, transferredBytes)); + const previousBytes = transferredByIndex.get(0) ?? 0; + const nextBytes = Math.max(previousBytes, transferredBytes); + if (nextBytes > previousBytes) { + transferredByIndex.set(0, nextBytes); + aggregateTransferredBytes += nextBytes - previousBytes; + } report(true); }, getLastBytes: () => lastBytes, - getActivityTime: () => lastActivityAt + getActivityTime: () => lastActivityAt, + setTotalBytes: total => { + const normalizedTotal = Math.max(0, total); + if (normalizedTotal <= knownTotalBytes) { + return; + } + knownTotalBytes = normalizedTotal; + report(true); + } }; } @@ -5705,17 +7543,17 @@ export class TurboFileSshMainService implements ITurboFileSshService { private beginRemoteFileGrowthTiming(progressId: string | undefined, connection: ITurboFileSshConnectionOptions, path: string, baselineSize = 0, expectedBytes = 0, onProgress?: (transferredBytes: number) => void, pollIntervalMs = TRANSFER_PROGRESS_EVENT_INTERVAL_MS, initialDelayMs = 0): () => void { let monitorClient: ISSHClient | undefined; let monitorSftp: ISFTPWrapper | undefined; - let reusableDirectorySessionKey: string | undefined; + let reusableDirectorySession: IRemoteDirectorySftpSession | undefined; let stopped = false; const stopGrowthTiming = this.beginTransferGrowthTiming(progressId, async () => { if (stopped) { return 0; } if (!monitorSftp) { - const reusableDirectorySession = await this.getReusableDirectorySftpSession(connection); - if (reusableDirectorySession && !reusableDirectorySession.closed) { - reusableDirectorySessionKey = reusableDirectorySession.key; - monitorSftp = reusableDirectorySession.sftp; + const candidateDirectorySession = await this.getReusableDirectorySftpSession(connection); + if (candidateDirectorySession && !candidateDirectorySession.closed) { + reusableDirectorySession = candidateDirectorySession; + monitorSftp = candidateDirectorySession.sftp; } else { const configResult = await this.createConnectConfig(connection); if (!configResult.ok) { @@ -5731,15 +7569,15 @@ export class TurboFileSshMainService implements ITurboFileSshService { return () => { stopped = true; stopGrowthTiming(); - if (reusableDirectorySessionKey) { - this.touchDirectorySftpSession(reusableDirectorySessionKey); + if (reusableDirectorySession) { + this.touchDirectorySftpSession(reusableDirectorySession.key, reusableDirectorySession.generation); } else { monitorSftp?.end(); monitorClient?.end(); } monitorSftp = undefined; monitorClient = undefined; - reusableDirectorySessionKey = undefined; + reusableDirectorySession = undefined; }; } @@ -5780,7 +7618,7 @@ export class TurboFileSshMainService implements ITurboFileSshService { }; } - private runCancelableLocalCommand(command: string, args: string[], options: { readonly env?: NodeJS.ProcessEnv; readonly onStdout?: (chunk: string) => void; readonly onStderr?: (chunk: string) => void; readonly inactivityTimeoutMs?: number; readonly inactivityMessage?: string } = {}, progressId?: string): Promise { + private runCancelableLocalCommand(command: string, args: string[], options: { readonly env?: NodeJS.ProcessEnv; readonly input?: Uint8Array; readonly onStdout?: (chunk: string) => void; readonly onStderr?: (chunk: string) => void; readonly inactivityTimeoutMs?: number; readonly inactivityMessage?: string } = {}, progressId?: string): Promise { this.throwIfTransferCanceled(progressId); return new Promise((resolve, reject) => { let settled = false; @@ -5799,7 +7637,7 @@ export class TurboFileSshMainService implements ITurboFileSshService { unregisterCancel(); callback(); }; - const child = spawn(command, args, { windowsHide: true, env: options.env, stdio: ['ignore', 'pipe', 'pipe'] }); + const child = spawn(command, args, { windowsHide: true, env: options.env, stdio: [options.input ? 'pipe' : 'ignore', 'pipe', 'pipe'] }); child.stdout?.on('data', data => { lastActivityAt = Date.now(); options.onStdout?.(data.toString()); @@ -5810,6 +7648,19 @@ export class TurboFileSshMainService implements ITurboFileSshService { stderrTail = `${stderrTail}${chunk}`.slice(-64 * 1024); options.onStderr?.(chunk); }); + child.stdin?.on('error', error => { + if (settled) { + return; + } + killLocalProcessTree(child); + finish(() => { + if (this.isTransferCanceled(progressId)) { + reject(new TurboFileTransferCanceledError()); + } else { + reject(new Error(formatLocalCommandError(stderrTail, error.message))); + } + }); + }); if (options.inactivityTimeoutMs && options.inactivityTimeoutMs > 0) { inactivityTimer = setInterval(() => { if (settled || Date.now() - lastActivityAt <= options.inactivityTimeoutMs!) { @@ -5856,6 +7707,9 @@ export class TurboFileSshMainService implements ITurboFileSshService { killLocalProcessTree(child); } }); + if (options.input) { + child.stdin?.end(Buffer.from(options.input)); + } }); } @@ -6086,7 +7940,43 @@ export class TurboFileSshMainService implements ITurboFileSshService { }); } + /** + * Acquires a slot from the SSH image-read concurrency limiter, awaiting a free slot when the cap + * is reached. Hand-off on release keeps the active count exact. Prevents the grid's burst of + * parallel fetches from exhausting the SSH connection's channels ("Unable to exec"). + */ + private async acquireImageReadSlot(): Promise { + if (this.imageReadActiveCount < SSH_IMAGE_READ_MAX_CONCURRENCY) { + this.imageReadActiveCount++; + return; + } + await new Promise(resolve => this.imageReadWaiters.push(resolve)); + } + + private releaseImageReadSlot(): void { + const next = this.imageReadWaiters.shift(); + if (next) { + // Hand the slot directly to the next waiter; active count stays the same. + next(); + return; + } + this.imageReadActiveCount = Math.max(0, this.imageReadActiveCount - 1); + } + private async readImageBuffer(endpoint: ITurboFileTransferEndpoint, options: ITurboFileReadImageOptions): Promise { + // Only SSH reads contend for connection channels; local reads run unthrottled. + if (endpoint.kind !== 'ssh') { + return this.readImageBufferInner(endpoint, options); + } + await this.acquireImageReadSlot(); + try { + return await this.readImageBufferInner(endpoint, options); + } finally { + this.releaseImageReadSlot(); + } + } + + private async readImageBufferInner(endpoint: ITurboFileTransferEndpoint, options: ITurboFileReadImageOptions): Promise { const targetWidth = clampPositiveInt(options.width); const targetHeight = clampPositiveInt(options.height); const quality = clampQuality(options.quality); @@ -6133,7 +8023,9 @@ export class TurboFileSshMainService implements ITurboFileSshService { buffer, mime: getMimeType(endpoint.path), width: size?.width, - height: size?.height + height: size?.height, + originalWidth: size?.width, + originalHeight: size?.height }; } @@ -6164,7 +8056,9 @@ export class TurboFileSshMainService implements ITurboFileSshService { buffer: sourceBuffer, mime: sourceMime, width: sourceSize?.width, - height: sourceSize?.height + height: sourceSize?.height, + originalWidth: sourceSize?.width, + originalHeight: sourceSize?.height }; } if (sourceSize && sourceSize.width > 0 && sourceSize.height > 0 && sourceSize.width < SMALL_IMAGE_DIM_LIMIT && sourceSize.height < SMALL_IMAGE_DIM_LIMIT) { @@ -6172,18 +8066,23 @@ export class TurboFileSshMainService implements ITurboFileSshService { buffer: sourceBuffer, mime: sourceMime, width: sourceSize.width, - height: sourceSize.height + height: sourceSize.height, + originalWidth: sourceSize.width, + originalHeight: sourceSize.height }; } try { - return this.transformImageBuffer(sourceBuffer, endpoint.path, targetWidth, targetHeight, quality, format); + const transformed = this.transformImageBuffer(sourceBuffer, endpoint.path, targetWidth, targetHeight, quality, format); + return transformed; } catch (error) { if (sourceMime.startsWith('image/')) { return { buffer: sourceBuffer, mime: sourceMime, width: sourceSize?.width, - height: sourceSize?.height + height: sourceSize?.height, + originalWidth: sourceSize?.width, + originalHeight: sourceSize?.height }; } throw error; @@ -6262,7 +8161,9 @@ export class TurboFileSshMainService implements ITurboFileSshService { buffer, mime: metadata.mime, width: typeof metadata.width === 'number' ? metadata.width : undefined, - height: typeof metadata.height === 'number' ? metadata.height : undefined + height: typeof metadata.height === 'number' ? metadata.height : undefined, + originalWidth: typeof metadata.originalWidth === 'number' ? metadata.originalWidth : undefined, + originalHeight: typeof metadata.originalHeight === 'number' ? metadata.originalHeight : undefined }; } catch { return undefined; @@ -6281,6 +8182,8 @@ export class TurboFileSshMainService implements ITurboFileSshService { size: result.buffer.byteLength, width: result.width, height: result.height, + originalWidth: result.originalWidth, + originalHeight: result.originalHeight, createdAt: Date.now() }; try { @@ -6388,41 +8291,44 @@ export class TurboFileSshMainService implements ITurboFileSshService { return undefined; } - const source = quotePosixShellArg(endpoint.path); + // Always emit JPEG (or PNG when explicitly requested) from the remote transform, never WebP. + // Some servers' ImageMagick WebP delegate produced byte streams that passed a shallow magic + // check but that this build's Chromium decoder rejected ("Failed to load image") for + // certain sources (e.g. super-resolution PNGs), while small images that take the local + // nativeImage path (JPEG/PNG output) always rendered. JPEG decodes universally and is + // verifiable via nativeImage, so it removes that whole failure class. + const effectiveFormat: 'jpeg' | 'png' = format === 'png' ? 'png' : 'jpeg'; + // `[0]` selects the first page of a multi-page TIFF; without it ImageMagick would stream every + // page and produce an undecodable multi-frame JPEG. + const lowerPath = endpoint.path.toLowerCase(); + const frame = lowerPath.endsWith('.tif') || lowerPath.endsWith('.tiff') ? '[0]' : ''; + const source = `${quotePosixShellArg(endpoint.path)}${frame}`; const thumbnail = ` -thumbnail ${quotePosixShellArg(geometry)}`; const filter = interpolation === 'lanczos' ? ' -filter Lanczos' : ''; - const qualityOption = format === 'png' ? '' : ` -quality ${quality}`; - const outputFormat = format === 'webp' ? 'webp:-' : format === 'png' ? 'png:-' : 'jpg:-'; - const mime = format === 'webp' ? 'image/webp' : format === 'png' ? 'image/png' : 'image/jpeg'; + const qualityOption = effectiveFormat === 'png' ? '' : ` -quality ${quality}`; + const outputFormat = effectiveFormat === 'png' ? 'png:-' : 'jpg:-'; + const mime = effectiveFormat === 'png' ? 'image/png' : 'image/jpeg'; const command = `${tool} ${source}${thumbnail}${filter} -strip${qualityOption} ${outputFormat}`; try { const buffer = await this.withDirectorySshClient(endpoint, client => this.execRemoteBytes(client, command, { timeoutMs: REMOTE_IMAGE_THUMBNAIL_TIMEOUT_MS })); assertImageBuffer(buffer, mime); const size = getImageSize(buffer); + if (!size) { + // The header looked valid but nativeImage (same decoder family as the renderer's ) + // could not actually decode the bytes — truncated output, a timeout cutting the stream + // short, or an incompatible encoder. Reject so the caller falls back to the local + // download + transform path, which is guaranteed renderable, instead of handing the + // renderer an source it cannot decode. + throw new Error('Remote thumbnail produced an undecodable image.'); + } return { buffer, mime, - width: size?.width, - height: size?.height + width: size.width, + height: size.height }; } catch (error) { - if (format === 'webp') { - try { - const jpegCommand = `${tool} ${source}${thumbnail}${filter} -strip${qualityOption} jpg:-`; - const buffer = await this.withDirectorySshClient(endpoint, client => this.execRemoteBytes(client, jpegCommand, { timeoutMs: REMOTE_IMAGE_THUMBNAIL_TIMEOUT_MS })); - assertImageBuffer(buffer, 'image/jpeg'); - const size = getImageSize(buffer); - return { - buffer, - mime: 'image/jpeg', - width: size?.width, - height: size?.height - }; - } catch { - // Fall through to clearing the tool cache below. - } - } // A single corrupt/unsupported image should not disable thumbnail acceleration for // the entire SSH device; keep the tool cache intact and fall back for this image only. return undefined; @@ -6483,40 +8389,80 @@ export class TurboFileSshMainService implements ITurboFileSshService { private openRemoteCommandStream(client: ISSHClient, command: string, context: string, drainStdout = false): Promise<{ readonly stream: ISSHExecStream; readonly close: Promise }> { return new Promise((resolve, reject) => { - client.exec(command, (error, stream) => { - if (error) { - reject(error); + let settled = false; + const channelTimeout = setTimeout(() => { + if (settled) { return; } - - let stderr = ''; - let settled = false; - const close = new Promise((closeResolve, closeReject) => { - const finish = (callback: () => void): void => { - if (settled) { - return; + settled = true; + reject(new Error(`${context} timed out while waiting for an SSH exec channel.`)); + }, REMOTE_EXEC_CHANNEL_OPEN_TIMEOUT_MS); + const closeLateStream = (stream: ISSHExecStream): void => { + try { + if (stream.close) { + stream.close(); + } + } catch { + // Best-effort cleanup only. + } + try { + if (stream.destroy) { + stream.destroy(); + } + } catch { + // Best-effort cleanup only. + } + }; + try { + client.exec(command, (error, stream) => { + if (settled) { + if (stream) { + closeLateStream(stream); } - settled = true; - callback(); - }; - stream.stderr.on('data', data => { - stderr += data.toString(); - }); - if (drainStdout) { - stream.on('data', () => undefined); + return; } - stream.on('error', err => finish(() => closeReject(err))); - stream.on('close', code => { - if (code === 0) { - finish(() => closeResolve()); - return; + settled = true; + clearTimeout(channelTimeout); + if (error) { + reject(error); + return; + } + + let stderr = ''; + let closeSettled = false; + const close = new Promise((closeResolve, closeReject) => { + const finish = (callback: () => void): void => { + if (closeSettled) { + return; + } + closeSettled = true; + callback(); + }; + stream.stderr.on('data', data => { + stderr += data.toString(); + }); + if (drainStdout) { + stream.on('data', () => undefined); } - finish(() => closeReject(new Error(stderr.trim() || `${context} failed with exit code ${code ?? 'unknown'}.`))); + stream.on('error', err => finish(() => closeReject(err))); + stream.on('close', code => { + if (code === 0) { + finish(() => closeResolve()); + return; + } + finish(() => closeReject(new Error(stderr.trim() || `${context} failed with exit code ${code ?? 'unknown'}.`))); + }); }); + close.catch(() => undefined); + resolve({ stream, close }); }); - close.catch(() => undefined); - resolve({ stream, close }); - }); + } catch (error) { + if (!settled) { + settled = true; + clearTimeout(channelTimeout); + reject(error); + } + } }); } @@ -6541,7 +8487,9 @@ export class TurboFileSshMainService implements ITurboFileSshService { buffer: output, mime: outputFormat === 'png' ? 'image/png' : 'image/jpeg', width: outputSize.width, - height: outputSize.height + height: outputSize.height, + originalWidth: sourceSize.width, + originalHeight: sourceSize.height }; } @@ -6573,18 +8521,25 @@ export class TurboFileSshMainService implements ITurboFileSshService { private execRemote(client: ISSHClient, command: string, options: IRemoteExecOptions = {}): Promise { return new Promise((resolve, reject) => { let settled = false; - let timeout: ReturnType | undefined; + let totalTimeout: ReturnType | undefined; + let channelTimeout: ReturnType | undefined; let inactivityTimer: ReturnType | undefined; - let streamRef: (NodeJS.ReadWriteStream & { close?: () => void; destroy?: (error?: Error) => void }) | undefined; + let streamRef: ISSHExecStream | undefined; + let unregisterCancel: (() => void) | undefined; let lastStreamActivityAt = Date.now(); const finish = (callback: () => void): void => { if (settled) { return; } settled = true; - if (timeout) { - clearTimeout(timeout); - timeout = undefined; + unregisterCancel?.(); + if (totalTimeout) { + clearTimeout(totalTimeout); + totalTimeout = undefined; + } + if (channelTimeout) { + clearTimeout(channelTimeout); + channelTimeout = undefined; } if (inactivityTimer) { clearInterval(inactivityTimer); @@ -6604,20 +8559,41 @@ export class TurboFileSshMainService implements ITurboFileSshService { // Best-effort cleanup only. } }; + // Cancelling the owning transfer forcibly closes this exec channel so the remote command is + // actually terminated (SIGHUP) rather than orphaned. Registered before exec so a cancel that + // arrives during the channel grant still settles the promise. + if (options.progressId) { + unregisterCancel = this.registerTransferCancelHandler(options.progressId, () => { + closeStream(); + finish(() => reject(new TurboFileTransferCanceledError())); + }); + } + if (options.timeoutMs && options.timeoutMs > 0) { + totalTimeout = setTimeout(() => { + closeStream(); + finish(() => reject(new Error(`Remote command timed out after ${options.timeoutMs} ms.`))); + }, options.timeoutMs); + } + channelTimeout = setTimeout(() => { + closeStream(); + finish(() => reject(new Error(`Remote command channel did not open within ${REMOTE_EXEC_CHANNEL_OPEN_TIMEOUT_MS} ms.`))); + }, Math.min(REMOTE_EXEC_CHANNEL_OPEN_TIMEOUT_MS, options.timeoutMs && options.timeoutMs > 0 ? options.timeoutMs : REMOTE_EXEC_CHANNEL_OPEN_TIMEOUT_MS)); client.exec(command, (error, stream) => { if (error) { finish(() => reject(error)); return; } - - streamRef = stream as unknown as NodeJS.ReadWriteStream & { close?: () => void; destroy?: (error?: Error) => void }; - let stderr = ''; - if (options.timeoutMs && options.timeoutMs > 0) { - timeout = setTimeout(() => { - closeStream(); - finish(() => reject(new Error(`Remote command timed out after ${options.timeoutMs} ms.`))); - }, options.timeoutMs); + if (settled) { + try { stream.close?.(); } catch { /* Best-effort late-channel cleanup. */ } + try { stream.destroy?.(); } catch { /* Best-effort late-channel cleanup. */ } + return; } + if (channelTimeout) { + clearTimeout(channelTimeout); + channelTimeout = undefined; + } + streamRef = stream; + let stderr = ''; if (options.inactivityTimeoutMs && options.inactivityTimeoutMs > 0) { inactivityTimer = setInterval(() => { const externalActivityAt = options.getActivityTime?.() ?? 0; @@ -6640,6 +8616,10 @@ export class TurboFileSshMainService implements ITurboFileSshService { stderr += chunk; options.onStderr?.(chunk); }); + stream.on('error', error => { + closeStream(); + finish(() => reject(this.isTransferCanceled(options.progressId) ? new TurboFileTransferCanceledError() : error)); + }); stream.on('close', code => { if (code === 0) { finish(() => resolve()); @@ -6647,6 +8627,9 @@ export class TurboFileSshMainService implements ITurboFileSshService { } finish(() => reject(new Error(stderr.trim() || `Remote command failed with exit code ${code ?? 'unknown'}.`))); }); + if (options.input) { + stream.end(Buffer.from(options.input)); + } }); }); } @@ -6654,47 +8637,228 @@ export class TurboFileSshMainService implements ITurboFileSshService { private execRemoteBytes(client: ISSHClient, command: string, options: IRemoteExecOptions = {}): Promise { return new Promise((resolve, reject) => { let settled = false; - let timeout: ReturnType | undefined; + let totalTimeout: ReturnType | undefined; + let channelTimeout: ReturnType | undefined; + let inactivityTimer: ReturnType | undefined; + let activeStream: ISSHExecStream | undefined; + let unregisterCancel: (() => void) | undefined; + const clearTimers = () => { + if (totalTimeout) { + clearTimeout(totalTimeout); + totalTimeout = undefined; + } + if (inactivityTimer) { + clearTimeout(inactivityTimer); + inactivityTimer = undefined; + } + if (channelTimeout) { + clearTimeout(channelTimeout); + channelTimeout = undefined; + } + }; const finish = (callback: () => void) => { if (settled) { return; } settled = true; - if (timeout) { - clearTimeout(timeout); - timeout = undefined; - } + clearTimers(); + unregisterCancel?.(); callback(); }; + const abort = (message: string) => { + const stream = activeStream; + try { + stream?.close?.(); + } catch { + // Best-effort: older ssh2 channel typings may not expose close. + } + try { + stream?.destroy?.(); + } catch { + // Best-effort cleanup only. + } + finish(() => reject(new Error(message))); + }; + if (options.progressId) { + unregisterCancel = this.registerTransferCancelHandler(options.progressId, () => { + const stream = activeStream; + try { stream?.close?.(); } catch { /* Best-effort cleanup only. */ } + try { stream?.destroy?.(); } catch { /* Best-effort cleanup only. */ } + finish(() => reject(new TurboFileTransferCanceledError())); + }); + } + // An inactivity timeout (reset on every chunk) keeps a long but actively-streaming command + // alive — a `find` listing a directory of tens of thousands of files streams output the whole + // time and must not be killed by a fixed total-duration cap, which previously forced a fall + // back to the much slower per-batch SFTP readdir and ultimately a "read timed out" failure. + const armInactivity = () => { + if (!options.inactivityTimeoutMs || options.inactivityTimeoutMs <= 0) { + return; + } + if (inactivityTimer) { + clearTimeout(inactivityTimer); + } + inactivityTimer = setTimeout(() => abort(options.inactivityMessage ?? `Remote command produced no output for ${options.inactivityTimeoutMs} ms.`), options.inactivityTimeoutMs); + }; + // Arm the timeouts BEFORE calling exec so a stalled channel grant can never hang forever. + // When the connection's MaxSessions pool is momentarily exhausted, ssh2 may not invoke the + // exec callback at all — with the timers armed only inside that callback (as before) the + // promise never settled, which left directory listings stuck waiting until the + // window was reopened. Arming here bounds the whole operation, including the channel wait. + if (options.timeoutMs && options.timeoutMs > 0) { + totalTimeout = setTimeout(() => abort(`Remote command timed out after ${options.timeoutMs} ms.`), options.timeoutMs); + } + channelTimeout = setTimeout(() => abort(`Remote command channel did not open within ${REMOTE_EXEC_CHANNEL_OPEN_TIMEOUT_MS} ms.`), Math.min(REMOTE_EXEC_CHANNEL_OPEN_TIMEOUT_MS, options.timeoutMs && options.timeoutMs > 0 ? options.timeoutMs : REMOTE_EXEC_CHANNEL_OPEN_TIMEOUT_MS)); client.exec(command, (error, stream) => { if (error) { finish(() => reject(error)); return; } + // If a timeout already aborted this operation, immediately release the channel that was + // granted late instead of leaking it — a leaked channel would make MaxSessions exhaustion + // (the very cause of the stall) progressively worse. + if (settled) { + try { + stream.close?.(); + } catch { + // Best-effort cleanup only. + } + try { + stream.destroy?.(); + } catch { + // Best-effort cleanup only. + } + return; + } + if (channelTimeout) { + clearTimeout(channelTimeout); + channelTimeout = undefined; + } + activeStream = stream; + + const chunks: Buffer[] = []; + let stderr = ''; + // Reset the inactivity clock now that the channel is live and output can begin streaming. + armInactivity(); + stream.on('data', data => { + armInactivity(); + chunks.push(typeof data === 'string' ? Buffer.from(data) : data); + }); + stream.stderr.on('data', data => { + armInactivity(); + stderr += data.toString(); + }); + stream.on('error', error => abort(this.isTransferCanceled(options.progressId) ? TRANSFER_CANCELED_MESSAGE : error.message)); + stream.on('close', code => { + if (code === 0) { + finish(() => resolve(Buffer.concat(chunks))); + return; + } + finish(() => reject(new Error(stderr.trim() || `Remote command failed with exit code ${code ?? 'unknown'}.`))); + }); + }); + }); + } + /** + * Runs a remote command, writing `input` to its stdin in one shot. Used to feed a NUL-delimited + * path list to `xargs -0 rm` so deleting any number of files is a single SSH round trip — xargs + * batches the removals server-side, so it never hits the shell argument-length limit. + */ + private execRemoteWithInput(client: ISSHClient, command: string, input: Uint8Array, options: IRemoteExecOptions = {}): Promise { + return new Promise((resolve, reject) => { + let settled = false; + let totalTimeout: ReturnType | undefined; + let channelTimeout: ReturnType | undefined; + let inactivityTimeout: ReturnType | undefined; + let activeStream: ISSHExecStream | undefined; + let unregisterCancel: (() => void) | undefined; + const finish = (callback: () => void) => { + if (settled) { + return; + } + settled = true; + if (totalTimeout) { + clearTimeout(totalTimeout); + totalTimeout = undefined; + } + if (channelTimeout) { + clearTimeout(channelTimeout); + channelTimeout = undefined; + } + if (inactivityTimeout) { + clearTimeout(inactivityTimeout); + inactivityTimeout = undefined; + } + unregisterCancel?.(); + callback(); + }; + const closeStream = (): void => { + try { activeStream?.close?.(); } catch { /* Best-effort cleanup only. */ } + try { activeStream?.destroy?.(); } catch { /* Best-effort cleanup only. */ } + }; + if (options.progressId) { + unregisterCancel = this.registerTransferCancelHandler(options.progressId, () => { + closeStream(); + finish(() => reject(new TurboFileTransferCanceledError())); + }); + } + if (options.timeoutMs && options.timeoutMs > 0) { + totalTimeout = setTimeout(() => { + closeStream(); + finish(() => reject(new Error(`Remote command timed out after ${options.timeoutMs} ms.`))); + }, options.timeoutMs); + } + channelTimeout = setTimeout(() => { + closeStream(); + finish(() => reject(new Error(`Remote command channel did not open within ${REMOTE_EXEC_CHANNEL_OPEN_TIMEOUT_MS} ms.`))); + }, Math.min(REMOTE_EXEC_CHANNEL_OPEN_TIMEOUT_MS, options.timeoutMs && options.timeoutMs > 0 ? options.timeoutMs : REMOTE_EXEC_CHANNEL_OPEN_TIMEOUT_MS)); + client.exec(command, (error, stream) => { + if (error) { + finish(() => reject(error)); + return; + } + if (settled) { + try { stream.close?.(); } catch { /* Best-effort late-channel cleanup. */ } + try { stream.destroy?.(); } catch { /* Best-effort late-channel cleanup. */ } + return; + } + if (channelTimeout) { + clearTimeout(channelTimeout); + channelTimeout = undefined; + } + activeStream = stream; const chunks: Buffer[] = []; let stderr = ''; - if (options.timeoutMs && options.timeoutMs > 0) { - timeout = setTimeout(() => { + const armInactivityTimeout = (): void => { + if (!options.inactivityTimeoutMs || options.inactivityTimeoutMs <= 0 || settled) { + return; + } + if (inactivityTimeout) { + clearTimeout(inactivityTimeout); + } + inactivityTimeout = setTimeout(() => { try { stream.close?.(); - } catch { - // Best-effort: older ssh2 channel typings may not expose close. - } - try { - stream.destroy?.(); } catch { // Best-effort cleanup only. } - finish(() => reject(new Error(`Remote command timed out after ${options.timeoutMs} ms.`))); - }, options.timeoutMs); - } + finish(() => reject(new Error(options.inactivityMessage ?? `Remote command produced no output for ${options.inactivityTimeoutMs} ms.`))); + }, options.inactivityTimeoutMs); + }; + armInactivityTimeout(); stream.on('data', data => { + armInactivityTimeout(); chunks.push(typeof data === 'string' ? Buffer.from(data) : data); }); stream.stderr.on('data', data => { + armInactivityTimeout(); stderr += data.toString(); }); + stream.on('error', error => { + closeStream(); + finish(() => reject(this.isTransferCanceled(options.progressId) ? new TurboFileTransferCanceledError() : error)); + }); stream.on('close', code => { if (code === 0) { finish(() => resolve(Buffer.concat(chunks))); @@ -6702,6 +8866,7 @@ export class TurboFileSshMainService implements ITurboFileSshService { } finish(() => reject(new Error(stderr.trim() || `Remote command failed with exit code ${code ?? 'unknown'}.`))); }); + stream.end(Buffer.from(input)); }); }); } @@ -6766,13 +8931,13 @@ export class TurboFileSshMainService implements ITurboFileSshService { return hasParsedMetric ? parsed : undefined; } - private async readRemoteDirectoryWithFallback(sftp: ISFTPWrapper, client: ISSHClient, options: ITurboFileSshConnectionOptions, remotePath: string): Promise { + private async readRemoteDirectoryWithFallback(sftp: ISFTPWrapper, client: ISSHClient, options: ITurboFileSshConnectionOptions, remotePath: string, namesOnly = false): Promise { try { - return { path: remotePath, entries: await this.readRemoteDirectoryFast(client, sftp, remotePath) }; + return { path: remotePath, entries: await this.readRemoteDirectoryFast(client, sftp, remotePath, namesOnly) }; } catch (firstError) { for (const fallbackPath of getRemoteDirectoryFallbackPaths(options, remotePath)) { try { - return { path: fallbackPath, entries: await this.readRemoteDirectoryFast(client, sftp, fallbackPath) }; + return { path: fallbackPath, entries: await this.readRemoteDirectoryFast(client, sftp, fallbackPath, namesOnly) }; } catch { // Try the next SFTP-visible spelling for devices with chrooted home paths. } @@ -6781,15 +8946,27 @@ export class TurboFileSshMainService implements ITurboFileSshService { } } - private async readRemoteDirectoryFast(client: ISSHClient, sftp: ISFTPWrapper, remotePath: string): Promise { + private async readRemoteDirectoryFastCommand(client: ISSHClient, remotePath: string, namesOnly = false): Promise { + // Bound by inactivity, not total duration: a `find` over a directory with tens of thousands + // of entries streams output for many seconds and must be allowed to finish. Only callers decide + // whether to fall back to slow SFTP readdir. + const command = namesOnly ? buildPosixNamesAndTypesListCommand(remotePath) : buildPosixFastDirectoryListCommand(remotePath); + return this.parseFastRemoteDirectoryEntries(await this.execRemoteBytes(client, command, { + inactivityTimeoutMs: REMOTE_DIRECTORY_READ_INACTIVITY_TIMEOUT_MS, + inactivityMessage: `Remote directory read produced no output for ${REMOTE_DIRECTORY_READ_INACTIVITY_TIMEOUT_MS} ms: ${remotePath}`, + timeoutMs: REMOTE_DIRECTORY_READ_MAX_TIMEOUT_MS + }), namesOnly); + } + + private async readRemoteDirectoryFast(client: ISSHClient, sftp: ISFTPWrapper, remotePath: string, namesOnly = false): Promise { try { - return this.parseFastRemoteDirectoryEntries(await this.execRemoteBytes(client, buildPosixFastDirectoryListCommand(remotePath), { timeoutMs: 8000 })); + return await this.readRemoteDirectoryFastCommand(client, remotePath, namesOnly); } catch { return this.readRemoteDirectory(sftp, remotePath); } } - private parseFastRemoteDirectoryEntries(output: Buffer): ISFTPFileEntry[] { + private parseFastRemoteDirectoryEntries(output: Buffer, namesOnly = false): ISFTPFileEntry[] { const text = output.toString('utf8'); if (!text) { return []; @@ -6797,14 +8974,15 @@ export class TurboFileSshMainService implements ITurboFileSshService { const parts = text.split('\0'); const entries: ISFTPFileEntry[] = []; - for (let index = 0; index + 3 < parts.length; index += 4) { + const stride = namesOnly ? 2 : 4; + for (let index = 0; index + stride - 1 < parts.length; index += stride) { const filename = parts[index]; if (!filename || filename === '.' || filename === '..') { continue; } const kind = parts[index + 1]; - const size = Number(parts[index + 2]); - const modifiedSeconds = Number(parts[index + 3]); + const size = namesOnly ? 0 : Number(parts[index + 2]); + const modifiedSeconds = namesOnly ? 0 : Number(parts[index + 3]); const type: TurboFileFileType = kind === 'd' ? 'directory' : kind === 'f' ? 'file' : kind === 'l' ? 'symlink' : 'unknown'; entries.push({ filename, @@ -6816,7 +8994,7 @@ export class TurboFileSshMainService implements ITurboFileSshService { } private readRemoteDirectory(sftp: ISFTPWrapper, remotePath: string): Promise { - return new Promise((resolve, reject) => { + return withTurboFileTimeout(new Promise((resolve, reject) => { sftp.readdir(remotePath, (error, list) => { if (error) { reject(error); @@ -6824,7 +9002,7 @@ export class TurboFileSshMainService implements ITurboFileSshService { } resolve(list); }); - }); + }), REMOTE_DIRECTORY_READ_TIMEOUT_MS, `Remote directory read timed out after ${REMOTE_DIRECTORY_READ_TIMEOUT_MS} ms: ${remotePath}`); } private async getRemotePathSize(sftp: ISFTPWrapper, remotePath: string): Promise { @@ -7327,19 +9505,12 @@ export class TurboFileSshMainService implements ITurboFileSshService { async function readWindowsClipboardFilePaths(): Promise { const paths: string[] = []; - const seen = new Set(); const acceptPath = (path: string): void => { const candidate = path.trim().replace(/^"|"$/g, '').replace(/^file:\/\/\/?/i, ''); if (!isWindowsClipboardPath(candidate)) { return; } - const key = candidate.replace(/\//g, '\\').replace(/\\+$/, '').toLowerCase(); - if (seen.has(key)) { - return; - } - - seen.add(key); paths.push(candidate); }; @@ -7356,7 +9527,7 @@ async function readWindowsClipboardFilePaths(): Promise { acceptPath(path); } - return paths; + return normalizeWindowsTransferPaths(paths, resolveLocalWindowsRealPath); } async function readWindowsClipboardDropListPaths(): Promise { @@ -7401,6 +9572,11 @@ function isWindowsClipboardPath(path: string): boolean { return /^[A-Za-z]:[\\/]/.test(path) || /^\\\\[^\\]+\\[^\\]+/.test(path); } +function resolveLocalWindowsRealPath(path: string): Promise { + const realpath = fsp.realpath as typeof fsp.realpath & { native?: (path: string) => Promise }; + return realpath.native ? realpath.native(path) : fsp.realpath(path); +} + // Match WinSCP's SCP path: run remote "scp -t/-f" and speak the SCP wire protocol over the SSH channel. class ScpProtocolReader { private readonly chunks: Buffer[] = []; @@ -8242,14 +10418,6 @@ async function mapLimit(items: readonly T[], limit: number, mapper: (item: return results; } -function collectBatchTarTargetNames(plans: readonly ITarBatchPlan[]): string[] { - const targetNames: string[] = []; - for (const plan of plans) { - targetNames.push(...plan.targetNames); - } - return targetNames; -} - function getTransferName(path: string): string { const slash = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')); if (slash >= 0) { @@ -8352,9 +10520,8 @@ function buildPrepareTarExtractToDirectoryFromListCommand(targetDirectory: strin 'set -e', 'command -v tar >/dev/null 2>&1', requireZstd ? 'command -v zstd >/dev/null 2>&1' : undefined, - `target=${target}`, - 'mkdir -p -- "$target"', - 'while IFS= read -r -d \'\' name; do if [ -n "$name" ]; then rm -rf -- "$target/$name"; fi; done' + `mkdir -p -- ${target}`, + 'LC_ALL=C xargs -0 -r rm -rf --' ].filter((command): command is string => !!command); return `bash -lc ${quotePosixShellArg(commands.join('; '))}`; } @@ -8363,7 +10530,7 @@ function buildTarSourcePushCommand(sourcePath: string, targetPath: string, targe const prepareTarget = `rm -rf -- ${quotePosixShellArg(targetPath)} && mkdir -p -- ${quotePosixShellArg(targetPath)} && command -v tar >/dev/null 2>&1`; const extractTarget = `tar -xf - -C ${quotePosixShellArg(targetPath)}`; const remoteShell = `${sshpassPrefix}${sshCommand} ${quotePosixShellArg(targetUserHost)}`; - return `command -v tar >/dev/null 2>&1 && ${remoteShell} ${quotePosixShellArg(prepareTarget)} && tar -cf - -C ${quotePosixShellArg(sourcePath)} . | ${remoteShell} ${quotePosixShellArg(extractTarget)}`; + return buildPipefailBashCommand(`command -v tar >/dev/null 2>&1 && ${buildDirectTarProgressPrerequisiteCommand()} && ${remoteShell} ${quotePosixShellArg(prepareTarget)} && tar -cf - -C ${quotePosixShellArg(sourcePath)} . | ${buildDirectTarProgressPipeCommand()} | ${remoteShell} ${quotePosixShellArg(extractTarget)}`); } function buildTarItemsSourcePushCommand(parentPath: string, names: readonly string[], targetDirectory: string, targetNames: readonly string[], targetUserHost: string, sshCommand: string, sshpassPrefix: string): string { @@ -8373,12 +10540,18 @@ function buildTarItemsSourcePushCommand(parentPath: string, names: readonly stri const prepareTarget = `command -v tar >/dev/null 2>&1 && mkdir -p -- ${target} && ${cleanup}true`; const extractTarget = `tar -xf - -C ${target}`; const remoteShell = `${sshpassPrefix}${sshCommand} ${quotePosixShellArg(targetUserHost)}`; - return `${remoteShell} ${quotePosixShellArg(prepareTarget)} && ${buildTarCreateFromItemsCommand(parentPath, names)} | ${remoteShell} ${quotePosixShellArg(extractTarget)}`; + return buildPipefailBashCommand(`${buildDirectTarProgressPrerequisiteCommand()} && ${remoteShell} ${quotePosixShellArg(prepareTarget)} && ${buildTarCreateFromItemsCommand(parentPath, names)} | ${buildDirectTarProgressPipeCommand()} | ${remoteShell} ${quotePosixShellArg(extractTarget)}`); +} + +function buildPreparedTarItemsSourcePushCommand(sourceCommand: string, targetDirectory: string, targetUserHost: string, sshCommand: string, sshpassPrefix: string): string { + const remoteShell = `${sshpassPrefix}${sshCommand} ${quotePosixShellArg(targetUserHost)}`; + const extractTarget = `command -v tar >/dev/null 2>&1 && tar -xf - -C ${quotePosixShellArg(targetDirectory)}`; + return buildPipefailBashCommand(`${buildDirectTarProgressPrerequisiteCommand()} && ${sourceCommand} | ${buildDirectTarProgressPipeCommand()} | ${remoteShell} ${quotePosixShellArg(extractTarget)}`); } function buildTarTargetPullCommand(sourcePath: string, targetPath: string, sourceUserHost: string, sshCommand: string, sshpassPrefix: string): string { const createSource = `command -v tar >/dev/null 2>&1 && tar -cf - -C ${quotePosixShellArg(sourcePath)} .`; - return `command -v tar >/dev/null 2>&1 && rm -rf -- ${quotePosixShellArg(targetPath)} && mkdir -p -- ${quotePosixShellArg(targetPath)} && ${sshpassPrefix}${sshCommand} ${quotePosixShellArg(sourceUserHost)} ${quotePosixShellArg(createSource)} | tar -xf - -C ${quotePosixShellArg(targetPath)}`; + return buildPipefailBashCommand(`command -v tar >/dev/null 2>&1 && ${buildDirectTarProgressPrerequisiteCommand()} && rm -rf -- ${quotePosixShellArg(targetPath)} && mkdir -p -- ${quotePosixShellArg(targetPath)} && ${sshpassPrefix}${sshCommand} ${quotePosixShellArg(sourceUserHost)} ${quotePosixShellArg(createSource)} | ${buildDirectTarProgressPipeCommand()} | tar -xf - -C ${quotePosixShellArg(targetPath)}`); } function buildTarItemsTargetPullCommand(parentPath: string, names: readonly string[], targetDirectory: string, targetNames: readonly string[], sourceUserHost: string, sshCommand: string, sshpassPrefix: string): string { @@ -8386,7 +10559,46 @@ function buildTarItemsTargetPullCommand(parentPath: string, names: readonly stri const cleanupTargets = targetNames.map(name => quotePosixShellArg(joinRemotePath(targetDirectory, name))).join(' '); const cleanup = cleanupTargets ? `rm -rf -- ${cleanupTargets} && ` : ''; const createSource = buildTarCreateFromItemsCommand(parentPath, names); - return `command -v tar >/dev/null 2>&1 && mkdir -p -- ${target} && ${cleanup}${sshpassPrefix}${sshCommand} ${quotePosixShellArg(sourceUserHost)} ${quotePosixShellArg(createSource)} | tar -xf - -C ${target}`; + return buildPipefailBashCommand(`command -v tar >/dev/null 2>&1 && ${buildDirectTarProgressPrerequisiteCommand()} && mkdir -p -- ${target} && ${cleanup}${sshpassPrefix}${sshCommand} ${quotePosixShellArg(sourceUserHost)} ${quotePosixShellArg(createSource)} | ${buildDirectTarProgressPipeCommand()} | tar -xf - -C ${target}`); +} + +function buildPreparedTarItemsTargetPullCommand(sourceCommand: string, targetDirectory: string, sourceUserHost: string, sshCommand: string, sshpassPrefix: string): string { + const target = quotePosixShellArg(targetDirectory); + return buildPipefailBashCommand(`command -v tar >/dev/null 2>&1 && ${buildDirectTarProgressPrerequisiteCommand()} && ${sshpassPrefix}${sshCommand} ${quotePosixShellArg(sourceUserHost)} ${quotePosixShellArg(sourceCommand)} | ${buildDirectTarProgressPipeCommand()} | tar -xf - -C ${target}`); +} + +function buildPipefailBashCommand(command: string): string { + return `bash -lc ${quotePosixShellArg(`set -o pipefail\n${command}`)}`; +} + +function buildDirectTarProgressPipeCommand(): string { + return 'LC_ALL=C dd bs=4M status=progress'; +} + +function buildDirectTarProgressPrerequisiteCommand(): string { + return 'command -v dd >/dev/null 2>&1 && dd if=/dev/null of=/dev/null status=progress 2>/dev/null'; +} + +function writeInputToWritableStream(stream: { on(event: 'error', listener: (error: Error) => void): unknown; end(data: Buffer, callback?: () => void): void }, input: Uint8Array, context: string): Promise { + return new Promise((resolve, reject) => { + let settled = false; + const finish = (callback: () => void): void => { + if (settled) { + return; + } + settled = true; + callback(); + }; + // Keep this listener attached after the write callback as a defensive sink for a late EPIPE. + // A closed SSH/process stdin must become a normal rejected operation, never an uncaught main- + // process exception dialog. + stream.on('error', error => finish(() => reject(new Error(`${context}: ${error.message}`)))); + try { + stream.end(Buffer.from(input), () => finish(resolve)); + } catch (error) { + finish(() => reject(error instanceof Error ? error : new Error(String(error)))); + } + }); } function waitForLocalProcessClose(child: ReturnType, context: string): Promise { @@ -8715,12 +10927,32 @@ function buildLocalWindowsMoveScript(sourcePath: string, targetPath: string): st return withPowerShellDefaults(`$src=${source}; $dst=${target}; Move-Item -LiteralPath $src -Destination $dst -Force`); } -function buildLocalWindowsBatchScript(sourcePaths: readonly string[], targetDirectory: string, operation: 'copy' | 'move'): string { - const sources = sourcePaths.map(path => quotePowerShellLiteral(path)).join(','); - const target = quotePowerShellLiteral(targetDirectory); - const command = operation === 'move' ? 'Move-Item' : 'Copy-Item'; - const recurse = operation === 'copy' ? ' -Recurse' : ''; - return withPowerShellDefaults(`$srcs=@(${sources}); $dst=${target}; if (-not (Test-Path -LiteralPath $dst)) { New-Item -ItemType Directory -Force -Path $dst | Out-Null }; foreach ($src in $srcs) { ${command} -LiteralPath $src -Destination $dst -Force${recurse} }`); +function buildWindowsMappedBatchFromInputScript(operation: 'copy' | 'move'): string { + return withPowerShellDefaults([ + '$items=[Console]::In.ReadToEnd() | ConvertFrom-Json', + '$completed=0', + 'foreach ($item in $items) {', + ' $src=[string]$item.sourcePath', + ' $dst=[string]$item.targetPath', + ...(operation === 'copy' ? [' if ([System.IO.File]::Exists($dst) -or [System.IO.Directory]::Exists($dst)) { throw "Target already exists: $dst" }'] : []), + ' $parent=[System.IO.Path]::GetDirectoryName($dst)', + ' if ($parent -and -not [System.IO.Directory]::Exists($parent)) { [System.IO.Directory]::CreateDirectory($parent) | Out-Null }', + ...(operation === 'move' + ? [ + ' if ([string]$item.sourceType -eq "directory") { [System.IO.Directory]::Move($src, $dst) }', + ' elseif ([string]$item.sourceType -eq "file") { [System.IO.File]::Move($src, $dst) }', + ' elseif ([System.IO.Directory]::Exists($src)) { [System.IO.Directory]::Move($src, $dst) }', + ' else { [System.IO.File]::Move($src, $dst) }' + ] + : [ + ' if ([System.IO.Directory]::Exists($src)) { Copy-Item -LiteralPath $src -Destination $dst -Recurse }', + ' else { [System.IO.File]::Copy($src, $dst, $false) }' + ]), + ' $completed++', + ' if (($completed % 64) -eq 0) { [Console]::Out.WriteLine($completed) }', + '}', + '[Console]::Out.WriteLine($completed)' + ].join('\n')); } function buildWindowsCopyCommand(sourcePath: string, targetPath: string): string { @@ -8742,55 +10974,85 @@ function buildPosixCopyCommand(sourcePath: string, targetPath: string): string { return `mkdir -p ${parent} && (cp -a --reflink=auto --sparse=always ${source} ${target} 2>/dev/null || cp -a ${source} ${target})`; } -function buildWindowsBatchCommand(sourcePaths: readonly string[], targetDirectory: string, operation: 'copy' | 'move'): string { - const sources = sourcePaths.map(path => quotePowerShellLiteral(path)).join(','); - const target = quotePowerShellLiteral(targetDirectory); - const command = operation === 'move' ? 'Move-Item' : 'Copy-Item'; - const recurse = operation === 'copy' ? ' -Recurse' : ''; - return buildPowerShellCommand(withPowerShellDefaults(`$srcs=@(${sources}); $dst=${target}; if (-not (Test-Path -LiteralPath $dst)) { New-Item -ItemType Directory -Force -Path $dst | Out-Null }; foreach ($src in $srcs) { ${command} -LiteralPath $src -Destination $dst -Force${recurse} }`)); -} - -function buildPosixBatchCommand(sourcePaths: readonly string[], targetDirectory: string, operation: 'copy' | 'move'): string { - const target = quotePosixShellArg(targetDirectory); - const sources = sourcePaths.map(path => quotePosixShellArg(path)).join(' '); - if (operation === 'move') { - return `mkdir -p ${target} && mv -f ${sources} ${target}`; - } - return `mkdir -p ${target} && (cp -a --reflink=auto --sparse=always ${sources} ${target} 2>/dev/null || cp -a ${sources} ${target})`; -} - -function buildWindowsBatchDeleteCommand(sourcePaths: readonly string[]): string { - const sources = sourcePaths.map(path => quotePowerShellLiteral(path)).join(','); - return buildPowerShellCommand(withPowerShellDefaults(`$srcs=@(${sources}); foreach ($src in $srcs) { if (Test-Path -LiteralPath $src) { Remove-Item -LiteralPath $src -Force -Recurse } }`)); +function buildWindowsMappedBatchFromInputCommand(operation: 'copy' | 'move'): string { + return buildPowerShellCommand(buildWindowsMappedBatchFromInputScript(operation)); } -function buildLocalWindowsReverseParallelRsyncCommand(sourceConnection: ITurboFileSshConnectionOptions, items: readonly ILocalWindowsReverseRsyncItem[], sshTransport: string, cleanupTargets: readonly string[]): string { +function buildLocalWindowsReverseParallelRsyncCommand(sourceConnection: ITurboFileSshConnectionOptions, sshTransport: string): string { const rsyncOptions = [...buildLocalRsyncOptionArgs(true), '--iconv=UTF-8,UTF-8'].join(' '); const sshpassPrefix = buildSshpassPrefix(sourceConnection.password); const sourceUserHost = `${sourceConnection.username}@${sourceConnection.host}`; - const commands = items.map((item, index) => { - const sourcePath = item.directory ? ensureTrailingRemoteSlash(item.sourcePath) : item.sourcePath; - const targetPath = item.directory ? ensureTrailingRemoteSlash(item.targetPath) : ensureTrailingRemoteSlash(getRemoteParentPath(item.targetPath)); - const mkdirPath = item.directory ? item.targetPath : getRemoteParentPath(item.targetPath); - const sourceSpec = `${sourceUserHost}:${sourcePath}`; - const rsyncCommand = `${sshpassPrefix}rsync ${rsyncOptions} -e ${quotePosixShellArg(sshTransport)} ${quotePosixShellArg(sourceSpec)} ${quotePosixShellArg(targetPath)}`; - const progressTag = quotePosixShellArg(`__TURBOFILE_RSYNC_${index}__ `); - const progressFilter = `tr '\\r' '\\n' | awk -v tag=${progressTag} ${quotePosixShellArg('$0 ~ /%/ { now=systime(); if (now != last || $0 ~ /100%/) { print tag $0; fflush(); last=now } }')}`; - return `(mkdir -p -- ${quotePosixShellArg(mkdirPath)} && ${rsyncCommand} 2>&1 | ${progressFilter}) & pids="$pids $!"`; - }); - const cleanupTargetArgs = Array.from(new Set(cleanupTargets.map(target => trimTrailingRemoteSlash(target)).filter(target => target && target !== '/'))).map(target => quotePosixShellArg(target)).join(' '); const script = [ 'set -o pipefail', + 'set -e', 'command -v rsync >/dev/null 2>&1', 'command -v sshpass >/dev/null 2>&1', - cleanupTargetArgs ? `rm -rf -- ${cleanupTargetArgs}` : undefined, - 'pids=""', - ...commands, + 'work=$(mktemp -d) || exit 1', + 'manifest="$work/manifest"', + 'direct_manifest="$work/direct"', + 'trap \'rm -rf -- "$work"\' EXIT HUP INT TERM', + 'cat > "$manifest"', + 'cleanup_count=0', + 'saw_end=0', + 'while IFS= read -r -d \'\' kind && IFS= read -r -d \'\' group_id && IFS= read -r -d \'\' source_path && IFS= read -r -d \'\' target_path && IFS= read -r -d \'\' value && IFS= read -r -d \'\' item_index; do', + ' if [ "$kind" = Z ]; then saw_end=1; break; fi', + ' if [ "$kind" = C ]; then rm -rf -- "$target_path" || exit 1; cleanup_count=$((cleanup_count + 1)); if [ $((cleanup_count % 64)) -eq 0 ]; then printf "." >&2; fi; continue; fi', + ' if [ "$kind" = G ]; then list="$work/group-$group_id.list"; meta="$work/group-$group_id.meta"; if [ ! -e "$meta" ]; then printf "%s\\0%s\\0%s\\0" "$source_path" "$target_path" "$item_index" > "$meta"; fi; printf "%s\\0" "$value" >> "$list"; continue; fi', + ' if [ "$kind" = D ]; then printf "%s\\0%s\\0%s\\0%s\\0" "$source_path" "$target_path" "$value" "$item_index" >> "$direct_manifest"; fi', + 'done < "$manifest"', + '[ "$saw_end" -eq 1 ] || exit 1', + 'run_group() {', + ' source_path=$1; target_path=$2; list_path=$3; item_index=$4', + ' source_path=${source_path%/}/; destination=${target_path%/}/', + ` source_spec=${quotePosixShellArg(`${sourceUserHost}:`)}$source_path`, + ` mkdir -p -- "$target_path" && ${sshpassPrefix}rsync ${rsyncOptions} --files-from="$list_path" --from0 -r -e ${quotePosixShellArg(sshTransport)} "$source_spec" "$destination" 2>&1 | tr '\\r' '\\n' | awk -v tag="__TURBOFILE_RSYNC_${'$'}{item_index}__ " ${quotePosixShellArg('$0 ~ /%/ { now=systime(); if (now != last || $0 ~ /100%/) { print tag $0; fflush(); last=now } }')}`, + '}', + 'run_direct() {', + ' source_path=$1; target_path=$2; directory=$3; item_index=$4', + ' staging_path=', + ' if [ "$directory" = 1 ]; then source_path=${source_path%/}/; destination=${target_path%/}/; mkdir_path=${target_path%/}; else mkdir_path=$(dirname -- "$target_path"); mkdir -p -- "$mkdir_path" || return 1; staging_path=$(mktemp -d -- "$mkdir_path/.turbofile-rsync.XXXXXX") || return 1; destination=${staging_path%/}/; fi', + ` source_spec=${quotePosixShellArg(`${sourceUserHost}:`)}$source_path`, + ` if mkdir -p -- "$mkdir_path" && ${sshpassPrefix}rsync ${rsyncOptions} -e ${quotePosixShellArg(sshTransport)} "$source_spec" "$destination" 2>&1 | tr '\\r' '\\n' | awk -v tag="__TURBOFILE_RSYNC_${'$'}{item_index}__ " ${quotePosixShellArg('$0 ~ /%/ { now=systime(); if (now != last || $0 ~ /100%/) { print tag $0; fflush(); last=now } }')}; then status=0; if [ "$directory" != 1 ]; then source_name=${'$'}{source_path##*/}; staged_source="$staging_path/$source_name"; mv -n -- "$staged_source" "$target_path" || status=$?; if [ "$status" -eq 0 ] && { [ -e "$staged_source" ] || [ -L "$staged_source" ]; }; then status=74; fi; fi; else status=$?; fi`, + ' if [ -n "$staging_path" ]; then rm -rf -- "$staging_path"; fi', + ' return "$status"', + '}', + 'pids=()', 'failed=0', - 'for pid in $pids; do if ! wait "$pid"; then failed=1; fi; done', + 'track_pid() { pids+=("$1"); if [ "${#pids[@]}" -ge ' + TURBOFILE_REVERSE_RSYNC_WORKERS + ' ]; then if ! wait "${pids[0]}"; then failed=1; fi; pids=("${pids[@]:1}"); fi; }', + 'shopt -s nullglob', + 'for meta in "$work"/group-*.meta; do exec 3< "$meta"; IFS= read -r -d \'\' source_path <&3; IFS= read -r -d \'\' target_path <&3; IFS= read -r -d \'\' item_index <&3; exec 3<&-; list_path=${meta%.meta}.list; run_group "$source_path" "$target_path" "$list_path" "$item_index" & track_pid "$!"; done', + 'if [ -f "$direct_manifest" ]; then while IFS= read -r -d \'\' source_path && IFS= read -r -d \'\' target_path && IFS= read -r -d \'\' directory && IFS= read -r -d \'\' item_index; do run_direct "$source_path" "$target_path" "$directory" "$item_index" & track_pid "$!"; done < "$direct_manifest"; fi', + 'for pid in "${pids[@]}"; do if ! wait "$pid"; then failed=1; fi; done', 'if [ "$failed" -ne 0 ]; then exit 1; fi' - ].filter((command): command is string => !!command); - return `bash -lc ${quotePosixShellArg(script.join('; '))}`; + ]; + return `bash -lc ${quotePosixShellArg(script.join('\n'))}`; +} + +function buildLocalWindowsReverseParallelRsyncInput(items: readonly ILocalWindowsReverseRsyncItem[], cleanupTargets: readonly string[]): Uint8Array { + const records: string[] = []; + for (const cleanupTarget of new Set(cleanupTargets.map(target => trimTrailingRemoteSlash(target)).filter(target => target && target !== '/'))) { + records.push('C', '', '', cleanupTarget, '', ''); + } + const mappings = items.map(item => ({ sourcePath: item.sourcePath, targetPath: item.targetPath })); + const plan = createTurboFileRsyncMappedGroups(mappings); + for (const [groupIndex, group] of plan.groups.entries()) { + for (const relativePath of group.relativePaths) { + records.push('G', String(groupIndex), group.sourceBase, group.targetBase, relativePath, String(items.length + groupIndex)); + } + } + const directMetadata = new Map>(); + for (const [index, item] of items.entries()) { + const key = `${item.sourcePath}\0${item.targetPath}`; + const values = directMetadata.get(key) ?? []; + values.push({ directory: item.directory, index }); + directMetadata.set(key, values); + } + for (const item of plan.direct) { + const metadata = directMetadata.get(`${item.sourcePath}\0${item.targetPath}`)?.shift(); + records.push('D', '', item.sourcePath, item.targetPath, metadata?.directory ? '1' : '0', String(metadata?.index ?? 0)); + } + records.push('Z', '', '', '', '', ''); + return createTarNullListBuffer(records); } function getLocalWindowsRsyncConfigPaths(): string[] { @@ -8894,6 +11156,437 @@ function buildWindowsDeleteCommand(path: string, recursive: boolean): string { return `powershell -NoProfile -NonInteractive -Command "$ErrorActionPreference='Stop'; $path='${literalPath}'; if (Test-Path -LiteralPath $path) { Remove-Item -LiteralPath $path -Force${recurseFlag} }"`; } +function trashPathsEqual(left: string, right: string, windowsPath: boolean): boolean { + const normalize = (value: string) => { + const normalized = windowsPath ? value.replace(/\//g, '\\') : value.replace(/\\/g, '/'); + const withoutTrailingSeparators = normalized.replace(windowsPath ? /\\+$/ : /\/+$/, ''); + return windowsPath ? withoutTrailingSeparators.toLowerCase() : withoutTrailingSeparators; + }; + return normalize(left) === normalize(right); +} + +function buildPosixTrashRootInvocations(functionName: string, rememberedRoots: readonly string[]): string { + const remembered = rememberedRoots.map(root => `${functionName} ${quotePosixShellArg(root)}`).join('; '); + return [ + remembered, + `${functionName} "$HOME/.local/share/Trash/turbofile"`, + 'uid="$(id -u)"', + `(findmnt -rn -o TARGET 2>/dev/null || df -P | awk 'NR>1 {print $6}') | while IFS= read -r mount_point; do [ -n "$mount_point" ] && ${functionName} "$mount_point/.Trash-$uid/turbofile"; done` + ].filter(Boolean).join('; '); +} + +function buildPosixTrashListCommand(rememberedRoots: readonly string[]): string { + const pythonScript = [ + 'import os', + 'import sys', + 'roots = sys.stdin.buffer.read().split(b"\\0")', + 'seen_roots = set()', + 'output = sys.stdout.buffer', + 'emitted = 0', + 'for root in roots:', + ' if not root or root in seen_roots:', + ' continue', + ' seen_roots.add(root)', + ' info = os.path.join(root, b"info")', + ' try:', + ' entries = os.scandir(info)', + ' except OSError:', + ' continue', + ' with entries:', + ' for entry in entries:', + ' try:', + ' if not entry.name.endswith(b".json") or not entry.is_file(follow_symlinks=False):', + ' continue', + ' with open(entry.path, "rb") as metadata_file:', + ' metadata = metadata_file.read()', + ' output.write(entry.path + b"\\0" + metadata + b"\\0")', + ' emitted += 1', + ' if emitted % 64 == 0:', + ' output.flush()', + ' except OSError:', + ' continue', + 'output.flush()' + ].join('\n'); + const emitRootPath = 'emit_trash_root_path() { [ -n "$1" ] && printf "%s\\0" "$1"; }'; + const rootInput = buildPosixTrashRootInvocations('emit_trash_root_path', rememberedRoots); + const emitRoot = 'emit_trash_root() { info="$1/info"; [ -d "$info" ] || return 0; find "$info" -maxdepth 1 -type f -name "*.json" -exec sh -c \'for metadata do printf "%s\\0" "$metadata"; cat -- "$metadata" || true; printf "\\0"; done\' sh {} +; }'; + return `${emitRootPath}; ${emitRoot}; if command -v python3 >/dev/null 2>&1; then { ${rootInput}; } | python3 -c ${quotePosixShellArg(pythonScript)}; else ${buildPosixTrashRootInvocations('emit_trash_root', rememberedRoots)}; fi`; +} + +function buildWindowsTrashListCommand(deviceIdentity: string, rememberedRoots: readonly string[]): string { + const userKey = createHash('sha256').update(deviceIdentity).digest('hex').slice(0, 12); + const remembered = rememberedRoots.map(quotePowerShellLiteral).join(','); + const script = withPowerShellDefaults([ + '$roots=[System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)', + `foreach ($root in @(${remembered})) { if ($root) { [void]$roots.Add([string]$root) } }`, + `Get-PSDrive -PSProvider FileSystem | ForEach-Object { [void]$roots.Add(([string]$_.Root).TrimEnd('\\') + '\\.turbofile-trash-${userKey}\\turbofile') }`, + '$records=[System.Collections.Generic.List[object]]::new()', + 'foreach ($root in $roots) {', + ' $info=Join-Path $root "info"', + ' if (-not [System.IO.Directory]::Exists($info)) { continue }', + ' foreach ($metadata in [System.IO.Directory]::EnumerateFiles($info, "*.json", [System.IO.SearchOption]::TopDirectoryOnly)) {', + ' try { $records.Add([pscustomobject]@{ metadataPath=$metadata; metadata=[System.IO.File]::ReadAllText($metadata) }) } catch { }', + ' }', + '}', + '[Console]::Out.Write((ConvertTo-Json -InputObject ([object[]]$records.ToArray()) -Compress -Depth 10))' + ].join('; ')); + return buildPowerShellCommand(script); +} + +function parseRemoteTrashMetadataRecords(output: Buffer, windowsPath: boolean): IRemoteTrashMetadataRecord[] { + if (windowsPath) { + const text = output.toString('utf8').trim(); + if (!text) { + return []; + } + const parsed = JSON.parse(text); + const values = Array.isArray(parsed) ? parsed : [parsed]; + return values + .filter(value => value && typeof value.metadataPath === 'string' && typeof value.metadata === 'string') + .map(value => ({ metadataPath: value.metadataPath, metadata: value.metadata })); + } + const frames = output.toString('utf8').split('\0'); + const records: IRemoteTrashMetadataRecord[] = []; + for (let index = 0; index + 1 < frames.length; index += 2) { + if (frames[index]) { + records.push({ metadataPath: frames[index], metadata: frames[index + 1] }); + } + } + return records; +} + +function buildPosixTrashPendingRecoveryCommand(): string { + return 'LC_ALL=C xargs -0 -r -n 5 sh -c \'id=$1; src=$2; payload=$3; pending=$4; metadata=$5; if { [ -e "$payload" ] || [ -L "$payload" ]; } && ! { [ -e "$src" ] || [ -L "$src" ]; }; then if mv -- "$pending" "$metadata"; then printf "%s\\t1\\t\\n" "$id"; else printf "%s\\t0\\trecovery failed\\n" "$id"; fi; elif { [ -e "$src" ] || [ -L "$src" ]; } && ! { [ -e "$payload" ] || [ -L "$payload" ]; }; then rm -f -- "$pending"; printf "%s\\t0\\tdiscarded\\n" "$id"; else printf "%s\\t0\\tunresolved\\n" "$id"; fi\' _'; +} + +function buildWindowsTrashPendingRecoveryCommand(): string { + const script = withPowerShellDefaults([ + '$items=[Console]::In.ReadToEnd() | ConvertFrom-Json', + '$results=[System.Collections.Generic.List[object]]::new()', + 'foreach ($item in $items) {', + ' try {', + ' $sourceExists=[System.IO.File]::Exists([string]$item.sourcePath) -or [System.IO.Directory]::Exists([string]$item.sourcePath)', + ' $payloadExists=[System.IO.File]::Exists([string]$item.payloadPath) -or [System.IO.Directory]::Exists([string]$item.payloadPath)', + ' if ($payloadExists -and -not $sourceExists) { [System.IO.File]::Move([string]$item.pendingPath, [string]$item.metadataPath); [void]$results.Add([pscustomobject]@{ id=[string]$item.id; ok=$true; message="" }) }', + ' elseif ($sourceExists -and -not $payloadExists) { [System.IO.File]::Delete([string]$item.pendingPath); [void]$results.Add([pscustomobject]@{ id=[string]$item.id; ok=$false; message="discarded" }) }', + ' else { [void]$results.Add([pscustomobject]@{ id=[string]$item.id; ok=$false; message="unresolved" }) }', + ' } catch { [void]$results.Add([pscustomobject]@{ id=[string]$item.id; ok=$false; message=$_.Exception.Message }) }', + '}', + '$results | ConvertTo-Json -Compress' + ].join('; ')); + return buildPowerShellCommand(script); +} + +function buildPosixTrashDeleteBatchCommand(): string { + const script = [ + buildPosixHeartbeatFunction(), + 'ids=(); payloads=(); metadatas=()', + "while IFS= read -r -d '' id && IFS= read -r -d '' payload && IFS= read -r -d '' metadata; do ids+=(\"$id\"); payloads+=(\"$payload\"); metadatas+=(\"$metadata\"); done", + 'delete_all() {', + ' local total=${#ids[@]} start end index status args', + ' for ((start=0; start `empty_trash_root ${quotePosixShellArg(root)}`).join('; '); + const script = [ + 'set -e', + 'set -o pipefail', + heartbeat, + 'total=0', + emptyRoot, + remembered, + 'empty_trash_root "$HOME/.local/share/Trash/turbofile"', + 'uid="$(id -u)"', + 'while IFS= read -r mount_point; do if [ -n "$mount_point" ]; then empty_trash_root "$mount_point/.Trash-$uid/turbofile"; fi; done < <(findmnt -rn -o TARGET 2>/dev/null || df -P | awk \'NR>1 {print $6}\')', + 'printf "%s\\n" "$total"' + ].filter(Boolean).join('\n'); + return `bash -lc ${quotePosixShellArg(script)}`; +} + +function buildWindowsTrashEmptyCommand(deviceIdentity: string, rememberedRoots: readonly string[]): string { + const userKey = createHash('sha256').update(deviceIdentity).digest('hex').slice(0, 12); + const remembered = rememberedRoots.map(quotePowerShellLiteral).join(','); + const script = withPowerShellDefaults([ + '$roots=[System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)', + `foreach ($root in @(${remembered})) { if ($root) { [void]$roots.Add([string]$root) } }`, + `Get-PSDrive -PSProvider FileSystem | ForEach-Object { [void]$roots.Add(([string]$_.Root).TrimEnd('\\') + '\\.turbofile-trash-${userKey}\\turbofile') }`, + '$count=0', + 'foreach ($root in $roots) {', + ' $info=Join-Path $root "info"', + ' if ([System.IO.Directory]::Exists($info)) { $count += @([System.IO.Directory]::EnumerateFiles($info, "*.json", [System.IO.SearchOption]::TopDirectoryOnly)).Count }', + ' $files=Join-Path $root "files"', + ' if ([System.IO.Directory]::Exists($files)) { [System.IO.Directory]::Delete($files, $true) }', + ' if ([System.IO.Directory]::Exists($info)) { [System.IO.Directory]::Delete($info, $true) }', + '}', + '[Console]::Out.Write([string]$count)' + ].join('; ')); + return buildPowerShellCommand(script); +} + +function buildPosixTrashRootBatchCommand(): string { + const script = [ + 'home_point="$(findmnt -T "$HOME" -n -o TARGET 2>/dev/null || df -P "$HOME" | awk \'NR==2 { print $6 }\')"', + 'uid="$(id -u)"', + "while IFS= read -r -d '' probe; do", + 'mount_point="$(findmnt -T "$probe" -n -o TARGET 2>/dev/null || df -P "$probe" | awk \'NR==2 { print $6 }\')"', + 'if [ -z "$mount_point" ]; then continue; fi', + 'if [ "$mount_point" = "$home_point" ]; then trash_root="$HOME/.local/share/Trash/turbofile"; else trash_root="$mount_point/.Trash-$uid/turbofile"; fi', + 'printf "%s\\0%s\\0%s\\0" "$probe" "$mount_point" "$trash_root"', + 'done' + ].join('\n'); + return `bash -lc ${quotePosixShellArg(script)}`; +} + +function buildPosixTrashMoveBatchCommand(): string { + const pythonRenameProbe = 'import ctypes; libc = ctypes.CDLL(None); raise SystemExit(0 if hasattr(libc, "renameat2") else 1)'; + const pythonScript = [ + 'import ctypes', + 'import os', + 'import sys', + 'AT_FDCWD = -100', + 'RENAME_NOREPLACE = 1', + 'libc = ctypes.CDLL(None, use_errno=True)', + 'renameat2 = libc.renameat2', + 'renameat2.argtypes = [ctypes.c_int, ctypes.c_char_p, ctypes.c_int, ctypes.c_char_p, ctypes.c_uint]', + 'renameat2.restype = ctypes.c_int', + 'def rename_noreplace(src, dst):', + ' ctypes.set_errno(0)', + ' if renameat2(AT_FDCWD, src, AT_FDCWD, dst, RENAME_NOREPLACE) != 0:', + ' error_number = ctypes.get_errno()', + ' raise OSError(error_number, os.strerror(error_number), os.fsdecode(src))', + 'fields = sys.stdin.buffer.read().split(b"\\0")', + 'if fields and not fields[-1]:', + ' fields.pop()', + 'if len(fields) % 6 != 0:', + ' raise SystemExit("Invalid recycle-bin batch input.")', + 'created_directories = set()', + 'output = sys.stdout.buffer', + 'for offset in range(0, len(fields), 6):', + ' item_id, src, dst, pending, final, metadata = fields[offset:offset + 6]', + ' pending_created = False', + ' try:', + ' for directory in (os.path.dirname(dst), os.path.dirname(pending)):', + ' if directory not in created_directories:', + ' os.makedirs(directory, exist_ok=True)', + ' created_directories.add(directory)', + ' if any(os.path.lexists(path) for path in (dst, pending, final)):', + ' raise FileExistsError("Recycle-bin target already exists.")', + ' descriptor = os.open(pending, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)', + ' pending_created = True', + ' try:', + ' view = memoryview(metadata)', + ' while view:', + ' written = os.write(descriptor, view)', + ' if written <= 0:', + ' raise OSError("Unable to write recycle-bin metadata.")', + ' view = view[written:]', + ' finally:', + ' os.close(descriptor)', + ' rename_noreplace(src, dst)', + ' rename_noreplace(pending, final)', + ' output.write(item_id + b"\\t1\\t\\n")', + ' except Exception as error:', + ' if pending_created and os.path.lexists(src):', + ' try:', + ' os.unlink(pending)', + ' except (FileNotFoundError, OSError):', + ' pass', + ' message = str(error).encode("utf-8", "replace").replace(b"\\t", b" ").replace(b"\\r", b" ").replace(b"\\n", b" ")', + ' output.write(item_id + b"\\t0\\t" + message + b"\\n")', + ' if (offset // 6 + 1) % 64 == 0:', + ' output.flush()', + 'output.flush()' + ].join('\n'); + const script = [ + buildPosixHeartbeatFunction(), + 'path_exists() { [ -e "$1" ] || [ -L "$1" ]; }', + 'process_trash_moves() {', + "while IFS= read -r -d '' id && IFS= read -r -d '' src && IFS= read -r -d '' dst && IFS= read -r -d '' pending && IFS= read -r -d '' final && IFS= read -r -d '' metadata; do", + 'dst_parent=${dst%/*}', + 'info_parent=${pending%/*}', + 'pending_created=0', + 'if path_exists "$dst" || path_exists "$pending" || path_exists "$final"; then', + 'printf "%s\\t0\\tmove failed\\n" "$id"', + 'continue', + 'fi', + 'if ! { [ -d "$dst_parent" ] || mkdir -p -- "$dst_parent"; } || ! { [ -d "$info_parent" ] || mkdir -p -- "$info_parent"; }; then', + 'printf "%s\\t0\\tmove failed\\n" "$id"', + 'continue', + 'fi', + 'if ! (umask 077; set -C; printf "%s" "$metadata" > "$pending"); then', + 'printf "%s\\t0\\tmove failed\\n" "$id"', + 'continue', + 'fi', + 'pending_created=1', + 'if mv -n -- "$src" "$dst" && ! path_exists "$src" && mv -n -- "$pending" "$final" && ! path_exists "$pending"; then', + 'printf "%s\\t1\\t\\n" "$id"', + 'else', + 'if [ "$pending_created" -eq 1 ] && path_exists "$src"; then rm -f -- "$pending"; fi', + 'printf "%s\\t0\\tmove failed\\n" "$id"', + 'fi', + 'done', + '}', + `if command -v python3 >/dev/null 2>&1 && python3 -c ${quotePosixShellArg(pythonRenameProbe)}; then run_with_heartbeat python3 -c ${quotePosixShellArg(pythonScript)}; else run_with_heartbeat process_trash_moves; fi` + ].join('\n'); + return `bash -lc ${quotePosixShellArg(script)}`; +} + +function buildWindowsTrashMoveBatchCommand(): string { + const script = withPowerShellDefaults([ + '$items=[Console]::In.ReadToEnd() | ConvertFrom-Json', + '$results=[System.Collections.Generic.List[object]]::new()', + '$completed=0', + '$pathExists={ param([string]$path) try { [void][System.IO.File]::GetAttributes($path); return $true } catch { $errorValue=$_.Exception; while ($errorValue.InnerException) { $errorValue=$errorValue.InnerException }; if ($errorValue -is [System.IO.FileNotFoundException] -or $errorValue -is [System.IO.DirectoryNotFoundException]) { return $false }; throw } }', + 'foreach ($item in $items) {', + ' $pendingCreated=$false', + ' try {', + ' $parent=[System.IO.Path]::GetDirectoryName([string]$item.payloadPath)', + ' $infoParent=[System.IO.Path]::GetDirectoryName([string]$item.pendingPath)', + ' if ($parent -and -not [System.IO.Directory]::Exists($parent)) { [System.IO.Directory]::CreateDirectory($parent) | Out-Null }', + ' if ($infoParent -and -not [System.IO.Directory]::Exists($infoParent)) { [System.IO.Directory]::CreateDirectory($infoParent) | Out-Null }', + ' if (& $pathExists ([string]$item.metadataPath)) { throw [System.IO.IOException]::new("Recycle-bin metadata target already exists.") }', + ' $metadataBytes=[System.Text.UTF8Encoding]::new($false).GetBytes([string]$item.metadata)', + ' $metadataStream=[System.IO.File]::Open([string]$item.pendingPath, [System.IO.FileMode]::CreateNew, [System.IO.FileAccess]::Write, [System.IO.FileShare]::None)', + ' $pendingCreated=$true', + ' try { $metadataStream.Write($metadataBytes, 0, $metadataBytes.Length) } finally { $metadataStream.Dispose() }', + ' if ([System.IO.Directory]::Exists([string]$item.sourcePath)) { [System.IO.Directory]::Move([string]$item.sourcePath, [string]$item.payloadPath) }', + ' else { [System.IO.File]::Move([string]$item.sourcePath, [string]$item.payloadPath) }', + ' [System.IO.File]::Move([string]$item.pendingPath, [string]$item.metadataPath)', + ' [void]$results.Add([pscustomobject]@{ id=[string]$item.id; ok=$true; message="" })', + ' } catch {', + ' $moveError=$_.Exception.Message', + ' $sourcePath=[string]$item.sourcePath; $payloadPath=[string]$item.payloadPath', + ' $sourceExists=$false; $payloadExists=$false', + ' try { $sourceExists=& $pathExists $sourcePath } catch {}', + ' try { $payloadExists=& $pathExists $payloadPath } catch {}', + ' if ($pendingCreated -and -not $sourceExists -and $payloadExists) {', + ' try {', + ' $payloadAttributes=[System.IO.File]::GetAttributes($payloadPath)', + ' if (($payloadAttributes -band [System.IO.FileAttributes]::Directory) -ne 0) { [System.IO.Directory]::Move($payloadPath, $sourcePath) } else { [System.IO.File]::Move($payloadPath, $sourcePath) }', + ' $sourceExists=$true; $payloadExists=$false', + ' } catch {}', + ' }', + ' if ($pendingCreated -and $sourceExists -and -not $payloadExists) { try { [System.IO.File]::Delete([string]$item.pendingPath) } catch {} }', + ' [void]$results.Add([pscustomobject]@{ id=[string]$item.id; ok=$false; message=$moveError })', + ' }', + ' $completed++', + ' if (($completed % 64) -eq 0) { [Console]::Error.WriteLine([string]$completed) }', + '}', + '[Console]::Error.WriteLine([string]$completed)', + '$results | ConvertTo-Json -Compress' + ].join('\n')); + return buildPowerShellCommand(script); +} + +function parseRemoteTrashMoveResults(output: Buffer, windowsPath: boolean): IRemoteTrashMoveResult[] { + const text = output.toString('utf8').trim(); + if (!text) { + return []; + } + if (windowsPath) { + const parsed = JSON.parse(text); + const values = Array.isArray(parsed) ? parsed : [parsed]; + return values + .filter(value => value && typeof value.id === 'string' && typeof value.ok === 'boolean') + .map(value => ({ id: value.id, ok: value.ok, message: typeof value.message === 'string' ? value.message : undefined })); + } + return text.split(/\r?\n/) + .map(line => line.split('\t')) + .filter(parts => parts.length >= 2 && !!parts[0]) + .map(parts => ({ id: parts[0], ok: parts[1] === '1', message: parts[2] || undefined })); +} + +function buildWindowsDeleteManyFromInputCommand(recursive: boolean): string { + const script = withPowerShellDefaults([ + '$paths=[Console]::In.ReadToEnd() | ConvertFrom-Json', + '$completed=0', + 'foreach ($path in $paths) {', + ' $value=[string]$path', + ' if ([System.IO.Directory]::Exists($value)) {', + recursive + ? ' [System.IO.Directory]::Delete($value, $true)' + : ' [System.IO.Directory]::Delete($value)', + ' } elseif ([System.IO.File]::Exists($value)) {', + ' [System.IO.File]::Delete($value)', + ' }', + ' $completed++', + ' if (($completed % 64) -eq 0) { [Console]::Out.WriteLine([string]$completed) }', + '}', + '[Console]::Out.WriteLine([string]$completed)' + ].join('\n')); + return buildPowerShellCommand(script); +} + +function buildPosixDeleteManyFromInputCommand(recursive: boolean): string { + const removeFlag = recursive ? '-rf' : '-f'; + const script = `${buildPosixHeartbeatFunction()}; status=0; run_with_heartbeat rm ${removeFlag} -- "$@" || status=$?; [ "$status" -eq 0 ] && printf "." >&2; exit "$status"`; + return `LC_ALL=C xargs -0 -r -n 128 bash -c ${quotePosixShellArg(script)} _`; +} + +function buildPosixMoveManyIntoDirectoryFromInputCommand(targetDirectory: string): string { + const target = quotePosixShellArg(targetDirectory); + const script = `${buildPosixHeartbeatFunction()}; status=0; run_with_heartbeat mv -n -- "$@" "$0" || status=$?; if [ "$status" -eq 0 ]; then for source in "$@"; do if [ -e "$source" ] || [ -L "$source" ]; then status=74; break; fi; done; fi; [ "$status" -eq 0 ] && printf "." >&2; exit "$status"`; + return `mkdir -p -- ${target} && LC_ALL=C xargs -0 -r -n 128 bash -c ${quotePosixShellArg(script)} ${target}`; +} + function buildWindowsSizeCommand(path: string): string { const target = quotePowerShellLiteral(path); return buildPowerShellCommand(withPowerShellDefaults(`$path=${target}; if (Test-Path -LiteralPath $path -PathType Container) { $dest=Join-Path ([System.IO.Path]::GetTempPath()) 'turbofile-size-probe'; $output=& robocopy $path $dest /L /E /BYTES /R:0 /W:0 /NFL /NDL /NJH /NP /NC /NS /XJ 2>&1; if ($LASTEXITCODE -gt 7) { throw (($output | Out-String).Trim()) }; $match=[regex]::Match(($output | Out-String), '(?im)^\\s*Bytes\\s*:\\s*([0-9][0-9,\\.]*)'); if (-not $match.Success) { throw 'Unable to parse robocopy size output.' }; [Int64]($match.Groups[1].Value -replace '[^0-9]', '') } elseif (Test-Path -LiteralPath $path -PathType Leaf) { [Int64](Get-Item -LiteralPath $path).Length } else { throw 'Path not found' }`)); @@ -8919,6 +11612,49 @@ function buildPosixFastDirectoryListCommand(path: string): string { return `LC_ALL=C find ${quotedPath} -mindepth 1 -maxdepth 1 -printf '%f\\0%y\\0%s\\0%T@\\0'`; } +// Names + type only. `%y` is resolved from the directory entry's d_type on common filesystems, so +// this avoids a stat() per file and returns instantly even for directories with hundreds of +// thousands of entries. Size and modified time are filled in lazily for visible rows afterwards. +function buildPosixNamesAndTypesListCommand(path: string): string { + const quotedPath = quotePosixShellArg(path); + return `LC_ALL=C find ${quotedPath} -mindepth 1 -maxdepth 1 -printf '%f\\0%y\\0'`; +} + +// Size + modified time for specific entries in one round trip. Names are passed relative to the +// directory (via `cd`) so `%n` echoes them back exactly, letting the caller map results by name. +function buildPosixStatEntriesCommand(directoryPath: string, names: readonly string[]): string { + return buildRemotePosixStatEntriesCommand(directoryPath, names); +} + +function parseRemoteStatEntries(text: string): ITurboFileEntryStat[] { + if (!text) { + return []; + } + const parts = text.split('\0'); + const stats: ITurboFileEntryStat[] = []; + for (let index = 0; index + 2 < parts.length; index += 3) { + const rawName = parts[index]; + if (!rawName) { + continue; + } + // `stat` echoes the argument exactly, i.e. with the leading "./" we passed; strip it back off. + const name = rawName.replace(/^\.\//, '').replace(/\r?\n$/, ''); + if (!name) { + continue; + } + const size = Number(parts[index + 1]); + const modifiedSeconds = Number(parts[index + 2]); + stats.push({ + name, + size: Number.isFinite(size) ? size : 0, + // `%Y` is epoch seconds; the rest of the app (see toRemoteEntry) carries modified time in + // milliseconds and formatDate expects ms, so convert here — otherwise every row renders 1970. + modified: Number.isFinite(modifiedSeconds) ? Math.floor(modifiedSeconds) * 1000 : 0 + }); + } + return stats; +} + function parseSizeCommandOutput(output: string): number { const match = output.trim().match(/\d+/); if (!match) { @@ -8931,6 +11667,164 @@ function parseSizeCommandOutput(output: string): number { return size; } +// Streams each file as "\n\n" in one round trip. Small originals (<= the raw +// passthrough size) stream as-is. Larger ones are downscaled to a small JPEG by a remote tool when +// present (vipsthumbnail — very fast and light on ARM boards — else ImageMagick magick/convert); this +// shrinks the transfer dramatically so big images load almost as fast as small ones. With no tool, +// files within `maxBytes` stream raw and larger ones report size -1 (caller falls back per-image). +// All of this is pure shell + an optional resize; nothing depends on the client. +function chunkImageMetadataPaths(paths: readonly string[]): string[][] { + const chunks: string[][] = []; + let current: string[] = []; + let currentChars = 0; + for (const path of paths) { + const pathChars = Math.max(quotePosixShellArg(path).length, quotePowerShellLiteral(path).length) + 2; + if (current.length && (current.length >= IMAGE_METADATA_PATHS_PER_CHUNK || currentChars + pathChars > IMAGE_METADATA_COMMAND_MAX_CHARS)) { + chunks.push(current); + current = []; + currentChars = 0; + } + current.push(path); + currentChars += pathChars; + } + if (current.length) { + chunks.push(current); + } + return chunks; +} + +function buildPosixReadImageMetadataBatchCommand(paths: readonly string[], headerBytes: number): string { + const list = paths.map(quotePosixShellArg).join(' '); + return [ + `if command -v vipsheader >/dev/null 2>&1; then T=vips; elif command -v magick >/dev/null 2>&1; then T=magick; elif command -v identify >/dev/null 2>&1; then T=identify; elif command -v convert >/dev/null 2>&1; then T=convert; else T=; fi`, + `tmp=$(mktemp 2>/dev/null) || exit 1`, + `: > "$tmp" || exit 1`, + `trap 'rm -f "$tmp"' 0 1 2 15`, + `for f in ${list}; do`, + `[ -f "$f" ] || continue`, + `sz=$(stat -c %s -- "$f" 2>/dev/null) || continue`, + `mt=$(stat -c %Y -- "$f" 2>/dev/null) || continue`, + `w=; h=`, + `case "$T" in vips) w=$(vipsheader -f width "$f" 2>/dev/null); h=$(vipsheader -f height "$f" 2>/dev/null);; magick) d=$(magick identify -ping -format '%w %h' "$f[0]" 2>/dev/null | head -n 1); set -- $d; w=$1; h=$2;; identify) d=$(identify -ping -format '%w %h' "$f[0]" 2>/dev/null | head -n 1); set -- $d; w=$1; h=$2;; convert) d=$(convert -ping "$f[0]" -format '%w %h' info: 2>/dev/null | head -n 1); set -- $d; w=$1; h=$2;; esac`, + `case "$w:$h" in *[!0-9:]*|:*|*:) w=; h=;; esac`, + `if [ -n "$w" ] && [ "$w" -gt 0 ] && [ "$h" -gt 0 ]; then n=0; else w=0; h=0; if head -c ${headerBytes} -- "$f" > "$tmp" 2>/dev/null; then n=$(wc -c < "$tmp" | tr -d ' \\t\\n'); else : > "$tmp"; n=0; fi; fi`, + `printf '%s\\0%s\\0%s\\0%s\\0%s\\0%s\\0' "$f" "$sz" "$mt" "$w" "$h" "$n"`, + `[ "$n" -eq 0 ] || cat "$tmp"`, + `done`, + `rm -f "$tmp"`, + `trap - 0 1 2 15`, + `:` + ].join('\n'); +} + +function buildWindowsReadImageMetadataBatchCommand(paths: readonly string[], headerBytes: number): string { + const list = paths.map(quotePowerShellLiteral).join(','); + return buildPowerShellCommand(withPowerShellDefaults(`$paths=@(${list}); $result=@(); foreach ($path in $paths) { try { if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { continue }; $item=Get-Item -LiteralPath $path; [int]$length=[Math]::Min([long]$item.Length,${headerBytes}); $buffer=New-Object byte[] $length; $stream=[System.IO.File]::OpenRead($path); try { $total=0; while ($total -lt $length) { $read=$stream.Read($buffer,$total,$length-$total); if ($read -le 0) { break }; $total+=$read }; if ($total -lt $length) { $partial=New-Object byte[] $total; [Array]::Copy($buffer,$partial,$total); $buffer=$partial } } finally { $stream.Dispose() }; $result+=[pscustomobject]@{ path=$path; size=[long]$item.Length; modified=[DateTimeOffset]::new($item.LastWriteTimeUtc).ToUnixTimeMilliseconds(); header=[Convert]::ToBase64String($buffer) } } catch { continue } }; @($result) | ConvertTo-Json -Compress`)); +} + +function parseWindowsImageMetadataBatchOutput(output: Buffer): ITurboFileImageMetadataEntry[] { + const text = output.toString('utf8').replace(/^\uFEFF/, '').trim(); + if (!text) { + return []; + } + const parsed: unknown = JSON.parse(text); + const values = Array.isArray(parsed) ? parsed : [parsed]; + const result: ITurboFileImageMetadataEntry[] = []; + for (const value of values) { + if (!value || typeof value !== 'object') { + continue; + } + const candidate = value as Record; + if (typeof candidate.path !== 'string' || typeof candidate.size !== 'number' || typeof candidate.modified !== 'number') { + continue; + } + const header = typeof candidate.header === 'string' ? Buffer.from(candidate.header, 'base64') : Buffer.alloc(0); + const dimensions = readTurboFileImageDimensions(candidate.path, header); + result.push({ path: candidate.path, size: candidate.size, modified: candidate.modified, ...(dimensions ? { width: dimensions.width, height: dimensions.height } : {}) }); + } + return result; +} + +function buildPosixReadImagesBatchCommand(paths: readonly string[], maxBytes: number, maxWidth: number): string { + const list = paths.map(path => quotePosixShellArg(path)).join(' '); + // maxWidth <= 0 means "raw original, no downscale" (used by the single-image preview). Otherwise we + // downscale large originals to a thumbnail when a remote tool exists. Everything is pure shell + + // `cat` (and an optional resize), so it works on minimal busybox shells and never depends on SFTP — + // boards/NAS where SFTP is flaky or no image tool exists still stream the raw bytes for the client + // to decode. A trailing `:` guarantees a zero exit so a single unreadable file never fails the batch. + const forceRaw = !maxWidth || maxWidth <= 0; + const width = Math.max(96, Math.floor(maxWidth) || 480); + const rawPassthrough = 768 * 1024; + // TIFF has no in-browser decoder, so it must be transcoded to JPEG by a remote tool regardless of + // size: full resolution for the single-image preview (forceRaw), the thumbnail width otherwise. + const tiffWidth = forceRaw ? 0 : width; + const lines: string[] = []; + // Always probe for an image tool: it downscales large originals AND is the only way to render TIFF. + // Probing is just a couple of `command -v` calls, so it stays cheap even on weak boards. + lines.push(`if command -v vipsthumbnail >/dev/null 2>&1; then T=vips`); + lines.push(`elif command -v magick >/dev/null 2>&1; then T=magick`); + lines.push(`elif command -v convert >/dev/null 2>&1; then T=convert`); + lines.push(`else T=; fi`); + lines.push(`emit(){ if [ "$2" -le ${maxBytes} ]; then printf '%s\\n%s\\n' "$1" "$2"; cat "$1"; else printf '%s\\n-1\\n' "$1"; fi; }`); + // Transcode $1 to JPEG file $2. $3>0 downscales to that width; $3=0 keeps the native resolution. + // `[0]` takes the first page of a multi-page TIFF for ImageMagick; vips reads page 0 by default. + lines.push(`mkjpg(){ if [ "$3" -gt 0 ]; then case "$T" in vips) vipsthumbnail "$1" --size "$3x" -o "$2[Q=82,strip]" >/dev/null 2>&1;; magick|convert) "$T" "$1[0]" -resize "$3x" -strip -quality 82 jpg:"$2" >/dev/null 2>&1;; *) return 1;; esac; else case "$T" in vips) vipsthumbnail "$1" --size 30000x30000 -o "$2[Q=88,strip]" >/dev/null 2>&1;; magick|convert) "$T" "$1[0]" -strip -quality 88 jpg:"$2" >/dev/null 2>&1;; *) return 1;; esac; fi }`); + // Emit a transcoded JPEG (length-framed); returns non-zero without emitting if transcoding fails. + lines.push(`tojpg(){ o=$(mktemp 2>/dev/null) || o=/tmp/tfthumb.$$; oj="$o.jpg"; if mkjpg "$1" "$oj" "$3" && [ -s "$oj" ]; then tz=$(wc -c < "$oj" 2>/dev/null | tr -d ' \\t\\n'); if [ -n "$tz" ] && [ "$tz" -le ${maxBytes} ]; then printf '%s\\n%s\\n' "$1" "$tz"; cat "$oj"; rm -f "$o" "$oj" 2>/dev/null; return 0; fi; fi; rm -f "$o" "$oj" 2>/dev/null; return 1; }`); + lines.push(`for f in ${list}; do`); + lines.push(`[ -f "$f" ] || continue`); + lines.push(`sz=$(wc -c < "$f" 2>/dev/null | tr -d ' \\t\\n'); [ -n "$sz" ] || continue`); + // TIFF can never stream raw (browsers can't decode it): always transcode, or report -1 if no tool. + lines.push(`case $(printf '%s' "$f" | tr 'A-Z' 'a-z') in *.tif|*.tiff) if [ -n "$T" ] && tojpg "$f" "$sz" ${tiffWidth}; then :; else printf '%s\\n-1\\n' "$f"; fi; continue;; esac`); + if (forceRaw) { + lines.push(`emit "$f" "$sz"`); + } else { + lines.push(`if [ "$sz" -le ${rawPassthrough} ] || [ -z "$T" ]; then emit "$f" "$sz"; continue; fi`); + lines.push(`if tojpg "$f" "$sz" ${width}; then :; else emit "$f" "$sz"; fi`); + } + lines.push(`done`); + lines.push(`:`); + return lines.join('\n'); +} + +function parseImageBatchOutput(buffer: Buffer): ITurboFileImageBatchEntry[] { + const entries: ITurboFileImageBatchEntry[] = []; + let position = 0; + while (position < buffer.length) { + const pathEnd = buffer.indexOf(0x0a, position); + if (pathEnd < 0) { + break; + } + const path = buffer.toString('utf8', position, pathEnd); + position = pathEnd + 1; + const sizeEnd = buffer.indexOf(0x0a, position); + if (sizeEnd < 0) { + break; + } + const size = parseInt(buffer.toString('utf8', position, sizeEnd), 10); + position = sizeEnd + 1; + if (!path) { + break; + } + if (!Number.isFinite(size) || size < 0) { + entries.push({ path, ok: false, oversized: true }); + continue; + } + if (position + size > buffer.length) { + entries.push({ path, ok: false, message: 'Truncated image batch stream.', transient: true }); + break; + } + const bytes = buffer.subarray(position, position + size); + position += size; + // TIFF is always returned transcoded to JPEG (browsers can't decode TIFF), so tag the bytes as + // JPEG rather than by the source extension. + const lowerPath = path.toLowerCase(); + const mime = lowerPath.endsWith('.tif') || lowerPath.endsWith('.tiff') ? 'image/jpeg' : getMimeType(path); + entries.push({ path, ok: true, content: bytes.toString('base64'), mime }); + } + return entries; +} + function buildPosixDeleteCommand(path: string, recursive: boolean): string { const quotedPath = quotePosixShellArg(path); const removeCommand = recursive ? `rm -rf -- ${quotedPath}` : `rm -f -- ${quotedPath}`; @@ -8954,6 +11848,9 @@ function getMimeType(path: string): string { if (lower.endsWith('.bmp')) { return 'image/bmp'; } + if (lower.endsWith('.tif') || lower.endsWith('.tiff')) { + return 'image/tiff'; + } if (lower.endsWith('.svg')) { return 'image/svg+xml'; } @@ -9010,7 +11907,7 @@ function buildImageGeometry(width: number, height: number): string { } function looksLikeWindowsPath(path: string): boolean { - return /^[A-Za-z]:[\\/]/.test(path); + return /^[A-Za-z]:[\\/]/.test(path) || /^\\\\[^\\]+\\[^\\]+(?:\\|$)/.test(path); } function getImageSize(buffer: Buffer): IImageSize | undefined { @@ -9073,7 +11970,9 @@ function tryCreateBmpPngVariant(buffer: Buffer, targetWidth: number, targetHeigh buffer: png, mime: 'image/png', width: outputSize.width, - height: outputSize.height + height: outputSize.height, + originalWidth: decoded.width, + originalHeight: decoded.height }; } @@ -9269,7 +12168,7 @@ function getDefaultExtractDirectory(path: string): string { function stripArchiveExtension(path: string): string { const lower = path.toLowerCase(); - for (const extension of ['.tar.gz', '.tgz', '.tar.bz2', '.tbz2', '.tar.xz', '.txz', '.zip', '.tar']) { + for (const extension of ['.tar.gz', '.tgz', '.tar.bz2', '.tbz2', '.tar.xz', '.txz', '.tar.zst', '.tzst', '.zip', '.rar', '.7z', '.tar', '.gz', '.bz2', '.xz']) { if (lower.endsWith(extension)) { return path.substring(0, path.length - extension.length); } @@ -9277,10 +12176,6 @@ function stripArchiveExtension(path: string): string { return path; } -function isZipPath(path: string): boolean { - return path.toLowerCase().endsWith('.zip'); -} - function formatBytes(size: number): string { if (size < 1024) { return `${size} B`; @@ -9361,21 +12256,6 @@ async function getLocalPathSizeByCommand(path: string): Promise { } } -async function getLocalBatchTargetSize(targetDirectory: string, targetNames: readonly string[]): Promise { - let total = 0; - for (const targetName of targetNames) { - total += await getLocalPathSize(join(targetDirectory, targetName)).catch(() => 0); - } - return total; -} - -async function getLocalPathTransferSize(path: string, expectedBytes?: number): Promise { - if (expectedBytes && expectedBytes > 0) { - return expectedBytes; - } - return getLocalPathSize(path); -} - function buildWindowsDirectorySizeRobocopyArgs(sourcePath: string, destinationPath: string): string[] { return [ sourcePath, diff --git a/src/vs/platform/turbofile/test/common/turbofileArchiveExtract.test.ts b/src/vs/platform/turbofile/test/common/turbofileArchiveExtract.test.ts new file mode 100644 index 00000000..e5b551af --- /dev/null +++ b/src/vs/platform/turbofile/test/common/turbofileArchiveExtract.test.ts @@ -0,0 +1,32 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { buildTurboFileRemoteExtractCommand, isTurboFileTarFamilyArchivePath } from '../../common/turbofileArchiveExtract.js'; + +suite('TurboFileArchiveExtract', function () { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('extracts zip with GBK-aware tools before generic bsdtar fallback', () => { + const command = buildTurboFileRemoteExtractCommand('/tmp/archive.zip', '\'/tmp/archive.zip\''); + + assert.ok(command.includes('unzip -O GBK'), command); + assert.ok(command.includes('metadata_encoding="gbk"'), command); + assert.ok(command.indexOf('unzip -O GBK') < command.indexOf('bsdtar -xf'), command); + assert.ok(command.indexOf('python3 -c') < command.indexOf('bsdtar -xf'), command); + assert.ok(!command.includes('then : elif'), command); + assert.ok(!command.includes('"$out" elif'), command); + }); + + test('keeps tar-family archives on tar first', () => { + assert.strictEqual(isTurboFileTarFamilyArchivePath('/tmp/data.tar.gz'), true); + + const command = buildTurboFileRemoteExtractCommand('/tmp/data.tar.gz', '\'/tmp/data.tar.gz\''); + assert.ok(command.startsWith('if command -v tar '), command); + assert.ok(!command.includes('unzip -O GBK'), command); + }); +}); diff --git a/src/vs/platform/turbofile/test/common/turbofileBatchStabilityStatic.test.ts b/src/vs/platform/turbofile/test/common/turbofileBatchStabilityStatic.test.ts new file mode 100644 index 00000000..a20aa680 --- /dev/null +++ b/src/vs/platform/turbofile/test/common/turbofileBatchStabilityStatic.test.ts @@ -0,0 +1,160 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; +import * as fs from 'fs'; +import * as path from 'path'; + +function methodBlock(source: string, name: string, nextName: string): string { + const start = source.indexOf(name); + assert.ok(start >= 0, `missing ${name}`); + const end = source.indexOf(nextName, start); + assert.ok(end > start, `missing ${nextName}`); + return source.slice(start, end); +} + +suite('TurboFileBatchStabilityStatic', () => { + const servicePath = () => path.join(process.cwd(), 'src/vs/platform/turbofile/electron-main/turbofileSshMainService.ts'); + + test('only recursively inspects known directories and unknown tar selections', () => { + const service = fs.readFileSync(servicePath(), 'utf8'); + const plain = methodBlock(service, 'private async runParallelLocalBatchTarUpload', 'private async runParallelLocalBatchTarZstdUpload'); + const zstd = methodBlock(service, 'private async runParallelLocalBatchTarZstdUpload', 'private async runParallelLocalTarUpload'); + + for (const block of [plain, zstd]) { + assert.ok(block.includes('plan.fileNames && plan.directoryNames && plan.unknownNames')); + assert.ok(block.includes('plan.unknownNames, plan.directoryNames, plan.fileNames')); + } + const collector = methodBlock(service, 'private async collectLocalTarRelativeEntries', 'private localTarListEncodingPromise'); + assert.ok(collector.includes('entries.push(...knownFileNames.map(normalizeTarListPath))')); + assert.ok(collector.includes('const directoryQueue = knownDirectoryNames.map(normalizeTarListPath)')); + assert.ok(collector.includes('mapLimit(unknownNames')); + assert.ok(collector.includes('active < LOCAL_DIRECTORY_STAT_CONCURRENCY')); + assert.ok(collector.includes('directoryQueue.push(...subDirectories)')); + assert.ok(!collector.includes('mapLimit(subDirectories')); + assert.ok(collector.includes('Cannot archive Unix socket')); + }); + + test('keeps the established high-throughput tar stream ceiling bounded', () => { + const service = fs.readFileSync(servicePath(), 'utf8'); + assert.ok(service.includes('const LOCAL_PARALLEL_TAR_CHUNK_COUNT = 12;')); + }); + + test('cleans full target paths through one null-delimited xargs process', () => { + const service = fs.readFileSync(servicePath(), 'utf8'); + const prepare = methodBlock(service, 'private async prepareRemoteBatchTarTarget', 'private async runNativeLocalTarUploadChunk'); + const builder = methodBlock(service, 'function buildPrepareTarExtractToDirectoryFromListCommand', 'function buildTarSourcePushCommand'); + + assert.ok(prepare.includes('targetNames.map(name => joinRemotePath(targetDirectory, name))')); + assert.ok(builder.includes('LC_ALL=C xargs -0 -r rm -rf --')); + assert.ok(!builder.includes('while IFS= read')); + }); + + test('aggregates fallback progress incrementally without forced per-item IPC', () => { + const service = fs.readFileSync(servicePath(), 'utf8'); + const concurrent = methodBlock(service, 'private async runConcurrentBatchTransfer', 'private fireBatchAggregateProgress'); + const aggregate = methodBlock(service, 'private fireBatchAggregateProgress', 'private async deleteBatchSources'); + + assert.ok(concurrent.includes('let aggregateTransferredBytes')); + assert.ok(concurrent.includes('let aggregateTotalBytes')); + assert.ok(!aggregate.includes('.reduce(')); + assert.ok(!concurrent.includes("'Batch transfer in progress.', completedItems, items.length, item.displayName ?? item.targetName, true")); + }); + + test('streams explicit rsync source lists instead of appending paths to commands', () => { + const service = fs.readFileSync(servicePath(), 'utf8'); + const local = methodBlock(service, 'private async tryLocalBatchRsyncTransfer', 'private async tryDirectBatchRsyncTransfer'); + const direct = methodBlock(service, 'private async tryDirectBatchRsyncTransferRoute', 'private async tryTunneledBatchRsyncRelayTransfer'); + const relay = methodBlock(service, 'private async tryTunneledBatchRsyncRelayTransfer', 'private async tryBatchTarTransfer'); + + for (const transfer of [local, direct, relay]) { + assert.ok(transfer.includes("'--files-from=-'")); + assert.ok(transfer.includes("'--from0'")); + assert.ok(transfer.includes('input:')); + } + assert.ok(!local.includes('const sourceArgs = items.map')); + assert.ok(!direct.includes('const sourceArgs = items.map')); + assert.ok(!relay.includes('const sourceArgs = items.map')); + }); + + test('limits reverse rsync workers and streams cleanup paths', () => { + const service = fs.readFileSync(servicePath(), 'utf8'); + const single = methodBlock(service, 'async tryLocalToSshReverseParallelRsyncUpload', 'private async createLocalWindowsReverseRsyncItems'); + const batch = methodBlock(service, 'async tryLocalToSshReverseParallelRsyncBatchUpload', 'private async resolveLocalWindowsRsyncSourceConnection'); + const builder = methodBlock(service, 'function buildLocalWindowsReverseParallelRsyncCommand', 'function buildLocalWindowsReverseParallelRsyncInput'); + const input = methodBlock(service, 'function buildLocalWindowsReverseParallelRsyncInput', 'function getLocalWindowsRsyncConfigPaths'); + + for (const transfer of [single, batch]) { + assert.ok(transfer.includes('input,')); + } + assert.ok(builder.includes('TURBOFILE_REVERSE_RSYNC_WORKERS')); + assert.ok(builder.includes("read -r -d \\'\\'")); + assert.ok(builder.includes('--files-from=')); + assert.ok(input.includes('createTurboFileRsyncMappedGroups')); + assert.ok(input.includes("records.push('C'")); + assert.ok(input.includes('String(items.length + groupIndex)')); + }); + + test('keeps reverse rsync progress aggregation constant-time and preserves renamed targets', () => { + const service = fs.readFileSync(servicePath(), 'utf8'); + const progress = methodBlock(service, 'private createParallelRsyncProgressReporter', 'private beginTransferGrowthTiming'); + const builder = methodBlock(service, 'function buildLocalWindowsReverseParallelRsyncCommand', 'function buildLocalWindowsReverseParallelRsyncInput'); + + assert.ok(progress.includes('aggregateTransferredBytes += nextBytes - previousBytes')); + assert.ok(progress.includes('aggregateSpeedBytesPerSecond += snapshot.speedBytesPerSecond - previousSpeed')); + assert.ok(!progress.includes('Array.from(transferredByIndex.values()).reduce')); + assert.ok(!progress.includes('Array.from(speedByIndex.values()).reduce')); + assert.ok(builder.includes('staging_path=$(mktemp -d')); + assert.ok(builder.includes('mv -n -- "$staged_source" "$target_path"')); + assert.ok(builder.includes('then status=74')); + }); + + test('allows long rsync startup scans without changing transfer stream settings', () => { + const service = fs.readFileSync(servicePath(), 'utf8'); + const direct = methodBlock(service, 'private async tryDirectBatchRsyncTransferRoute', 'private async tryTunneledBatchRsyncRelayTransfer'); + const relay = methodBlock(service, 'private async tryTunneledBatchRsyncRelayTransfer', 'private async tryBatchTarTransfer'); + + assert.ok(service.includes('const DIRECT_RSYNC_STALL_TIMEOUT_MS = 10 * 60 * 1000;')); + for (const transfer of [direct, relay]) { + assert.ok(transfer.includes('inactivityTimeoutMs: DIRECT_RSYNC_STALL_TIMEOUT_MS')); + assert.ok(transfer.includes('progressId: request.progressId')); + } + }); + + test('does not serially stat known reverse rsync batch item types', () => { + const service = fs.readFileSync(servicePath(), 'utf8'); + const batch = methodBlock(service, 'async tryLocalToSshReverseParallelRsyncBatchUpload', 'private async resolveLocalWindowsRsyncSourceConnection'); + + assert.ok(batch.includes('item.sourceType')); + assert.ok(batch.includes('mapLimit(')); + assert.ok(!batch.includes('for (const item of batchItems) {\n\t\t\t\tconst stat = await fsp.stat')); + }); + + test('keeps symlinks in reverse rsync splits and falls back for special entries', () => { + const service = fs.readFileSync(servicePath(), 'utf8'); + const reader = methodBlock(service, 'private async readLocalRsyncSplitEntries', 'async tryLocalToSshReverseParallelRsyncBatchUpload'); + + assert.ok(reader.includes('entry.isSymbolicLink()')); + assert.ok(reader.includes('return [];')); + assert.ok(!reader.includes('.filter(entry => entry.isDirectory() || entry.isFile())')); + }); + + test('does not force a progress IPC after every local copied item', () => { + const service = fs.readFileSync(servicePath(), 'utf8'); + const local = methodBlock(service, 'private async runLocalNativeBatchTransfer', 'private async runSameRemoteNativeBatchTransfer'); + + assert.ok(local.includes('index === 0')); + assert.ok(!local.includes("'Copying locally.', true, {\n\t\t\t\t\tcompletedItems: index + 1")); + }); + + test('streams large local POSIX move selections through stdin', () => { + const service = fs.readFileSync(servicePath(), 'utf8'); + const local = methodBlock(service, 'private async runLocalNativeBatchTransfer', 'private async runSameRemoteNativeBatchTransfer'); + + assert.ok(local.includes('createPosixNativeBatchTransferPlan')); + assert.ok(local.includes('input: plan.input')); + assert.ok(!local.includes("runLocalCommand('mv', ['-f', ...sources")); + }); +}); diff --git a/src/vs/platform/turbofile/test/common/turbofileBatchTarPlan.test.ts b/src/vs/platform/turbofile/test/common/turbofileBatchTarPlan.test.ts index 796d4fbc..a15622a5 100644 --- a/src/vs/platform/turbofile/test/common/turbofileBatchTarPlan.test.ts +++ b/src/vs/platform/turbofile/test/common/turbofileBatchTarPlan.test.ts @@ -5,7 +5,7 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { createLocalBatchTarPlans, createTarNullListBuffer } from '../../common/turbofileBatchTarPlan.js'; +import { createLocalBatchTarPlans, createRemoteBatchTarSourcePlan, createTarNullListBuffer, isTurboFileBatchCommandSafe } from '../../common/turbofileBatchTarPlan.js'; suite('TurboFileBatchTarPlan', function () { @@ -21,7 +21,10 @@ suite('TurboFileBatchTarPlan', function () { { parentPath: 'D:\\Work\\test\\images', names: ['a.png', 'b.png'], - targetNames: ['a.png', 'b.png'] + targetNames: ['a.png', 'b.png'], + fileNames: ['a.png', 'b.png'], + directoryNames: [], + unknownNames: [] } ]); }); @@ -37,12 +40,18 @@ suite('TurboFileBatchTarPlan', function () { { parentPath: 'D:\\Work\\test\\images', names: ['a.png', 'c.png'], - targetNames: ['a.png', 'c.png'] + targetNames: ['a.png', 'c.png'], + fileNames: ['a.png', 'c.png'], + directoryNames: [], + unknownNames: [] }, { parentPath: 'D:\\Work\\test\\detect', names: ['b.png'], - targetNames: ['b.png'] + targetNames: ['b.png'], + fileNames: ['b.png'], + directoryNames: [], + unknownNames: [] } ]); }); @@ -57,7 +66,40 @@ suite('TurboFileBatchTarPlan', function () { { parentPath: 'D:\\Work\\test', names: ['1024x40', 'images'], - targetNames: ['1024x40', 'images'] + targetNames: ['1024x40', 'images'], + fileNames: [], + directoryNames: ['1024x40', 'images'], + unknownNames: [], + containsDirectories: true + } + ]); + }); + + test('marks only plans that contain directories for recursive discovery', () => { + const plans = createLocalBatchTarPlans([ + { sourcePath: 'D:\\Work\\files\\a.png', targetName: 'a.png', sourceType: 'file' }, + { sourcePath: 'D:\\Work\\folders\\images', targetName: 'images', sourceType: 'directory' } + ]); + + assert.strictEqual(plans?.[0].containsDirectories, undefined); + assert.strictEqual(plans?.[1].containsDirectories, true); + }); + + test('marks non-ascii Windows parents for a safe tar root alias', () => { + const plans = createLocalBatchTarPlans([ + { sourcePath: 'D:\\Download\\测试目录\\a.png', targetName: 'a.png', sourceType: 'file' }, + { sourcePath: 'D:\\Download\\测试目录\\b.png', targetName: 'b.png', sourceType: 'file' } + ]); + + assert.deepStrictEqual(plans, [ + { + parentPath: 'D:\\Download\\测试目录', + names: ['a.png', 'b.png'], + targetNames: ['a.png', 'b.png'], + fileNames: ['a.png', 'b.png'], + directoryNames: [], + unknownNames: [], + requiresSafeParentPath: true } ]); }); @@ -69,12 +111,86 @@ suite('TurboFileBatchTarPlan', function () { ]), /Duplicate target name in batch tar transfer: same\.png/); }); + test('classifies a ten-thousand-file mixed selection without losing the one directory', () => { + const files = Array.from({ length: 10_000 }, (_, index) => ({ + sourcePath: `D:\\Work\\test\\images\\${String(index).padStart(5, '0')}.png`, + targetName: `${String(index).padStart(5, '0')}.png`, + sourceType: 'file' as const + })); + const plans = createLocalBatchTarPlans([ + ...files, + { sourcePath: 'D:\\Work\\test\\images\\nested', targetName: 'nested', sourceType: 'directory' } + ]); + + assert.strictEqual(plans?.length, 1); + assert.strictEqual(plans[0].names.length, 10_001); + assert.strictEqual(plans[0].fileNames.length, 10_000); + assert.deepStrictEqual(plans[0].directoryNames, ['nested']); + assert.deepStrictEqual(plans[0].unknownNames, []); + assert.strictEqual(plans[0].containsDirectories, true); + }); + + test('keeps missing and unknown source types in the unknown list', () => { + const plans = createLocalBatchTarPlans([ + { sourcePath: 'D:\\Work\\test\\mystery-a', targetName: 'mystery-a' }, + { sourcePath: 'D:\\Work\\test\\mystery-b', targetName: 'mystery-b', sourceType: 'unknown' } + ]); + + assert.deepStrictEqual(plans, [{ + parentPath: 'D:\\Work\\test', + names: ['mystery-a', 'mystery-b'], + targetNames: ['mystery-a', 'mystery-b'], + fileNames: [], + directoryNames: [], + unknownNames: ['mystery-a', 'mystery-b'] + }]); + }); + test('creates null-delimited list input for large full-directory selections', () => { const names = Array.from({ length: 715 }, (_, index) => `202506201400${String(index).padStart(6, '0')}.bmp`); const buffer = createTarNullListBuffer(names); assert.strictEqual(buffer[buffer.length - 1], 0); - assert.strictEqual(buffer.toString('utf8').split('\0').filter(Boolean).length, 715); + assert.strictEqual(new TextDecoder().decode(buffer).split('\0').filter(Boolean).length, 715); assert.ok(buffer.length > names.join(' ').length); }); + + test('rejects NUL characters in every tar manifest entry position', () => { + assert.throws(() => createTarNullListBuffer(['bad\0name', 'ok']), /cannot contain NUL/); + assert.throws(() => createTarNullListBuffer(['ok', 'bad\0name']), /cannot contain NUL/); + assert.strictEqual(createTarNullListBuffer([]).byteLength, 0); + }); + + test('keeps small remote tar selections on the inline fast path', () => { + const names = ['a.png', 'b c.png', "d'e.png"]; + const plan = createRemoteBatchTarSourcePlan('/home/ubuntu/data', names); + + assert.strictEqual(plan.input, undefined); + assert.ok(plan.command.includes('tar -cf -')); + assert.ok(plan.command.includes("'b c.png'")); + assert.ok(isTurboFileBatchCommandSafe(plan.command)); + }); + + test('streams the 6172-item reproduction through a compact null-delimited manifest', () => { + const names = Array.from({ length: 6172 }, (_, index) => `202606190023${String(index).padStart(8, '0')}_TH23061_M.png`); + const plan = createRemoteBatchTarSourcePlan('/home/th/work/Qingwei_Autotrain/data/TuoShuiShuCai/classifier_train/caigan', names); + + assert.ok(plan.input); + assert.ok(plan.command.includes('--null -T -')); + assert.ok(plan.command.length < 1024, `expected compact command, got ${plan.command.length} chars`); + assert.ok(!plan.command.includes(names[0])); + assert.strictEqual(new TextDecoder().decode(plan.input).split('\0').filter(Boolean).length, names.length); + }); + + test('preserves one hundred thousand special-character names in the manifest', () => { + const names = Array.from({ length: 100_000 }, (_, index) => `中文 空格 ' ${String(index).padStart(8, '0')}.png`); + const plan = createRemoteBatchTarSourcePlan('/home/ubuntu/极限目录', names); + + assert.ok(plan.input); + const decoded = new TextDecoder().decode(plan.input).split('\0'); + assert.strictEqual(decoded.length, names.length + 1); + assert.strictEqual(decoded[0], names[0]); + assert.strictEqual(decoded[names.length - 1], names[names.length - 1]); + assert.strictEqual(decoded[names.length], ''); + }); }); diff --git a/src/vs/platform/turbofile/test/common/turbofileImageMetadata.test.ts b/src/vs/platform/turbofile/test/common/turbofileImageMetadata.test.ts new file mode 100644 index 00000000..3b36c3e3 --- /dev/null +++ b/src/vs/platform/turbofile/test/common/turbofileImageMetadata.test.ts @@ -0,0 +1,163 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; +import { readTurboFileImageDimensions } from '../../common/turbofileImageMetadata.js'; + +suite('TurboFileImageMetadata', () => { + test('reads PNG, JPEG, GIF, and BMP dimensions from finite headers', () => { + assert.deepStrictEqual(readTurboFileImageDimensions('a.png', pngHeader(1920, 1080)), { width: 1920, height: 1080 }); + assert.deepStrictEqual(readTurboFileImageDimensions('a.jpg', jpegHeader(4032, 3024)), { width: 4032, height: 3024 }); + assert.deepStrictEqual(readTurboFileImageDimensions('a.gif', gifHeader(640, 360)), { width: 640, height: 360 }); + assert.deepStrictEqual(readTurboFileImageDimensions('a.bmp', bmpHeader(800, 600)), { width: 800, height: 600 }); + }); + + test('reads WebP and ICO dimensions without decoding image pixels', () => { + assert.deepStrictEqual(readTurboFileImageDimensions('a.webp', webpExtendedHeader(3840, 2160)), { width: 3840, height: 2160 }); + assert.deepStrictEqual(readTurboFileImageDimensions('lossless.webp', webpLosslessHeader(1024, 768)), { width: 1024, height: 768 }); + assert.deepStrictEqual(readTurboFileImageDimensions('lossy.webp', webpLossyHeader(1280, 720)), { width: 1280, height: 720 }); + assert.deepStrictEqual(readTurboFileImageDimensions('a.ico', icoHeader(256, 128)), { width: 256, height: 128 }); + assert.deepStrictEqual(readTurboFileImageDimensions('multi.ico', multiIcoHeader()), { width: 256, height: 256 }); + }); + + test('reads TIFF, SVG, and AVIF metadata containers', () => { + assert.deepStrictEqual(readTurboFileImageDimensions('a.tiff', tiffHeader(1200, 900)), { width: 1200, height: 900 }); + assert.deepStrictEqual(readTurboFileImageDimensions('a.svg', new TextEncoder().encode('')), { width: 1600, height: 900 }); + assert.deepStrictEqual(readTurboFileImageDimensions('percent.svg', new TextEncoder().encode('')), { width: 800, height: 600 }); + assert.deepStrictEqual(readTurboFileImageDimensions('prefixed.svg', new TextEncoder().encode('')), { width: 640, height: 480 }); + assert.deepStrictEqual(readTurboFileImageDimensions('a.avif', avifHeader(2560, 1440)), { width: 2560, height: 1440 }); + }); + + test('rejects truncated, corrupt, and zero-sized metadata', () => { + assert.strictEqual(readTurboFileImageDimensions('bad.png', new Uint8Array([1, 2, 3])), undefined); + assert.strictEqual(readTurboFileImageDimensions('bad.jpg', new Uint8Array([0xff, 0xd8, 0xff])), undefined); + assert.strictEqual(readTurboFileImageDimensions('bad.svg', new TextEncoder().encode('')), undefined); + }); +}); + +function pngHeader(width: number, height: number): Uint8Array { + const bytes = new Uint8Array(24); + bytes.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a], 0); + bytes.set([0x49, 0x48, 0x44, 0x52], 12); + const view = new DataView(bytes.buffer); + view.setUint32(16, width); + view.setUint32(20, height); + return bytes; +} + +function jpegHeader(width: number, height: number): Uint8Array { + return new Uint8Array([ + 0xff, 0xd8, + 0xff, 0xe0, 0x00, 0x04, 0x00, 0x00, + 0xff, 0xc0, 0x00, 0x07, 0x08, + (height >>> 8) & 0xff, height & 0xff, + (width >>> 8) & 0xff, width & 0xff, + 0xff, 0xd9 + ]); +} + +function gifHeader(width: number, height: number): Uint8Array { + const bytes = new Uint8Array(10); + bytes.set(new TextEncoder().encode('GIF89a')); + const view = new DataView(bytes.buffer); + view.setUint16(6, width, true); + view.setUint16(8, height, true); + return bytes; +} + +function bmpHeader(width: number, height: number): Uint8Array { + const bytes = new Uint8Array(30); + bytes.set([0x42, 0x4d]); + const view = new DataView(bytes.buffer); + view.setUint32(14, 40, true); + view.setInt32(18, width, true); + view.setInt32(22, height, true); + return bytes; +} + +function webpExtendedHeader(width: number, height: number): Uint8Array { + const bytes = new Uint8Array(30); + bytes.set(new TextEncoder().encode('RIFF'), 0); + bytes.set(new TextEncoder().encode('WEBP'), 8); + bytes.set(new TextEncoder().encode('VP8X'), 12); + writeUint24LE(bytes, 24, width - 1); + writeUint24LE(bytes, 27, height - 1); + return bytes; +} + +function webpLosslessHeader(width: number, height: number): Uint8Array { + const bytes = new Uint8Array(25); + bytes.set(new TextEncoder().encode('RIFF'), 0); + bytes.set(new TextEncoder().encode('WEBP'), 8); + bytes.set(new TextEncoder().encode('VP8L'), 12); + bytes[20] = 0x2f; + const encodedWidth = width - 1; + const encodedHeight = height - 1; + bytes[21] = encodedWidth & 0xff; + bytes[22] = ((encodedWidth >>> 8) & 0x3f) | ((encodedHeight & 0x03) << 6); + bytes[23] = (encodedHeight >>> 2) & 0xff; + bytes[24] = (encodedHeight >>> 10) & 0x0f; + return bytes; +} + +function webpLossyHeader(width: number, height: number): Uint8Array { + const bytes = new Uint8Array(30); + bytes.set(new TextEncoder().encode('RIFF'), 0); + bytes.set(new TextEncoder().encode('WEBP'), 8); + bytes.set(new TextEncoder().encode('VP8 '), 12); + bytes.set([0x9d, 0x01, 0x2a], 20); + const view = new DataView(bytes.buffer); + view.setUint16(23, width, true); + view.setUint16(25, height, true); + return bytes; +} + +function icoHeader(width: number, height: number): Uint8Array { + const bytes = new Uint8Array(22); + bytes.set([0, 0, 1, 0, 1, 0, width === 256 ? 0 : width, height === 256 ? 0 : height]); + return bytes; +} + +function multiIcoHeader(): Uint8Array { + const bytes = new Uint8Array(38); + bytes.set([0, 0, 1, 0, 2, 0]); + bytes.set([32, 32], 6); + bytes.set([0, 0], 22); + return bytes; +} + +function tiffHeader(width: number, height: number): Uint8Array { + const bytes = new Uint8Array(38); + bytes.set([0x49, 0x49, 42, 0]); + const view = new DataView(bytes.buffer); + view.setUint32(4, 8, true); + view.setUint16(8, 2, true); + writeTiffLongEntry(view, 10, 256, width); + writeTiffLongEntry(view, 22, 257, height); + return bytes; +} + +function avifHeader(width: number, height: number): Uint8Array { + const bytes = new Uint8Array(20); + const view = new DataView(bytes.buffer); + view.setUint32(0, 20); + bytes.set(new TextEncoder().encode('ispe'), 4); + view.setUint32(12, width); + view.setUint32(16, height); + return bytes; +} + +function writeUint24LE(bytes: Uint8Array, offset: number, value: number): void { + bytes[offset] = value & 0xff; + bytes[offset + 1] = (value >>> 8) & 0xff; + bytes[offset + 2] = (value >>> 16) & 0xff; +} + +function writeTiffLongEntry(view: DataView, offset: number, tag: number, value: number): void { + view.setUint16(offset, tag, true); + view.setUint16(offset + 2, 4, true); + view.setUint32(offset + 4, 1, true); + view.setUint32(offset + 8, value, true); +} diff --git a/src/vs/platform/turbofile/test/common/turbofileImageMetadataBatch.test.ts b/src/vs/platform/turbofile/test/common/turbofileImageMetadataBatch.test.ts new file mode 100644 index 00000000..1f816716 --- /dev/null +++ b/src/vs/platform/turbofile/test/common/turbofileImageMetadataBatch.test.ts @@ -0,0 +1,70 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; +import { parseTurboFileImageMetadataFrames } from '../../common/turbofileImageMetadataBatch.js'; + +suite('TurboFileImageMetadataBatch', () => { + test('parses multiple length-framed headers without confusing binary zero bytes for separators', () => { + const png = pngHeader(1920, 1080); + const jpeg = jpegHeader(800, 600); + const result = parseTurboFileImageMetadataFrames(concat( + frame('/photos/a.png', 4321, 1710000000, 0, 0, png), + frame('/photos/b.jpg', 9876, 1720000000, 0, 0, jpeg) + )); + + assert.deepStrictEqual(result, [ + { path: '/photos/a.png', size: 4321, modified: 1710000000000, width: 1920, height: 1080 }, + { path: '/photos/b.jpg', size: 9876, modified: 1720000000000, width: 800, height: 600 } + ]); + }); + + test('prefers dimensions supplied by a remote metadata tool', () => { + assert.deepStrictEqual(parseTurboFileImageMetadataFrames(frame('/photos/a.tiff', 123, 1700000000, 6000, 4000, new Uint8Array())), [ + { path: '/photos/a.tiff', size: 123, modified: 1700000000000, width: 6000, height: 4000 } + ]); + }); + + test('keeps stat metadata when dimensions are unknown and ignores a truncated frame', () => { + const complete = frame('/photos/a.raw', 10, 1700000000, 0, 0, new Uint8Array([1, 2, 3])); + const truncated = frame('/photos/b.png', 20, 1700000001, 0, 0, pngHeader(10, 20)).subarray(0, -2); + assert.deepStrictEqual(parseTurboFileImageMetadataFrames(concat(complete, truncated)), [ + { path: '/photos/a.raw', size: 10, modified: 1700000000000 } + ]); + }); +}); + +function frame(path: string, size: number, modifiedSeconds: number, width: number, height: number, header: Uint8Array): Uint8Array { + return concat(new TextEncoder().encode(`${path}\0${size}\0${modifiedSeconds}\0${width}\0${height}\0${header.byteLength}\0`), header); +} + +function concat(...parts: readonly Uint8Array[]): Uint8Array { + const result = new Uint8Array(parts.reduce((total, part) => total + part.byteLength, 0)); + let offset = 0; + for (const part of parts) { + result.set(part, offset); + offset += part.byteLength; + } + return result; +} + +function pngHeader(width: number, height: number): Uint8Array { + const bytes = new Uint8Array(24); + bytes.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a], 0); + bytes.set([0x49, 0x48, 0x44, 0x52], 12); + const view = new DataView(bytes.buffer); + view.setUint32(16, width); + view.setUint32(20, height); + return bytes; +} + +function jpegHeader(width: number, height: number): Uint8Array { + return new Uint8Array([ + 0xff, 0xd8, + 0xff, 0xc0, 0x00, 0x07, 0x08, + (height >>> 8) & 0xff, height & 0xff, + (width >>> 8) & 0xff, width & 0xff + ]); +} diff --git a/src/vs/platform/turbofile/test/common/turbofileKeyedGeneration.test.ts b/src/vs/platform/turbofile/test/common/turbofileKeyedGeneration.test.ts new file mode 100644 index 00000000..8c753d12 --- /dev/null +++ b/src/vs/platform/turbofile/test/common/turbofileKeyedGeneration.test.ts @@ -0,0 +1,27 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; +import { TurboFileKeyedGeneration } from '../../common/turbofileKeyedGeneration.js'; + +suite('TurboFileKeyedGeneration', () => { + test('stale generations cannot clear a newer resource for the same key', () => { + const generations = new TurboFileKeyedGeneration(); + const oldGeneration = generations.next('ssh-session'); + const newGeneration = generations.next('ssh-session'); + + assert.strictEqual(generations.clearIfCurrent('ssh-session', oldGeneration), false); + assert.strictEqual(generations.isCurrent('ssh-session', newGeneration), true); + }); + + test('the current generation can be cleared exactly once', () => { + const generations = new TurboFileKeyedGeneration(); + const generation = generations.next('ssh-session'); + + assert.strictEqual(generations.clearIfCurrent('ssh-session', generation), true); + assert.strictEqual(generations.clearIfCurrent('ssh-session', generation), false); + assert.strictEqual(generations.current('ssh-session'), undefined); + }); +}); diff --git a/src/vs/platform/turbofile/test/common/turbofileMutationStabilityStatic.test.ts b/src/vs/platform/turbofile/test/common/turbofileMutationStabilityStatic.test.ts new file mode 100644 index 00000000..91900e36 --- /dev/null +++ b/src/vs/platform/turbofile/test/common/turbofileMutationStabilityStatic.test.ts @@ -0,0 +1,283 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; +import * as fs from 'fs'; +import * as path from 'path'; + +function block(source: string, startName: string, endName: string): string { + const start = source.indexOf(startName); + assert.ok(start >= 0, `missing ${startName}`); + const end = source.indexOf(endName, start); + assert.ok(end > start, `missing ${endName}`); + return source.slice(start, end); +} + +suite('TurboFileMutationStabilityStatic', () => { + const servicePath = () => path.join(process.cwd(), 'src/vs/platform/turbofile/electron-main/turbofileSshMainService.ts'); + + test('moves POSIX recycle-bin batches with metadata in one remote command', () => { + const service = fs.readFileSync(servicePath(), 'utf8'); + const move = block(service, 'private async moveRemotePathsToTrash', 'private async listRemoteTrashItems'); + const builder = block(service, 'function buildPosixTrashMoveBatchCommand', 'function buildWindowsTrashMoveBatchCommand'); + + assert.ok(!move.includes('writeRemoteTextFile')); + assert.ok(!move.includes('renameRemotePath')); + assert.ok(move.includes('prepared.item.id, prepared.source.endpoint.path, prepared.item.payloadPath, prepared.pendingPath, prepared.metadataPath')); + assert.ok(builder.includes("read -r -d '' metadata")); + assert.ok(builder.includes('> "$pending"')); + assert.ok(builder.includes('mv -n -- "$pending" "$final"')); + assert.ok(move.includes('this.resolveRemoteTrashRoots')); + }); + + test('moves large POSIX recycle-bin batches without spawning external mv processes per item', () => { + const service = fs.readFileSync(servicePath(), 'utf8'); + const builder = block(service, 'function buildPosixTrashMoveBatchCommand', 'function buildWindowsTrashMoveBatchCommand'); + + assert.ok(builder.includes('command -v python3')); + assert.ok(builder.includes('run_with_heartbeat python3 -c')); + assert.ok(builder.includes('ctypes.CDLL(None, use_errno=True)')); + assert.ok(builder.includes('RENAME_NOREPLACE = 1')); + assert.ok(builder.includes('rename_noreplace(src, dst)')); + assert.ok(builder.includes('rename_noreplace(pending, final)')); + assert.ok(!builder.includes('os.rename(src, dst)')); + assert.ok(builder.includes('mv -n -- "$src" "$dst"')); + assert.ok(builder.includes('! path_exists "$src"')); + assert.ok(builder.includes('created_directories')); + assert.ok(builder.includes('output.flush()')); + }); + + test('lists large POSIX recycle bins in one Python process with a portable fallback', () => { + const service = fs.readFileSync(servicePath(), 'utf8'); + const builder = block(service, 'function buildPosixTrashListCommand', 'function buildWindowsTrashListCommand'); + + assert.ok(builder.includes('command -v python3')); + assert.ok(builder.includes('os.scandir(info)')); + assert.ok(builder.includes('with open(entry.path, "rb")')); + assert.ok(builder.includes('find "$info"')); + }); + + test('keeps a streaming remote recycle-bin listing alive while output continues', () => { + const service = fs.readFileSync(servicePath(), 'utf8'); + const remoteList = block(service, 'private async listRemoteTrashItems', 'private async recoverRemotePendingTrashItemsNative'); + + assert.ok(remoteList.includes('inactivityTimeoutMs: REMOTE_DIRECTORY_READ_INACTIVITY_TIMEOUT_MS')); + assert.ok(!remoteList.includes('timeoutMs: REMOTE_DIRECTORY_READ_MAX_TIMEOUT_MS')); + }); + + test('never removes pre-existing pending recycle-bin metadata on a move conflict', () => { + const service = fs.readFileSync(servicePath(), 'utf8'); + const builder = block(service, 'function buildPosixTrashMoveBatchCommand', 'function buildWindowsTrashMoveBatchCommand'); + const windowsBuilder = block(service, 'function buildWindowsTrashMoveBatchCommand', 'function parseRemoteTrashMoveResults'); + + assert.ok(builder.includes('pending_created = False')); + assert.ok(builder.includes('pending_created = True')); + assert.ok(builder.includes('if pending_created and os.path.lexists(src)')); + assert.ok(builder.includes('pending_created=0')); + assert.ok(builder.includes('pending_created=1')); + assert.ok(builder.includes('[ "$pending_created" -eq 1 ]')); + assert.ok(windowsBuilder.includes('$pendingCreated=$false')); + assert.ok(windowsBuilder.includes('$pendingCreated=$true')); + assert.ok(windowsBuilder.includes('if ($pendingCreated -and')); + assert.ok(windowsBuilder.includes('$pathExists={')); + assert.ok(windowsBuilder.includes('[System.IO.File]::GetAttributes($path)')); + const finalConflictCheck = windowsBuilder.indexOf('if (& $pathExists ([string]$item.metadataPath))'); + const pendingCreate = windowsBuilder.indexOf('[System.IO.FileMode]::CreateNew'); + assert.ok(finalConflictCheck >= 0 && finalConflictCheck < pendingCreate); + assert.ok(windowsBuilder.includes('if ($pendingCreated -and -not $sourceExists -and $payloadExists)')); + assert.ok(windowsBuilder.includes('[System.IO.Directory]::Move($payloadPath, $sourcePath)')); + assert.ok(windowsBuilder.includes('[System.IO.File]::Move($payloadPath, $sourcePath)')); + assert.ok(windowsBuilder.includes('$sourceExists=$true; $payloadExists=$false')); + }); + + test('uses bounded workers for local recycle-bin and delete mutations', () => { + const service = fs.readFileSync(servicePath(), 'utf8'); + const moveToTrash = block(service, 'async movePathsToTrash', 'async listTrashItems'); + const deleteTrash = block(service, 'private async deleteTrashItemsNative', 'private async emptyTrashNative'); + const deletePaths = block(service, 'async deletePaths', 'private async deleteRemotePathNative'); + const movePaths = block(service, 'async movePathsIntoDirectory', 'async calculatePathSize'); + + for (const mutation of [moveToTrash, deleteTrash, deletePaths, movePaths]) { + assert.ok(mutation.includes('mapLimit(')); + } + assert.ok(!deleteTrash.includes('return Promise.all(items.map')); + assert.ok(!deletePaths.includes('Promise.all(targets.map')); + assert.ok(!movePaths.includes('Promise.all(targets.map')); + }); + + test('streams large Windows SSH deletion lists through stdin', () => { + const service = fs.readFileSync(servicePath(), 'utf8'); + const deletePaths = block(service, 'async deletePaths', 'private async deleteRemotePathNative'); + + assert.ok(deletePaths.includes('buildWindowsDeleteManyFromInputCommand(recursive)')); + assert.ok(deletePaths.includes('execRemoteWithInput')); + assert.ok(deletePaths.includes('JSON.stringify(targets.map(endpoint => endpoint.path))')); + }); + + test('streams large Windows local and same-device move lists through stdin', () => { + const service = fs.readFileSync(servicePath(), 'utf8'); + const localBatch = block(service, 'private async runLocalNativeBatchTransfer', 'private async runSameRemoteNativeBatchTransfer'); + const remoteBatch = block(service, 'private async runSameRemoteNativeBatchTransfer', 'private async tryLocalBatchRsyncTransfer'); + const cleanup = block(service, 'private async deleteBatchSources', 'private joinBatchTargetPath'); + const builder = block(service, 'function buildWindowsMappedBatchFromInputScript', 'function buildWindowsCopyCommand'); + + assert.ok(localBatch.includes('buildWindowsMappedBatchFromInputScript')); + assert.ok(localBatch.includes('targetPath: this.joinBatchTargetPath')); + assert.ok(localBatch.includes('input: Buffer.from(JSON.stringify(mappings)')); + assert.ok(remoteBatch.includes('buildWindowsMappedBatchFromInputCommand')); + assert.ok(remoteBatch.includes('targetPath: this.joinBatchTargetPath')); + assert.ok(remoteBatch.includes('execRemoteWithInput')); + assert.ok(builder.includes('$item.sourcePath')); + assert.ok(builder.includes('$item.targetPath')); + assert.ok(!builder.includes('-Force')); + assert.ok(cleanup.includes('buildWindowsDeleteManyFromInputCommand(true)')); + assert.ok(cleanup.includes('execRemoteWithInput')); + assert.ok(!service.includes('function buildWindowsBatchDeleteCommand')); + }); + + test('uses activity-based timeouts for long stdin batch commands', () => { + const service = fs.readFileSync(servicePath(), 'utf8'); + const execWithInput = block(service, 'private execRemoteWithInput', 'private parseRsyncProgressSnapshot'); + + assert.ok(execWithInput.includes('armInactivityTimeout')); + assert.ok(execWithInput.includes('options.inactivityTimeoutMs')); + assert.ok(execWithInput.includes('options.inactivityMessage')); + }); + + test('keeps long mutation batches alive and handles stdin failures', () => { + const service = fs.readFileSync(servicePath(), 'utf8'); + const cleanup = block(service, 'private async deleteBatchSources', 'private joinBatchTargetPath'); + const deletePaths = block(service, 'async deletePaths', 'private async deleteRemotePathNative'); + const trashMove = block(service, 'private async moveRemotePathsToTrash', 'private async listRemoteTrashItems'); + const trashDelete = block(service, 'private async deleteTrashItemsNative', 'private async emptyTrashNative'); + const localCommand = block(service, 'private runCancelableLocalCommand', 'private fireTransferProgress'); + const deleteBuilder = block(service, 'function buildWindowsDeleteManyFromInputCommand', 'function buildWindowsSizeCommand'); + const trashMoveBuilder = block(service, 'function buildWindowsTrashMoveBatchCommand', 'function parseRemoteTrashMoveResults'); + const trashDeleteBuilder = block(service, 'function buildWindowsTrashDeleteBatchCommand', 'function buildPosixTrashHasItemsCommand'); + + assert.ok(cleanup.includes('progressId')); + assert.ok(deletePaths.includes('inactivityTimeoutMs: REMOTE_DIRECTORY_READ_MAX_TIMEOUT_MS')); + assert.ok(trashMove.includes("? {}\n\t\t\t\t: { inactivityTimeoutMs: REMOTE_DIRECTORY_READ_MAX_TIMEOUT_MS")); + assert.ok(trashDelete.includes("? {}\n\t\t\t\t: { inactivityTimeoutMs: REMOTE_DIRECTORY_READ_MAX_TIMEOUT_MS")); + assert.ok(deleteBuilder.includes('[Console]::Out.WriteLine')); + assert.ok(trashMoveBuilder.includes('[Console]::Error.WriteLine')); + assert.ok(trashDeleteBuilder.includes('[Console]::Error.WriteLine')); + assert.ok(localCommand.includes("child.stdin?.on('error'")); + assert.ok(!trashMoveBuilder.includes('$results +=')); + assert.ok(!trashDeleteBuilder.includes('$results +=')); + assert.ok(trashMoveBuilder.includes('[System.Collections.Generic.List[object]]::new()')); + assert.ok(trashDeleteBuilder.includes('[System.Collections.Generic.List[object]]::new()')); + }); + + test('bounds remote exec channel acquisition and consumes stream input errors', () => { + const service = fs.readFileSync(servicePath(), 'utf8'); + const exec = block(service, 'private execRemote(client', 'private execRemoteBytes'); + const execBytes = block(service, 'private execRemoteBytes', 'private execRemoteWithInput'); + const execWithInput = block(service, 'private execRemoteWithInput', 'private parseRsyncProgressSnapshot'); + const nativeInput = block(service, 'private async runNativeSshCommandWithInput', 'private async prepareRemoteBatchTarTarget'); + const inputHelper = block(service, 'function writeInputToWritableStream', 'function waitForLocalProcessClose'); + + for (const implementation of [exec, execBytes, execWithInput]) { + assert.ok(implementation.includes('REMOTE_EXEC_CHANNEL_OPEN_TIMEOUT_MS')); + assert.ok(implementation.includes("stream.on('error'")); + assert.ok(implementation.includes('if (settled)')); + } + assert.ok(execWithInput.includes('options.progressId')); + assert.ok(nativeInput.includes('writeInputToWritableStream')); + assert.ok(inputHelper.includes("stream.on('error'")); + }); + + test('does not impose a fixed total timeout while emptying a remote recycle bin', () => { + const service = fs.readFileSync(servicePath(), 'utf8'); + const emptyTrash = block(service, 'private async emptyTrashNative', 'private async hasTrashItemsNative'); + const posixBuilder = block(service, 'function buildPosixTrashEmptyCommand', 'function buildWindowsTrashEmptyCommand'); + + assert.ok(!emptyTrash.includes('timeoutMs: REMOTE_DIRECTORY_READ_MAX_TIMEOUT_MS')); + assert.ok(emptyTrash.includes('inactivityTimeoutMs: REMOTE_DIRECTORY_READ_MAX_TIMEOUT_MS')); + assert.ok(posixBuilder.includes('run_with_heartbeat')); + }); + + test('propagates POSIX recycle-bin empty failures without command-substitution masking', () => { + const service = fs.readFileSync(servicePath(), 'utf8'); + const posixBuilder = block(service, 'function buildPosixTrashEmptyCommand', 'function buildWindowsTrashEmptyCommand'); + + assert.ok(posixBuilder.includes('set -e')); + assert.ok(posixBuilder.includes('pipefail')); + assert.ok(posixBuilder.includes('total=0')); + assert.ok(posixBuilder.includes('total=$((total + count))')); + assert.ok(posixBuilder.includes('run_with_heartbeat rm -rf')); + assert.ok(!posixBuilder.includes('counts=')); + assert.ok(!posixBuilder.includes('$({ ${invocations}; })')); + assert.ok(!posixBuilder.includes("awk '{ total += $1 }")); + }); + + test('uses nul-safe heartbeat commands for long POSIX mutations', () => { + const service = fs.readFileSync(servicePath(), 'utf8'); + const cleanup = block(service, 'private async deleteBatchSources', 'private joinBatchTargetPath'); + const deletePaths = block(service, 'private async deletePathsCore', 'private async deleteRemotePathNative'); + const movePaths = block(service, 'private async movePathsIntoDirectoryCore', 'async calculatePathSize'); + + for (const mutation of [cleanup, deletePaths, movePaths]) { + assert.ok(mutation.includes('execRemoteWithInput')); + assert.ok(mutation.includes('inactivityTimeoutMs:')); + assert.ok(!mutation.includes('timeoutMs: REMOTE_DIRECTORY_READ_MAX_TIMEOUT_MS')); + } + assert.ok(service.includes('function buildPosixDeleteManyFromInputCommand')); + assert.ok(service.includes('function buildPosixMoveManyIntoDirectoryFromInputCommand')); + }); + + test('stops POSIX heartbeat timers immediately when a mutation finishes', () => { + const heartbeat = fs.readFileSync(path.join(process.cwd(), 'src/vs/platform/turbofile/common/turbofilePosixHeartbeat.ts'), 'utf8'); + + assert.ok(heartbeat.includes('coproc TURBOFILE_HEARTBEAT_COMMAND')); + assert.ok(heartbeat.includes('read -r -t 30 command_status')); + assert.ok(heartbeat.includes('return "$command_status"')); + assert.ok(!heartbeat.includes('sleep 30')); + assert.ok(!heartbeat.includes('wait -n')); + }); + + test('deletes large POSIX recycle-bin selections in chunks under one heartbeat', () => { + const service = fs.readFileSync(servicePath(), 'utf8'); + const builder = block(service, 'function buildPosixTrashDeleteBatchCommand', 'function buildWindowsTrashDeleteBatchCommand'); + + assert.ok(builder.includes('start+=64')); + assert.ok(builder.includes('args+=("${payloads[index]}" "${metadatas[index]}")')); + assert.strictEqual(builder.match(/run_with_heartbeat/g)?.length, 1); + assert.ok(!builder.includes('run_with_heartbeat rm -rf')); + }); + + test('keeps POSIX heartbeat compatible with Bash 4.0 without polling process storms', () => { + const heartbeat = fs.readFileSync(path.join(process.cwd(), 'src/vs/platform/turbofile/common/turbofilePosixHeartbeat.ts'), 'utf8'); + + assert.ok(heartbeat.includes('coproc TURBOFILE_HEARTBEAT_COMMAND')); + assert.ok(heartbeat.includes('jobs -pr')); + assert.ok(heartbeat.includes('wait "$command_pid"')); + assert.ok(!heartbeat.includes('help wait')); + assert.ok(!heartbeat.includes('BASH_VERSINFO')); + assert.ok(!heartbeat.includes('sleep 30')); + }); + + test('bounds local recycle-bin metadata reads', () => { + const service = fs.readFileSync(servicePath(), 'utf8'); + const listLocalTrash = block(service, 'private async listLocalTrashItems', 'private async recoverLocalPendingTrashItem'); + + assert.ok(listLocalTrash.includes('mapLimit(')); + assert.ok(!listLocalTrash.includes('Promise.all(entries.map')); + }); + + test('queues every file-system mutation on its device key', () => { + const service = fs.readFileSync(servicePath(), 'utf8'); + const createDirectory = block(service, 'async createDirectory', 'private async createDirectoryCore'); + const writeFile = block(service, 'async writeFile', 'private async writeFileCore'); + const rename = block(service, 'async renamePath', 'private async renamePathCore'); + const compress = block(service, 'async compressPath', 'private async compressPathCore'); + const extract = block(service, 'async extractPath', 'private async extractPathCore'); + + for (const mutation of [createDirectory, writeFile, rename, compress, extract]) { + assert.ok(mutation.includes('this.fileOperationQueue.run')); + assert.ok(mutation.includes('getFileOperationDeviceKeys(endpoint)')); + } + }); +}); diff --git a/src/vs/platform/turbofile/test/common/turbofileOperationQueue.test.ts b/src/vs/platform/turbofile/test/common/turbofileOperationQueue.test.ts new file mode 100644 index 00000000..18e891a9 --- /dev/null +++ b/src/vs/platform/turbofile/test/common/turbofileOperationQueue.test.ts @@ -0,0 +1,297 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { TurboFileOperationCanceledError, TurboFileOperationQueue } from '../../common/turbofileOperationQueue.js'; + +suite('TurboFileOperationQueue', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('serializes operations that share a device key', async () => { + const queue = new TurboFileOperationQueue(); + const order: string[] = []; + let releaseFirst!: () => void; + const firstGate = new Promise(resolve => releaseFirst = resolve); + + const first = queue.run(['ssh:user@host:22'], async () => { + order.push('first-start'); + await firstGate; + order.push('first-end'); + }); + const second = queue.run(['ssh:user@host:22'], async () => { + order.push('second-start'); + }); + + await Promise.resolve(); + assert.deepStrictEqual(order, ['first-start']); + releaseFirst(); + await Promise.all([first, second]); + assert.deepStrictEqual(order, ['first-start', 'first-end', 'second-start']); + }); + + test('runs operations on disjoint devices concurrently', async () => { + const queue = new TurboFileOperationQueue(); + let started = 0; + let release!: () => void; + const gate = new Promise(resolve => release = resolve); + + const left = queue.run(['ssh:left'], async () => { started++; await gate; }); + const right = queue.run(['ssh:right'], async () => { started++; await gate; }); + await Promise.resolve(); + assert.strictEqual(started, 2); + release(); + await Promise.all([left, right]); + }); + + test('runs shared operations on the same device concurrently', async () => { + const queue = new TurboFileOperationQueue(); + let started = 0; + let release!: () => void; + const gate = new Promise(resolve => release = resolve); + + const first = queue.runShared(['ssh:shared'], async () => { started++; await gate; }); + const second = queue.runShared(['ssh:shared'], async () => { started++; await gate; }); + await Promise.resolve(); + assert.strictEqual(started, 2); + release(); + await Promise.all([first, second]); + }); + + test('waits for active shared operations before starting a write', async () => { + const queue = new TurboFileOperationQueue(); + const order: string[] = []; + let releaseReaders!: () => void; + const readerGate = new Promise(resolve => releaseReaders = resolve); + + const firstReader = queue.runShared(['ssh:shared'], async () => { order.push('reader-1'); await readerGate; }); + const secondReader = queue.runShared(['ssh:shared'], async () => { order.push('reader-2'); await readerGate; }); + const writer = queue.run(['ssh:shared'], async () => { order.push('writer'); }); + await Promise.resolve(); + assert.deepStrictEqual(order, ['reader-1', 'reader-2']); + releaseReaders(); + await Promise.all([firstReader, secondReader, writer]); + assert.deepStrictEqual(order, ['reader-1', 'reader-2', 'writer']); + }); + + test('does not let a later shared operation overtake a waiting writer', async () => { + const queue = new TurboFileOperationQueue(); + const order: string[] = []; + let releaseFirstReader!: () => void; + let releaseWriter!: () => void; + const firstReaderGate = new Promise(resolve => releaseFirstReader = resolve); + const writerGate = new Promise(resolve => releaseWriter = resolve); + + const firstReader = queue.runShared(['ssh:shared'], async () => { order.push('reader-1'); await firstReaderGate; }); + const writer = queue.run(['ssh:shared'], async () => { order.push('writer'); await writerGate; }); + const laterReader = queue.runShared(['ssh:shared'], async () => { order.push('reader-2'); }); + await Promise.resolve(); + assert.deepStrictEqual(order, ['reader-1']); + releaseFirstReader(); + await firstReader; + await Promise.resolve(); + assert.deepStrictEqual(order, ['reader-1', 'writer']); + releaseWriter(); + await Promise.all([writer, laterReader]); + assert.deepStrictEqual(order, ['reader-1', 'writer', 'reader-2']); + }); + + test('merges duplicate read and write keys into exclusive write access', async () => { + const queue = new TurboFileOperationQueue(); + const order: string[] = []; + let release!: () => void; + const gate = new Promise(resolve => release = resolve); + + const mixed = queue.runWithAccess({ read: ['ssh:shared'], write: ['ssh:shared'] }, async () => { + order.push('mixed'); + await gate; + }); + const reader = queue.runShared(['ssh:shared'], async () => { order.push('reader'); }); + await Promise.resolve(); + assert.deepStrictEqual(order, ['mixed']); + release(); + await Promise.all([mixed, reader]); + assert.deepStrictEqual(order, ['mixed', 'reader']); + }); + + test('acquires mixed multi-device access atomically without deadlock', async () => { + const queue = new TurboFileOperationQueue(); + const order: string[] = []; + let release!: () => void; + const gate = new Promise(resolve => release = resolve); + + const first = queue.runWithAccess({ read: ['ssh:b'], write: ['ssh:a'] }, async () => { + order.push('first'); + await gate; + }); + const second = queue.runWithAccess({ read: ['ssh:a'], write: ['ssh:b'] }, async () => { order.push('second'); }); + await Promise.resolve(); + assert.deepStrictEqual(order, ['first']); + release(); + await Promise.all([first, second]); + assert.deepStrictEqual(order, ['first', 'second']); + }); + + test('normalizes multi-device keys so opposite transfer directions cannot deadlock', async () => { + const queue = new TurboFileOperationQueue(); + const order: string[] = []; + let release!: () => void; + const gate = new Promise(resolve => release = resolve); + + const first = queue.run(['ssh:b', 'ssh:a', 'ssh:a'], async () => { + order.push('first'); + await gate; + }); + const second = queue.run(['ssh:a', 'ssh:b'], async () => order.push('second')); + await Promise.resolve(); + assert.deepStrictEqual(order, ['first']); + release(); + await Promise.all([first, second]); + assert.deepStrictEqual(order, ['first', 'second']); + }); + + test('preserves FIFO order for queued operations sharing a key', async () => { + const queue = new TurboFileOperationQueue(); + const order: number[] = []; + let release!: () => void; + const gate = new Promise(resolve => release = resolve); + + const first = queue.run(['local'], async () => { order.push(1); await gate; }); + const second = queue.run(['local'], async () => { order.push(2); }); + const third = queue.run(['local'], async () => { order.push(3); }); + await Promise.resolve(); + release(); + await Promise.all([first, second, third]); + assert.deepStrictEqual(order, [1, 2, 3]); + }); + + test('releases device keys after an operation rejects', async () => { + const queue = new TurboFileOperationQueue(); + await assert.rejects(queue.run(['local'], async () => { throw new Error('failed'); }), /failed/); + assert.strictEqual(await queue.run(['local'], async () => 42), 42); + }); + + test('releases shared access after a reader rejects', async () => { + const queue = new TurboFileOperationQueue(); + await assert.rejects(queue.runShared(['local'], async () => { throw new Error('failed'); }), /failed/); + assert.strictEqual(await queue.run(['local'], async () => 42), 42); + }); + + test('cancels a queued operation without starting it', async () => { + const queue = new TurboFileOperationQueue(); + let release!: () => void; + const gate = new Promise(resolve => release = resolve); + let secondStarted = false; + + const first = queue.run(['local'], async () => gate); + const second = queue.run(['local'], async () => { secondStarted = true; }, { id: 'queued-operation' }); + assert.strictEqual(queue.cancel('queued-operation'), true); + await assert.rejects(second, error => error instanceof TurboFileOperationCanceledError); + assert.strictEqual(secondStarted, false); + release(); + await first; + }); + + test('cancels a queued shared operation without affecting the active writer', async () => { + const queue = new TurboFileOperationQueue(); + let release!: () => void; + const gate = new Promise(resolve => release = resolve); + let readerStarted = false; + + const writer = queue.run(['local'], async () => gate); + const reader = queue.runShared(['local'], async () => { readerStarted = true; }, { id: 'queued-reader' }); + assert.strictEqual(queue.cancel('queued-reader'), true); + await assert.rejects(reader, error => error instanceof TurboFileOperationCanceledError); + assert.strictEqual(readerStarted, false); + release(); + await writer; + }); + + test('keeps draining when lifecycle observers throw', async () => { + const queue = new TurboFileOperationQueue(); + let release!: () => void; + const gate = new Promise(resolve => release = resolve); + const order: string[] = []; + + const first = queue.run(['local'], async () => { order.push('first'); await gate; }, { + onStarted: () => { throw new Error('observer failed'); } + }); + const second = queue.run(['local'], async () => { order.push('second'); }, { + onQueued: () => { throw new Error('observer failed'); } + }); + await Promise.resolve(); + assert.deepStrictEqual(order, ['first']); + release(); + await Promise.all([first, second]); + assert.deepStrictEqual(order, ['first', 'second']); + }); + + test('releases device keys before the completed operation promise settles', async () => { + const queue = new TurboFileOperationQueue(); + await queue.run(['local'], async () => undefined); + + let queuedAfterAwait = false; + await queue.run(['local'], async () => undefined, { + onQueued: () => queuedAfterAwait = true + }); + assert.strictEqual(queuedAfterAwait, false); + }); + + test('never overlaps device keys under a large mixed-key workload', async () => { + const queue = new TurboFileOperationQueue(); + const activeKeys = new Set(); + const tasks = Array.from({ length: 500 }, (_, index) => { + const keys = index % 5 === 0 + ? [`ssh:${index % 11}`, `ssh:${(index + 3) % 11}`] + : [`ssh:${index % 11}`]; + return queue.run(keys, async () => { + for (const key of keys) { + assert.ok(!activeKeys.has(key), `overlapping operation on ${key}`); + activeKeys.add(key); + } + await Promise.resolve(); + for (const key of keys) { + activeKeys.delete(key); + } + return index; + }); + }); + + const results = await Promise.all(tasks); + assert.strictEqual(results.length, 500); + assert.strictEqual(activeKeys.size, 0); + }); + + test('preserves shared and exclusive access rules under a 500 operation workload', async () => { + const queue = new TurboFileOperationQueue(); + const activeReaders = new Map(); + const activeWriters = new Set(); + const tasks = Array.from({ length: 500 }, (_, index) => { + const key = `ssh:${index % 11}`; + const shared = index % 3 !== 0; + const operation = async () => { + assert.ok(!activeWriters.has(key), `operation overlapped writer on ${key}`); + if (shared) { + activeReaders.set(key, (activeReaders.get(key) ?? 0) + 1); + } else { + assert.strictEqual(activeReaders.get(key) ?? 0, 0, `writer overlapped readers on ${key}`); + activeWriters.add(key); + } + await Promise.resolve(); + if (shared) { + activeReaders.set(key, (activeReaders.get(key) ?? 1) - 1); + } else { + activeWriters.delete(key); + } + return index; + }; + return shared ? queue.runShared([key], operation) : queue.run([key], operation); + }); + + const results = await Promise.all(tasks); + assert.deepStrictEqual([results.length, activeWriters.size, [...activeReaders.values()].reduce((sum, count) => sum + count, 0)], [500, 0, 0]); + }); +}); diff --git a/src/vs/platform/turbofile/test/common/turbofileRemoteDirectoryRetry.test.ts b/src/vs/platform/turbofile/test/common/turbofileRemoteDirectoryRetry.test.ts new file mode 100644 index 00000000..597ca5cf --- /dev/null +++ b/src/vs/platform/turbofile/test/common/turbofileRemoteDirectoryRetry.test.ts @@ -0,0 +1,29 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; +import { getRemoteDirectoryReadRetryDelay, REMOTE_DIRECTORY_READ_MAX_ATTEMPTS, shouldRetryRemoteDirectoryRead } from '../../common/turbofileRemoteDirectoryRetry.js'; + +suite('TurboFileRemoteDirectoryRetry', () => { + test('retries transient SSH/SFTP session failures', () => { + assert.strictEqual(shouldRetryRemoteDirectoryRead(new Error('No response from server')), true); + assert.strictEqual(shouldRetryRemoteDirectoryRead(new Error('Channel is not open')), true); + assert.strictEqual(shouldRetryRemoteDirectoryRead(new Error('Remote directory read timed out after 8000 ms: /home/th')), true); + }); + + test('does not retry real path or permission errors', () => { + assert.strictEqual(shouldRetryRemoteDirectoryRead(new Error('No such file or directory')), false); + assert.strictEqual(shouldRetryRemoteDirectoryRead(new Error('Permission denied')), false); + assert.strictEqual(shouldRetryRemoteDirectoryRead(new Error('Not a directory')), false); + assert.strictEqual(shouldRetryRemoteDirectoryRead(new Error('All configured authentication methods failed')), false); + }); + + test('uses two short backoffs before the third and final attempt', () => { + assert.strictEqual(REMOTE_DIRECTORY_READ_MAX_ATTEMPTS, 3); + assert.strictEqual(getRemoteDirectoryReadRetryDelay(0), 100); + assert.strictEqual(getRemoteDirectoryReadRetryDelay(1), 250); + assert.strictEqual(getRemoteDirectoryReadRetryDelay(2), undefined); + }); +}); diff --git a/src/vs/platform/turbofile/test/common/turbofileRemoteNativeBatchPlan.test.ts b/src/vs/platform/turbofile/test/common/turbofileRemoteNativeBatchPlan.test.ts new file mode 100644 index 00000000..ce6d93ce --- /dev/null +++ b/src/vs/platform/turbofile/test/common/turbofileRemoteNativeBatchPlan.test.ts @@ -0,0 +1,84 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { createPosixNativeBatchTransferPlan } from '../../common/turbofileRemoteNativeBatchPlan.js'; + +suite('TurboFileRemoteNativeBatchPlan', function () { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('keeps large same-remote POSIX copy selections out of the exec command line', () => { + const items = Array.from({ length: 2527 }, (_, index) => { + const name = `202606250105${String(index).padStart(6, '0')}_T_TH23061.png`; + return { + sourcePath: `/home/ubuntu/work/Qingwei_Autotrain/data/xiaomai/small_images/BrokenTip/${name}`, + targetName: name, + sourceType: 'file' as const + }; + }); + + const plan = createPosixNativeBatchTransferPlan(items, '/home/ubuntu/work/fankun/test/11', 'copy'); + + assert.ok(plan.command.length < 5000, `expected compact command, got ${plan.command.length} chars`); + assert.ok(plan.command.includes('read -r -d'), plan.command); + assert.ok(plan.command.includes('sources+=("$source")'), plan.command); + assert.ok(plan.command.includes('chunk_size=128'), plan.command); + assert.ok(plan.command.includes('copy_args=(-a -n)'), plan.command); + assert.ok(!plan.command.includes(items[0].sourcePath), 'source paths must be streamed through stdin'); + assert.strictEqual(plan.input[plan.input.length - 1], 0); + assert.strictEqual(new TextDecoder().decode(plan.input).split('\0').filter(Boolean).length, items.length * 3); + }); + + test('builds a stdin-driven POSIX move command', () => { + const plan = createPosixNativeBatchTransferPlan([ + { sourcePath: '/tmp/source/a.txt', targetName: 'a.txt', sourceType: 'file' }, + { sourcePath: '/tmp/source/b.txt', targetName: 'b.txt', sourceType: 'file' } + ], '/tmp/target', 'move'); + + assert.ok(plan.command.includes('read -r -d'), plan.command); + assert.ok(plan.command.includes('mv -n'), plan.command); + assert.ok(plan.command.includes('[ ! -e "$source" ] && [ ! -L "$source" ]'), plan.command); + assert.ok(!plan.command.includes('mv -f'), plan.command); + assert.deepStrictEqual(new TextDecoder().decode(plan.input).split('\0'), [ + '/tmp/source/a.txt', + 'a.txt', + 'file', + '/tmp/source/b.txt', + 'b.txt', + 'file', + '' + ]); + }); + + test('preserves conflict-generated target names outside the basename batch fast path', () => { + const plan = createPosixNativeBatchTransferPlan([ + { sourcePath: '/tmp/source/a.txt', targetName: 'a (1).txt', sourceType: 'file' } + ], '/tmp/target', 'move'); + + assert.ok(plan.command.includes('read -r -d'), plan.command); + assert.ok(plan.command.includes('dst="$target/$name"'), plan.command); + assert.ok(plan.command.includes('[ -e "$dst" ] || [ -L "$dst" ]'), plan.command); + assert.ok(!plan.command.includes('mv -f'), plan.command); + assert.ok(!plan.command.includes('/tmp/source/a.txt'), 'source paths must remain on stdin'); + assert.deepStrictEqual(new TextDecoder().decode(plan.input).split('\0'), [ + '/tmp/source/a.txt', + 'a (1).txt', + 'file', + '' + ]); + }); + + test('rejects duplicate targets and nul-delimited path corruption before execution', () => { + assert.throws(() => createPosixNativeBatchTransferPlan([ + { sourcePath: '/tmp/source/a.txt', targetName: 'same.txt', sourceType: 'file' }, + { sourcePath: '/tmp/source/b.txt', targetName: 'same.txt', sourceType: 'file' } + ], '/tmp/target', 'move'), /Duplicate target names/); + assert.throws(() => createPosixNativeBatchTransferPlan([ + { sourcePath: '/tmp/source/a\0.txt', targetName: 'a.txt', sourceType: 'file' } + ], '/tmp/target', 'copy'), /NUL/); + }); +}); diff --git a/src/vs/platform/turbofile/test/common/turbofileRemoteStat.test.ts b/src/vs/platform/turbofile/test/common/turbofileRemoteStat.test.ts new file mode 100644 index 00000000..c12f7730 --- /dev/null +++ b/src/vs/platform/turbofile/test/common/turbofileRemoteStat.test.ts @@ -0,0 +1,27 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; +import { buildPosixStatEntriesCommand, parseRemoteStatEntries } from '../../common/turbofileRemoteStat.js'; + +suite('TurboFileRemoteStat', () => { + test('keeps lazy directory metadata enrichment best-effort when one entry cannot be statted', () => { + const command = buildPosixStatEntriesCommand('/home/ubuntu/work/Qingwei_Autotrain/data', ['ok', 'missing']); + + assert.ok(command.includes('stat --printf=')); + assert.ok(command.includes('|| true')); + }); + + test('parses stat output into millisecond modified times', () => { + const nul = '\u0000'; + assert.deepStrictEqual( + parseRemoteStatEntries(`./folder${nul}8${nul}1782455840${nul}./file.txt${nul}917${nul}1782454980${nul}`), + [ + { name: 'folder', size: 8, modified: 1782455840000 }, + { name: 'file.txt', size: 917, modified: 1782454980000 } + ] + ); + }); +}); diff --git a/src/vs/platform/turbofile/test/common/turbofileRsyncBatchPlan.test.ts b/src/vs/platform/turbofile/test/common/turbofileRsyncBatchPlan.test.ts new file mode 100644 index 00000000..239a94b6 --- /dev/null +++ b/src/vs/platform/turbofile/test/common/turbofileRsyncBatchPlan.test.ts @@ -0,0 +1,67 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; +import { createTurboFileRsyncMappedGroups, createTurboFileRsyncSourceGroups } from '../../common/turbofileRsyncBatchPlan.js'; + +suite('TurboFileRsyncBatchPlan', () => { + test('groups POSIX sources by parent while preserving basenames', () => { + assert.deepStrictEqual(createTurboFileRsyncSourceGroups([ + '/srv/a/one file.png', + '/srv/b/two\nlines.png', + '/srv/a/three.png' + ], 'posix'), [ + { sourceBase: '/srv/a', relativePaths: ['one file.png', 'three.png'] }, + { sourceBase: '/srv/b', relativePaths: ['two\nlines.png'] } + ]); + }); + + test('groups Windows sources case-insensitively without changing names', () => { + assert.deepStrictEqual(createTurboFileRsyncSourceGroups([ + 'C:\\Data\\A.txt', + 'c:\\data\\B.txt', + 'D:\\Other\\C.txt' + ], 'windows'), [ + { sourceBase: 'C:\\Data', relativePaths: ['A.txt', 'B.txt'] }, + { sourceBase: 'D:\\Other', relativePaths: ['C.txt'] } + ]); + }); + + test('rejects roots and nul-containing paths', () => { + assert.throws(() => createTurboFileRsyncSourceGroups(['/'], 'posix')); + assert.throws(() => createTurboFileRsyncSourceGroups(['/srv/a\0bad'], 'posix')); + }); + + test('combines reverse rsync mappings only when basenames stay unchanged', () => { + assert.deepStrictEqual(createTurboFileRsyncMappedGroups([ + { sourcePath: '/cygdrive/d/data/a.png', targetPath: '/srv/out/a.png' }, + { sourcePath: '/cygdrive/d/data/b.png', targetPath: '/srv/out/b.png' }, + { sourcePath: '/cygdrive/d/data/c.png', targetPath: '/srv/renamed.png' } + ]), { + groups: [{ + sourceBase: '/cygdrive/d/data', + targetBase: '/srv/out', + relativePaths: ['a.png', 'b.png'] + }], + direct: [{ sourcePath: '/cygdrive/d/data/c.png', targetPath: '/srv/renamed.png' }] + }); + }); + + test('collapses ten thousand same-directory reverse mappings into one rsync process group', () => { + const mappings = Array.from({ length: 10_000 }, (_, index) => { + const name = `${String(index).padStart(5, '0')}_图像 -- "样本".png`; + return { + sourcePath: `/cygdrive/d/data/${name}`, + targetPath: `/srv/out/${name}` + }; + }); + + const plan = createTurboFileRsyncMappedGroups(mappings); + + assert.strictEqual(plan.groups.length, 1); + assert.strictEqual(plan.groups[0].relativePaths.length, mappings.length); + assert.strictEqual(plan.direct.length, 0); + }); +}); diff --git a/src/vs/platform/turbofile/test/common/turbofileSshMainServiceStatic.test.ts b/src/vs/platform/turbofile/test/common/turbofileSshMainServiceStatic.test.ts new file mode 100644 index 00000000..9d17ae42 --- /dev/null +++ b/src/vs/platform/turbofile/test/common/turbofileSshMainServiceStatic.test.ts @@ -0,0 +1,373 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; +import * as fs from 'fs'; +import * as path from 'path'; + +function methodBlock(source: string, name: string, nextName: string): string { + const start = source.indexOf(name); + return source.slice(start, source.indexOf(nextName, start)); +} + +function functionBlock(source: string, name: string): string { + const start = source.indexOf(`function ${name}`); + assert.ok(start >= 0, `missing function ${name}`); + const end = source.indexOf('\nfunction ', start + 1); + return source.slice(start, end === -1 ? source.length : end); +} + +function numericProductConstant(source: string, name: string): number { + const match = new RegExp(`const ${name} = ([0-9 *]+);`).exec(source); + assert.ok(match, `missing numeric constant ${name}`); + return match[1].split('*').reduce((product, factor) => product * Number(factor.trim()), 1); +} + +suite('TurboFileSshMainServiceStatic', () => { + const servicePath = () => path.join(process.cwd(), 'src/vs/platform/turbofile/electron-main/turbofileSshMainService.ts'); + + test('keeps remote directory retries and cache generations safe', () => { + const service = fs.readFileSync(servicePath(), 'utf8'); + const listCoreIndex = service.indexOf('private async listRemoteDirectoryCore'); + const listCoreEnd = service.indexOf('private createRemoteDirectoryListingResult', listCoreIndex); + const block = service.slice(listCoreIndex, listCoreEnd); + + assert.ok(listCoreIndex >= 0); + assert.ok(block.includes('REMOTE_DIRECTORY_READ_MAX_ATTEMPTS')); + assert.ok(block.includes('getRemoteDirectoryReadRetryDelay(attempt)')); + assert.ok(block.includes('this.remoteDirectoryListingGenerations.isCurrent')); + }); + + test('shares source reads while serializing target writes without nesting the operation queue', () => { + const service = fs.readFileSync(servicePath(), 'utf8'); + const transferFile = methodBlock(service, 'async transferFile', 'private async transferFileCore'); + const transferBatch = methodBlock(service, 'async transferBatch', 'private async transferBatchCore'); + const concurrentBatch = methodBlock(service, 'private async runConcurrentBatchTransfer', 'private fireBatchAggregateProgress'); + const uploadVerification = methodBlock(service, 'private async verifySshUploadSize', 'private async tryLocalToSshTarTransfer'); + const deletePathsCore = methodBlock(service, 'private async deletePathsCore', 'private async deleteRemotePathNative'); + + assert.ok(service.includes("import { TurboFileOperationCanceledError, TurboFileOperationQueue } from '../common/turbofileOperationQueue.js';")); + assert.ok(service.includes('private readonly fileOperationQueue = new TurboFileOperationQueue();')); + assert.ok(service.includes('function getFileOperationDeviceKey(')); + assert.ok(service.includes("return 'local';")); + assert.ok(service.includes('connection.username')); + assert.ok(service.includes('connection.host')); + assert.ok(service.includes('connection.port')); + assert.ok(service.includes("operation === 'move'")); + assert.ok(service.includes('{ read: [sourceKey], write: [targetKey] }')); + assert.ok(service.includes('this.fileOperationQueue.cancel(progressId)')); + + assert.ok(transferFile.includes('this.fileOperationQueue.runWithAccess')); + assert.ok(transferFile.includes("getTransferOperationAccess(request.source, request.target, 'copy')")); + assert.ok(transferFile.includes('id: request.progressId')); + assert.ok(transferBatch.includes('this.fileOperationQueue.runWithAccess')); + assert.ok(transferBatch.includes('getTransferOperationAccess(request.source, request.targetDirectory, operation)')); + assert.ok(transferBatch.includes('id: request.progressId')); + + assert.ok(concurrentBatch.includes('this.transferFileCore({')); + assert.ok(!concurrentBatch.includes('this.transferFile({')); + assert.ok(uploadVerification.includes('this.deletePathCore(target, true)')); + assert.ok(deletePathsCore.includes('this.deletePathCore(targets[0], recursive)')); + }); + + test('queues recycle-bin, delete, and same-device move writes on their device keys', () => { + const service = fs.readFileSync(servicePath(), 'utf8'); + const moveToTrash = methodBlock(service, 'async movePathsToTrash', 'private async movePathsToTrashCore'); + const restoreTrash = methodBlock(service, 'async restoreTrashItems', 'private async restoreTrashItemsCore'); + const permanentDelete = methodBlock(service, 'async permanentlyDeleteTrashItems', 'private async permanentlyDeleteTrashItemsCore'); + const emptyTrash = methodBlock(service, 'async emptyTrash', 'private async emptyTrashCore'); + const deletePaths = methodBlock(service, 'async deletePaths', 'private async deletePathsCore'); + const movePaths = methodBlock(service, 'async movePathsIntoDirectory', 'private async movePathsIntoDirectoryCore'); + + for (const block of [moveToTrash, restoreTrash, permanentDelete, emptyTrash, deletePaths, movePaths]) { + assert.ok(block.includes('this.fileOperationQueue.run')); + } + assert.ok(moveToTrash.includes('getFileOperationDeviceKey(request.device)')); + assert.ok(restoreTrash.includes('getFileOperationDeviceKey(request.device)')); + assert.ok(permanentDelete.includes('getFileOperationDeviceKey(request.device)')); + assert.ok(emptyTrash.includes('getFileOperationDeviceKey(device)')); + assert.ok(deletePaths.includes('getFileOperationDeviceKeys(...endpoints)')); + assert.ok(movePaths.includes('getFileOperationDeviceKeys(...sources)')); + }); + + test('lists local directory names without per-entry lstat', () => { + const service = fs.readFileSync(servicePath(), 'utf8'); + const namesIndex = service.indexOf('async listLocalDirectoryNames'); + const nextMethodIndex = service.indexOf('async statLocalEntries', namesIndex); + + assert.ok(namesIndex >= 0); + assert.ok(nextMethodIndex > namesIndex); + const block = service.slice(namesIndex, nextMethodIndex); + assert.ok(block.includes('dirent.isDirectory()')); + assert.ok(!block.includes('fsp.lstat')); + }); + + test('keeps batch direct tar transfers alive when target files are growing', () => { + const service = fs.readFileSync(servicePath(), 'utf8'); + const batchDirectIndex = service.indexOf('private async tryDirectBatchTarTransferRoute'); + const batchDirectBlock = service.slice(batchDirectIndex, service.indexOf('private async tryRelayBatchTarTransfer', batchDirectIndex)); + + assert.ok(batchDirectIndex >= 0); + assert.ok(batchDirectBlock.includes('let lastActivityAt = Date.now();')); + assert.ok(batchDirectBlock.includes('lastActivityAt = Date.now();')); + assert.ok(batchDirectBlock.includes('getActivityTime: () => lastActivityAt')); + assert.ok(batchDirectBlock.includes('Direct batch tar')); + }); + + test('reports direct tar bytes from the live stream without recursively scanning the target', () => { + const service = fs.readFileSync(servicePath(), 'utf8'); + const batchDirect = methodBlock(service, 'private async tryDirectBatchTarTransferRoute', 'private async tryRelayBatchTarTransfer'); + const singleDirect = methodBlock(service, 'private async tryDirectTarTransferRoute', 'private async tryDirectRsyncTransfer'); + const builders = service.slice(service.indexOf('function buildTarSourcePushCommand'), service.indexOf('function waitForLocalProcessClose')); + + assert.ok(batchDirect.includes('onStderr: reportTarProgress')); + assert.ok(singleDirect.includes('onStderr: reportTarProgress')); + assert.ok(!batchDirect.includes('beginRemoteBatchTargetGrowthTiming')); + assert.ok(!singleDirect.includes('beginRemotePathGrowthTiming')); + assert.ok(builders.includes('buildDirectTarProgressPipeCommand()')); + assert.ok(builders.includes('status=progress')); + }); + + test('propagates failures from every direct tar pipeline stage', () => { + const service = fs.readFileSync(servicePath(), 'utf8'); + const pipefail = functionBlock(service, 'buildPipefailBashCommand'); + const builders = [ + functionBlock(service, 'buildTarSourcePushCommand'), + functionBlock(service, 'buildTarItemsSourcePushCommand'), + functionBlock(service, 'buildPreparedTarItemsSourcePushCommand'), + functionBlock(service, 'buildTarTargetPullCommand'), + functionBlock(service, 'buildTarItemsTargetPullCommand'), + functionBlock(service, 'buildPreparedTarItemsTargetPullCommand') + ]; + + assert.ok(pipefail.includes('set -o pipefail')); + for (const builder of builders) { + assert.ok(builder.includes('buildPipefailBashCommand(')); + assert.ok(builder.includes('|')); + } + }); + + test('uses a separate long inactivity timeout for direct tar finalization', () => { + const service = fs.readFileSync(servicePath(), 'utf8'); + const batchDirect = methodBlock(service, 'private async tryDirectBatchTarTransferRoute', 'private async tryRelayBatchTarTransfer'); + const singleDirect = methodBlock(service, 'private async tryDirectTarTransferRoute', 'private async tryDirectRsyncTransfer'); + const tarTimeout = numericProductConstant(service, 'DIRECT_TAR_STALL_TIMEOUT_MS'); + const otherTransferTimeout = numericProductConstant(service, 'DIRECT_TRANSFER_STALL_TIMEOUT_MS'); + + assert.ok(tarTimeout >= 10 * 60 * 1000); + assert.ok(tarTimeout > otherTransferTimeout); + for (const transfer of [batchDirect, singleDirect]) { + assert.ok(transfer.includes('inactivityTimeoutMs: DIRECT_TAR_STALL_TIMEOUT_MS')); + assert.ok(transfer.includes('${DIRECT_TAR_STALL_TIMEOUT_MS} ms')); + } + }); + + test('bounds remote tar stream channel acquisition and closes late streams', () => { + const service = fs.readFileSync(servicePath(), 'utf8'); + const openStream = methodBlock(service, 'private openRemoteCommandStream', 'private transformImageBuffer'); + + assert.ok(openStream.includes('REMOTE_EXEC_CHANNEL_OPEN_TIMEOUT_MS')); + assert.ok(openStream.includes('setTimeout(')); + assert.ok(openStream.includes('clearTimeout(')); + assert.ok(openStream.indexOf('setTimeout(') < openStream.indexOf('client.exec(')); + assert.match(openStream, /if \(settled\) \{[\s\S]*?stream\.(?:close|destroy)\(\)/); + assert.match(openStream, /try \{[\s\S]*?client\.exec\([\s\S]*?\} catch \(error\) \{/); + }); + + test('feeds large remote batch tar selections through stdin in native and ssh2 downloads', () => { + const service = fs.readFileSync(servicePath(), 'utf8'); + const nativeDownload = methodBlock(service, 'private async runNativeLocalTarDownload', 'private async runParallelLocalDirectoryTarUpload'); + const batchDownload = methodBlock(service, 'private async runRemoteBatchTarDownload', 'private async tryDirectBatchTarTransfer'); + + assert.ok(nativeDownload.includes('remoteInput?: Uint8Array')); + assert.ok(nativeDownload.includes("writeInputToWritableStream(sshProcess.stdin, remoteInput, 'native tar download path list')")); + assert.ok(batchDownload.includes('createRemoteBatchTarSourcePlan')); + assert.ok(batchDownload.includes('sourcePlan.input')); + assert.ok(batchDownload.includes("writeInputToWritableStream(sourceStream.stream, sourcePlan.input, 'batch tar download path list')")); + }); + + test('keeps large direct and relay tar commands compact while preserving streaming progress', () => { + const service = fs.readFileSync(servicePath(), 'utf8'); + const direct = methodBlock(service, 'private async tryDirectBatchTarTransferRoute', 'private async tryRelayBatchTarTransfer'); + const relay = methodBlock(service, 'private async tryRelayBatchTarTransfer', 'private async runConcurrentBatchTransfer'); + + assert.ok(direct.includes('isTurboFileBatchCommandSafe')); + assert.ok(direct.includes('prepareRemoteBatchTarTarget')); + assert.ok(direct.includes('input: sourcePlan.input')); + assert.ok(relay.includes('createRemoteBatchTarSourcePlan')); + assert.ok(relay.includes('execRemoteWithInput')); + assert.ok(relay.includes("writeInputToWritableStream(sourceStream.stream, sourcePlan.input, 'batch tar relay path list')")); + }); + + test('requires a complete reverse rsync stdin manifest before source cleanup can succeed', () => { + const service = fs.readFileSync(servicePath(), 'utf8'); + const builder = methodBlock(service, 'function buildLocalWindowsReverseParallelRsyncCommand', 'function buildLocalWindowsReverseParallelRsyncInput'); + const input = methodBlock(service, 'function buildLocalWindowsReverseParallelRsyncInput', 'function getLocalWindowsRsyncConfigPaths'); + + assert.ok(builder.includes("'set -e'")); + assert.ok(builder.indexOf("'set -e'") < builder.indexOf("'cat > \"$manifest\"'")); + assert.ok(builder.includes('saw_end=0')); + assert.match(builder, /kind[^\n]*Z[^\n]*saw_end=1/); + assert.ok(builder.indexOf('kind" = Z') < builder.indexOf('kind" = C')); + assert.ok( + builder.includes('[ "$saw_end" -eq 1 ] || exit 1') + || builder.includes('if [ "$saw_end" -ne 1 ]; then exit 1; fi') + ); + assert.ok(input.includes("records.push('Z', '', '', '', '', '')")); + }); + + test('deletes a large remote cut selection through a null-delimited list', () => { + const service = fs.readFileSync(servicePath(), 'utf8'); + const cleanup = methodBlock(service, 'private async deleteBatchSources', 'private joinBatchTargetPath'); + + assert.ok(cleanup.includes('buildPosixDeleteManyFromInputCommand(true)')); + assert.ok(cleanup.includes('execRemoteWithInput')); + assert.ok(cleanup.includes('inactivityTimeoutMs')); + }); + + test('captures a Windows target before local cut drag but keeps local copy direct', () => { + const service = fs.readFileSync(servicePath(), 'utf8'); + const startIndex = service.indexOf('async startWindowsFileDrag'); + const startBlock = service.slice(startIndex, service.indexOf('private async startCapturedWindowsFileDrag', startIndex)); + + assert.ok(startIndex >= 0); + assert.ok(startBlock.includes("const operation = request.operation ?? 'copy';")); + assert.ok(startBlock.includes("request.source.kind === 'local' && operation === 'copy'")); + assert.ok(startBlock.includes('return this.startCapturedWindowsFileDrag(request, window);')); + }); + + test('isolates captured Windows shell drag from the Electron main process', () => { + const service = fs.readFileSync(servicePath(), 'utf8'); + const capturedDrag = methodBlock(service, 'private async startCapturedWindowsFileDrag', 'private getWindowsDragCopyHookPaths'); + + assert.ok(capturedDrag.includes('startWindowsDragSourceHelper(paths.fakeDirectory)')); + assert.ok(!capturedDrag.includes('window.webContents.startDrag')); + assert.ok(service.includes("TurboFileDragSource.exe")); + }); + + test('starts local folder and batch copies without recursive size discovery', () => { + const service = fs.readFileSync(servicePath(), 'utf8'); + const transferFile = methodBlock(service, 'async transferFile', 'async transferBatch'); + const localBatch = methodBlock(service, 'private async runLocalNativeBatchTransfer', 'private async runSameRemoteNativeBatchTransfer'); + const nativeDirectoryCopy = methodBlock(service, 'private async copyLocalDirectoryNativeWithProgress', 'private async moveLocalPathNative'); + + assert.ok(!transferFile.includes('getLocalPathTransferSize(request.source.path')); + assert.ok(!localBatch.includes('getLocalBatchTransferSize')); + assert.ok(!service.includes('private async getLocalBatchTransferSize')); + assert.ok(localBatch.includes('totalBytes > 0 ? Math.min(transferredBytes, totalBytes) : transferredBytes')); + assert.ok(nativeDirectoryCopy.indexOf('runLocalRobocopyCommand') < nativeDirectoryCopy.indexOf('getLocalPathSize(targetPath)')); + }); + + test('does not discover directory totals before protocol dispatch', () => { + const service = fs.readFileSync(servicePath(), 'utf8'); + const batchTar = methodBlock(service, 'private async tryBatchTarTransfer', 'private createRemoteBatchTarPlan'); + const sameRemote = methodBlock(service, 'private async trySameRemoteCopy', 'private async tryDirectTarTransfer'); + const directTar = methodBlock(service, 'private async tryDirectTarTransfer', 'private async tryDirectTarTransferRoute'); + const directScp = methodBlock(service, 'private async tryDirectScpTransfer', 'private async tryDirectScpTransferRoute'); + const directSftp = methodBlock(service, 'private async tryDirectSftpTransferRoute', 'private async tryTunneledRsyncRelayTransfer'); + const tarRelay = methodBlock(service, 'private async tryTarRelayTransfer', 'private async tryStreamRelayTransfer'); + + assert.ok(!batchTar.includes('getLocalBatchTarBytes')); + assert.ok(!batchTar.includes('getRemoteBatchSourceContentSize')); + assert.ok(!sameRemote.includes("if (sourceType === 'directory')")); + assert.ok(!directTar.includes('getRemotePathContentSizeByCommand(request.source)')); + assert.ok(!directScp.includes('|| await this.getRemotePathSizeByCommand(request.source)')); + assert.ok(!directSftp.includes('if (bytes === 0)')); + assert.ok(!tarRelay.includes('getRemotePathContentSizeByCommand(request.source)')); + }); + + test('keeps recycle-bin deletion separate from permanent cleanup', () => { + const service = fs.readFileSync(servicePath(), 'utf8'); + const moveToTrash = methodBlock(service, 'async movePathsToTrash', 'async listTrashItems'); + const localMove = methodBlock(service, 'private async moveLocalPathToTrash', 'private async listLocalTrashItems'); + const remoteMove = methodBlock(service, 'private async moveRemotePathsToTrash', 'private async listRemoteTrashItems'); + + assert.ok(moveToTrash.includes('moveLocalPathToTrash')); + assert.ok(moveToTrash.includes('moveRemotePathsToTrash')); + assert.ok(localMove.includes('fsp.rename(source.endpoint.path, payloadPath)')); + assert.ok(remoteMove.includes('execRemoteWithInput')); + assert.ok(!moveToTrash.includes('fsp.rm(')); + assert.ok(!moveToTrash.includes('deletePath(')); + assert.ok(!moveToTrash.includes('deletePaths(')); + assert.ok(!moveToTrash.includes('transferFile(')); + assert.ok(!moveToTrash.includes('transferBatch(')); + }); + + test('writes pending recycle-bin metadata before moving a local source', () => { + const service = fs.readFileSync(servicePath(), 'utf8'); + const localMove = methodBlock(service, 'private async moveLocalPathToTrash', 'private async listLocalTrashItems'); + + assert.ok(localMove.indexOf('writeFile(pendingPath') < localMove.indexOf('fsp.rename(source.endpoint.path, payloadPath)')); + assert.ok(localMove.indexOf('fsp.rename(source.endpoint.path, payloadPath)') < localMove.indexOf('fsp.rename(pendingPath, metadataPath)')); + assert.ok(!localMove.includes('calculatePathSize')); + assert.ok(!localMove.includes('getLocalPathSize')); + }); + + test('moves a remote recycle-bin batch through one native command round trip', () => { + const service = fs.readFileSync(servicePath(), 'utf8'); + const remoteBatch = methodBlock(service, 'private async moveRemotePathsToTrash', 'private async listRemoteTrashItems'); + + assert.ok(remoteBatch.includes('execRemoteWithInput')); + assert.ok(remoteBatch.includes('buildPosixTrashMoveBatchCommand')); + assert.ok(remoteBatch.includes('buildWindowsTrashMoveBatchCommand')); + assert.ok(remoteBatch.includes('prepared.pendingPath, prepared.metadataPath, JSON.stringify(prepared.item)')); + assert.ok(!remoteBatch.includes('writeRemoteTextFile')); + assert.ok(!remoteBatch.includes('renameRemotePath')); + assert.ok(!remoteBatch.includes('transferFile(')); + assert.ok(!remoteBatch.includes('transferBatch(')); + }); + + test('lists remote recycle-bin metadata in bounded native command batches', () => { + const service = fs.readFileSync(servicePath(), 'utf8'); + const remoteList = methodBlock(service, 'private async listRemoteTrashItems', 'private async restoreRemoteTrashItem'); + + assert.ok(remoteList.includes('buildPosixTrashListCommand')); + assert.ok(remoteList.includes('buildWindowsTrashListCommand')); + assert.ok(remoteList.includes('execRemoteBytes')); + assert.ok(!remoteList.includes('withDirectorySftp')); + assert.ok(!remoteList.includes('readRemoteDirectory')); + assert.ok(!remoteList.includes('readRemoteFileBufferDirect')); + assert.ok(!remoteList.includes('remotePathExists')); + }); + + test('permanently deletes recycle-bin payloads and metadata in one native batch', () => { + const service = fs.readFileSync(servicePath(), 'utf8'); + const permanentDelete = methodBlock(service, 'async permanentlyDeleteTrashItems', 'async emptyTrash'); + + assert.ok(permanentDelete.includes('deleteTrashItemsNative')); + assert.ok(!permanentDelete.includes('deletePaths(')); + }); + + test('empties a device recycle bin directly without listing every item first', () => { + const service = fs.readFileSync(servicePath(), 'utf8'); + const emptyTrash = methodBlock(service, 'async emptyTrash', 'async getTrashState'); + + assert.ok(emptyTrash.includes('emptyTrashNative')); + assert.ok(!emptyTrash.includes('listTrashItems(')); + assert.ok(!emptyTrash.includes('permanentlyDeleteTrashItems(')); + }); + + test('probes the recycle-bin icon state without loading every metadata record', () => { + const service = fs.readFileSync(servicePath(), 'utf8'); + const getTrashState = methodBlock(service, 'async getTrashState', 'private withLocalTrashIdentity'); + + assert.ok(getTrashState.includes('hasTrashItemsNative')); + assert.ok(!getTrashState.includes('listTrashItems(')); + }); + + test('uses Chinese recycle-bin permanent deletion confirmations', () => { + const editorPath = path.join(process.cwd(), 'src/vs/workbench/contrib/turbofile/browser/turbofileEditor.ts'); + const editor = fs.readFileSync(editorPath, 'utf8'); + const permanentDelete = methodBlock(editor, 'private async permanentlyDeleteSelectedTrashItems', 'private async emptyTrash'); + const emptyTrash = methodBlock(editor, 'private async emptyTrash', 'private async copySelectedTrashOriginalPath'); + + assert.ok(permanentDelete.includes('永久删除')); + assert.ok(permanentDelete.includes('永久删除选中的 {0} 个项目吗?此操作无法撤销。')); + assert.ok(emptyTrash.includes('清空回收站')); + assert.ok(emptyTrash.includes('永久删除当前设备回收站中的所有项目吗?此操作无法撤销。')); + assert.ok(!permanentDelete.includes('Permanently Delete')); + assert.ok(!emptyTrash.includes('Empty Recycle Bin')); + }); +}); diff --git a/src/vs/platform/turbofile/test/common/turbofileTarProgress.test.ts b/src/vs/platform/turbofile/test/common/turbofileTarProgress.test.ts new file mode 100644 index 00000000..d1dd47ec --- /dev/null +++ b/src/vs/platform/turbofile/test/common/turbofileTarProgress.test.ts @@ -0,0 +1,22 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; +import { parseDirectTarProgressBytes } from '../../common/turbofileTarProgress.js'; + +suite('TurboFileTarProgress', () => { + test('parses live and final dd byte counters', () => { + assert.strictEqual(parseDirectTarProgressBytes('\r4194304 bytes (4.2 MB, 4.0 MiB) copied, 1 s, 4.2 MB/s'), 4194304); + assert.strictEqual(parseDirectTarProgressBytes('1+0 records in\n1+0 records out\n10485760 bytes (10 MB, 10 MiB) copied, 2.1 s, 5.0 MB/s\n'), 10485760); + }); + + test('returns the newest maximum from a rolling stderr buffer', () => { + assert.strictEqual(parseDirectTarProgressBytes('\r4096 bytes copied\r8192 bytes copied\r6144 bytes copied'), 8192); + }); + + test('ignores unrelated ssh and tar diagnostics', () => { + assert.strictEqual(parseDirectTarProgressBytes('ssh: connect to host 192.168.9.64 port 22 failed\ntar: exiting with failure status 2'), undefined); + }); +}); diff --git a/src/vs/platform/turbofile/test/common/turbofileTimeout.test.ts b/src/vs/platform/turbofile/test/common/turbofileTimeout.test.ts new file mode 100644 index 00000000..a91c2d72 --- /dev/null +++ b/src/vs/platform/turbofile/test/common/turbofileTimeout.test.ts @@ -0,0 +1,20 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; +import { withTurboFileTimeout } from '../../common/turbofileTimeout.js'; + +suite('TurboFileTimeout', () => { + test('resolves when the operation finishes before the timeout', async () => { + assert.strictEqual(await withTurboFileTimeout(Promise.resolve('ok'), 1000, 'timeout'), 'ok'); + }); + + test('rejects when the operation does not finish before the timeout', async () => { + await assert.rejects( + withTurboFileTimeout(new Promise(() => undefined), 1, 'directory read timed out'), + /directory read timed out/ + ); + }); +}); diff --git a/src/vs/platform/turbofile/test/common/turbofileTransfer148Baseline.test.ts b/src/vs/platform/turbofile/test/common/turbofileTransfer148Baseline.test.ts new file mode 100644 index 00000000..73f80fb4 --- /dev/null +++ b/src/vs/platform/turbofile/test/common/turbofileTransfer148Baseline.test.ts @@ -0,0 +1,42 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; +import * as fs from 'fs'; +import * as path from 'path'; + +function serviceSource(): string { + return fs.readFileSync(path.join(process.cwd(), 'src/vs/platform/turbofile/electron-main/turbofileSshMainService.ts'), 'utf8'); +} + +suite('TurboFileTransfer148Baseline', () => { + test('uses the 1.148.0 direct tar pipelines without a progress relay', () => { + const source = serviceSource(); + + assert.deepStrictEqual({ + hasDirectTarPipe: source.includes('tar -cf - -C'), + hasProgressRelayBuilder: source.includes('buildTarProgressPipeCommand'), + hasProgressMarker: source.includes('TURBOFILE_TAR_PROGRESS_MARKER') + }, { + hasDirectTarPipe: true, + hasProgressRelayBuilder: false, + hasProgressMarker: false + }); + }); + + test('does not retain post-1.148 tar staging and watchdog behavior', () => { + const source = serviceSource(); + + assert.deepStrictEqual({ + hasTarWatchdog: source.includes('createTarStreamWatchdog'), + hasTarDrainWait: source.includes('waitForTarDrain'), + hasHiddenTransferStaging: source.includes('.turbofile-transfer-${randomUUID()}.partial') + }, { + hasTarWatchdog: false, + hasTarDrainWait: false, + hasHiddenTransferStaging: false + }); + }); +}); diff --git a/src/vs/platform/turbofile/test/common/turbofileTrash.test.ts b/src/vs/platform/turbofile/test/common/turbofileTrash.test.ts new file mode 100644 index 00000000..660143a7 --- /dev/null +++ b/src/vs/platform/turbofile/test/common/turbofileTrash.test.ts @@ -0,0 +1,83 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { ITurboFileTrashItem } from '../../common/turbofile.js'; +import { createTurboFileKeepBothName, getTurboFileTrashDeviceIdentity, isTurboFileProtectedTrashSource, sortTurboFileTrashItems } from '../../common/turbofileTrash.js'; + +suite('TurboFileTrash', function () { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('keeps SSH identities stable while separating users', () => { + assert.deepStrictEqual([ + getTurboFileTrashDeviceIdentity({ + kind: 'ssh', + connection: { host: 'NAS.local', port: 22, username: 'alice' } + }), + getTurboFileTrashDeviceIdentity({ + kind: 'ssh', + connection: { host: 'nas.LOCAL', port: 22, username: 'bob' } + }) + ], [ + 'ssh:nas.local:22:alice', + 'ssh:nas.local:22:bob' + ]); + }); + + test('uses a stable identity for the local Windows user', () => { + assert.strictEqual(getTurboFileTrashDeviceIdentity({ + kind: 'local', + localUserIdentity: 'DOMAIN\\Alice' + }), 'local:domain\\alice'); + }); + + test('creates keep-both names before the final extension', () => { + assert.deepStrictEqual([ + createTurboFileKeepBothName('photo.jpg', 1), + createTurboFileKeepBothName('archive.tar.gz', 2), + createTurboFileKeepBothName('folder', 3), + createTurboFileKeepBothName('.env', 4) + ], [ + 'photo (1).jpg', + 'archive.tar (2).gz', + 'folder (3)', + '.env (4)' + ]); + }); + + test('sorts newest deletion first without mutating the source array', () => { + const items = [ + { id: 'old', deletedAt: 1 }, + { id: 'new', deletedAt: 2 }, + { id: 'middle', deletedAt: 1.5 } + ] as ITurboFileTrashItem[]; + + assert.deepStrictEqual({ + sorted: sortTurboFileTrashItems(items).map(item => item.id), + original: items.map(item => item.id) + }, { + sorted: ['new', 'middle', 'old'], + original: ['old', 'new', 'middle'] + }); + }); + + test('protects filesystem roots and the recycle-bin namespace', () => { + assert.deepStrictEqual([ + isTurboFileProtectedTrashSource('/', false), + isTurboFileProtectedTrashSource('C:\\', true), + isTurboFileProtectedTrashSource('D:\\data', true), + isTurboFileProtectedTrashSource('/mnt/data/.Trash-1000/turbofile/files/a', false), + isTurboFileProtectedTrashSource('C:\\.turbofile-trash-alice\\turbofile\\files\\a', true) + ], [ + true, + true, + false, + true, + true + ]); + }); +}); diff --git a/src/vs/platform/turbofile/test/common/turbofileWindowsPath.test.ts b/src/vs/platform/turbofile/test/common/turbofileWindowsPath.test.ts new file mode 100644 index 00000000..c1b6562b --- /dev/null +++ b/src/vs/platform/turbofile/test/common/turbofileWindowsPath.test.ts @@ -0,0 +1,46 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; +import { normalizeWindowsTransferPaths } from '../../common/turbofileWindowsPath.js'; + +suite('TurboFileWindowsPath', () => { + test('deduplicates Windows short and long names after realpath resolution', async () => { + const paths = await normalizeWindowsTransferPaths([ + 'C:\\Users\\me\\Desktop\\BAOYU_~1', + 'C:\\Users\\me\\Desktop\\BaoYu_1344_8_0_3_2' + ], async path => path.includes('BAOYU_~1') + ? 'C:\\Users\\me\\Desktop\\BaoYu_1344_8_0_3_2' + : path); + + assert.deepStrictEqual(paths, ['C:\\Users\\me\\Desktop\\BaoYu_1344_8_0_3_2']); + }); + + test('keeps the original path when realpath is unavailable', async () => { + const paths = await normalizeWindowsTransferPaths([ + 'C:\\Users\\me\\Desktop\\missing' + ], async () => { + throw new Error('not found'); + }); + + assert.deepStrictEqual(paths, ['C:\\Users\\me\\Desktop\\missing']); + }); + + test('resolves very large Explorer selections concurrently with a fixed upper bound', async () => { + const inputs = Array.from({ length: 100 }, (_, index) => `C:\\Data\\${String(index).padStart(3, '0')}.png`); + let active = 0; + let maximumActive = 0; + const paths = await normalizeWindowsTransferPaths(inputs, async path => { + active++; + maximumActive = Math.max(maximumActive, active); + await new Promise(resolve => setTimeout(resolve, 1)); + active--; + return path; + }); + + assert.deepStrictEqual(paths, inputs); + assert.strictEqual(maximumActive, 16); + }); +}); diff --git a/src/vs/platform/update/common/turbofileUpdate.ts b/src/vs/platform/update/common/turbofileUpdate.ts new file mode 100644 index 00000000..ec038fd9 --- /dev/null +++ b/src/vs/platform/update/common/turbofileUpdate.ts @@ -0,0 +1,315 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IUpdate, UpdateType } from './update.js'; + +export interface ITurboFileGitHubAsset { + readonly name?: string; + readonly browser_download_url?: string; +} + +export interface ITurboFileGitHubRelease { + readonly tag_name?: string; + readonly name?: string; + readonly draft?: boolean; + readonly prerelease?: boolean; + readonly html_url?: string; + readonly assets?: readonly ITurboFileGitHubAsset[]; +} + +export interface ITurboFileUpdateResolveOptions { + readonly arch?: string; + readonly assetNamePattern?: string; + readonly allowPrerelease?: boolean; +} + +export interface ITurboFileAutoInstallOptions { + readonly turboFileGitHubUpdatesEnabled: boolean; + readonly updateType: UpdateType; + readonly productTarget?: string; +} + +export interface ITurboFileUserSetupAdminDisablementOptions { + readonly isAdmin: boolean; + readonly productTarget?: string; + readonly turboFileGitHubUpdatesEnabled: boolean; +} + +export interface ITurboFileUpdateManifestPlatform { + readonly url?: string; + readonly sha256?: string; + readonly sha256hash?: string; + readonly signature?: string; +} + +export interface ITurboFileUpdateManifest { + readonly version?: string; + readonly name?: string; + readonly notes?: string; + readonly pub_date?: string; + readonly platforms?: Record; +} + +interface IParsedVersion { + readonly major: number; + readonly minor: number; + readonly patch: number; + readonly prerelease: readonly string[]; +} + +const VERSION_PATTERN = /(?\d+)\.(?\d+)\.(?\d+)(?:-(?[0-9A-Za-z.-]+))?/; + +export function normalizeTurboFileVersion(version: string | undefined): string | undefined { + if (!version) { + return undefined; + } + + const match = VERSION_PATTERN.exec(version); + if (!match?.groups) { + return undefined; + } + + const major = Number(match.groups.major); + const minor = Number(match.groups.minor); + const patch = Number(match.groups.patch); + if (!Number.isInteger(major) || !Number.isInteger(minor) || !Number.isInteger(patch)) { + return undefined; + } + + const prerelease = match.groups.prerelease ? `-${match.groups.prerelease}` : ''; + return `${major}.${minor}.${patch}${prerelease}`; +} + +export function compareTurboFileVersions(left: string, right: string): number { + const parsedLeft = parseTurboFileVersion(left); + const parsedRight = parseTurboFileVersion(right); + + if (!parsedLeft || !parsedRight) { + return left.localeCompare(right); + } + + for (const key of ['major', 'minor', 'patch'] as const) { + const diff = parsedLeft[key] - parsedRight[key]; + if (diff !== 0) { + return diff; + } + } + + if (parsedLeft.prerelease.length === 0 && parsedRight.prerelease.length === 0) { + return 0; + } + + if (parsedLeft.prerelease.length === 0) { + return 1; + } + + if (parsedRight.prerelease.length === 0) { + return -1; + } + + const length = Math.max(parsedLeft.prerelease.length, parsedRight.prerelease.length); + for (let i = 0; i < length; i++) { + const leftPart = parsedLeft.prerelease[i]; + const rightPart = parsedRight.prerelease[i]; + if (leftPart === undefined) { + return -1; + } + if (rightPart === undefined) { + return 1; + } + + const diff = comparePrereleaseIdentifier(leftPart, rightPart); + if (diff !== 0) { + return diff; + } + } + + return 0; +} + +export function resolveTurboFileUpdateFromGitHubRelease(release: ITurboFileGitHubRelease, currentVersion: string, options: ITurboFileUpdateResolveOptions = {}): IUpdate | undefined { + if (release.draft || (release.prerelease && !options.allowPrerelease)) { + return undefined; + } + + const version = normalizeTurboFileVersion(release.tag_name) ?? normalizeTurboFileVersion(release.name); + const normalizedCurrentVersion = normalizeTurboFileVersion(currentVersion); + if (!version || !normalizedCurrentVersion || compareTurboFileVersions(version, normalizedCurrentVersion) <= 0) { + return undefined; + } + + const asset = pickInstallerAsset(release.assets ?? [], options); + if (!asset?.browser_download_url) { + return undefined; + } + + return { + version, + productVersion: version, + url: asset.browser_download_url, + }; +} + +export function resolveTurboFileUpdateFromManifest(manifest: ITurboFileUpdateManifest, currentVersion: string, options: ITurboFileUpdateResolveOptions = {}): IUpdate | undefined { + const version = normalizeTurboFileVersion(manifest.version) ?? normalizeTurboFileVersion(manifest.name); + const normalizedCurrentVersion = normalizeTurboFileVersion(currentVersion); + if (!version || !normalizedCurrentVersion || compareTurboFileVersions(version, normalizedCurrentVersion) <= 0) { + return undefined; + } + + const platform = pickManifestPlatform(manifest.platforms, options.arch); + if (!platform?.url) { + return undefined; + } + + const update: IUpdate = { + version, + productVersion: version, + url: platform.url, + }; + const sha256hash = normalizeTurboFileSha256Hash(platform.sha256hash ?? platform.sha256); + if (sha256hash) { + update.sha256hash = sha256hash; + } + + return update; +} + +function normalizeTurboFileSha256Hash(hash: string | undefined): string | undefined { + const normalized = hash?.trim(); + if (!normalized) { + return undefined; + } + + return /^[a-fA-F0-9]{64}$/.test(normalized) ? normalized.toLowerCase() : normalized; +} + +export function shouldAutoInstallTurboFileSetupUpdate(options: ITurboFileAutoInstallOptions): boolean { + return options.turboFileGitHubUpdatesEnabled + && options.updateType === UpdateType.Setup + && options.productTarget === 'user'; +} + +export function shouldDisableWindowsUserSetupUpdatesAsAdmin(options: ITurboFileUserSetupAdminDisablementOptions): boolean { + return options.isAdmin + && options.productTarget === 'user' + && !options.turboFileGitHubUpdatesEnabled; +} + +function parseTurboFileVersion(version: string): IParsedVersion | undefined { + const normalized = normalizeTurboFileVersion(version); + if (!normalized) { + return undefined; + } + + const match = VERSION_PATTERN.exec(normalized); + if (!match?.groups) { + return undefined; + } + + return { + major: Number(match.groups.major), + minor: Number(match.groups.minor), + patch: Number(match.groups.patch), + prerelease: match.groups.prerelease ? match.groups.prerelease.split('.') : [], + }; +} + +function comparePrereleaseIdentifier(left: string, right: string): number { + const leftNumber = /^\d+$/.test(left) ? Number(left) : undefined; + const rightNumber = /^\d+$/.test(right) ? Number(right) : undefined; + + if (leftNumber !== undefined && rightNumber !== undefined) { + return leftNumber - rightNumber; + } + + if (leftNumber !== undefined) { + return -1; + } + + if (rightNumber !== undefined) { + return 1; + } + + return left.localeCompare(right); +} + +function pickInstallerAsset(assets: readonly ITurboFileGitHubAsset[], options: ITurboFileUpdateResolveOptions): ITurboFileGitHubAsset | undefined { + const pattern = createAssetPattern(options.assetNamePattern); + const arch = options.arch?.toLowerCase(); + + return assets + .filter(asset => !!asset.browser_download_url && !!asset.name && isInstallerAsset(asset.name, pattern)) + .sort((left, right) => scoreAsset(right.name!, arch) - scoreAsset(left.name!, arch))[0]; +} + +function createAssetPattern(pattern: string | undefined): RegExp | undefined { + if (!pattern) { + return undefined; + } + + try { + return new RegExp(pattern, 'i'); + } catch { + return undefined; + } +} + +function isInstallerAsset(name: string, pattern: RegExp | undefined): boolean { + if (!/\.exe$/i.test(name)) { + return false; + } + + return pattern ? pattern.test(name) : true; +} + +function pickManifestPlatform(platforms: Record | undefined, arch: string | undefined): ITurboFileUpdateManifestPlatform | undefined { + if (!platforms) { + return undefined; + } + + const keys = getManifestPlatformKeys(arch); + for (const key of keys) { + const platform = platforms[key]; + if (platform?.url) { + return platform; + } + } + + return undefined; +} + +function getManifestPlatformKeys(arch: string | undefined): readonly string[] { + switch (arch?.toLowerCase()) { + case 'arm64': + return ['windows-arm64', 'windows-aarch64', 'win32-arm64']; + case 'ia32': + case 'x86': + return ['windows-x86', 'windows-ia32', 'windows-i686', 'win32-ia32']; + case 'x64': + default: + return ['windows-x64', 'windows-x86_64', 'win32-x64', 'win64']; + } +} + +function scoreAsset(name: string, arch: string | undefined): number { + const lowerName = name.toLowerCase(); + let score = 0; + + if (lowerName.includes('setup')) { + score += 100; + } + if (lowerName.includes('user')) { + score += 20; + } + if (lowerName.includes('win32') || lowerName.includes('windows')) { + score += 10; + } + if (arch && lowerName.includes(arch)) { + score += 5; + } + + return score; +} diff --git a/src/vs/platform/update/electron-main/abstractUpdateService.ts b/src/vs/platform/update/electron-main/abstractUpdateService.ts index 1652c62e..8ead1bcd 100644 --- a/src/vs/platform/update/electron-main/abstractUpdateService.ts +++ b/src/vs/platform/update/electron-main/abstractUpdateService.ts @@ -197,11 +197,10 @@ export abstract class AbstractUpdateService implements IUpdateService { if (updateMode === 'start') { this.logService.info('update#ctor - startup checks only; automatic updates are disabled by user preference'); - // Check for updates only once after 30 seconds - setTimeout(() => this.checkForUpdates(false), 30 * 1000); + // Check for updates only once after the configured startup delay. + setTimeout(() => this.checkForUpdates(false), this.getAutomaticUpdateInitialDelayMs()); } else { - // Start checking for updates after 30 seconds - this.scheduleCheckForUpdates(30 * 1000).then(undefined, err => this.logService.error(err)); + this.scheduleCheckForUpdates(this.getAutomaticUpdateInitialDelayMs()).then(undefined, err => this.logService.error(err)); } } @@ -277,15 +276,24 @@ export abstract class AbstractUpdateService implements IUpdateService { return updateMode === 'none' ? undefined : this.productService.quality; } - private scheduleCheckForUpdates(delay = 60 * 60 * 1000): Promise { + private scheduleCheckForUpdates(delay = this.getAutomaticUpdateCheckIntervalMs()): Promise { return timeout(delay) .then(() => this.checkForUpdates(false)) .then(() => { - // Check again after 1 hour - return this.scheduleCheckForUpdates(60 * 60 * 1000); + return this.scheduleCheckForUpdates(this.getAutomaticUpdateCheckIntervalMs()); }); } + private getAutomaticUpdateInitialDelayMs(): number { + const value = this.productService.turbofileUpdates?.initialCheckDelayMs; + return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : 30 * 1000; + } + + private getAutomaticUpdateCheckIntervalMs(): number { + const value = this.productService.turbofileUpdates?.checkIntervalMs; + return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : 60 * 60 * 1000; + } + async checkForUpdates(explicit: boolean): Promise { this.logService.trace('update#checkForUpdates, state = ', this.state.type); diff --git a/src/vs/platform/update/electron-main/updateService.win32.ts b/src/vs/platform/update/electron-main/updateService.win32.ts index 63327397..dff293d9 100644 --- a/src/vs/platform/update/electron-main/updateService.win32.ts +++ b/src/vs/platform/update/electron-main/updateService.win32.ts @@ -33,9 +33,15 @@ import { IProductService } from '../../product/common/productService.js'; import { asJson, IRequestService } from '../../request/common/request.js'; import { IApplicationStorageMainService } from '../../storage/electron-main/storageMainService.js'; import { ITelemetryService } from '../../telemetry/common/telemetry.js'; +import { ITurboFileGitHubRelease, ITurboFileUpdateManifest, resolveTurboFileUpdateFromGitHubRelease, resolveTurboFileUpdateFromManifest, shouldAutoInstallTurboFileSetupUpdate as shouldAutoInstallTurboFileSetupUpdatePolicy, shouldDisableWindowsUserSetupUpdatesAsAdmin } from '../common/turbofileUpdate.js'; import { AvailableForDownload, DisablementReason, IUpdate, State, StateType, UpdateType } from '../common/update.js'; import { AbstractUpdateService, createUpdateURL, getUpdateRequestHeaders, IUpdateURLOptions, UpdateErrorClassification } from './abstractUpdateService.js'; +interface ITurboFileUpdateCheckResult { + readonly checked: boolean; + readonly update?: IUpdate; +} + interface IAvailableUpdate { packagePath: string; updateFilePath?: string; @@ -122,11 +128,19 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun const osNodeRelease = release(); this.telemetryService.publicLog2('windowsUpdateInit', { osRelease, osNodeRelease }); - if (this.productService.target === 'user' && await this.nativeHostMainService.isAdmin(undefined)) { + const isAdmin = await this.nativeHostMainService.isAdmin(undefined); + if (shouldDisableWindowsUserSetupUpdatesAsAdmin({ + isAdmin, + productTarget: this.productService.target, + turboFileGitHubUpdatesEnabled: this.isTurboFileGitHubUpdatesEnabled(), + })) { this.setState(State.Disabled(DisablementReason.RunningAsAdmin)); this.logService.info('update#ctor - updates are disabled due to running as Admin in user setup'); return; } + if (isAdmin && this.productService.target === 'user' && this.isTurboFileGitHubUpdatesEnabled()) { + this.logService.info('update#ctor - TurboFile GitHub user setup updates remain enabled while running as Admin'); + } await super.initialize(); } @@ -176,7 +190,68 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun } } + private isTurboFileGitHubUpdatesEnabled(): boolean { + const config = this.productService.turbofileUpdates; + return !!config && ( + (!!config.owner && !!config.repo) || + !!config.manifestUrl || + !!config.manifestUrls?.length + ); + } + + private getTurboFileUpdateManifestUrls(): readonly string[] { + const config = this.productService.turbofileUpdates; + if (!config) { + return []; + } + + const urls = new Set(); + for (const url of config.manifestUrls ?? []) { + if (url) { + urls.add(url); + } + } + if (config.manifestUrl) { + urls.add(config.manifestUrl); + } + if (config.owner && config.repo) { + const releaseUrl = `https://github.com/${encodeURIComponent(config.owner)}/${encodeURIComponent(config.repo)}/releases/download/updater`; + urls.add(`${releaseUrl}/update-proxy.json`); + urls.add(`${releaseUrl}/update.json`); + } + + return Array.from(urls); + } + + private buildTurboFileGitHubReleaseUrl(): string | undefined { + const config = this.productService.turbofileUpdates; + if (!config?.owner || !config.repo) { + return undefined; + } + + return config.latestReleaseUrl ?? `https://api.github.com/repos/${encodeURIComponent(config.owner)}/${encodeURIComponent(config.repo)}/releases/latest`; + } + + private getTurboFileGitHubRequestHeaders(): Record { + return { + ...(getUpdateRequestHeaders(this.productService.version) ?? {}), + Accept: 'application/vnd.github+json', + }; + } + + private getTurboFileManifestRequestHeaders(): Record { + return { + ...(getUpdateRequestHeaders(this.productService.version) ?? {}), + Accept: 'application/json', + }; + } + protected buildUpdateFeedUrl(quality: string, commit: string, options?: IUpdateURLOptions): string | undefined { + const turboFileReleaseUrl = this.buildTurboFileGitHubReleaseUrl(); + if (turboFileReleaseUrl) { + return turboFileReleaseUrl; + } + let platform = `win32-${process.arch}`; if (getUpdateType() === UpdateType.Archive) { @@ -193,6 +268,11 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun return; } + if (this.isTurboFileGitHubUpdatesEnabled()) { + this.doCheckForTurboFileGitHubUpdates(explicit, pendingCommit); + return; + } + const internalOrg = this.getInternalOrg(); const background = !explicit && !internalOrg; const url = this.buildUpdateFeedUrl(this.quality, pendingCommit ?? this.productService.commit!, { background, internalOrg }); @@ -308,13 +388,219 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun }); } + private doCheckForTurboFileGitHubUpdates(explicit: boolean, pendingVersion?: string): void { + if (this.state.type !== StateType.Overwriting) { + this.setState(State.CheckingForUpdates(explicit)); + } + + this.resolveTurboFileUpdate(pendingVersion ?? this.productService.version, CancellationToken.None) + .then(update => { + if (!update) { + if (this.state.type === StateType.Overwriting) { + this._overwrite = false; + this.setState(State.Ready(this.state.update, this.state.explicit, false)); + } else { + this.setState(State.Idle(getUpdateType(), undefined, explicit || undefined)); + } + return; + } + + this.setState(State.AvailableForDownload(update, true)); + }) + .then(undefined, err => { + this.telemetryService.publicLog2<{ messageHash: string }, UpdateErrorClassification>('update:error', { messageHash: String(hash(String(err))) }); + this.logService.error(err); + + const message: string | undefined = explicit ? (err.message || err) : undefined; + + if (this.state.type === StateType.Overwriting) { + this._overwrite = false; + this.setState(State.Ready(this.state.update, this.state.explicit, false)); + } else { + this.setState(State.Idle(getUpdateType(), message)); + } + }); + } + + private async resolveTurboFileUpdate(currentVersion: string, token: CancellationToken): Promise { + const manifestResult = await this.resolveTurboFileUpdateFromManifestUrls(currentVersion, token); + if (manifestResult.checked && manifestResult.update) { + return manifestResult.update; + } + + // Fall back to the latest GitHub release whenever the manifest is missing OR reports no update. + // Previously a readable-but-stale update.json short-circuited the whole check (checked === true), + // so publishing a new release without also refreshing the manifest meant only some clients ever + // saw the update. Checking the release here makes "just publish a versioned release with the + // installer asset" reliably reach every client, while a manifest that DOES carry an update still + // wins above (for controlled rollout / sha256 verification). Guarded so a transient GitHub API + // failure (e.g. rate limiting) never breaks the check — it simply retries on the next cycle. + try { + const releaseUpdate = await this.resolveTurboFileUpdateFromGitHubReleaseUrl(currentVersion, token); + if (releaseUpdate) { + return releaseUpdate; + } + } catch (error) { + this.logService.warn('update#resolveTurboFileUpdate(): GitHub release fallback failed'); + this.logService.warn(error); + } + + return manifestResult.checked ? manifestResult.update : undefined; + } + + private async resolveTurboFileUpdateFromManifestUrls(currentVersion: string, token: CancellationToken): Promise { + const urls = this.getTurboFileUpdateManifestUrls(); + if (!urls.length) { + return { checked: false }; + } + + const config = this.productService.turbofileUpdates; + for (const url of urls) { + try { + const context = await this.requestService.request({ url, headers: this.getTurboFileManifestRequestHeaders(), callSite: 'updateService.win32.checkTurboFileManifestUpdates' }, token); + if (context.res.statusCode === 404) { + continue; + } + if (context.res.statusCode && context.res.statusCode >= 400) { + throw new Error(`TurboFile update manifest returned HTTP ${context.res.statusCode}`); + } + + const manifest = await asJson(context); + if (!manifest) { + continue; + } + return { + checked: true, + update: resolveTurboFileUpdateFromManifest(manifest, currentVersion, { + allowPrerelease: config?.allowPrerelease, + assetNamePattern: config?.assetNamePattern, + arch: process.arch, + }), + }; + } catch (error) { + this.logService.warn(`update#resolveTurboFileUpdateFromManifestUrls(): failed to read ${url}`); + this.logService.warn(error); + } + } + + return { checked: false }; + } + + private async resolveTurboFileUpdateFromGitHubReleaseUrl(currentVersion: string, token: CancellationToken): Promise { + const url = this.buildTurboFileGitHubReleaseUrl(); + const config = this.productService.turbofileUpdates; + if (!url || !config) { + return undefined; + } + + const context = await this.requestService.request({ url, headers: this.getTurboFileGitHubRequestHeaders(), callSite: 'updateService.win32.checkTurboFileGitHubUpdates' }, token); + const release = context.res.statusCode === 404 ? null : await asJson(context); + return release ? resolveTurboFileUpdateFromGitHubRelease(release, currentVersion, { + allowPrerelease: config.allowPrerelease, + assetNamePattern: config.assetNamePattern, + arch: process.arch, + }) : undefined; + } + protected override async doDownloadUpdate(state: AvailableForDownload): Promise { + if (this.isTurboFileGitHubUpdatesEnabled() && state.update.url) { + try { + await this.downloadAndPrepareUpdate(state.update, true); + } catch (err) { + this.telemetryService.publicLog2<{ messageHash: string }, UpdateErrorClassification>('update:error', { messageHash: String(hash(String(err))) }); + this.logService.error('update#doDownloadUpdate(): failed to download TurboFile update'); + this.logService.error(err); + const message = err instanceof Error ? err.message : String(err); + this.setState(State.Idle(getUpdateType(), message)); + } + return; + } + if (state.update.url) { this.nativeHostMainService.openExternal(undefined, state.update.url); } this.setState(State.Idle(getUpdateType())); } + private async downloadAndPrepareUpdate(update: IUpdate, explicit: boolean): Promise { + if (!update.url) { + throw new Error('Missing update download URL'); + } + + const startTime = Date.now(); + this.setState(State.Downloading(update, explicit, this._overwrite, 0, undefined, startTime)); + + await this.cleanup(update.version); + const updatePackagePath = await this.getUpdatePackagePath(update.version); + const packagePath = await pfs.Promises.exists(updatePackagePath) + ? updatePackagePath + : await this.downloadUpdatePackage(update, updatePackagePath, explicit, startTime); + + this.availableUpdate = { packagePath }; + await this.saveUpdateMetadata(update); + this.setState(State.Downloaded(update, explicit, this._overwrite)); + + if (this.shouldAutoInstallTurboFileSetupUpdate()) { + this.setState(State.Ready(update, explicit, this._overwrite)); + void this.quitAndInstall(); + return; + } + + const fastUpdatesEnabled = this.configurationService.getValue('update.enableWindowsBackgroundUpdates'); + if (fastUpdatesEnabled && this.productService.target === 'user') { + void this.doApplyUpdate(); + } else { + this.setState(State.Ready(update, explicit, this._overwrite)); + } + } + + private shouldAutoInstallTurboFileSetupUpdate(): boolean { + return shouldAutoInstallTurboFileSetupUpdatePolicy({ + turboFileGitHubUpdatesEnabled: this.isTurboFileGitHubUpdatesEnabled(), + updateType: getUpdateType(), + productTarget: this.productService.target, + }); + } + + private async downloadUpdatePackage(update: IUpdate, updatePackagePath: string, explicit: boolean, startTime: number): Promise { + if (!update.url) { + throw new Error('Missing update download URL'); + } + + const downloadPath = `${updatePackagePath}.tmp`; + const context = await this.requestService.request({ url: update.url, callSite: 'updateService.win32.downloadTurboFileGitHubUpdate' }, CancellationToken.None); + + const contentLengthHeader = context.res.headers['content-length']; + const contentLength = typeof contentLengthHeader === 'string' ? contentLengthHeader : undefined; + const totalBytes = contentLength ? parseInt(contentLength, 10) : undefined; + + let downloadedBytes = 0; + const progressDelayer = new Delayer(500); + const progressStream = transform( + context.stream, + { + data: data => { + downloadedBytes += data.byteLength; + progressDelayer.trigger(() => { + this.setState(State.Downloading(update, explicit, this._overwrite, downloadedBytes, totalBytes, startTime)); + }); + return data; + } + }, + chunks => VSBuffer.concat(chunks) + ); + + await this.fileService.writeFile(URI.file(downloadPath), progressStream) + .finally(() => progressDelayer.dispose()); + + if (update.sha256hash) { + await checksum(downloadPath, update.sha256hash); + } + + await pfs.Promises.rename(downloadPath, updatePackagePath, false /* no retry */); + return updatePackagePath; + } + private async getUpdatePackagePath(version: string): Promise { const cachePath = await this.cachePath; return path.join(cachePath, `CodeSetup-${this.productService.quality}-${version}.exe`); @@ -532,6 +818,21 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun return getUpdateType(); } + override async isLatestVersion(commit?: string, token: CancellationToken = CancellationToken.None): Promise { + if (!this.isTurboFileGitHubUpdatesEnabled()) { + return super.isLatestVersion(commit, token); + } + + try { + const update = await this.resolveTurboFileUpdate(commit ?? this.productService.version, token); + return !update; + } catch (error) { + this.logService.error('update#isLatestVersion(): failed to check TurboFile update'); + this.logService.error(error); + return undefined; + } + } + override async _applySpecificUpdate(packagePath: string, commit?: string): Promise { if (this.state.type !== StateType.Idle) { return; @@ -544,8 +845,14 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun this.availableUpdate = { packagePath }; this.setState(State.Downloaded(update, true, false)); + if (this.shouldAutoInstallTurboFileSetupUpdate()) { + this.setState(State.Ready(update, true, false)); + void this.quitAndInstall(); + return; + } + if (fastUpdatesEnabled && this.productService.target === 'user') { - this.doApplyUpdate(); + void this.doApplyUpdate(); } else { this.setState(State.Ready(update, true, false)); } diff --git a/src/vs/platform/update/test/common/turbofileUpdate.test.ts b/src/vs/platform/update/test/common/turbofileUpdate.test.ts new file mode 100644 index 00000000..e5d1cde4 --- /dev/null +++ b/src/vs/platform/update/test/common/turbofileUpdate.test.ts @@ -0,0 +1,154 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; +import { resolveTurboFileUpdateFromGitHubRelease, resolveTurboFileUpdateFromManifest, shouldAutoInstallTurboFileSetupUpdate, shouldDisableWindowsUserSetupUpdatesAsAdmin } from '../../common/turbofileUpdate.js'; +import { UpdateType } from '../../common/update.js'; + +suite('TurboFile GitHub Updates', () => { + test('resolves newer Windows installer from static manifest', () => { + const update = resolveTurboFileUpdateFromManifest({ + version: '1.123.0', + name: 'TurboFile Windows 1.123.0', + notes: 'Fix updates', + pub_date: '2026-06-26T00:00:00.000Z', + platforms: { + 'windows-x64': { + url: 'https://example.com/TurboFileSetup-win32-x64-1.123.0.exe', + sha256: 'FA2686F937F3FECB5BD430E33CD1CCF2DF3BCA6997930EB276F4915178279716', + }, + 'windows-arm64': { + url: 'https://example.com/TurboFileSetup-win32-arm64-1.123.0.exe', + }, + }, + }, '1.122.0', { arch: 'x64' }); + + assert.deepStrictEqual(update, { + version: '1.123.0', + productVersion: '1.123.0', + url: 'https://example.com/TurboFileSetup-win32-x64-1.123.0.exe', + sha256hash: 'fa2686f937f3fecb5bd430e33cd1ccf2df3bca6997930eb276f4915178279716', + }); + }); + + test('ignores stale or platformless static manifest', () => { + const stale = resolveTurboFileUpdateFromManifest({ + version: '1.122.0', + platforms: { + 'windows-x64': { + url: 'https://example.com/TurboFileSetup-win32-x64-1.122.0.exe', + }, + }, + }, '1.122.0', { arch: 'x64' }); + + const missingPlatform = resolveTurboFileUpdateFromManifest({ + version: '1.123.0', + platforms: { + 'windows-arm64': { + url: 'https://example.com/TurboFileSetup-win32-arm64-1.123.0.exe', + }, + }, + }, '1.122.0', { arch: 'x64' }); + + assert.deepStrictEqual([stale, missingPlatform], [undefined, undefined]); + }); + + test('resolves newer Windows installer asset', () => { + const update = resolveTurboFileUpdateFromGitHubRelease({ + tag_name: 'v1.120.0', + draft: false, + prerelease: false, + html_url: 'https://github.com/DMwarrior/TurboFile-Windows/releases/tag/v1.120.0', + assets: [ + { name: 'TurboFile-win32-x64.zip', browser_download_url: 'https://example.com/archive.zip' }, + { name: 'TurboFileSetup-win32-x64-1.120.0.exe', browser_download_url: 'https://example.com/setup.exe' }, + ], + }, '1.119.0', { arch: 'x64' }); + + assert.deepStrictEqual(update, { + version: '1.120.0', + productVersion: '1.120.0', + url: 'https://example.com/setup.exe', + }); + }); + + test('ignores current or older release', () => { + const update = resolveTurboFileUpdateFromGitHubRelease({ + tag_name: 'v1.119.0', + draft: false, + prerelease: false, + html_url: 'https://github.com/DMwarrior/TurboFile-Windows/releases/tag/v1.119.0', + assets: [ + { name: 'TurboFileSetup-win32-x64-1.119.0.exe', browser_download_url: 'https://example.com/setup.exe' }, + ], + }, '1.119.0', { arch: 'x64' }); + + assert.strictEqual(update, undefined); + }); + + test('ignores draft and prerelease by default', () => { + const draft = resolveTurboFileUpdateFromGitHubRelease({ + tag_name: 'v1.120.0', + draft: true, + prerelease: false, + html_url: 'https://github.com/DMwarrior/TurboFile-Windows/releases/tag/v1.120.0', + assets: [ + { name: 'TurboFileSetup-win32-x64-1.120.0.exe', browser_download_url: 'https://example.com/setup.exe' }, + ], + }, '1.119.0', { arch: 'x64' }); + + const prerelease = resolveTurboFileUpdateFromGitHubRelease({ + tag_name: 'v1.120.0-beta.1', + draft: false, + prerelease: true, + html_url: 'https://github.com/DMwarrior/TurboFile-Windows/releases/tag/v1.120.0-beta.1', + assets: [ + { name: 'TurboFileSetup-win32-x64-1.120.0-beta.1.exe', browser_download_url: 'https://example.com/setup.exe' }, + ], + }, '1.119.0', { arch: 'x64' }); + + assert.deepStrictEqual([draft, prerelease], [undefined, undefined]); + }); + + test('honors custom asset name pattern', () => { + const update = resolveTurboFileUpdateFromGitHubRelease({ + tag_name: 'release-1.120.0', + draft: false, + prerelease: false, + html_url: 'https://github.com/DMwarrior/TurboFile-Windows/releases/tag/release-1.120.0', + assets: [ + { name: 'TurboFilePortable-win32-x64.exe', browser_download_url: 'https://example.com/portable.exe' }, + { name: 'TurboFileUserSetup-win32-x64.exe', browser_download_url: 'https://example.com/user.exe' }, + ], + }, '1.119.0', { + arch: 'x64', + assetNamePattern: 'UserSetup.*\\.exe$', + }); + + assert.deepStrictEqual(update, { + version: '1.120.0', + productVersion: '1.120.0', + url: 'https://example.com/user.exe', + }); + }); + + test('auto-installs TurboFile GitHub user setup updates after download', () => { + assert.deepStrictEqual([ + shouldAutoInstallTurboFileSetupUpdate({ turboFileGitHubUpdatesEnabled: true, updateType: UpdateType.Setup, productTarget: 'user' }), + shouldAutoInstallTurboFileSetupUpdate({ turboFileGitHubUpdatesEnabled: false, updateType: UpdateType.Setup, productTarget: 'user' }), + shouldAutoInstallTurboFileSetupUpdate({ turboFileGitHubUpdatesEnabled: true, updateType: UpdateType.Archive, productTarget: 'user' }), + shouldAutoInstallTurboFileSetupUpdate({ turboFileGitHubUpdatesEnabled: true, updateType: UpdateType.Setup, productTarget: 'system' }), + ], [true, false, false, false]); + }); + + test('keeps TurboFile GitHub user setup updates enabled when running as administrator', () => { + assert.deepStrictEqual([ + shouldDisableWindowsUserSetupUpdatesAsAdmin({ isAdmin: true, productTarget: 'user', turboFileGitHubUpdatesEnabled: false }), + shouldDisableWindowsUserSetupUpdatesAsAdmin({ isAdmin: true, productTarget: 'user', turboFileGitHubUpdatesEnabled: true }), + shouldDisableWindowsUserSetupUpdatesAsAdmin({ isAdmin: false, productTarget: 'user', turboFileGitHubUpdatesEnabled: true }), + shouldDisableWindowsUserSetupUpdatesAsAdmin({ isAdmin: true, productTarget: 'system', turboFileGitHubUpdatesEnabled: true }), + ], [true, false, false, false]); + }); +}); diff --git a/src/vs/platform/windows/electron-main/windowImpl.ts b/src/vs/platform/windows/electron-main/windowImpl.ts index 07551319..c6f62da4 100644 --- a/src/vs/platform/windows/electron-main/windowImpl.ts +++ b/src/vs/platform/windows/electron-main/windowImpl.ts @@ -1209,8 +1209,6 @@ export class CodeWindow extends BaseWindow implements ICodeWindow { let windowUrl: string; if (process.env.VSCODE_DEV && process.env.VSCODE_DEV_SERVER_URL) { windowUrl = process.env.VSCODE_DEV_SERVER_URL; // support URL override for development - } else if (configuration.isSessionsWindow) { - windowUrl = FileAccess.asBrowserUri(`vs/sessions/electron-browser/sessions${this.environmentMainService.isBuilt ? '' : '-dev'}.html`).toString(true); } else { windowUrl = FileAccess.asBrowserUri(`vs/code/electron-browser/workbench/workbench${this.environmentMainService.isBuilt ? '' : '-dev'}.html`).toString(true); } diff --git a/src/vs/server/node/serverAgentHostManager.ts b/src/vs/server/node/serverAgentHostManager.ts deleted file mode 100644 index cc49d52e..00000000 --- a/src/vs/server/node/serverAgentHostManager.ts +++ /dev/null @@ -1,143 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { Event } from '../../base/common/event.js'; -import { Disposable, MutableDisposable, toDisposable } from '../../base/common/lifecycle.js'; -import { ProxyChannel } from '../../base/parts/ipc/common/ipc.js'; -import { IAgentHostConnection, IAgentHostStarter } from '../../platform/agentHost/common/agent.js'; -import { AgentHostIpcChannels, IAgentService } from '../../platform/agentHost/common/agentService.js'; -import { createDecorator } from '../../platform/instantiation/common/instantiation.js'; -import { ILogService, ILoggerService } from '../../platform/log/common/log.js'; -import { RemoteLoggerChannelClient } from '../../platform/log/common/logIpc.js'; -import { IServerLifetimeService } from './serverLifetimeService.js'; - -export const IServerAgentHostManager = createDecorator('serverAgentHostManager'); - -/** - * Server-specific agent host manager. Eagerly starts the agent host process, - * handles crash recovery, and tracks both active agent sessions and connected - * WebSocket clients via {@link IServerLifetimeService} to keep the server - * alive while either signal is active. - * - * The lifetime token is held when active sessions > 0 OR connected clients > 0. - * It is released only when both are zero. - */ -export interface IServerAgentHostManager { - readonly _serviceBrand: undefined; -} - -/** - * Proxy interface for the connection tracker IPC channel exposed by the agent - * host process. This is NOT part of the agent host protocol -- it is a - * server-only process-management concern. - */ -interface IConnectionTrackerService { - readonly onDidChangeConnectionCount: Event; -} - -enum Constants { - MaxRestarts = 5, -} - -export class ServerAgentHostManager extends Disposable implements IServerAgentHostManager { - declare readonly _serviceBrand: undefined; - - private _restartCount = 0; - - /** Lifetime token held while sessions are active or clients are connected. */ - private readonly _lifetimeToken = this._register(new MutableDisposable()); - - private _hasActiveSessions = false; - private _connectionCount = 0; - - constructor( - private readonly _starter: IAgentHostStarter, - @ILogService private readonly _logService: ILogService, - @ILoggerService private readonly _loggerService: ILoggerService, - @IServerLifetimeService private readonly _serverLifetimeService: IServerLifetimeService, - ) { - super(); - this._register(this._starter); - this._start(); - } - - private async _start(): Promise { - try { - const connection = await this._starter.start(); - - if (this._store.isDisposed) { - connection.store.dispose(); - return; - } - - this._logService.info('ServerAgentHostManager: agent host started'); - - // Connect logger channel so agent host logs appear in the output channel - connection.store.add(new RemoteLoggerChannelClient(this._loggerService, connection.client.getChannel(AgentHostIpcChannels.Logger))); - - this._trackActiveSessions(connection); - this._trackClientConnections(connection); - - // Handle unexpected exit - connection.store.add(connection.onDidProcessExit(e => { - if (!this._store.isDisposed) { - // Both signals are gone when the process exits - this._hasActiveSessions = false; - this._connectionCount = 0; - this._lifetimeToken.clear(); - - if (this._restartCount <= Constants.MaxRestarts) { - this._logService.error(`ServerAgentHostManager: agent host terminated unexpectedly with code ${e.code}`); - this._restartCount++; - connection.store.dispose(); - this._start(); - } else { - this._logService.error(`ServerAgentHostManager: agent host terminated with code ${e.code}, giving up after ${Constants.MaxRestarts} restarts`); - } - } - })); - - this._register(toDisposable(() => connection.store.dispose())); - } catch (error) { - if (this._store.isDisposed) { - return; - } - - if (this._restartCount <= Constants.MaxRestarts) { - this._logService.error('ServerAgentHostManager: agent host failed to start', error); - this._restartCount++; - this._start(); - } else { - this._logService.error(`ServerAgentHostManager: agent host failed to start, giving up after ${Constants.MaxRestarts} restarts`, error); - } - } - } - - private _trackActiveSessions(connection: IAgentHostConnection): void { - const agentService = ProxyChannel.toService(connection.client.getChannel(AgentHostIpcChannels.AgentHost)); - connection.store.add(agentService.onDidAction(envelope => { - if (envelope.action.type === 'root/activeSessionsChanged') { - this._hasActiveSessions = envelope.action.activeSessions > 0; - this._updateLifetimeToken(); - } - })); - } - - private _trackClientConnections(connection: IAgentHostConnection): void { - const connectionTracker = ProxyChannel.toService(connection.client.getChannel(AgentHostIpcChannels.ConnectionTracker)); - connection.store.add(connectionTracker.onDidChangeConnectionCount(count => { - this._connectionCount = count; - this._updateLifetimeToken(); - })); - } - - private _updateLifetimeToken(): void { - if (this._hasActiveSessions || this._connectionCount > 0) { - this._lifetimeToken.value ??= this._serverLifetimeService.active('AgentHost'); - } else { - this._lifetimeToken.clear(); - } - } -} diff --git a/src/vs/server/node/serverServices.ts b/src/vs/server/node/serverServices.ts index e8fc3214..024ee2e0 100644 --- a/src/vs/server/node/serverServices.ts +++ b/src/vs/server/node/serverServices.ts @@ -56,7 +56,7 @@ import { ServerTelemetryChannel } from '../../platform/telemetry/common/remoteTe import { IServerTelemetryService, ServerNullTelemetryService, ServerTelemetryService } from '../../platform/telemetry/common/serverTelemetryService.js'; import { RemoteTerminalChannel } from './remoteTerminalChannel.js'; import { createURITransformer } from '../../base/common/uriTransformer.js'; -import { ServerConnectionToken, ServerConnectionTokenType } from './serverConnectionToken.js'; +import { ServerConnectionToken } from './serverConnectionToken.js'; import { ServerEnvironmentService, ServerParsedArgs } from './serverEnvironmentService.js'; import { REMOTE_TERMINAL_CHANNEL_NAME } from '../../workbench/contrib/terminal/common/remote/remoteTerminalChannel.js'; import { REMOTE_FILE_SYSTEM_CHANNEL_NAME } from '../../workbench/services/remote/common/remoteFileSystemProviderClient.js'; @@ -77,28 +77,12 @@ import { RemoteExtensionsScannerChannel, RemoteExtensionsScannerService } from ' import { RemoteExtensionsScannerChannelName } from '../../platform/remote/common/remoteExtensionsScanner.js'; import { RemoteUserDataProfilesServiceChannel } from '../../platform/userDataProfile/common/userDataProfileIpc.js'; import { NodePtyHostStarter } from '../../platform/terminal/node/nodePtyHostStarter.js'; -import { NodeAgentHostStarter } from '../../platform/agentHost/node/nodeAgentHostStarter.js'; -import { ServerAgentHostManager } from './serverAgentHostManager.js'; import { IServerLifetimeService, ServerLifetimeService } from './serverLifetimeService.js'; import { CSSDevelopmentService, ICSSDevelopmentService } from '../../platform/cssDev/node/cssDevService.js'; import { AllowedExtensionsService } from '../../platform/extensionManagement/common/allowedExtensionsService.js'; import { TelemetryLogAppender } from '../../platform/telemetry/common/telemetryLogAppender.js'; -import { INativeMcpDiscoveryHelperService, NativeMcpDiscoveryHelperChannelName } from '../../platform/mcp/common/nativeMcpDiscoveryHelper.js'; -import { NativeMcpDiscoveryHelperChannel } from '../../platform/mcp/node/nativeMcpDiscoveryHelperChannel.js'; -import { NativeMcpDiscoveryHelperService } from '../../platform/mcp/node/nativeMcpDiscoveryHelperService.js'; -import { IMcpGatewayService, McpGatewayChannelName } from '../../platform/mcp/common/mcpGateway.js'; -import { McpGatewayService } from '../../platform/mcp/node/mcpGatewayService.js'; -import { McpGatewayChannel } from '../../platform/mcp/node/mcpGatewayChannel.js'; import { IExtensionGalleryManifestService } from '../../platform/extensionManagement/common/extensionGalleryManifest.js'; import { ExtensionGalleryManifestIPCService } from '../../platform/extensionManagement/common/extensionGalleryManifestServiceIpc.js'; -import { IAllowedMcpServersService, IMcpGalleryService, IMcpManagementService } from '../../platform/mcp/common/mcpManagement.js'; -import { McpManagementService } from '../../platform/mcp/node/mcpManagementService.js'; -import { McpGalleryService } from '../../platform/mcp/common/mcpGalleryService.js'; -import { IMcpResourceScannerService, McpResourceScannerService } from '../../platform/mcp/common/mcpResourceScannerService.js'; -import { McpManagementChannel } from '../../platform/mcp/common/mcpManagementIpc.js'; -import { AllowedMcpServersService } from '../../platform/mcp/common/allowedMcpServersService.js'; -import { IMcpGalleryManifestService } from '../../platform/mcp/common/mcpGalleryManifest.js'; -import { McpGalleryManifestIPCService } from '../../platform/mcp/common/mcpGalleryManifestServiceIpc.js'; import { SANDBOX_HELPER_CHANNEL_NAME, SandboxHelperChannel } from '../../platform/sandbox/common/sandboxHelperIpc.js'; import { SandboxHelperService } from '../../platform/sandbox/node/sandboxHelper.js'; @@ -205,7 +189,6 @@ export async function setupServerServices(connectionToken: ServerConnectionToken } services.set(IExtensionGalleryManifestService, new ExtensionGalleryManifestIPCService(socketServer, productService)); - services.set(IMcpGalleryManifestService, new McpGalleryManifestIPCService(socketServer)); services.set(IExtensionGalleryService, new SyncDescriptor(ExtensionGalleryServiceWithNoStorageService)); const downloadChannel = socketServer.getChannel('download', router); @@ -216,8 +199,6 @@ export async function setupServerServices(connectionToken: ServerConnectionToken services.set(IExtensionSignatureVerificationService, new SyncDescriptor(ExtensionSignatureVerificationService)); services.set(IAllowedExtensionsService, new SyncDescriptor(AllowedExtensionsService)); services.set(INativeServerExtensionManagementService, new SyncDescriptor(ExtensionManagementService)); - services.set(INativeMcpDiscoveryHelperService, new SyncDescriptor(NativeMcpDiscoveryHelperService)); - services.set(IMcpGatewayService, new SyncDescriptor(McpGatewayService)); const instantiationService: IInstantiationService = new InstantiationService(services); services.set(ILanguagePackService, instantiationService.createInstance(NativeLanguagePackService)); @@ -239,24 +220,7 @@ export async function setupServerServices(connectionToken: ServerConnectionToken }); services.set(IServerLifetimeService, serverLifetimeService); - if (args['agent-host-port'] || args['agent-host-path']) { - const agentHostStarter = instantiationService.createInstance(NodeAgentHostStarter); - agentHostStarter.setWebSocketConfig({ - port: args['agent-host-port'], - socketPath: args['agent-host-path'], - host: args.host || 'localhost', - connectionToken: connectionToken.type === ServerConnectionTokenType.Mandatory ? connectionToken.value : undefined, - }); - disposables.add(instantiationService.createInstance(ServerAgentHostManager, agentHostStarter)); - } - - services.set(IAllowedMcpServersService, new SyncDescriptor(AllowedMcpServersService)); - services.set(IMcpResourceScannerService, new SyncDescriptor(McpResourceScannerService)); - services.set(IMcpGalleryService, new SyncDescriptor(McpGalleryService)); - services.set(IMcpManagementService, new SyncDescriptor(McpManagementService)); - instantiationService.invokeFunction(accessor => { - const mcpManagementService = accessor.get(IMcpManagementService); const extensionManagementService = accessor.get(INativeServerExtensionManagementService); const extensionsScannerService = accessor.get(IExtensionsScannerService); const extensionGalleryService = accessor.get(IExtensionGalleryService); @@ -274,9 +238,6 @@ export async function setupServerServices(connectionToken: ServerConnectionToken const remoteExtensionsScanner = new RemoteExtensionsScannerService(instantiationService.createInstance(ExtensionManagementCLI, productService.extensionsForceVersionByQuality ?? [], logService), environmentService, userDataProfilesService, extensionsScannerService, logService, extensionGalleryService, languagePackService, extensionManagementService); socketServer.registerChannel(RemoteExtensionsScannerChannelName, new RemoteExtensionsScannerChannel(remoteExtensionsScanner, (ctx: RemoteAgentConnectionContext) => getUriTransformer(ctx.remoteAuthority))); - socketServer.registerChannel(NativeMcpDiscoveryHelperChannelName, instantiationService.createInstance(NativeMcpDiscoveryHelperChannel, (ctx: RemoteAgentConnectionContext) => getUriTransformer(ctx.remoteAuthority))); - socketServer.registerChannel(McpGatewayChannelName, instantiationService.createInstance(McpGatewayChannel, socketServer)); - const remoteFileSystemChannel = disposables.add(new RemoteAgentFileSystemProviderChannel(logService, environmentService, configurationService)); socketServer.registerChannel(REMOTE_FILE_SYSTEM_CHANNEL_NAME, remoteFileSystemChannel); @@ -285,8 +246,6 @@ export async function setupServerServices(connectionToken: ServerConnectionToken const channel = new ExtensionManagementChannel(extensionManagementService, (ctx: RemoteAgentConnectionContext) => getUriTransformer(ctx.remoteAuthority)); socketServer.registerChannel('extensions', channel); - socketServer.registerChannel('mcpManagement', new McpManagementChannel(mcpManagementService, (ctx: RemoteAgentConnectionContext) => getUriTransformer(ctx.remoteAuthority))); - // clean up extensions folder remoteExtensionsScanner.whenExtensionsReady().then(() => extensionManagementService.cleanUp()); diff --git a/src/vs/server/test/node/serverAgentHostManager.test.ts b/src/vs/server/test/node/serverAgentHostManager.test.ts deleted file mode 100644 index 021f558a..00000000 --- a/src/vs/server/test/node/serverAgentHostManager.test.ts +++ /dev/null @@ -1,217 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import assert from 'assert'; -import { Emitter, Event } from '../../../base/common/event.js'; -import { DisposableStore, IDisposable, toDisposable } from '../../../base/common/lifecycle.js'; -import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../base/test/common/utils.js'; -import { IChannel, IChannelClient } from '../../../base/parts/ipc/common/ipc.js'; -import { IAgentHostConnection, IAgentHostStarter } from '../../../platform/agentHost/common/agent.js'; -import { AgentHostIpcChannels } from '../../../platform/agentHost/common/agentService.js'; -import { NullLogService, NullLoggerService } from '../../../platform/log/common/log.js'; -import { ServerAgentHostManager } from '../../node/serverAgentHostManager.js'; -import { IServerLifetimeService } from '../../node/serverLifetimeService.js'; - -// ---- Mock helpers ----------------------------------------------------------- - -class MockChannel implements IChannel { - private readonly _listeners = new Map>(); - private readonly _callResults = new Map(); - - getEmitter(event: string): Emitter { - let emitter = this._listeners.get(event); - if (!emitter) { - emitter = new Emitter(); - this._listeners.set(event, emitter); - } - return emitter; - } - - setCallResult(command: string, value: unknown): void { - this._callResults.set(command, value); - } - - call(command: string, _arg?: unknown): Promise { - return Promise.resolve((this._callResults.get(command) ?? undefined) as T); - } - - listen(event: string, _arg?: unknown): Event { - return this.getEmitter(event).event as Event; - } - - dispose(): void { - for (const emitter of this._listeners.values()) { - emitter.dispose(); - } - this._listeners.clear(); - } -} - -class MockAgentHostStarter implements IAgentHostStarter { - private readonly _onDidProcessExit = new Emitter<{ code: number; signal: string }>(); - - readonly agentHostChannel = new MockChannel(); - readonly loggerChannel: MockChannel; - readonly connectionTrackerChannel = new MockChannel(); - - constructor() { - this.loggerChannel = new MockChannel(); - this.loggerChannel.setCallResult('getRegisteredLoggers', []); - } - - async start(): Promise { - const store = new DisposableStore(); - const client: IChannelClient = { - getChannel: (name: string): T => { - switch (name) { - case AgentHostIpcChannels.AgentHost: - return this.agentHostChannel as unknown as T; - case AgentHostIpcChannels.Logger: - return this.loggerChannel as unknown as T; - case AgentHostIpcChannels.ConnectionTracker: - return this.connectionTrackerChannel as unknown as T; - default: - throw new Error(`Unknown channel: ${name}`); - } - }, - }; - return { - client, - store, - onDidProcessExit: this._onDidProcessExit.event, - }; - } - - fireProcessExit(code: number): void { - this._onDidProcessExit.fire({ code, signal: '' }); - } - - dispose(): void { - this._onDidProcessExit.dispose(); - this.agentHostChannel.dispose(); - this.loggerChannel.dispose(); - this.connectionTrackerChannel.dispose(); - } -} - -class MockServerLifetimeService implements IServerLifetimeService { - declare readonly _serviceBrand: undefined; - - private _activeCount = 0; - - get hasActiveConsumers(): boolean { - return this._activeCount > 0; - } - - active(_consumer: string): IDisposable { - this._activeCount++; - return toDisposable(() => { this._activeCount--; }); - } - - delay(): void { } -} - -suite('ServerAgentHostManager', () => { - const ds = ensureNoDisposablesAreLeakedInTestSuite(); - - let starter: MockAgentHostStarter; - let lifetimeService: MockServerLifetimeService; - - setup(() => { - starter = new MockAgentHostStarter(); - lifetimeService = new MockServerLifetimeService(); - }); - - function createManager(): ServerAgentHostManager { - return ds.add(new ServerAgentHostManager( - starter, - new NullLogService(), - ds.add(new NullLoggerService()), - lifetimeService, - )); - } - - // `ServerAgentHostManager._start()` is async (awaits `starter.start()`). - // Wait a microtask so the channel listeners are wired up before tests fire events. - async function waitForStart(): Promise { - await Promise.resolve(); - } - - function fireActiveSessions(count: number): void { - starter.agentHostChannel.getEmitter('onDidAction').fire({ - action: { type: 'root/activeSessionsChanged', activeSessions: count }, - serverSeq: 1, - origin: undefined, - }); - } - - function fireConnectionCount(count: number): void { - starter.connectionTrackerChannel.getEmitter('onDidChangeConnectionCount').fire(count); - } - - test('no lifetime token initially', async () => { - createManager(); - await waitForStart(); - assert.strictEqual(lifetimeService.hasActiveConsumers, false); - }); - - test('acquires token when sessions become active', async () => { - createManager(); - await waitForStart(); - fireActiveSessions(1); - assert.strictEqual(lifetimeService.hasActiveConsumers, true); - }); - - test('acquires token when clients connect (no active sessions)', async () => { - createManager(); - await waitForStart(); - fireConnectionCount(2); - assert.strictEqual(lifetimeService.hasActiveConsumers, true); - }); - - test('releases token only when both sessions and connections are zero', async () => { - createManager(); - await waitForStart(); - - // Sessions active, no connections - fireActiveSessions(1); - assert.strictEqual(lifetimeService.hasActiveConsumers, true); - - // Connections appear too - fireConnectionCount(1); - assert.strictEqual(lifetimeService.hasActiveConsumers, true); - - // Sessions go idle, but connections remain - fireActiveSessions(0); - assert.strictEqual(lifetimeService.hasActiveConsumers, true); - - // Connections drop to zero -- now both are idle - fireConnectionCount(0); - assert.strictEqual(lifetimeService.hasActiveConsumers, false); - }); - - test('releases token only when connections drop after sessions already idle', async () => { - createManager(); - await waitForStart(); - - fireConnectionCount(3); - assert.strictEqual(lifetimeService.hasActiveConsumers, true); - - fireConnectionCount(0); - assert.strictEqual(lifetimeService.hasActiveConsumers, false); - }); - - test('process exit resets both signals and clears token', async () => { - createManager(); - await waitForStart(); - - fireActiveSessions(2); - fireConnectionCount(1); - assert.strictEqual(lifetimeService.hasActiveConsumers, true); - - starter.fireProcessExit(1); - assert.strictEqual(lifetimeService.hasActiveConsumers, false); - }); -}); diff --git a/src/vs/sessions/AI_CUSTOMIZATIONS.md b/src/vs/sessions/AI_CUSTOMIZATIONS.md deleted file mode 100644 index e719e773..00000000 --- a/src/vs/sessions/AI_CUSTOMIZATIONS.md +++ /dev/null @@ -1,292 +0,0 @@ -# AI Customizations – Design Document - -This document describes the AI customization experience: a management editor and tree view that surface customization items (agents, skills, instructions, prompts, hooks, MCP servers) across workspace, user, and extension storage. - -## Architecture - -### File Structure - -The management editor lives in `vs/workbench` (shared between core VS Code and sessions): - -``` -src/vs/workbench/contrib/chat/browser/aiCustomization/ -├── aiCustomizationManagement.contribution.ts # Commands + context menus -├── aiCustomizationManagement.ts # IDs + context keys -├── aiCustomizationManagementEditor.ts # SplitView list/editor -├── aiCustomizationManagementEditorInput.ts # Singleton input -├── aiCustomizationListWidget.ts # Search + grouped list + harness toggle -├── aiCustomizationItemSource.ts # Item pipeline: ICustomizationItem → IAICustomizationListItem view model -├── promptsServiceCustomizationItemProvider.ts # Adapts IPromptsService → ICustomizationItemProvider -├── aiCustomizationListWidgetUtils.ts # List item helpers (truncation, etc.) -├── aiCustomizationDebugPanel.ts # Debug diagnostics panel -├── aiCustomizationWorkspaceService.ts # Core VS Code workspace service impl -├── customizationHarnessService.ts # Core harness service impl (agent-gated) -├── customizationCreatorService.ts # AI-guided creation flow -├── customizationGroupHeaderRenderer.ts # Collapsible group header renderer -├── mcpListWidget.ts # MCP servers section (Extensions + Built-in groups) -├── pluginListWidget.ts # Agent plugins section -├── aiCustomizationIcons.ts # Icons -└── media/ - └── aiCustomizationManagement.css - -src/vs/workbench/contrib/chat/common/ -├── aiCustomizationWorkspaceService.ts # IAICustomizationWorkspaceService + IStorageSourceFilter + BUILTIN_STORAGE -└── customizationHarnessService.ts # ICustomizationHarnessService + ICustomizationItem + ICustomizationItemProvider + helpers -``` - -The tree view and overview live in `vs/sessions` (agent sessions window only): - -``` -src/vs/sessions/contrib/aiCustomizationTreeView/browser/ -├── aiCustomizationTreeView.contribution.ts # View + actions -├── aiCustomizationTreeView.ts # IDs + menu IDs -├── aiCustomizationTreeViewViews.ts # Tree data source + view -├── aiCustomizationOverviewView.ts # Overview view (counts + deep links) -└── media/ - └── aiCustomizationTreeView.css -``` - -Sessions-specific overrides: - -``` -src/vs/sessions/contrib/chat/browser/ -├── aiCustomizationWorkspaceService.ts # Sessions workspace service override -├── customizationHarnessService.ts # Sessions harness service (accepts any content-provider-backed session type) -└── promptsService.ts # AgenticPromptsService (CLI user roots) -src/vs/sessions/contrib/sessions/browser/ -├── aiCustomizationShortcutsWidget.ts # Sidebar shortcuts widget with header overview action -└── customizationsToolbar.contribution.ts # Sidebar customization links -``` - -### IAICustomizationWorkspaceService - -The `IAICustomizationWorkspaceService` interface controls per-window behavior: - -| Property / Method | Core VS Code | Agent Sessions Window | -|----------|-------------|----------| -| `managementSections` | All sections except Models | All sections except Models | -| `getStorageSourceFilter(type)` | Delegates to `ICustomizationHarnessService` | Delegates to `ICustomizationHarnessService` | -| `isSessionsWindow` | `false` | `true` | -| `activeProjectRoot` | First workspace folder | Active session worktree | -| `welcomePageFeatures` | Shows getting-started banner + per-card AI actions | Shows getting-started banner, hides per-card AI actions | - -### ICustomizationHarnessService - -A harness represents the AI execution environment that consumes customizations. -Storage answers "where did this come from?"; harness answers "who consumes it?". - -The service is defined in `common/customizationHarnessService.ts` which also provides: -- **`CustomizationHarnessServiceBase`** — reusable base class handling active-harness state, the observable list, and `getStorageSourceFilter` dispatch. -- **`ISectionOverride`** — per-section UI customization: `commandId` (command invocation), `rootFile` + `label` (root-file creation), `typeLabel` (custom type name), `fileExtension` (override default), `rootFileShortcuts` (dropdown shortcuts). -- **Factory functions** — `createVSCodeHarnessDescriptor`, `createCliHarnessDescriptor`, `createClaudeHarnessDescriptor`. The VS Code harness receives `[PromptsStorage.extension, BUILTIN_STORAGE]` as extras; CLI and Claude in core receive `[]` (no extension source). Sessions CLI receives `[BUILTIN_STORAGE]`. -- **Well-known root helpers** — `getCliUserRoots(userHome)` and `getClaudeUserRoots(userHome)` centralize the `~/.copilot`, `~/.claude`, `~/.agents` path knowledge. -- **Filter helpers** — `matchesWorkspaceSubpath()` for segment-safe subpath matching; `matchesInstructionFileFilter()` for filename/path-prefix pattern matching. - -Available harnesses: - -| Harness | Label | Description | -|---------|-------|-------------| -| `vscode` | Local | Shows all storage sources (default in core) | -| `cli` | Copilot CLI | Restricts user roots to `~/.copilot`, `~/.claude`, `~/.agents` | -| `claude` | Claude | Restricts user roots to `~/.claude`; hides Prompts + Plugins sections | - -In core VS Code, all three harnesses are registered but CLI and Claude only appear when their respective agents are registered (`requiredAgentId` checked via `IChatAgentService`). VS Code is the default. -In sessions, harnesses are accepted for any session type that has a registered content provider (checked via `IChatSessionsService.getContentProviderSchemes()`). AHP remote servers register directly via `registerExternalHarness`. - -Remote agent hosts can also register **external harnesses** dynamically. Each remote agent harness may contribute: -- an `itemProvider` that surfaces plugins already configured on the remote host (or synced into the active remote session), -- a `disableProvider` that lets users opt out individual files/plugins from auto-sync, and -- `pluginActions` that replace the default local install/create buttons in the Plugins section toolbar with environment-specific commands such as "Add Remote Plugin". - -The Plugins section renders remote harness `itemProvider` entries with `type: 'plugin'` directly. This is separate from the prompt-file pipeline used for Agents, Skills, Instructions, Prompts, and Hooks. - -### IHarnessDescriptor - -Key properties on the harness descriptor: - -| Property | Purpose | -|----------|--------| -| `itemProvider` | `ICustomizationItemProvider` supplying items; when absent, falls back to `PromptsServiceCustomizationItemProvider` | -| `disableProvider` | `ICustomizationDisableProvider` enabling opt-out of individual items from auto-sync | -| `hiddenSections` | Sidebar sections to hide (e.g. Claude: `[Prompts, Plugins]`) | -| `workspaceSubpaths` | Restrict file creation/display to directories (e.g. Claude: `['.claude']`) | -| `hideGenerateButton` | Replace "Generate X" sparkle button with "New X" | -| `sectionOverrides` | Per-section `ISectionOverride` map for button behavior | -| `requiredAgentId` | Agent ID that must be registered for harness to appear | -| `instructionFileFilter` | Filename/path patterns to filter instruction items | - -### IStorageSourceFilter - -A unified per-type filter controlling which storage sources and user file roots are visible. -Replaces the old `visibleStorageSources`, `getVisibleStorageSources(type)`, and `excludedUserFileRoots`. - -```typescript -interface IStorageSourceFilter { - sources: readonly PromptsStorage[]; // Which storage groups to display - includedUserFileRoots?: readonly URI[]; // Allowlist for user roots (undefined = all) -} -``` - -The shared `applyStorageSourceFilter()` helper applies this filter to any `{uri, storage}` array. - -**Sessions filter behavior (CLI harness):** - -| Type | sources | includedUserFileRoots | -|------|---------|----------------------| -| Hooks | `[local, plugin]` | N/A | -| Prompts | `[local, user, plugin, builtin]` | `undefined` (all roots) | -| Agents, Skills, Instructions | `[local, user, plugin, builtin]` | `[~/.copilot, ~/.claude, ~/.agents]` | - -**Core VS Code filter behavior:** - -Local harness: all types use `[local, user, extension, plugin, builtin]` with no user root filter. Items from the default chat extension (`productService.defaultChatAgent.chatExtensionId`) are grouped under "Built-in" via `groupKey` override in the list widget. - -CLI harness (core): - -| Type | sources | includedUserFileRoots | -|------|---------|----------------------| -| Hooks | `[local, plugin]` | N/A | -| Prompts | `[local, user, plugin]` | `undefined` (all roots) | -| Agents, Skills, Instructions | `[local, user, plugin]` | `[~/.copilot, ~/.claude, ~/.agents]` | - -Claude harness (core): - -| Type | sources | includedUserFileRoots | -|------|---------|----------------------| -| Hooks | `[local, plugin]` | N/A | -| Prompts | `[local, user, plugin]` | `undefined` (all roots) | -| Agents, Skills, Instructions | `[local, user, plugin]` | `[~/.claude]` | - -Claude additionally applies: -- `hiddenSections: [Prompts, Plugins]` -- `instructionFileFilter: ['CLAUDE.md', 'CLAUDE.local.md', '.claude/rules/', 'copilot-instructions.md']` -- `workspaceSubpaths: ['.claude']` (instruction files matching `instructionFileFilter` are exempt) -- `sectionOverrides`: Hooks → `copilot.claude.hooks` command; Instructions → "Add CLAUDE.md" primary, "Rule" type label, `.md` file extension - -### Built-in Extension Grouping (Core VS Code) - -In core VS Code, customization items contributed by the default chat extension (`productService.defaultChatAgent.chatExtensionId`, typically `GitHub.copilot-chat`) are grouped under the "Built-in" header in the management editor list widget, separate from third-party "Extensions". - -`PromptsServiceCustomizationItemProvider` handles this via `applyBuiltinGroupKeys()`: it builds a URI→extension-ID lookup from prompt file metadata, then sets `groupKey: BUILTIN_STORAGE` on items whose extension matches the chat extension ID (checked via the shared `isChatExtensionItem()` utility). The underlying `storage` remains `PromptsStorage.extension` — the grouping is a `groupKey` override that keeps `applyStorageSourceFilter` working while visually distinguishing chat-extension items from third-party extension items. - -`BUILTIN_STORAGE` is defined in `aiCustomizationWorkspaceService.ts` (common layer) and re-exported by both `aiCustomizationManagement.ts` (browser) and `builtinPromptsStorage.ts` (sessions) for backward compatibility. - -### Management Editor Item Pipeline - -All customization sources — `IPromptsService`, extension-contributed providers, and AHP remote servers — produce items conforming to the same `ICustomizationItem` contract (defined in `customizationHarnessService.ts`). This contract carries `uri`, `type`, `name`, `description`, optional `storage`, `groupKey`, `badge`, and status fields. - -``` -promptsService ──→ PromptsServiceCustomizationItemProvider ──→ ICustomizationItem[] - │ -Extension Provider ───────────────────────────────────────→ ICustomizationItem[] - │ -AHP Remote Server ────────────────────────────────────────→ ICustomizationItem[] - │ - ▼ - CustomizationItemSource (aiCustomizationItemSource.ts) - ├── normalizes → IAICustomizationListItem[] - ├── expands hooks from file content - └── normalizes items from provider - │ - ▼ - List Widget renders -``` - -**Key files:** - -- **`aiCustomizationItemSource.ts`** — The browser-side pipeline: `IAICustomizationListItem` (view model), `IAICustomizationItemSource` (data contract), `AICustomizationItemNormalizer` (maps `ICustomizationItem` → view model, inferring storage/grouping from URIs when the provider doesn't supply them), `ProviderCustomizationItemSource` (orchestrates provider + sync + normalizer), and shared utilities (`expandHookFileItems`, `getFriendlyName`, `isChatExtensionItem`). - -- **`promptsServiceCustomizationItemProvider.ts`** — Adapts `IPromptsService` to `ICustomizationItemProvider`. Reads agents, skills, instructions, hooks, and prompts from the core service, expands instruction categories and hook entries, applies harness-specific filters (storage sources, workspace subpaths, instruction file patterns), and returns `ICustomizationItem[]` with `storage` set from the authoritative promptsService metadata. Used as the default item provider for harnesses that don't supply their own. - -- **`customizationHarnessService.ts`** (common layer) — Defines `ICustomizationItem`, `ICustomizationItemProvider`, `ICustomizationDisableProvider`, and `IHarnessDescriptor`. A harness descriptor optionally carries an `itemProvider`; when absent, the widget falls back to `PromptsServiceCustomizationItemProvider`. - -### Structured Detail Preview - -For markdown-backed customizations (`.agent.md`, `SKILL.md`, `.instructions.md`, `.prompt.md`), the management editor opens a **structured preview** by default instead of showing the raw file immediately. - -- The preview parses the file with `PromptFileParser` -- Header metadata is rendered as labeled rows -- Each row includes an inline help affordance whose hover text comes from `getAttributeDefinition(...)` -- The markdown body is rendered via `IMarkdownRendererService` -- A header button switches between the structured preview and the raw editor/viewer - -Hooks and other non-markdown detail views continue to open directly in their existing raw/detail experiences. - -### AgenticPromptsService (Sessions) - -Sessions overrides `PromptsService` via `AgenticPromptsService` (in `promptsService.ts`): - -- **Discovery**: `AgenticPromptFilesLocator` scopes workspace folders to the active session's worktree -- **Built-in skills**: Discovers bundled `SKILL.md` files from `vs/sessions/skills/{name}/` and surfaces them with `PromptsStorage.builtin` storage type -- **User override**: Built-in skills are omitted when a user or workspace skill with the same name exists -- **Creation targets**: `getSourceFolders()` override replaces VS Code profile user roots with `~/.copilot/{subfolder}` for CLI compatibility -- **Hook folders**: Falls back to `.github/hooks` in the active worktree - -### Built-in Skills - -All built-in customizations bundled with the Sessions app are skills, living in `src/vs/sessions/skills/{name}/SKILL.md`. They are: - -- Discovered at runtime via `FileAccess.asFileUri('vs/sessions/skills')` -- Tagged with `PromptsStorage.builtin` storage type -- Shown in a "Built-in" group in the AI Customization tree view and management editor -- Filtered out when a user/workspace skill shares the same name (override behavior) -- Skills with UI integrations (e.g. `act-on-feedback`, `generate-run-commands`) display a "UI Integration" badge in the management editor - -### UI Integration Badges - -Skills that are directly invoked by UI elements (toolbar buttons, menu items) are annotated with a "UI Integration" badge in the management editor. The mapping is provided by `IAICustomizationWorkspaceService.getSkillUIIntegrations()`, which the Sessions implementation populates with the relevant skill names and tooltip descriptions. The badge appears on both the built-in skill and any user/workspace override, ensuring users understand that overriding the skill affects a UI surface. - -### Count Consistency - -Counts shown in the sidebar (per-link badges and the header total in `AICustomizationShortcutsWidget`) are driven by the same `IAICustomizationItemsModel` singleton (`workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemsModel.ts`) that feeds the customizations editor's list widget. The model owns the per-active-harness `ProviderCustomizationItemSource` cache and exposes per-section `IObservable`; sidebar consumers `read` `.length` from those observables. There is exactly one discovery path, so editor and sidebar counts cannot diverge. McpServers and Plugins use their own service observables (`IMcpService.servers`, `IAgentPluginService.plugins`) directly. - -### Sidebar Overview Entrypoint - -The Agents sidebar `AICustomizationShortcutsWidget` exposes a home action in the Customizations header. The header label remains the collapse toggle, while the separate icon-only action opens the AI Customization management editor and calls `showWelcomePage()` so users can return to the overview/welcome page from any customization section. The action lives in the header, rather than inside the collapsible list content, so it remains visible and is not clipped by the sidebar's scrollable layout. - -### Item Badges - -`IAICustomizationListItem.badge` is an optional string that renders as a small inline tag next to the item name (same visual style as the MCP "Bridged" badge). For context instructions, this badge shows the raw `applyTo` pattern (e.g. a glob like `**/*.ts`), while the tooltip (`badgeTooltip`) explains the behavior. For skills with UI integrations, the badge reads "UI Integration" with a tooltip describing which UI surface invokes the skill. The badge text is also included in search filtering. - -### Embedded Detail Editors - -The management editor opens inline detail panes for prompt files, MCP servers, and plugins. Prompt-file details use the standard text editor pane. MCP and plugin details render dedicated compact widgets — `EmbeddedMcpServerDetail` and `EmbeddedAgentPluginDetail` — purpose-built for the narrow split-pane host. They show the icon, name, scope/source, and description. Do **not** embed the full extension-editor panes inside the split-pane host: they assume a wide page-level layout and don't shrink cleanly. - -The MCP detail fixture in `src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts` must open a real server row (not a group header) and use a local server with concrete config so the compact widget's scope/description rendering is covered by screenshots. - -### Debug Panel - -Toggle via Command Palette: "Toggle Customizations Debug Panel". Shows a diagnostic view of the item pipeline: - -1. **Provider data** — items returned by the active `ICustomizationItemProvider` -2. **After filtering** — what was removed by storage source and workspace subpath filters -3. **Widget state** — allItems vs displayEntries with group counts -4. **Source/resolved folders** — creation targets and discovery order - -## Key Services - -- **Prompt discovery**: `IPromptsService` — parsing, lifecycle, storage enumeration -- **MCP servers**: `IMcpService` — server list, tool access -- **Active worktree**: `IActiveSessionService` — source of truth for workspace scoping (sessions only) -- **File operations**: `IFileService`, `ITextModelService` — file and model plumbing - -Browser compatibility is required — no Node.js APIs. - -## Feature Gating - -All commands and UI respect `ChatContextKeys.enabled`. - -### Commands - -| Command ID | Purpose | -|-----------|---------| -| `aiCustomization.openManagementEditor` | Opens the management editor, optionally accepting an `AICustomizationManagementSection` to deep-link | -| `aiCustomization.openMarketplace` | Opens the management editor with marketplace browse mode active. Accepts an optional section (`mcpServers` or `plugins`); defaults to `mcpServers` | - -## Settings - -User-facing settings use the `chat.customizations.` namespace: - -| Setting | Default | Description | -|---------|---------|-------------| -| `chat.customizations.harnessSelector.enabled` | `true` | Show the harness selector dropdown in the sidebar | diff --git a/src/vs/sessions/LAYOUT.md b/src/vs/sessions/LAYOUT.md deleted file mode 100644 index e2e93d5c..00000000 --- a/src/vs/sessions/LAYOUT.md +++ /dev/null @@ -1,740 +0,0 @@ -# Agent Sessions Workbench Layout Specification - -This document is the **authoritative specification** for the Agent Sessions workbench layout. All implementation changes must be reflected here, and all development work should reference this document. - ---- - -## 1. Overview - -The Agent Sessions Workbench (`Workbench` in `sessions/browser/workbench.ts`) provides a simplified, fixed layout optimized for agent session workflows. Unlike the default VS Code workbench, this layout: - -- Does **not** support settings-based customization -- Has **fixed** part positions -- Excludes several standard workbench parts - ---- - -## 2. Layout Structure - -### 2.1 Visual Representation - -``` -┌─────────┬────────────────────────────────────────────────────────────────────┐ -│ │ Titlebar │ -│ ├───────────────────────────┬────────────────┬───────────────────────┤ -│ Sidebar │ Chat Bar │ Editor (hid.) │ Auxiliary Bar │ -│ ├───────────────────────────┴────────────────┴───────────────────────┤ -│ │ Panel │ -└─────────┴────────────────────────────────────────────────────────────────────┘ - -Editors open via MODAL_GROUP into the standard ModalEditorPart overlay -(created on-demand by EditorParts.createModalEditorPart). The main -editor part is hidden by default and is only shown for explicit -editor opens or restores that target the main editor part. -``` - -### 2.2 Parts - -#### Included Parts - -| Part | ID Constant | Position | Default Visibility | ViewContainerLocation | -|------|-------------|----------|------------|----------------------| -| Titlebar | `Parts.TITLEBAR_PART` | Top of right section | Always visible | — | -| Sidebar | `Parts.SIDEBAR_PART` | Left, spans full height from top to bottom | Visible | `ViewContainerLocation.Sidebar` | -| Chat Bar | `Parts.CHATBAR_PART` | Top-right section, takes remaining width | Visible | `ViewContainerLocation.ChatBar` | -| Editor | `Parts.EDITOR_PART` | Top-right section, in the grid but hidden by default; explicit opens or restores that target the main editor part can reveal it while modal editors still open via `MODAL_GROUP` into `ModalEditorPart` overlay | Hidden | — | -| Auxiliary Bar | `Parts.AUXILIARYBAR_PART` | Top-right section, right side | Visible | `ViewContainerLocation.AuxiliaryBar` | -| Panel | `Parts.PANEL_PART` | Below Chat Bar and Auxiliary Bar (right section only) | Hidden | `ViewContainerLocation.Panel` | - -#### Excluded Parts - -The following parts from the default workbench are **not included**: - -| Part | ID Constant | Reason | -|------|-------------|--------| -| Activity Bar | `Parts.ACTIVITYBAR_PART` | Simplified navigation; global activities (Accounts, Manage) are in titlebar instead | -| Status Bar | `Parts.STATUSBAR_PART` | Reduced chrome | -| Banner | `Parts.BANNER_PART` | Not needed | - ---- - -## 3. Titlebar Configuration - -The Agent Sessions workbench uses a fully independent titlebar part (`TitlebarPart`) with its own title service (`TitleService`), implemented in `sessions/browser/parts/titlebarPart.ts`. This is a standalone implementation (not extending `BrowserTitlebarPart`) with a simple three-section layout driven entirely by menus. - -### 3.1 Titlebar Part Architecture - -The titlebar is divided into three sections, each rendered by a `MenuWorkbenchToolBar`: - -| Section | Menu ID | Purpose | -|---------|---------|--------| -| Left | `Menus.TitleBarLeft` | Toggle sidebar and other left-aligned actions | -| Center | `Menus.CommandCenter` | Session picker widget (rendered via `IActionViewItemService`) | -| Right | `Menus.TitleBarRight` | Run script split button, open submenu, toggle secondary sidebar | - -No menubar, no editor actions, no layout controls, no `WindowTitle` dependency. - -### 3.2 Command Center - -The Agent Sessions titlebar includes a command center with a custom title bar widget (`SessionsTitleBarWidget`). It uses custom menu IDs separate from the default workbench command center to avoid conflicts: - -- **`Menus.CommandCenter`** — The center toolbar menu (replaces `MenuId.CommandCenter`) -- **`Menus.TitleBarControlMenu`** — A submenu registered in the command center whose rendering is intercepted by `IActionViewItemService` to display the custom widget - -The widget: -- Extends `BaseActionViewItem` and renders a clickable label showing the active session title -- Shows kind icon (provider type icon), session title, repository folder name, and the active git branch/worktree name in parentheses when available, plus the changes summary (+insertions -deletions) -- Uses spacing between titlebar groups instead of vertical separator bars, and shows the session title metadata without the previous dot separator before the folder/worktree label -- Keeps the command center focused on the session picker widget itself, without an adjacent "Mark as Done" action button -- Truncates the repository/worktree metadata with ellipsis before truncating the primary AI-generated session title when command center space is constrained -- On click, opens the `AgentSessionsPicker` quick pick to switch between sessions -- Gets the active session label from `IActiveSessionService.getActiveSession()` and the live model title from `IChatService`, falling back to "New Session" if no active session is found -- Re-renders automatically when the active session changes via `autorun` on `IActiveSessionService.activeSession`, and when session data changes via `IAgentSessionsService.model.onDidChangeSessions` -- Is registered via `SessionsTitleBarContribution` (an `IWorkbenchContribution` in `contrib/sessions/browser/sessionsTitleBarWidget.ts`) that calls `IActionViewItemService.register()` to intercept the submenu rendering -- Uses `padding-left: 0` while the sidebar is visible, and restores `padding-left: 16px` when the sidebar is hidden via the `nosidebar` workbench class - -### 3.3 Left Toolbar - -The Agent Sessions titlebar includes a custom left toolbar that appears after the app icon. This toolbar: - -- Uses `Menus.TitleBarLeft` for its actions -- Uses `HiddenItemStrategy.NoHide` so actions cannot be hidden by users -- Displays actions registered to `Menus.TitleBarLeft` - -### 3.4 Titlebar Actions - -| Action | ID | Location | Behavior | -|--------|-----|----------|----------| -| Toggle Sidebar | `workbench.action.agentToggleSidebarVisibility` | Left toolbar (`TitleBarLeft`) | Toggles primary sidebar visibility | -| Agent Host Filter | `sessions.agentHostFilter.pick` | Left toolbar (`TitleBarLeft`) | Dropdown indicator of the host the workbench is scoped to; lets the user switch hosts when more than one is known. When no hosts are known the pill becomes a re-discover trigger (refresh icon; click calls `IAgentHostFilterService.rediscover()`). Renders via a custom `HostFilterActionViewItem`. | -| Run Script | `workbench.action.agentSessions.runScript` | Right toolbar (`TitleBarRight`) | Split button: runs configured script or shows configure dialog | -| Open... | (submenu) | Right toolbar (`TitleBarRight`) | Split button submenu: Open Terminal, Open in VS Code | -| Toggle Secondary Sidebar | `workbench.action.agentToggleSecondarySidebarVisibility` | Right toolbar (`TitleBarRight`) | Toggles auxiliary bar visibility | - -The toggle sidebar action: -- Shows `layoutSidebarLeft` icon when sidebar is visible -- Shows `layoutSidebarLeftOff` icon when sidebar is hidden -- Bound to `Ctrl+B` / `Cmd+B` keybinding -- Announces visibility changes to screen readers - -The Run Script action: -- Displayed as a split button via `RunScriptDropdownMenuId` submenu on `Menus.TitleBarRight` -- Primary action runs the configured script command in a terminal -- Dropdown includes "Configure Run Action..." to set/change the script -- Registered in `contrib/chat/browser/runScriptAction.ts` - -The Open... action: -- Contributed on `Menus.TitleBarSessionMenu` and laid out on `Menus.TitleBarRightLayout` -- Contains "Open Terminal" (opens terminal at session worktree) and "Open in VS Code" (opens worktree in new VS Code window) -- The "Open in VS Code" titlebar widget mirrors the core "Open in Agents" affordance: the product icon is greyscale at rest, returns to full color on hover/focus while respecting reduced-motion preferences, uses secondary-button hover chrome, and draws a separator before the adjacent Run split button -- "Open in VS Code" is contributed in `contrib/chat/browser/openInVSCode.contribution.ts`, and its custom titlebar widget is registered in `contrib/chat/browser/openInVSCodeWidget.ts` (imported from `contrib/chat/browser/chat.contribution.ts`) - -### 3.5 Panel Title Actions - -The panel title bar includes actions for controlling the panel: - -| Action | ID | Icon | Order | Behavior | -|--------|-----|------|-------|----------| -| Hide Panel | `workbench.action.agentTogglePanelVisibility` | `close` | 2 | Hides the panel | - -### 3.6 Account Widget - -The account widget is rendered in the **right side of the titlebar** as a custom `TitleBarAccountWidget` action view item: - -- Registered in `contrib/accountMenu/browser/account.contribution.ts` -- Uses the `Menus.TitleBarRightLayout` menu -- Shows the signed-in GitHub profile image when available, and falls back to the existing account codicon when it is not -- Renders the GitHub profile image at `18px × 18px` inside the `22px × 22px` titlebar widget, and gives it a subtle 1px circular border using the titlebar command center border tokens so the avatar stays legible against nearby chrome in both active and inactive window states -- Opens a combined account and Copilot status hover panel with sign-in/sign-out, settings, and update actions - ---- - -## 4. Grid Structure - -The layout uses `SerializableGrid` from `vs/base/browser/ui/grid/grid.js`. - -### 4.1 Grid Tree - -The Editor part is **not** in the grid — it is rendered as a modal overlay (see Section 4.3). - -``` -Orientation: HORIZONTAL (root) -├── Sidebar (leaf, size: 300px default) -└── Right Section (branch, VERTICAL, size: remaining width) - ├── Titlebar (leaf, size: titleBarHeight) - ├── Top Right (branch, HORIZONTAL, size: remaining height - panel) - │ ├── Chat Bar (leaf, size: remaining width) - │ └── Auxiliary Bar (leaf, size: 300px default) - └── Panel (leaf, size: 300px default, hidden by default) -``` - -This structure places the sidebar at the root level spanning the full window height. The titlebar, chat bar, auxiliary bar, and panel are all within the right section. - -### 4.2 Default Sizes - -| Part | Default Size | -|------|--------------| -| Sidebar | 300px width | -| Auxiliary Bar | 380px width | -| Chat Bar | Remaining space | -| Panel | 300px height | -| Titlebar | Determined by `minimumHeight` (~30px) | - -The sessions sidebar can be resized down to a minimum width of 170px (desktop) or 270px (web, sized to fit the titlebar's left toolbar which includes the host filter combo). - -The sessions auxiliary bar can generally be resized down to 270px. When the main editor part is visible (i.e. any editor is open in the main editor area adjacent to the auxiliary bar), the sash no longer snaps it closed; the titlebar toggle action still hides and shows the auxiliary bar as before. This behavior is automatic and applies to all editor types without requiring an explicit allowlist. - -### 4.3 Editor Modal - -The main editor part is created hidden (`display:none`) and remains hidden for the default sessions experience. Flows that explicitly open or restore an editor into the main editor part can reveal it, and modal editor opens do not change the visibility of an already visible main editor. Editors without an explicit main-part target still open in the `ModalEditorPart` overlay via the standard `createModalEditorPart()` mechanism. - -#### How It Works - -The sessions configuration sets `workbench.editor.useModal` to `'all'` (in `contrib/configuration/browser/configuration.contribution.ts`). This causes `findGroup()` in `editorGroupFinder.ts` to redirect all editor opens (that do not specify an explicit preferred group) to `createModalEditorPart()`, which creates the standard workbench `ModalEditorPart` overlay on-demand. - -When the setting is `'all'`: -- All editors without an explicit preferred group open in the modal editor part -- The modal is not auto-closed when editors open without explicit `MODAL_GROUP` as preferred group - -#### Behavior - -| Trigger | Action | -|---------|--------| -| Any editor opens (no explicit group) | `ModalEditorPart` overlay created/reused automatically | -| All editors closed in modal | Modal closes and is disposed | -| Click backdrop | Close all editors, hide modal | -| Press Escape | Close all editors, hide modal | - -#### Configuration - -The setting `workbench.editor.useModal` is an enum with three values: -- `'off'`: Editors never open in a modal overlay -- `'some'`: Certain editors (e.g. Settings, Keyboard Shortcuts) may open in a modal overlay when requested via `MODAL_GROUP` -- `'all'`: All editors open in a modal overlay (used by agent sessions window) - -The sessions default configuration also sets `workbench.notifications.position` to `'bottom-right'` so notifications anchor in the bottom-right corner of the sessions window without changing the default notification placement in the regular workbench. The sessions-specific stylesheet adjusts both notification center and toast offsets to `15px` from the bottom/right or bottom/left edges, and to `top: 40px; right: 15px;` for the top-right placement. Because the shared workbench notification controllers also compute a top-right inline offset for custom titlebar windows, the sessions workbench reapplies its fixed `40px` top offset after those controllers run so the sessions-only placement stays stable. - - ---- - -## 5. Feature Support Matrix - -| Feature | Default Workbench | Agent Sessions | Notes | -|---------|-------------------|----------------|-------| -| Activity Bar | ✅ Configurable | ❌ Not included | — | -| Status Bar | ✅ Configurable | ❌ Not included | — | -| Sidebar Position | ✅ Left/Right | 🔒 Fixed: Left | `getSideBarPosition()` returns `Position.LEFT` | -| Panel Position | ✅ Top/Bottom/Left/Right | 🔒 Fixed: Bottom | `getPanelPosition()` returns `Position.BOTTOM` | -| Panel Alignment | ✅ Left/Center/Right/Justify | 🔒 Fixed: Justify | `getPanelAlignment()` returns `'justify'` | -| Maximize Panel | ✅ Supported | ✅ Supported | Excludes titlebar when maximizing | -| Maximize Auxiliary Bar | ✅ Supported | ❌ No-op | `toggleMaximizedAuxiliaryBar()` does nothing | -| Zen Mode | ✅ Supported | ❌ No-op | `toggleZenMode()` does nothing | -| Centered Editor Layout | ✅ Supported | ❌ No-op | `centerMainEditorLayout()` does nothing | -| Menu Bar Toggle | ✅ Supported | ❌ No-op | `toggleMenuBar()` does nothing | -| Resize Parts | ✅ Supported | ✅ Supported | Via grid or programmatic API | -| Hide/Show Parts | ✅ Supported | ✅ Supported | Via `setPartHidden()` | -| Window Maximized State | ✅ Supported | ✅ Supported | Tracked per window ID | -| Fullscreen | ✅ Supported | ✅ Supported | CSS class applied | - ---- - -## 6. API Reference - -### 6.1 Part Visibility - -```typescript -// Check if a part is visible -isVisible(part: Parts): boolean - -// Show or hide a part -setPartHidden(hidden: boolean, part: Parts): void -``` - -**Behavior:** -- Hiding a part also hides its active pane composite -- Showing a part restores the last active pane composite -- **Panel Part:** - - If the panel is maximized when hiding, it exits maximized state first -- **Editor Part:** - - The main editor part is hidden by default but can be shown for explicit editor workflows that target the main editor part - - Modal editor opens do not change the current main editor visibility state - - The sessions **Maximize Editor** action temporarily hides the panel when the visible panel is the terminal view, and the matching **Restore Editor** action reopens that terminal panel if maximize hid it - - All editors open via `MODAL_GROUP` into the `ModalEditorPart` overlay, which manages its own lifecycle - -### 6.2 Part Sizing - -```typescript -// Get current size of a part -getSize(part: Parts): IViewSize - -// Set absolute size of a part -setSize(part: Parts, size: IViewSize): void - -// Resize by delta values -resizePart(part: Parts, sizeChangeWidth: number, sizeChangeHeight: number): void -``` - -### 6.3 Focus Management - -```typescript -// Focus a specific part -focusPart(part: Parts): void - -// Check if a part has focus -hasFocus(part: Parts): boolean - -// Focus the Chat Bar (default focus target) -focus(): void -``` - -### 6.4 Container Access - -```typescript -// Get the main container or active container -get mainContainer(): HTMLElement -get activeContainer(): HTMLElement - -// Get container for a specific part -getContainer(targetWindow: Window, part?: Parts): HTMLElement | undefined -``` - -### 6.5 Layout Offset - -```typescript -// Get offset info for positioning elements -get mainContainerOffset(): ILayoutOffsetInfo -get activeContainerOffset(): ILayoutOffsetInfo -``` - -Returns `{ top, quickPickTop }` where `top` is the titlebar height. - ---- - -## 7. Events - -| Event | Fired When | -|-------|------------| -| `onDidChangePartVisibility` | Any part visibility changes | -| `onDidLayoutMainContainer` | Main container is laid out | -| `onDidLayoutActiveContainer` | Active container is laid out | -| `onDidLayoutContainer` | Any container is laid out | -| `onDidChangeWindowMaximized` | Window maximized state changes | -| `onDidChangeNotificationsVisibility` | Notification visibility changes | -| `onWillShutdown` | Workbench is about to shut down | -| `onDidShutdown` | Workbench has shut down | - -**Events that never fire** (unsupported features): -- `onDidChangeZenMode` -- `onDidChangeMainEditorCenteredLayout` -- `onDidChangePanelAlignment` -- `onDidChangePanelPosition` -- `onDidChangeAuxiliaryBarMaximized` - ---- - -## 8. CSS Classes - -### 8.1 Visibility Classes - -Applied to `mainContainer` based on part visibility: - -| Class | Applied When | -|-------|--------------| -| `nosidebar` | Sidebar is hidden | -| `nomaineditorarea` | Editor part is hidden (always applied — main editor part is permanently hidden) | -| `noauxiliarybar` | Auxiliary bar is hidden | -| `nochatbar` | Chat bar is hidden | -| `nopanel` | Panel is hidden | - -### 8.2 Window State Classes - -| Class | Applied When | -|-------|--------------| -| `fullscreen` | Window is in fullscreen mode | -| `maximized` | Window is maximized | - -### 8.3 Platform Classes - -Applied during workbench render: -- `monaco-workbench` -- `agent-sessions-workbench` -- `windows` / `linux` / `mac` -- `web` (if running in browser) -- `chromium` / `firefox` / `safari` - ---- - -## 9. Agent Session Parts - -The Agent Sessions workbench uses specialized part implementations that extend the base pane composite infrastructure but are simplified for agent session contexts. - -### 9.1 Part Classes - -| Part | Class | Extends | Location | -|------|-------|---------|----------| -| Sidebar | `SidebarPart` | `AbstractPaneCompositePart` | `sessions/browser/parts/sidebarPart.ts` | -| Auxiliary Bar | `AuxiliaryBarPart` | `AbstractPaneCompositePart` | `sessions/browser/parts/auxiliaryBarPart.ts` | -| Panel | `PanelPart` | `AbstractPaneCompositePart` | `sessions/browser/parts/panelPart.ts` | -| Chat Bar | `ChatBarPart` | `AbstractPaneCompositePart` | `sessions/browser/parts/chatBarPart.ts` | -| Titlebar | `TitlebarPart` / `MainTitlebarPart` | `Part` | `sessions/browser/parts/titlebarPart.ts` | -| Project Bar | `ProjectBarPart` | `Part` | `sessions/browser/parts/projectBarPart.ts` | - -### 9.2 Key Differences from Standard Parts - -| Feature | Standard Parts | Agent Session Parts | -|---------|----------------|---------------------| -| Activity Bar integration | Full support | No activity bar; account widget in the titlebar | -| Composite bar position | Configurable (top/bottom/title/hidden) | Fixed: Title | -| Composite bar visibility | Configurable | Sidebar: hidden (`shouldShowCompositeBar()` returns `false`); ChatBar: hidden; Auxiliary Bar & Panel: visible. Separately, the internal chat tab strip shown inside the Chat Bar preserves each chat title's original casing instead of forcing per-word capitalization via CSS. | -| Auto-hide support | Configurable | Disabled | -| Configuration listening | Many settings | Minimal | -| Context menu actions | Full set | Simplified | -| Title bar | Full support | Sidebar: `hasTitle: true` (with footer); ChatBar: `hasTitle: false`; Auxiliary Bar & Panel: `hasTitle: true` | -| Visual margins | None | Auxiliary Bar: 16px top/right, 18px bottom (card appearance); Panel: 18px bottom, 16px left/right (card appearance); Sidebar: 0 (flush) | - -### 9.3 Part Creation - -The agent sessions pane composite parts are created and registered via the `AgenticPaneCompositePartService` in `sessions/browser/paneCompositePartService.ts`. This service is registered as a singleton for `IPaneCompositePartService` and directly instantiates each part: - -```typescript -// In AgenticPaneCompositePartService constructor -this.registerPart(ViewContainerLocation.Panel, instantiationService.createInstance(PanelPart)); -this.registerPart(ViewContainerLocation.Sidebar, instantiationService.createInstance(SidebarPart)); -this.registerPart(ViewContainerLocation.AuxiliaryBar, instantiationService.createInstance(AuxiliaryBarPart)); -this.registerPart(ViewContainerLocation.ChatBar, instantiationService.createInstance(ChatBarPart)); -``` - -This architecture ensures that: -1. The agent sessions workbench uses its own part implementations rather than the standard workbench parts -2. Each part is instantiated eagerly in the constructor, as the service delegates all operations to the appropriate part by `ViewContainerLocation` - -### 9.4 Storage Keys - -Each agent session part uses separate storage keys to avoid conflicts with regular workbench state: - -| Part | Setting | Storage Key | -|------|---------|-------------| -| Sidebar | Active viewlet | `workbench.agentsession.sidebar.activeviewletid` | -| Sidebar | Pinned viewlets | `workbench.agentsession.pinnedViewlets2` | -| Sidebar | Placeholders | `workbench.agentsession.placeholderViewlets` | -| Sidebar | Workspace state | `workbench.agentsession.viewletsWorkspaceState` | -| Auxiliary Bar | Active panel | `workbench.agentsession.auxiliarybar.activepanelid` | -| Auxiliary Bar | Pinned views | `workbench.agentsession.auxiliarybar.pinnedPanels` | -| Auxiliary Bar | Placeholders | `workbench.agentsession.auxiliarybar.placeholderPanels` | -| Auxiliary Bar | Workspace state | `workbench.agentsession.auxiliarybar.viewContainersWorkspaceState` | -| Panel | Active panel | `workbench.agentsession.panelpart.activepanelid` | -| Panel | Pinned panels | `workbench.agentsession.panel.pinnedPanels` | -| Panel | Placeholders | `workbench.agentsession.panel.placeholderPanels` | -| Panel | Workspace state | `workbench.agentsession.panel.viewContainersWorkspaceState` | -| Chat Bar | Active panel | `workbench.chatbar.activepanelid` | -| Chat Bar | Pinned panels | `workbench.chatbar.pinnedPanels` | -| Chat Bar | Placeholders | `workbench.chatbar.placeholderPanels` | -| Chat Bar | Workspace state | `workbench.chatbar.viewContainersWorkspaceState` | - -### 9.5 Part Borders and Card Appearance - -Parts manage their own border and background styling via the `updateStyles()` method. In the default light theme, the sessions workbench surface uses the off-white workbench/sidebar background while the card-like chat, auxiliary bar, and panel surfaces use the brighter editor background. Light themes also override the chat, auxiliary bar, and panel card border color in CSS to use `editorWidget.border`, giving those cards a darker outline. Dark and high-contrast mappings continue to use the existing part border tokens. The sessions shell now applies its accent-tinted gradient treatment by default via a single root-level background layer in `browser/media/style.css` (`.agent-sessions-workbench.shell-gradient-background::before`) that sits behind the workbench parts and falls back to the normal solid shell background when `color-mix(...)` is unavailable. When supported, the layer derives its tint from the theme's primary accent signal in `button.background`. The gradient runs from the base shell color at the top-left toward a gentle, deliberately low-contrast accent tint in the bottom-right; light themes use a transparentized accent overlay to preserve a bit more of the original accent hue without letting it dominate the shell, dark themes use shallower direct mixes into the shell background, and high-contrast themes disable the gradient entirely for accessibility. Titlebar/sidebar wrappers are made transparent so that one shared layer reads continuously across the whole window chrome without clipping at part boundaries. These surfaces use a **card appearance** with CSS variables for background and border: - -| Part | Styling | Notes | -|------|---------|-------| -| Sidebar | Right border via `SIDE_BAR_BORDER` / `contrastBorder` | Flush appearance; the sidebar wrappers are transparent so the shared root shell gradient reads through continuously | -| Chat Bar | Card appearance via CSS variables `--part-background` / `--part-border-color`, with a light-theme-only CSS border-color override | Uses `sessionsChatBarBackground`; remains a solid view surface so chat content is unaffected | -| Auxiliary Bar | Card appearance via CSS variables `--part-background` / `--part-border-color`, with a light-theme-only CSS border-color override | Uses `sessionsAuxiliaryBarBackground` / `PANEL_BORDER`; remains a solid view surface so files/changes content is unaffected | -| Panel | Card appearance via CSS variables `--part-background` / `--part-border-color`, with a light-theme-only CSS border-color override | Uses `sessionsPanelBackground` / `PANEL_BORDER`; remains a solid view surface so terminal/debug content is unaffected | -| Titlebar | Simplified sessions titlebar part | Its wrappers are transparent so the shared root shell gradient reads through the top chrome as one continuous surface | - -The sessions workbench also scopes its resize sash styling in `browser/media/style.css`, rounding the sash hover indicator and orthogonal drag handles so the layout chrome matches the card surfaces. -Both sessions chat input surfaces keep the unfocused `editorWidget.border` outline in light themes, but switch to `focusBorder` while focused so the new-chat view and the active chat input match the core workbench chat widget focus treatment. - ---- - -### 9.6 Auxiliary Bar Run Script Dropdown - -The `AuxiliaryBarPart` provides a custom `DropdownWithPrimaryActionViewItem` for the run script action (`workbench.action.agentSessions.runScript`). This is rendered as a split button with: - -- **Primary action**: Runs the main script action -- **Dropdown**: Shows additional actions from the `AgentSessionsRunScriptDropdown` menu -- The dropdown menu is created from `MenuId.for('AgentSessionsRunScriptDropdown')` and updates dynamically when menu items change - -### 9.7 Sidebar Footer - -The `SidebarPart` includes a footer section (35px height) positioned below the pane composite content. The sidebar uses a custom `layout()` override that reduces the content height by `FOOTER_HEIGHT` and renders a `MenuWorkbenchToolBar` driven by `Menus.SidebarFooter`. The footer hosts the account widget (see Section 3.6). - -On macOS native with custom titlebar, the sidebar title area includes a traffic light spacer (70px) to push content past the system window controls. The spacer is hidden in fullscreen mode and is not created when using native titlebar (since the OS renders traffic lights in its own title bar). - -The sessions appear animation applies only to the sidebar body (`.part.sidebar > .content`). The sidebar container, title area, and footer do not participate in the reveal animation, so the header region stays visually fixed while the body slides/fades in. Normal hover and pressed feedback for header/footer controls is preserved. - ---- - -## 10. Workbench Contributions - -The Agent Sessions workbench registers contributions via module imports in `sessions.desktop.main.ts` (and `sessions.common.main.ts`). Key contributions: - -| Contribution | Class | Phase | Location | -|-------------|-------|-------|----------| -| Run Script | `RunScriptContribution` | `AfterRestored` | `contrib/chat/browser/runScriptAction.ts` | -| Title Bar Widget | `SessionsTitleBarContribution` | `AfterRestored` | `contrib/sessions/browser/sessionsTitleBarWidget.ts` | -| Account Widget | `AccountWidgetContribution` | `AfterRestored` | `contrib/accountMenu/browser/account.contribution.ts` | -| Active Session Service | `ActiveSessionService` | Singleton | `contrib/sessions/browser/activeSessionService.ts` | -| Prompts Service | `AgenticPromptsService` | Singleton | `contrib/chat/browser/promptsService.ts` | - -Additionally, `BranchChatSessionAction` is registered in `contrib/chat/browser/chat.contribution.ts`. - -### 10.1 Changes View - -The Changes view is registered in `contrib/changesView/browser/changesView.contribution.ts`: - -- **Container**: `CHANGES_VIEW_CONTAINER_ID` in `ViewContainerLocation.AuxiliaryBar` (default, hidden if empty) -- **View**: `CHANGES_VIEW_ID` with `ChangesViewPane` -- **Window visibility**: `WindowVisibility.Sessions` (only visible in agent sessions workbench) - -### 10.2 Sessions View - -The Sessions view is registered in `contrib/sessions/browser/sessions.contribution.ts`: - -- **Container**: Sessions container in `ViewContainerLocation.Sidebar` (default) -- **View**: `SessionsViewId` with `SessionsView` (`contrib/sessions/browser/views/sessionsView.ts`) -- **Window visibility**: `WindowVisibility.Sessions` -- **Primary action**: The sidebar content starts with a left-aligned secondary "New Session" button rendered as `$(plus) Session`, with an inline shortcut hint that reflects the active `workbench.action.sessions.newChat` keybinding when one is available -- **Header layout**: The sessions list header label remains visible as the sidebar narrows and truncates with ellipsis instead of being hidden outright; the inline find widget still replaces both the label and actions while open -- **Pinned section**: Pinned chats render in their own uppercase "Pinned" section header at the top of the list; that section reuses the standard section-header styling and only exposes the section-level "Mark All as Done" action (not workspace-specific actions like "New Session") - ---- - -## 11. File Structure - -``` -src/vs/sessions/ -├── README.md # Layer specification -├── LAYOUT.md # This specification -├── AI_CUSTOMIZATIONS.md # AI customization design document -├── sessions.common.main.ts # Common entry point (browser + desktop) -├── sessions.desktop.main.ts # Desktop entry point (imports all contributions) -├── common/ -│ └── contextkeys.ts # ChatBar context keys -├── browser/ # Core workbench implementation -│ ├── workbench.ts # Main layout implementation (Workbench class) -│ ├── menus.ts # Agent sessions menu IDs (Menus export) -│ ├── layoutActions.ts # Layout actions (toggle sidebar, secondary sidebar, panel) -│ ├── paneCompositePartService.ts # AgenticPaneCompositePartService -│ ├── style.css # Layout-specific styles -│ ├── widget/ # Agent sessions chat widget -│ │ ├── AGENTS_CHAT_WIDGET.md # Chat widget architecture documentation -│ │ ├── agentSessionsChatWidget.ts # Main chat widget wrapper -│ │ ├── agentSessionsChatTargetConfig.ts # Target configuration (observable) -│ │ ├── agentSessionsTargetPickerActionItem.ts # Target picker for input toolbar -│ │ └── media/ -│ │ └── agentSessionsChatWidget.css -│ └── parts/ -│ ├── titlebarPart.ts # Simplified titlebar part, MainTitlebarPart, AuxiliaryTitlebarPart, and TitleService -│ ├── sidebarPart.ts # Agent session sidebar (with footer and macOS traffic light spacer) -│ ├── auxiliaryBarPart.ts # Agent session auxiliary bar (with run script dropdown) -│ ├── panelPart.ts # Agent session panel -│ ├── chatBarPart.ts # Chat Bar part implementation -│ ├── projectBarPart.ts # Project bar part (folder entries, icon customization) -│ ├── parts.ts # AgenticParts enum -│ ├── agentSessionsChatInputPart.ts # Chat input part adapter -│ ├── agentSessionsChatWelcomePart.ts # Chat welcome part -│ └── media/ -│ ├── titlebarpart.css -│ ├── sidebarPart.css -│ ├── chatBarPart.css -│ ├── projectBarPart.css -│ └── agentSessionsChatWelcomePart.css -├── electron-browser/ # Desktop-specific entry points -│ ├── sessions.main.ts -│ ├── sessions.ts -│ ├── sessions.html -│ └── sessions-dev.html -├── contrib/ # Feature contributions -│ ├── accountMenu/browser/ # Account menu and titlebar account widget -│ │ ├── account.contribution.ts -│ │ └── media/ -│ ├── aiCustomizationManagement/browser/ # AI customization management editor -│ ├── aiCustomizationTreeView/browser/ # AI customization tree view sidebar -│ ├── changesView/browser/ # File changes view -│ │ ├── changesView.contribution.ts -│ │ ├── changesView.ts -│ │ └── media/ -│ ├── chat/browser/ # Chat actions and services -│ │ ├── chat.contribution.ts # Open in VS Code, Open Terminal, branch chat, run script, prompts service -│ │ ├── branchChatSessionAction.ts # Branch chat session action -│ │ ├── runScriptAction.ts # Run script contribution and split button -│ │ └── promptsService.ts # Agentic prompts service override -│ ├── configuration/browser/ # Configuration contribution -│ │ └── configuration.contribution.ts -│ └── sessions/browser/ # Sessions view and title bar widget -│ ├── sessions.contribution.ts # Sessions view container, view, and title bar widget registration -│ ├── sessionsViewPane.ts # Sessions list view pane -│ ├── sessionsTitleBarWidget.ts # Title bar widget (SessionsTitleBarWidget, SessionsTitleBarContribution) -│ ├── activeSessionService.ts # IActiveSessionService implementation -│ └── media/ -│ └── sessionsTitleBarWidget.css -``` - ---- - -## 12. Implementation Requirements - -When modifying the Agent Sessions layout: - -1. **Maintain fixed positions** — Do not add settings-based position customization -2. **Panel must span the right section width** — The grid structure places the panel below Chat Bar and Auxiliary Bar only -3. **Sidebar spans full window height** — Sidebar is at the root grid level, spanning from top to bottom independently of the titlebar -4. **New parts go in right section** — Any new parts should be added to the right section alongside Titlebar, Chat Bar, and Auxiliary Bar -5. **Update this spec** — All changes must be documented here -5. **Preserve no-op methods** — Unsupported features should remain as no-ops, not throw errors -6. **Handle pane composite lifecycle** — When hiding/showing parts, manage the associated pane composites -7. **Use agent session parts** — New functionality for parts should be added to the agent session part classes, not the standard parts - ---- - -## 13. Lifecycle - -### 13.1 Startup Sequence - -1. `constructor()` — Register error handlers -2. `startup()` — Initialize services and layout -3. `initServices()` — Set up service collection (including `TitleService`), register singleton services, set lifecycle to `Ready` -4. `initLayout()` — Get services, register layout listeners -5. `renderWorkbench()` — Create DOM, create parts, create hidden editor part, set up notifications -6. `createWorkbenchLayout()` — Build the grid structure -7. `createWorkbenchManagement()` — (No-op in agent sessions layout) -8. `layout()` — Perform initial layout -9. `restore()` — Restore parts (open default view containers), set lifecycle to `Restored`, then `Eventually` - -Note: Contributions are registered via module imports in `sessions.desktop.main.ts` (through `registerWorkbenchContribution2`, `registerAction2`, `registerSingleton` calls), not via a central registration function. - -### 13.2 Part Restoration - -During the `restore()` phase, `restoreParts()` is called to open the default view container for each visible part: - -```typescript -private restoreParts(): void { - const partsToRestore = [ - { location: ViewContainerLocation.Sidebar, visible: this.partVisibility.sidebar }, - { location: ViewContainerLocation.Panel, visible: this.partVisibility.panel }, - { location: ViewContainerLocation.AuxiliaryBar, visible: this.partVisibility.auxiliaryBar }, - { location: ViewContainerLocation.ChatBar, visible: this.partVisibility.chatBar }, - ]; - - for (const { location, visible } of partsToRestore) { - if (visible) { - const defaultViewContainer = this.viewDescriptorService.getDefaultViewContainer(location); - if (defaultViewContainer) { - this.paneCompositeService.openPaneComposite(defaultViewContainer.id, location); - } - } - } -} -``` - -This ensures that when a part is visible, its default view container is automatically opened and displayed. - -### 13.3 State Tracking - -```typescript -interface IPartVisibilityState { - sidebar: boolean; - auxiliaryBar: boolean; - editor: boolean; - panel: boolean; - chatBar: boolean; -} -``` - -**Initial state:** - -| Part | Initial Visibility | -|------|--------------------| -| Sidebar | `true` (visible) | -| Auxiliary Bar | `true` (visible) | -| Chat Bar | `true` (visible) | -| Editor | `false` (hidden) | -| Panel | `false` (hidden) | - ---- - -## 14. Sidebar Reveal Buttons - -> **Note:** Sidebar reveal buttons (`SidebarRevealButton`) have been removed from the implementation. The corresponding file `parts/sidebarRevealButton.ts` no longer exists. Sidebar visibility is controlled via the toggle actions in the titlebar (see Section 3.4). - ---- - -## Revision History - -| Date | Change | -|------|--------| -| 2026-04-28 | Updated the sessions "Open in VS Code" titlebar widget to match the core "Open in Agents" affordance more closely: the product icon is greyscale by default, animates back to full color on hover/focus when motion is enabled, uses secondary-button hover chrome instead of quality-tinted backgrounds, and draws a separator before the Run split button. | -| 2026-04-27 | Made the sessions shell gradient background the default treatment by removing the `sessions.experimental.shellGradientBackground` opt-in, always applying the root shell gradient layer, and renaming the workbench CSS hook to `shell-gradient-background`. | -| 2026-04-23 | Updated mobile layout policy platform detection to use shared `platform.isMobile`, and reduced phone-layout CSS `!important` usage where selector specificity already provides stable overrides. | -| 2026-04-22 | Increased the sessions titlebar account widget's GitHub profile image from `16px × 16px` to `18px × 18px` while keeping the existing `22px × 22px` control footprint and avatar border treatment. | -| 2026-04-22 | Added sessions-only toast offset overrides so notification toasts now use `right: 15px` in the default bottom-right placement and `left: 15px` in the bottom-left placement, matching the notification center spacing. | -| 2026-04-22 | Added a sessions-workbench notification offset override so the shared notification controllers no longer push top-right notifications down to `42px`; sessions now reapply a fixed `40px` top offset for top-right notification center/toast placement. | -| 2026-04-22 | Generalized the auxiliary bar snap-close prevention to trigger whenever the main editor part is visible (any editor type), so the behavior now applies automatically without maintaining an editor-type allowlist. | -| 2026-04-22 | Updated the sessions auxiliary bar sizing rules so attached diff editors and integrated browser editors keep the normal 270px auxiliary-bar minimum width while disabling sash snap-to-close in that state, and the titlebar toggle continues to hide/show the secondary sidebar normally. | -| 2026-04-22 | Updated the sessions **Maximize Editor** and **Restore Editor** actions so maximize hides the panel only when the terminal view is currently visible, and restore reopens that terminal panel when maximize hid it. | -| 2026-04-21 | Renamed the command-center "Add Chat" titlebar action to "New Sub-Session" so the plus-button tooltip matches the sub-session workflow. | -| 2026-04-21 | Removed the remaining left-margin spacing after the titlebar's VS Code and session-picker items, and dropped the command-center "Mark as Done" checkmark button next to the active session title. | -| 2026-04-21 | Removed the titlebar's vertical separator bars in favor of spacing-only group separation, and removed the dot separator between the active session title and its folder/worktree metadata. | -| 2026-04-21 | Updated the sessions chat composite bar tabs to preserve each chat title's original casing instead of applying per-word capitalization. | -| 2026-04-21 | Moved the sessions-only default notification placement to bottom-right and documented the sessions-specific notification center offsets: `15px` from the bottom/right or bottom/left edges, and `top: 40px; right: 15px;` for top-right placement. | -| 2026-04-17 | Added a subtle 1px titlebar-token border around the sessions account widget's GitHub profile image, including the inactive-window variant, and documented the avatar chrome in the layout spec. | -| 2026-04-16 | Softened the experimental sessions shell gradient by reducing the accent tint mix strength across the shared default, light-theme, and dark-theme variants so the primary color reads more subtly behind the workbench chrome. | -| 2026-04-16 | Updated the layout visual representation to show the editor part in the top-right row and mark it as hidden by default. | -| 2026-04-16 | Fixed the sessions workbench so modal editor opens no longer hide an already visible main editor part, and documented that the main editor stays hidden by default but can be revealed by explicit non-modal editor flows. | -| 2026-04-15 | Updated the Sessions sidebar so pinned chats render in their own "Pinned" section header using the standard uppercase section styling, and that header only exposes the "Mark All as Done" section action. | -| 2026-04-14 | Documented the sessions account control as a titlebar widget again and noted that it now prefers the signed-in GitHub profile image, falling back to the existing account codicon when the image is unavailable. | -| 2026-04-14 | Updated the sessions-only default configuration so notification toasts default to the top-right corner via `workbench.notifications.position: 'top-right'`, without changing the regular workbench default. | -| 2026-04-10 | Updated the sessions titlebar widget so repository/worktree metadata truncates with ellipsis before the primary AI-generated session title when the command center gets narrow. | -| 2026-04-10 | Updated workspace/repository section headers in the Sessions sidebar to keep their uppercase titles visible via ellipsis truncation so the section toolbar actions remain reachable when names are long. | -| 2026-04-10 | Updated the Sessions view header so the sidebar "Sessions" label stays visible and truncates with ellipsis when space is tight instead of being hidden; documented the find-widget exception in the Sessions view spec. | -| 2026-04-10 | Updated both sessions chat input surfaces so the standalone new-chat input and the active chat widget input switch their border to `focusBorder` while focused, matching the core workbench chat widget focus treatment. | -| 2026-04-14 | Reworked the sessions accent-tinted background into a single root-level pseudo-element behind the workbench parts, then gated it behind `sessions.experimental.shellGradientBackground` so the shell gradient is opt-in while it is being dogfooded and the default styling remains identical to the upstream non-experimental shell. | -| 2026-04-08 | Darkened the light-theme-only chat, auxiliary bar, and panel card borders with a sessions-specific CSS `border-color` override that uses `editorWidget.border`; dark and high-contrast themes continue using the existing part border tokens. | -| 2026-04-08 | Rounded the sessions workbench sash hover indicators and orthogonal drag handles via `browser/media/style.css` so resize handles use rounded corners instead of square edges. | -| 2026-04-04 | Inverted the default light-theme surface mapping so the sessions window background uses the off-white workbench/sidebar surface while the chat, changes, and panel cards use the brighter editor background; dark and high-contrast mappings remain unchanged. | -| 2026-04-03 | Updated `SessionsTitleBarWidget` to format active session titles as `{Title} · {repo name} ({git branch/worktree name})` when repository detail metadata is available, falling back to the worktree folder name when needed. | -| 2026-04-03 | Reduced the sessions left sidebar minimum resizable width from 270px to 170px so it can shrink significantly more while keeping the default 300px width unchanged | -| 2026-03-30 | Adjusted `.agent-sessions-titlebar-container` padding so it sits flush when the sidebar is visible and restores 16px left padding when the sidebar is hidden | -| 2026-03-26 | Updated the sessions sidebar appear animation so only the body content (`.part.sidebar > .content`) slides/fades in during reveal while the sidebar title/header and footer remain fixed | -| 2026-03-24 | Polished the sessions task configuration quick input modal to use stronger modal-style header chrome, increased horizontal padding in the quick input/form content, and added an explicit close action in the modal header | -| 2026-03-25 | Updated Sessions view documentation to reflect the refactored `SessionsView` implementation in `contrib/sessions/browser/views/sessionsView.ts` and documented the left-aligned "+ Session" sidebar action with its inline keybinding hint | -| 2026-03-24 | Updated the sessions new-chat empty state: removed the watermark, vertically centered the empty-state controls block, restyled the workspace picker as an inline `New session in {dropdown}` title row aligned to the chat input, and tuned empty-state dropdown icon/chevron and local-mode spacing for the final visual polish. | -| 2026-03-02 | Fixed macOS sidebar traffic light spacer to only render with custom titlebar; added `!hasNativeTitlebar()` guard to `SidebarPart.createTitleArea()` so the 70px spacer is not created when using native titlebar (traffic lights are in the OS title bar, not overlapping the sidebar) | -| 2026-02-20 | Replaced custom `EditorModal` with standard `ModalEditorPart` via `MODAL_GROUP`; main editor part created but hidden; changed `workbench.editor.useModal` from boolean to enum (`off`/`some`/`all`); sessions config uses `all`; removed `editorModal.ts` and editor modal CSS | -| 2026-02-17 | Added `-webkit-app-region: drag` to sidebar title area so it can be used to drag the window; interactive children (actions, composite bar, labels) marked `no-drag`; CSS rules scoped to `.agent-sessions-workbench` in `parts/media/sidebarPart.css` | -| 2026-02-13 | Documentation sync: Updated all file names, class names, and references to match current implementation. `AgenticWorkbench` → `Workbench`, `AgenticSidebarPart` → `SidebarPart`, `AgenticAuxiliaryBarPart` → `AuxiliaryBarPart`, `AgenticPanelPart` → `PanelPart`, `agenticWorkbench.ts` → `workbench.ts`, `agenticWorkbenchMenus.ts` → `menus.ts`, `agenticLayoutActions.ts` → `layoutActions.ts`, `AgenticTitleBarWidget` → `SessionsTitleBarWidget`, `AgenticTitleBarContribution` → `SessionsTitleBarContribution`. Removed references to deleted files (`sidebarRevealButton.ts`, `floatingToolbar.ts`, `agentic.contributions.ts`, `agenticTitleBarWidget.ts`). Updated pane composite architecture from `SyncDescriptor`-based to `AgenticPaneCompositePartService`. Moved account widget docs from titlebar to sidebar footer. Added documentation for sidebar footer, project bar, traffic light spacer, card appearance styling, widget directory, and new contrib structure (`accountMenu/`, `chat/`, `configuration/`, `sessions/`). Updated titlebar actions to reflect Run Script split button and Open submenu. Removed Toggle Maximize panel action (no longer registered). Updated contributions section with all current contributions and their locations. | -| 2026-02-13 | Changed grid structure: sidebar now spans full window height at root level (HORIZONTAL root orientation); Titlebar moved inside right section; Grid is now `Sidebar \| [Titlebar / TopRight / Panel]` instead of `Titlebar / [Sidebar \| RightSection]`; Panel maximize now excludes both titlebar and sidebar; Floating toolbar positioning no longer depends on titlebar height | -| 2026-02-11 | Simplified titlebar: replaced `BrowserTitlebarPart`-derived implementation with standalone `TitlebarPart` using three `MenuWorkbenchToolBar` sections (left/center/right); Removed `CommandCenterControl`, `WindowTitle`, layout toolbar, and manual toolbar management; Center section uses `Menus.CommandCenter` which renders session picker via `IActionViewItemService`; Right section uses `Menus.TitleBarRight` which includes account submenu; Removed `commandCenterControl.ts` file | -| 2026-02-11 | Removed activity actions (Accounts, Manage) from titlebar; Added `AgenticAccount` submenu to `TitleBarRight` with account icon; Menu shows signed-in user label from `IDefaultAccountService` (or Sign In action if no account), Settings, and Check for Updates; Added `AgenticAccountContribution` workbench contribution for dynamic account state; Added `AgenticAccount` menu ID to `Menus` | -| 2026-02-10 | Titlebar customization now uses class inheritance with protected getter overrides on `BrowserTitlebarPart`; Base class retains original API — no `ITitlebarPartOptions`/`ITitlebarPartConfiguration` removed; `AgenticTitlebarPart` and `AgenticTitleService` in `parts/agenticTitlebarPart.ts` override `isCommandCenterVisible`, `editorActionsEnabled`, `installMenubar()`, and menu ID getters | -| 2026-02-07 | Comprehensive spec update: fixed widget class names (`AgenticTitleBarWidget`/`AgenticTitleBarContribution`), corrected click behavior (uses `AgentSessionsPicker` not `FocusAgentSessionsAction`), corrected session label source (`IActiveSessionService`), fixed toggle terminal details (uses standard `toggleTerminal` command via `MenuRegistry.appendMenuItem` on right toolbar), added sidebar/chatbar storage keys, added chatbar to part classes table, documented contributions section with `RunScriptContribution`/`AgenticTitleBarContribution`/Changes view, added `agent-sessions-workbench` platform class, documented auxiliary bar run script dropdown, updated file structure with `actions/`, `views/`, `media/` directories, fixed lifecycle section numbering, corrected `focus()` target to ChatBar | -| 2026-02-07 | Moved `ToggleTerminalAction` to `contrib/terminal/browser/terminalAgentSessionActions.ts`; Menu item registered via `MenuRegistry.appendMenuItem` from `agenticLayoutActions.ts` to avoid layering violation |\n| 2026-02-07 | Added `TitleBarLeft`, `TitleBarCenter`, `TitleBarRight` menu IDs to `AgenticWorkbenchMenus`; Added `titleBarMenuId` option to `ITitlebarPartOptions` for overriding the global toolbar menu; Actions now use agent-session-specific menu IDs instead of shared `MenuId.TitleBarLeft` / `MenuId.TitleBar` | -| 2026-02-07 | Moved agent sessions workbench menu IDs to `agenticWorkbenchMenus.ts`; Renamed `AgentSessionMenus` to `AgenticWorkbenchMenus` | -| 2026-02-07 | Added `MenuId.AgentSessionsTitleBarContext` as a separate titlebar context menu ID; `contextMenuId` option now set in both main and auxiliary titlebar configurations | -| 2026-02-07 | Added `ToggleTerminalAction` to left toolbar; toggles panel with terminal view; bound to `` Ctrl+` `` | -| 2026-02-06 | `AgentSessionsTitleBarStatusWidget` now shows active chat session title instead of workspace label; Clicking opens sessions view via `FocusAgentSessionsAction`; Removed folder picker and recent folders | -| 2026-02-06 | Replaced command center folder picker with `AgentSessionsTitleBarStatusWidget` (custom `BaseActionViewItem`); Uses `IActionViewItemService` to intercept `AgentSessionsTitleBarControlMenu` submenu; Shows workspace label pill with quick pick for recent folders | -| 2026-02-06 | Added Command Center with custom `AgenticCommandCenter` menu IDs; Dropdown shows recent folders and Open Folder action; Added `AgenticCommandCenterContribution` | -| 2026-02-06 | Added sidebar reveal buttons (`SidebarRevealButton`) — round edge-hover buttons that appear when sidebars are hidden; implemented in `parts/sidebarRevealButton.ts` | -| 2026-02-06 | Auxiliary Bar now visible by default; Removed `AuxiliaryBarVisibilityContribution` (no longer auto-shows/hides based on chat state) | -| 2026-02-06 | Removed Command Center and Project Bar completely; Layout is now: Sidebar \| Chat Bar \| Auxiliary Bar; Global activities (Accounts, Settings) in titlebar via `supportsActivityActions` | -| 2026-02-06 | ~~Removed Project Bar; Added Command Center to titlebar~~ (superseded) | -| 2026-02-06 | ~~Project Bar now stores folder entries in workspace storage~~ (superseded) | -| 2026-02-05 | Auxiliary Bar now hidden by default; Added `AuxiliaryBarVisibilityContribution` to auto-show when chat session has requests, auto-hide when empty | -| 2026-02-05 | Hiding panel now exits maximized state first if panel was maximized | -| 2026-02-05 | Added panel maximize/minimize support via `toggleMaximizedPanel()`; Uses `Grid.maximizeView()` with exclusions for titlebar; Added `TogglePanelMaximizedAction` and `TogglePanelVisibilityAction` to panel title bar | -| 2026-02-05 | Changed layout structure: Panel is now below Chat Bar and Auxiliary Bar only (not full width); Sidebar spans full height | -| 2026-02-05 | Added configurable titlebar via `ITitlebarPartOptions` and `ITitlebarPartConfiguration`; Titlebar now disables command center, menubar, and editor actions; Added left toolbar with `MenuId.TitleBarLeft`; Added `ToggleSidebarVisibilityAction` in `agenticLayoutActions.ts` | -| 2026-02-04 | Modal sizing (80%, min/max constraints) moved from CSS to TypeScript; `EditorModal.layout()` now accepts workbench dimensions | -| 2026-02-04 | Editor now renders as modal overlay instead of in grid; Added `EditorModal` class in `parts/editorModal.ts`; Closing modal closes all editors; Grid layout is now Sidebar \| Chat Bar \| Auxiliary Bar | -| 2026-02-04 | Changed part creation to use `SyncDescriptor0` for lazy instantiation—parts are created when first accessed, not at service construction time | -| 2026-02-04 | Refactored part creation: each layout class now creates and passes parts to `PaneCompositePartService` via `IPaneCompositePartsConfiguration`, removing `isAgentSessionsWorkspace` dependency from the service | -| 2026-02-04 | Added `restoreParts()` to automatically open default view containers for visible parts during startup | -| 2026-02-04 | Restored Editor part; Layout order is now Sidebar \| Chat Bar \| Editor \| Auxiliary Bar | -| 2026-02-04 | Removed Editor part; Chat Bar now takes max width; Layout order changed to Sidebar \| Auxiliary Bar \| Chat Bar | -| 2026-02-04 | Added agent session specific parts (AgenticSidebarPart, AgenticAuxiliaryBarPart, AgenticPanelPart) in `sessions/browser/parts/`; PaneCompositePartService now selects parts based on isAgentSessionsWorkspace | -| 2026-02-04 | Editor and Panel hidden by default; Editor auto-shows on editor open, auto-hides when last editor closes | -| 2026-02-04 | Added Chat Bar part with `ViewContainerLocation.ChatBar` | -| Initial | Document created with base layout specification | diff --git a/src/vs/sessions/MOBILE.md b/src/vs/sessions/MOBILE.md deleted file mode 100644 index 6f010382..00000000 --- a/src/vs/sessions/MOBILE.md +++ /dev/null @@ -1,154 +0,0 @@ -# Mobile Agent Sessions — Architecture - -## Core Principle - -**Every feature accessible in the desktop window must be accessible on mobile — same functionality, different presentation.** Mobile is NOT "desktop minus stuff." It is a parallel UI layer where the same services, views, and actions are rendered through mobile-native interaction patterns. - -## Architecture - -### Mobile Part Subclasses - -Desktop Parts (`ChatBarPart`, `SidebarPart`, `PanelPart`, `AuxiliaryBarPart`) remain unchanged. Each has a **mobile subclass** that extends it and overrides only `layout()` and/or `updateStyles()`. `AgenticPaneCompositePartService` conditionally instantiates the mobile or desktop variant at startup based on viewport width (`< 640px` → phone). - -Each mobile Part checks the current layout class (via `isPhoneLayout(layoutService)`) at every call. When the viewport is phone it applies mobile behavior (full-cell layout, no card chrome, no session-bar subtraction). When the viewport is tablet/desktop — which happens when a real phone rotates past the 640px breakpoint — it delegates to the desktop `super` implementation. This means a `Mobile*Part` instance is safe to keep through a viewport-class transition without producing wrong layout math. - -This means: -- Desktop code has **zero** phone-layout checks — all mobile logic lives in mobile subclasses, `MobileTitlebarPart`, and CSS. -- Phone-instantiated parts adapt correctly to rotation across the 640px breakpoint by delegating to `super`. - -After a viewport-class transition the workbench calls `updateStyles()` on each pane composite part so card-chrome inline styles get re-applied (desktop) or cleared (phone) for the new class. - -### View & Action Gating - -Views, menu items, and actions use `when` clauses with the `sessionsIsPhoneLayout` context key to control visibility in phone layout. This follows a **default-deny** approach for phone: - -- **Desktop-only features** add `when: IsPhoneLayoutContext.negate()` to their view descriptors and menu registrations. They simply don't appear on phone. -- **Phone-compatible features** (chat, sessions list) have no phone gate — they render on all viewports. -- **Phone-specific replacements** (when ready) register with `when: IsPhoneLayoutContext` and live in separate files under `parts/mobile/contributions/`. - -Tablet and larger viewports currently fall back to the desktop layout; no separate tablet design exists yet. - -Two registrations can target the same slot with opposite `when` clauses, pointing to different view classes in different files — giving full file separation with no internal branching. - -#### Current Gating Status - -| Feature | Phone Status | Mechanism | -|---------|--------------|-----------| -| Sessions list (sidebar) | ✅ Compatible | No gate | -| Chat views (ChatBar) | ✅ Compatible | No gate | -| Changes view (AuxiliaryBar) | ❌ Gated | `when: !sessionsIsPhoneLayout` on view descriptor | -| Files view (AuxiliaryBar) | ❌ Gated | `when: !sessionsIsPhoneLayout` on view descriptor | -| Logs view (Panel) | ❌ Gated | `when: !sessionsIsPhoneLayout` on view descriptor | -| Terminal actions | ❌ Gated | `when: !sessionsIsPhoneLayout` on menu item | -| "Open in VS Code" action | ❌ Gated | `when: !sessionsIsPhoneLayout` on menu item | -| Code review toolbar | ❌ Gated | `when: !sessionsIsPhoneLayout` on menu item | -| Customizations toolbar | ❌ Hidden | CSS `display: none` on phone | -| Titlebar | ❌ Hidden | Grid `visible: false` + CSS + MobileTitlebarPart replacement | - -### Phone Layout - -On phone-sized viewports (`< 640px` width): - -``` -┌──────────────────────────────────┐ -│ [☰] Session Title [+|👤] │ ← MobileTitlebarPart (prepended before grid) -├──────────────────────────────────┤ -│ │ -│ Chat (edge-to-edge) │ ← Grid: ChatBarPart fills 100% -│ │ -│ │ -│ │ -│ ┌──────────────────────────┐ │ -│ │ Chat input │ │ ← Pinned to bottom -│ └──────────────────────────┘ │ -└──────────────────────────────────┘ -``` - -- **MobileTitlebarPart** is a DOM element prepended above the grid. It has a hamburger (☰), session title, and a contextual right slot that swaps between the new session (+) button (when in a chat) and the account indicator 👤 (on the welcome / new session screen). -- **Sidebar** is hidden by default and opens as an **85% width drawer overlay** with a backdrop when the hamburger is tapped. CSS makes its `split-view-view` absolutely positioned with `z-index: 250`. The workbench manually calls `sidebarPart.layout()` with drawer dimensions after opening. Closing the drawer clears the navigation stack. -- **Titlebar** is hidden in the grid (`visible: false`) and via CSS — replaced by MobileTitlebarPart. -- **SessionCompositeBar** (chat tabs) is hidden via CSS. -- The grid uses `display: flex; flex-direction: column` and all `split-view-view:has(> .part)` containers are positioned absolutely at `100% width/height`. - -### Viewport Classification - -`SessionsLayoutPolicy` classifies the viewport: -- **phone**: `width < 640px` -- **tablet**: `640px ≤ width < 1024px` (treated as desktop; no phone-specific chrome) -- **desktop**: `width ≥ 1024px` - -The workbench toggles the `phone-layout` CSS class on `layout()` and creates/destroys mobile components when the viewport class changes at runtime (e.g., DevTools device emulation, or a real phone rotating across the 640px breakpoint). MobileTitlebarPart lifecycle is managed via a `DisposableStore` that is cleared on viewport transitions to prevent leaks. - -### Context Keys - -| Key | Type | Purpose | -|-----|------|---------| -| `sessionsIsPhoneLayout` | `boolean` | `true` when the viewport is phone (< 640px) | -| `sessionsKeyboardVisible` | `boolean` | `true` when the virtual keyboard is visible | - -### Desktop → Mobile Component Mapping - -| Desktop Component | Mobile Equivalent | How Accessed | -|---|---|---| -| **Titlebar** (3-section toolbar) | **MobileTitlebarPart** (☰ / title / +|👤) | Always visible at top | -| **Sidebar** (sessions list) | Drawer overlay (85% width) | Hamburger button (☰) | -| **ChatBar** (chat widget) | Same Part, edge-to-edge, no card chrome | Default view (always visible) | -| **AuxiliaryBar** (files, changes) | Gated — not shown on mobile | Planned: mobile-specific view | -| **Panel** (terminal, output) | Gated — not shown on mobile | Planned: mobile-specific view | -| **SessionCompositeBar** (chat tabs) | Hidden on phone | — | -| **New Session** (sidebar button) | + button in MobileTitlebarPart | Visible in top bar when in a chat | -| **Account indicator** (titlebar) | Account button in MobileTitlebarPart | Visible in top bar on welcome/new session | - -## File Map - -### Mobile Part Subclasses - -| File | Purpose | -|------|---------| -| `browser/parts/mobile/mobileChatBarPart.ts` | Extends `ChatBarPart`. Overrides `layout()` (no card margins) and `updateStyles()` (no inline card styles). | -| `browser/parts/mobile/mobileSidebarPart.ts` | Extends `SidebarPart`. Overrides `updateStyles()` (no inline card/title styles). | -| `browser/parts/mobile/mobileAuxiliaryBarPart.ts` | Extends `AuxiliaryBarPart`. Overrides `layout()` and `updateStyles()` (no card margins or inline styles). | -| `browser/parts/mobile/mobilePanelPart.ts` | Extends `PanelPart`. Overrides `layout()` and `updateStyles()` (no card margins or inline styles). | - -### Mobile Chrome Components - -| File | Purpose | -|------|---------| -| `browser/parts/mobile/mobileTitlebarPart.ts` | Phone top bar: hamburger (☰), session title, contextual right slot (+ for in-chat, account indicator for welcome). Emits `onDidClickHamburger`, `onDidClickNewSession`, `onDidClickTitle`. Includes account state tracking, avatar loading, and account panel with copilot dashboard. | -| `browser/parts/mobile/mobileChatShell.css` | **Single source of truth** for all phone-layout CSS: flex column layout, split-view-view absolute positioning, card chrome removal, part/content width overrides, sidebar title hiding, composite bar hiding, welcome page layout, sash hiding, button focus overrides, mobile pickers. | - -### Layout & Navigation - -| File | Purpose | -|------|---------| -| `browser/layoutPolicy.ts` | `SessionsLayoutPolicy`: observable viewport classification (phone/tablet/desktop), platform flags (isIOS, isAndroid, isTouchDevice), part visibility and size defaults. | -| `browser/mobileNavigationStack.ts` | `MobileNavigationStack`: Android back button integration via `history.pushState` / `popstate`. Supports `push()`, `pop()`, and `clear()`. | -| `common/contextkeys.ts` | Phone context keys: `IsPhoneLayoutContext`, `KeyboardVisibleContext`. | - -### Part Instantiation - -| File | Purpose | -|------|---------| -| `browser/paneCompositePartService.ts` | `AgenticPaneCompositePartService`: checks viewport width at construction time and instantiates `Mobile*Part` vs desktop `*Part` classes accordingly. | - -### Workbench Integration - -| File | Key Changes | -|------|-------------| -| `browser/workbench.ts` | Layout policy integration, MobileTitlebarPart creation/destruction (via `DisposableStore`), sidebar drawer open/close with backdrop, viewport-class-change detection, window resize listener, grid height calculation (subtracts MobileTitlebarPart height), titlebar grid visibility toggle, `ISessionsManagementService` for new session button. | -| `browser/parts/chatBarPart.ts` | `_lastLayout` changed from `private` to `protected` for mobile subclass access. | - -### Styling - -| File | Purpose | -|------|---------| -| `browser/parts/mobile/mobileChatShell.css` | All phone-layout CSS (see above). | -| `browser/parts/media/sidebarPart.css` | Sidebar drawer overlay CSS: 85% width, z-index 250, slide-in animation, backdrop. | -| `browser/media/style.css` | Mobile overscroll containment, 44px touch targets, quick pick bottom sheets, context menu action sheets, dialog sizing, notification positioning, hover card suppression, editor modal full-screen. | - -## Remaining Work - -- **Files & Terminal access**: Should become phone-specific views gated with `when: IsPhoneLayoutContext`. -- **iOS keyboard handling**: Adjust layout when virtual keyboard appears (context key exists, but no layout response yet). -- **Session list inline actions**: Make always-visible on touch devices (no hover-to-reveal). -- **Customizations on mobile**: Currently hidden — needs a mobile-friendly alternative. diff --git a/src/vs/sessions/README.md b/src/vs/sessions/README.md deleted file mode 100644 index ac86f0bb..00000000 --- a/src/vs/sessions/README.md +++ /dev/null @@ -1,383 +0,0 @@ -# vs/sessions — Agentic Sessions Window Layer - -## Overview - -The `vs/sessions` layer hosts the implementation of the **Agentic Window**, a dedicated workbench experience optimized for agent session workflows. This is a distinct top-level layer within the VS Code architecture, sitting alongside `vs/workbench`. - -## Architecture - -### Layering Rules - -``` -vs/base ← Foundation utilities -vs/platform ← Platform services -vs/editor ← Text editor core -vs/workbench ← Standard workbench -vs/sessions ← Agentic window (this layer) -``` - -**Key constraint:** `vs/sessions` may import from `vs/workbench` (and all layers below it), but `vs/workbench` must **never** import from `vs/sessions`. This ensures the standard workbench remains independent of the agentic window implementation. - -### Allowed Dependencies - -| From `vs/sessions` | Can Import | -|--------------------|------------| -| `vs/base/**` | ✅ | -| `vs/platform/**` | ✅ | -| `vs/editor/**` | ✅ | -| `vs/workbench/**` | ✅ | -| `vs/sessions/**` | ✅ (internal) | - -| From `vs/workbench` | Can Import | -|----------------------|------------| -| `vs/sessions/**` | ❌ **Forbidden** | - -### Folder Structure - -The `vs/sessions` layer follows the same layering conventions as `vs/workbench`: - -``` -src/vs/sessions/ -├── README.md ← This specification -├── LAYOUT.md ← Layout specification for the agentic workbench -├── AI_CUSTOMIZATIONS.md ← AI customization design document -├── sessions.common.main.ts ← Common (browser + desktop) entry point -├── sessions.desktop.main.ts ← Desktop entry point -├── common/ ← Shared types and context keys -│ └── contextkeys.ts ← ChatBar context keys -├── browser/ ← Core workbench implementation -│ ├── workbench.ts ← Main workbench layout (Workbench class) -│ ├── layoutActions.ts ← Layout toggle actions -│ ├── menus.ts ← Menu IDs for agent sessions menus (Menus export) -│ ├── paneCompositePartService.ts ← AgenticPaneCompositePartService -│ ├── style.css ← Layout styles -│ ├── widget/ ← Agent sessions chat widget -│ │ ├── AGENTS_CHAT_WIDGET.md ← Chat widget architecture documentation -│ │ ├── agentSessionsChatWidget.ts ← Main chat widget wrapper -│ │ ├── agentSessionsChatTargetConfig.ts ← Target configuration (observable) -│ │ ├── agentSessionsTargetPickerActionItem.ts ← Target picker for input toolbar -│ │ └── media/ -│ │ └── agentSessionsChatWidget.css -│ └── parts/ ← Workbench part implementations -│ ├── titlebarPart.ts ← Simplified titlebar part & title service -│ ├── sidebarPart.ts ← Sidebar part (with footer) -│ ├── auxiliaryBarPart.ts ← Auxiliary bar part (with run script dropdown) -│ ├── panelPart.ts ← Panel part -│ ├── chatBarPart.ts ← Chat bar part -│ ├── projectBarPart.ts ← Project bar part (folder entries) -│ ├── parts.ts ← AgenticParts enum -│ ├── agentSessionsChatInputPart.ts ← Chat input part adapter -│ ├── agentSessionsChatWelcomePart.ts ← Chat welcome part -│ └── media/ ← Part CSS -├── electron-browser/ ← Desktop-specific entry points -│ ├── sessions.main.ts -│ ├── sessions.ts -│ ├── sessions.html -│ └── sessions-dev.html -├── contrib/ ← Feature contributions -│ ├── accountMenu/browser/ ← Account menu and titlebar account widget -│ │ └── account.contribution.ts -│ ├── aiCustomizationManagement/ ← AI customization management editor -│ │ └── browser/ -│ ├── aiCustomizationTreeView/ ← AI customization tree view sidebar -│ │ └── browser/ -│ ├── changesView/browser/ ← File changes view -│ │ ├── changesView.contribution.ts -│ │ └── changesView.ts -│ ├── chat/browser/ ← Chat-related actions and services -│ │ ├── chat.contribution.ts -│ │ ├── branchChatSessionAction.ts -│ │ ├── runScriptAction.ts -│ │ └── promptsService.ts -│ ├── configuration/browser/ ← Configuration contribution -│ │ └── configuration.contribution.ts -│ └── sessions/browser/ ← Sessions view and title bar widget -│ ├── sessions.contribution.ts -│ ├── sessionsViewPane.ts -│ ├── sessionsTitleBarWidget.ts -│ ├── activeSessionService.ts -│ └── media/ -``` - -## What is the Agentic Window? - -The Agentic Window (`Workbench`) provides a simplified, fixed-layout workbench tailored for agent session workflows. Unlike the standard VS Code workbench: - -- **Fixed layout** — Part positions are not configurable via settings -- **Simplified chrome** — No activity bar, no status bar, no banner -- **Chat-first UX** — Chat bar is a primary part alongside sidebar and auxiliary bar -- **Modal editor** — Editors appear as modal overlays rather than in the main grid -- **Session-aware titlebar** — Titlebar shows the active session, session picker, and signed-in account widget - -See [LAYOUT.md](LAYOUT.md) for the detailed layout specification. - -## Sessions Provider Architecture - -The agent sessions window uses an extensible provider model to manage sessions. Instead of hardcoding session type logic (CLI, Cloud, Agent Host) throughout the codebase, all session behavior is encapsulated in **sessions providers** that register with a central registry. - -### Overview Diagram - -``` -┌──────────────────────────────────────────────────────────────────────────────────┐ -│ UI Components │ -│ ┌──────────────┐ ┌──────────────┐ ┌────────────────────┐ ┌──────────────┐ │ -│ │ SessionsView │ │ TitleBar │ │ NewChatWidget │ │ ChangesView │ │ -│ │ Pane │ │ Widget │ │ (workspace/type │ │ │ │ -│ │ │ │ │ │ pickers) │ │ │ │ -│ └──────┬───────┘ └──────┬───────┘ └────────┬───────────┘ └──────┬───────┘ │ -│ │ │ │ │ │ -│ │ reads ISessionData observables │ │ │ -│ │ (title, status, changes, workspace, isArchived, ...) │ │ -│ └─────────────────┼────────────────────┼─────────────────────┘ │ -│ │ │ │ -│ ┌──────▼────────────────────▼──┐ │ -│ │ Sessions Management Service │ ISessionsManagementService │ -│ │ - activeSession: IObservable │ -│ │ - getSessions(): ISessionData[] │ -│ │ - openSession / createNewSession │ -│ │ - sendRequest / setSessionType │ -│ │ - onDidChangeSessions │ -│ └──────────────┬────────────────┘ │ -│ │ │ -│ ┌─────────────▼─────────────┐ │ -│ │ Sessions Providers Service │ ISessionsProvidersService │ -│ │ - registerProvider(p) │ │ -│ │ - getProviders() │ │ -│ │ - getSessions() (merged) │ │ -│ └─────────────┬──────────────┘ │ -│ │ │ -│ ┌───────────────────┼───────────────────┐ │ -│ │ │ │ │ -│ ┌──────▼──────┐ ┌──────▼──────┐ ┌──────▼──────┐ │ -│ │ Copilot │ │ Remote Agent│ │ Custom │ │ -│ │ Chat │ │ Host │ │ Provider │ │ -│ │ Sessions │ │ Provider │ │ (future) │ │ -│ │ Provider │ │ │ │ │ │ -│ └──────┬──────┘ └──────┬──────┘ └─────────────┘ │ -│ │ │ │ -│ │ Each provider returns ISessionData[] │ -│ │ │ │ -│ ┌──────▼──────┐ ┌──────▼──────┐ │ -│ │ Agent │ │ Agent Host │ │ -│ │ Sessions │ │ Protocol │ │ -│ │ Service │ │ │ │ -│ └─────────────┘ └─────────────┘ │ -└──────────────────────────────────────────────────────────────────────────────────┘ - -ISessionData (reactive session facade) -┌─────────────────────────────────────────────────────────────┐ -│ sessionId: string providerId: string │ -│ resource: URI sessionType: string │ -│ icon: ThemeIcon createdAt: Date │ -├─────────────────────────────────────────────────────────────┤ -│ Observable properties (auto-update UI when changed): │ -│ │ -│ title ─────────── "Fix login bug" │ -│ status ────────── InProgress | NeedsInput | Completed │ -│ workspace ─────── { label, icon, repositories[] } │ -│ changes ───────── [{ modifiedUri, insertions, deletions }] │ -│ updatedAt ─────── Date │ -│ lastTurnEnd ───── Date | undefined │ -│ isArchived ────── boolean │ -│ isRead ────────── boolean │ -│ modelId ───────── "gpt-4o" | undefined │ -│ mode ──────────── { id, kind } | undefined │ -│ loading ───────── boolean │ -└─────────────────────────────────────────────────────────────┘ - -ISessionWorkspace (nested in ISessionData.workspace) -┌─────────────────────────────────────────────────────────────┐ -│ label: "my-app" icon: Codicon.folder │ -│ repositories: [{ │ -│ uri ──────────── file:///repo or github-remote-file:// │ -│ workingDirectory ── file:///worktree (if isolation) │ -│ detail ─────────── "feature-branch" │ -│ baseBranchProtected ── true/false │ -│ }] │ -└─────────────────────────────────────────────────────────────┘ -``` - -### Core Concepts - -#### Session Type (`ISessionType`) - -A lightweight label identifying an agent backend. Says nothing about where it runs or how it's configured. - -```typescript -// Platform-level session type (registered once) -interface ISessionType { - readonly id: string; // e.g., 'copilot-cli', 'copilot-cloud' - readonly label: string; // e.g., 'Copilot CLI', 'Cloud' - readonly icon: ThemeIcon; -} -``` - -#### Sessions Provider (`ISessionsProvider`) - -A compute environment adapter. One provider can serve multiple session types. Multiple provider instances can serve the same session type. - -```typescript -interface ISessionsProvider { - readonly id: string; // 'default-copilot', 'agenthost-hostA' - readonly label: string; - readonly sessionTypes: readonly ISessionType[]; - - // Workspace browsing - getWorkspaces(): ISessionWorkspace[]; - readonly browseActions: readonly ISessionsBrowseAction[]; - - // Session CRUD - getSessions(): ISessionData[]; - createNewSession(workspace: ISessionWorkspace): ISessionData; - sendRequest(sessionId: string, options: ISendRequestOptions): Promise; - - // Lifecycle - archiveSession(sessionId: string): Promise; - deleteSession(sessionId: string): Promise; - renameSession(sessionId: string, title: string): Promise; -} -``` - -#### Session Data (`ISessionData`) - -The universal session interface. All reactive properties are observables — UI components subscribe and update automatically. - -```typescript -interface ISessionData { - readonly sessionId: string; // Globally unique: 'providerId:localId' - readonly resource: URI; - readonly providerId: string; - readonly sessionType: string; // e.g., 'copilot-cli' - - // Reactive properties - readonly title: IObservable; - readonly status: IObservable; - readonly workspace: IObservable; - readonly changes: IObservable; - readonly isArchived: IObservable; - readonly isRead: IObservable; - readonly lastTurnEnd: IObservable; -} -``` - -### Examples - -#### Example 1: CopilotChatSessionsProvider - -The default provider wrapping existing CLI and Cloud sessions: - -``` -CopilotChatSessionsProvider -├── id: 'default-copilot' -├── sessionTypes: [CopilotCLI, CopilotCloud] -├── browseActions: -│ ├── "Browse Folders..." → file dialog -│ └── "Browse Repositories..." → GitHub repo picker -├── getSessions() → wraps IAgentSession[] as AgentSessionAdapter[] -├── createNewSession(workspace) -│ ├── file:// URI → CopilotCLISession (local background agent) -│ └── github-remote-file:// → RemoteNewSession (cloud agent) -└── sendRequest() → delegates to IChatService -``` - -#### Example 2: RemoteAgentHostSessionsProvider - -One instance per connected remote agent host: - -``` -RemoteAgentHostSessionsProvider -├── id: 'agenthost-' -├── sessionTypes: [CopilotCLI] (reuses platform type) -├── browseActions: -│ └── "Browse Remote Folders..." → remote folder picker -├── getSessions() → sessions from this specific host -└── createNewSession(workspace) - └── Creates session on the remote agent host -``` - -Agent-host session metadata may include a server-owned project. When present, -providers map that project to the session workspace's source repository URI and -label, while keeping the working directory as the repository working directory. -This lets the sessions view group agent-host sessions by project/source repo -instead of by the isolated worktree or checkout folder. - -### Data Flow - -#### Creating a New Session - -``` -User picks workspace in WorkspacePicker - │ - ▼ -SessionsManagementService.createNewSession(providerId, workspace) - │ - ├── Finds provider by ID - ├── Calls provider.createNewSession(workspace) - │ │ - │ ▼ - │ Provider creates ISessionData - │ (e.g., CopilotCLISession or RemoteNewSession) - │ - ├── Sets as active session - └── Returns ISessionData to widget - -User types message and sends - │ - ▼ -SessionsManagementService.sendRequest(session, options) - │ - ├── Finds provider by session.providerId - ├── Calls provider.sendRequest(sessionId, options) - │ │ - │ ▼ - │ Provider creates real agent session - │ (e.g., starts CLI agent, opens cloud session) - │ - └── Returns created ISessionData (now backed by real session) -``` - -#### Session Change Events - -``` -Agent session completes a turn - │ - ▼ -AgentSessionsService fires onDidChangeSessions - │ - ▼ -CopilotChatSessionsProvider._refreshSessionCache() - ├── Diffs current sessions vs cache - ├── Updates AgentSessionAdapter observables (title, status, changes) - └── Fires onDidChangeSessions { added, removed, changed, archived } - │ - ▼ - SessionsProvidersService forwards event - │ - ▼ - SessionsManagementService forwards event - │ - ├── UI re-renders (sessions list, titlebar, changes view) - └── Context keys updated (hasChanges, isBackground, etc.) -``` - -### Key Files - -| File | Purpose | -|------|---------| -| `contrib/sessions/common/sessionData.ts` | `ISessionData`, `ISessionWorkspace`, `ISessionRepository`, `SessionStatus` | -| `contrib/sessions/browser/sessionsProvider.ts` | `ISessionsProvider`, `ISessionType`, `ISessionsChangeEvent` | -| `contrib/sessions/browser/sessionsProvidersService.ts` | `ISessionsProvidersService` + implementation | -| `contrib/sessions/browser/sessionsManagementService.ts` | `ISessionsManagementService` — active session, routing | -| `contrib/copilotChatSessions/browser/copilotChatSessionsProvider.ts` | Default Copilot provider | -| `contrib/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts` | Remote agent host provider | - -## Adding New Functionality - -When adding features to the agentic window: - -1. **Core workbench code** (layout, parts, services) goes under `browser/` -2. **Feature contributions** (views, actions, editors) go under `contrib//browser/` -3. Register contributions by importing them in `sessions.desktop.main.ts` (or `sessions.common.main.ts` for browser-compatible code) -4. Do **not** add imports from `vs/workbench` back to `vs/sessions` -5. Contributions can import from `vs/sessions/browser/` (core) and other `vs/sessions/contrib/*/` modules -6. Update the layout spec (`LAYOUT.md`) for any layout changes diff --git a/src/vs/sessions/SESSIONS_PROVIDER.md b/src/vs/sessions/SESSIONS_PROVIDER.md deleted file mode 100644 index 451ac8bd..00000000 --- a/src/vs/sessions/SESSIONS_PROVIDER.md +++ /dev/null @@ -1,474 +0,0 @@ -# Sessions Provider Architecture - -## Overview - -The Sessions Provider architecture introduces an **extensible provider model** for managing agent sessions in the Agent Sessions window. Instead of hardcoding session types and backends, multiple providers register with a central registry (`ISessionsProvidersService`), and the management service (`ISessionsManagementService`) aggregates sessions from all providers and routes actions to the correct one. - -This design allows new compute environments (remote agent hosts, cloud backends, third-party agents) to plug in without modifying core session management code. - -### Architectural Flow - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ UI Components │ -│ (SessionsView, TitleBar, NewSession, Changes | Terminal) │ -└───────────────────────────┬─────────────────────────────────────┘ - │ - ┌───────────▼────────────┐ - │ SessionsManagementService│ ← High-level orchestration - │ (active session, send, │ context keys, session - │ session types, etc.) │ aggregation & routing - └───────────┬──────────────┘ - │ - ┌───────────▼────────────┐ - │ SessionsProvidersService │ ← Central registry - │ (register providers, │ - │ lookup by ID) │ - └──────┬──────────┬────────┘ - │ │ - ┌────────────▼──┐ ┌───▼──────────────────┐ - │ CopilotChat │ │ RemoteAgentHost │ - │ Sessions │ │ Sessions Provider │ - │ Provider │ │ (one per connection) │ - └───────┬───────┘ └──────────┬────────────┘ - │ │ - ┌───────▼───────┐ ┌──────────▼────────────┐ - │ AgentSessions │ │ Agent Host Connection │ - │ Service / │ │ (WebSocket, HTTP) │ - │ ChatService │ │ │ - └───────────────┘ └────────────────────────┘ -``` - -## Core Interfaces - -### `ISession` — Universal Session Facade - -**File:** `src/vs/sessions/services/sessions/common/session.ts` - -The common session interface exposed by all providers. It is a self-contained facade — consumers should not reach back to underlying services to resolve additional data. All mutable properties are **observables** for reactive UI binding. A session groups one or more chats together; `ISession` fields are propagated from the primary (first) chat. - -| Property | Type | Description | -|----------|------|-------------| -| `sessionId` | `string` | Globally unique ID in the format `providerId:localId` | -| `resource` | `URI` | Resource URI identifying this session | -| `providerId` | `string` | ID of the owning provider | -| `sessionType` | `string` | Session type ID (e.g., `'copilot-cli'`, `'copilot-cloud'`) | -| `icon` | `ThemeIcon` | Display icon | -| `createdAt` | `Date` | Creation timestamp | -| `workspace` | `IObservable` | Workspace info (repositories, label, icon) | -| `title` | `IObservable` | Display title (auto-titled or renamed) | -| `updatedAt` | `IObservable` | Last update timestamp | -| `status` | `IObservable` | Current status (Untitled, InProgress, NeedsInput, Completed, Error) | -| `changes` | `IObservable` | File changes produced by the session | -| `modelId` | `IObservable` | Selected model identifier | -| `mode` | `IObservable<{id, kind} \| undefined>` | Selected mode identifier and kind | -| `loading` | `IObservable` | Whether the session is initializing | -| `isArchived` | `IObservable` | Archive state | -| `isRead` | `IObservable` | Read/unread state | -| `description` | `IObservable` | Status description (e.g., current agent action), supports markdown | -| `lastTurnEnd` | `IObservable` | When the last agent turn ended | -| `gitHubInfo` | `IObservable` | GitHub owner/repo/PR info associated with the session | -| `chats` | `IObservable` | The chats belonging to this session group | -| `mainChat` | `IChat` | The main (first) chat of this session | - -#### Supporting Types - -**`ISessionWorkspace`** — Workspace information for a session: -- `label: string` — Display label (e.g., "my-app", "org/repo") -- `icon: ThemeIcon` — Workspace icon -- `repositories: ISessionRepository[]` — One or more repositories -- `requiresWorkspaceTrust: boolean` — Whether workspace trust is required to operate - -**Workspace label and session grouping:** The sessions list groups sessions by `workspace.label`. Sessions whose workspace is `undefined` or whose label is empty appear under "Unknown". For the `CopilotChatSessionsProvider`, the workspace label is derived from session metadata via `getRepositoryName()` (in `agentSessionsViewer.ts`), which checks these metadata keys in priority order: `remoteAgentHost`, `owner`+`name`, `repositoryNwo`, `repository`, `repositoryUrl`, `repositoryPath`, `worktreePath`, `workingDirectoryPath`, then `badge`. See the [ChatSessionItem Metadata Contract](#chatsessionitem-metadata-contract) section below for full details. - -**`ISessionRepository`** — A repository within a workspace: -- `uri: URI` — Source repository URI (`file://` or `github-remote-file://`) -- `workingDirectory: URI | undefined` — Worktree or checkout path -- `detail: string | undefined` — Provider-chosen display detail (e.g., branch name) -- `baseBranchName: string | undefined` — Name of the base branch -- `baseBranchProtected: boolean | undefined` — Whether the base branch is protected - -**`IGitHubInfo`** — GitHub information associated with a session: -- `owner: string` — GitHub repository owner -- `repo: string` — GitHub repository name -- `pullRequest?: { number: number; uri: URI; icon?: ThemeIcon }` — Associated pull request - -**`IChat`** — A single chat within a session: -- `resource: URI` — Resource URI identifying this chat -- `createdAt: Date` — When the chat was created -- `title: IObservable` — Chat display title -- `updatedAt: IObservable` — When the chat was last updated -- `status: IObservable` — Current chat status -- `changes: IObservable` — File changes -- `modelId: IObservable` — Selected model -- `mode: IObservable<{ id: string; kind: string } | undefined>` — Selected mode -- `isArchived: IObservable` — Archive state -- `isRead: IObservable` — Read/unread state -- `description: IObservable` — Status description -- `lastTurnEnd: IObservable` — When the last agent turn ended - -**`SessionStatus`** — Enum: `Untitled`, `InProgress`, `NeedsInput`, `Completed`, `Error` - ---- - -### `ISessionsProvider` — Provider Contract - -**File:** `src/vs/sessions/services/sessions/common/sessionsProvider.ts` - -A sessions provider encapsulates a compute environment. It owns workspace discovery, session creation, session listing, and picker contributions. One provider can serve multiple session types, and multiple provider instances can serve the same session type (e.g., one per remote agent host). - -#### Identity - -| Property | Type | Description | -|----------|------|-------------| -| `id` | `string` | Unique provider instance ID (e.g., `'default-copilot'`, `'agenthost-hostA'`) | -| `label` | `string` | Display label | -| `icon` | `ThemeIcon` | Provider icon | -| `sessionTypes` | `readonly ISessionType[]` | Session types this provider supports | -| `onDidChangeSessionTypes?` | `Event` | Optional; fires when session types change dynamically (e.g., a remote host advertises a new agent) | - -#### Workspace Discovery - -| Member | Description | -|--------|-------------| -| `browseActions: readonly ISessionWorkspaceBrowseAction[]` | Actions shown in the workspace picker (e.g., "Folders", "Repositories") | -| `resolveWorkspace(repositoryUri: URI): ISessionWorkspace` | Resolve a URI to a session workspace with label and icon | - -#### Session Listing - -| Member | Description | -|--------|-------------| -| `getSessions(): ISession[]` | Returns all sessions owned by this provider | -| `onDidChangeSessions: Event` | Fires when sessions are added, removed, or changed | -| `onDidReplaceSession?: Event<{ from: ISession; to: ISession }>` | Optional; fires when a temporary (untitled) session is atomically replaced by a committed session after the first turn | - -#### Session Lifecycle - -| Method | Description | -|--------|-------------| -| `createNewSession(workspace)` | Create a new session for a given workspace | -| `setSessionType(sessionId, type)` | Change the session type; returns the updated `ISession` | -| `getSessionTypes(sessionId)` | Get available session types for a session | -| `renameChat(sessionId, chatUri, title)` | Rename a chat within a session | -| `setModel(sessionId, modelId)` | Set the model | -| `archiveSession(sessionId)` | Archive a session | -| `unarchiveSession(sessionId)` | Unarchive a session | -| `deleteSession(sessionId)` | Delete a session | -| `deleteChat(sessionId, chatUri)` | Delete a single chat from a session | -| `setRead(sessionId, read)` | Mark read/unread | - -#### Send - -| Method | Description | -|--------|-------------| -| `sendAndCreateChat(sessionId, options)` | Send a request, creating a new chat in the session; returns the updated `ISession` | - -#### Supporting Types - -**`ISessionType`** — A platform-level session type identifying an agent backend: -- `id: string` — Unique identifier (e.g., `'copilot-cli'`, `'copilot-cloud'`) -- `label: string` — Display label -- `icon: ThemeIcon` — Icon - -**`ISessionWorkspaceBrowseAction`** — A browse action shown in the workspace picker: -- `label: string`, `icon: ThemeIcon`, `providerId: string` -- `run(): Promise` — Opens the browse dialog - -**`ISessionChangeEvent`** — Change event: -- `added: readonly ISession[]` -- `removed: readonly ISession[]` -- `changed: readonly ISession[]` - -**`ISendRequestOptions`** — Send request options: -- `query: string` — Query text -- `attachedContext?: IChatRequestVariableEntry[]` — Optional attached context entries - ---- - -### `ISessionsProvidersService` — Central Registry - -**File:** `src/vs/sessions/services/sessions/browser/sessionsProvidersService.ts` - -Central service that manages the provider registry. Providers register and unregister here; the service fires change events to notify consumers. - -#### Provider Registry - -| Member | Description | -|--------|-------------| -| `registerProvider(provider): IDisposable` | Register a provider; returns disposable to unregister | -| `getProviders(): ISessionsProvider[]` | Get all registered providers | -| `getProvider(providerId): T \| undefined` | Get a specific provider by ID | -| `onDidChangeProviders: Event` | Fires with `{ added, removed }` arrays when providers are registered or unregistered | - -**Note:** The providers service is a pure registry. It does **not** aggregate sessions or route actions — that responsibility belongs to `ISessionsManagementService`. - -**`ISessionsProvidersChangeEvent`**: -- `added: readonly ISessionsProvider[]` -- `removed: readonly ISessionsProvider[]` - ---- - -### `ISessionsManagementService` — High-Level Orchestration - -**File:** `src/vs/sessions/services/sessions/common/sessionsManagement.ts` (interface) / `src/vs/sessions/services/sessions/browser/sessionsManagementService.ts` (implementation) - -Coordinates active session tracking, provider selection, and user workflows. Sits above the providers service and adds UI-facing concerns like context keys, session aggregation, and action routing. - -#### Sessions - -| Member | Description | -|--------|-------------| -| `getSessions(): ISession[]` | Get all sessions from all registered providers | -| `getSession(resource: URI): ISession \| undefined` | Look up a session by its resource URI | -| `getSessionTypes(session): ISessionType[]` | Get session types available for a session | -| `getAllSessionTypes(): ISessionType[]` | Get all session types from all registered providers | -| `onDidChangeSessionTypes: Event` | Fires when available session types change | -| `onDidChangeSessions: Event` | Fires when sessions change across any provider | - -#### Active Session - -| Member | Description | -|--------|-------------| -| `activeSession: IObservable` | The currently active session (extends `ISession` with `activeChat: IObservable`) | -| `activeProviderId: IObservable` | Auto-selects when one provider exists, persists to storage | -| `setActiveProvider(providerId)` | Set the active sessions provider by ID | -| `openSession(sessionResource, options?)` | Select an existing session as active | -| `openChat(session, chatUri)` | Open a specific chat within a session | -| `openNewSessionView()` | Switch to the new-session view | - -#### Session Creation & Send - -| Method | Description | -|--------|-------------| -| `createNewSession(providerId, workspace)` | Create a new session, delegates to the correct provider | -| `unsetNewSession()` | Unset the current new session | -| `sendAndCreateChat(session, options)` | Send a request, creating a new chat in the session | -| `setSessionType(session, type)` | Update the session type for a new session | - -#### Context Keys Managed - -The management service binds and updates these context keys: - -| Context Key | Type | Description | -|-------------|------|-------------| -| `isNewChatSession` | `boolean` | `true` when viewing the new-session form | -| `activeSessionProviderId` | `string` | Provider ID of the active session | -| `activeSessionType` | `string` | Session type of the active session | -| `isActiveSessionBackgroundProvider` | `boolean` | Whether the active session uses the background agent provider | -| `isActiveSessionArchived` | `boolean` | Whether the active session is archived | -| `activeSessionSupportsMultiChat` | `boolean` | Whether the active session supports multiple chats | - ---- - -## Multi-Chat Sessions - -A session can contain **multiple chats** (conversations), controlled by the session's `capabilities.supportsMultipleChats` property. When enabled, users can start additional conversations within the same session, sharing its workspace context. - -### Session–Chat Relationship - -`ISession` groups one or more `IChat` instances: - -``` -ISession -├── mainChat: IChat ← primary (first) chat -├── chats: IObservable ← all chats in creation order -├── capabilities ← session capabilities -│ └── supportsMultipleChats ← whether this session supports multi-chat -└── session-level properties ← derived from chats -``` - -**Property derivation from chats:** -- `title`, `changes`, `modelId`, `mode`, `loading`, `isArchived`, `description`, `gitHubInfo` — all come from `mainChat` -- `updatedAt` — latest `updatedAt` across all chats -- `status` — aggregated: `NeedsInput` > `InProgress` > other statuses -- `isRead` — `true` only when **all** chats are read -- `lastTurnEnd` — latest `lastTurnEnd` across all chats - -### Context Keys - -When the active session supports multi-chat (`capabilities.supportsMultipleChats` is `true`), the context key `activeSessionSupportsMultiChat` is set to `true`, enabling multi-chat UI elements (e.g., "New Chat" button). - -### Active Chat Tracking - -`IActiveSession` extends `ISession` with an `activeChat` observable: - -```typescript -interface IActiveSession extends ISession { - readonly activeChat: IObservable; -} -``` - -- Initialized to the first chat when a session becomes active -- Updated when `openChat(session, chatUri)` is called to switch between chats -- Automatically updated to the latest chat when `sendAndCreateChat` creates a new one - -### Multi-Chat Provider Methods - -| Method | Description | -|--------|-------------| -| `sendAndCreateChat(sessionId, options)` | Creates a new chat in the session and sends the request. For new sessions this is the first chat; for existing sessions it adds another chat to the group. | -| `deleteChat(sessionId, chatUri)` | Deletes an individual chat; if only one chat remains, deletes the entire session | -| `renameChat(sessionId, chatUri, title)` | Renames a specific chat by its URI | -| `openChat(session, chatUri)` | Switches the active chat within the session (management service) | - ---- - -## Data Flow: Session Lifecycle - -### Creating a New Session - -``` -1. User picks a workspace in the workspace picker - → SessionsManagementService.createNewSession(providerId, workspace) - → Looks up provider by ID in SessionsProvidersService - → Calls provider.createNewSession(workspace) - → Provider creates CopilotCLISession (file://) or RemoteNewSession (github-remote-file://) - → Returns ISession, sets as activeSession observable - -2. User configures session (model, isolation mode, branch) - → Modifies observable fields on the new session object - → Selections synced to IChatSessionsService via setOption() - -3. User types a message and sends - → SessionsManagementService.sendAndCreateChat(session, {query, attachedContext}) - → Delegates to provider.sendAndCreateChat(sessionId, options) - → Provider sends the request and returns the session - → isNewChatSession context → false -``` - -### Session Change Propagation - -``` -Agent session state changes (turn complete, status update, etc.) - → AgentSessionsService.model.onDidChangeSessions - → CopilotChatSessionsProvider._refreshSessionCache() - - Diffs current IAgentSession list vs cache - - Updates existing AgentSessionAdapter observables - - Creates/removes entries as needed - - Fires onDidChangeSessions { added, removed, changed } - → SessionsProvidersService forwards the event - → SessionsManagementService forwards and updates active session - → UI re-renders via observable subscriptions -``` - ---- - -## ChatSessionItem Metadata Contract - -**File (type definition):** `src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts` -**File (metadata consumer):** `src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts` (`getRepositoryName()`) -**File (adapter consumer):** `src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsProvider.ts` (`AgentSessionAdapter._buildWorkspace()`) - -Extensions that implement a `ChatSessionItemController` or `ChatSessionContentProvider` create `ChatSessionItem` objects. These items have a `metadata` property (a `Record`) that the sessions list uses to determine workspace grouping, repository information, and display labels. - -### Why This Matters - -The sessions list groups sessions by workspace folder. `AgentSessionAdapter._buildWorkspace()` reads `item.metadata` to construct an `ISessionWorkspace` — specifically its `label`, which determines which section the session appears under. **If `metadata` is missing or lacks a recognizable key, the session appears under "Unknown".** - -### Required Metadata - -Every `ChatSessionItem` created by a controller — whether from `newChatSessionItemHandler`, `forkHandler`, or `_createChatSessionItem` during refresh — **must** set `item.metadata` with at least one of the keys listed below so the session groups correctly. - -For local sessions, the minimum is: - -```typescript -item.metadata = { workingDirectoryPath: '/absolute/path/to/workspace' }; -``` - -### Metadata Key Priority - -`getRepositoryName()` checks metadata keys in this priority order to derive the workspace label. The first match wins: - -| Priority | Key(s) | Used By | Example | -|----------|--------|---------|---------| -| 1 | `remoteAgentHost` + `workingDirectoryPath` | Remote agent host sessions | `"myproject [dev-box]"` | -| 2 | `owner` + `name` | Cloud sessions | `"my-repo"` | -| 3 | `repositoryNwo` | Sessions with `owner/repo` format | `"repo"` from `"owner/repo"` | -| 4 | `repository` | Git remote URL or `owner/repo` string | Parsed repo name | -| 5 | `repositoryUrl` | Full GitHub URL | Parsed repo name | -| 6 | `repositoryPath` | Repository directory path | Basename of path | -| 7 | `worktreePath` | Git worktree path | Basename of path | -| 8 | `workingDirectoryPath` | Working directory (fallback) | Basename of path | -| 9 | _(badge fallback)_ | `session.badge` value | Parsed from badge string | - -### Common Patterns - -**Local sessions (Copilot CLI, Claude):** Set `workingDirectoryPath` to the workspace folder's absolute path. This is the most common case and the minimum required for correct grouping. - -```typescript -// In newChatSessionItemHandler or forkHandler: -const item = controller.createChatSessionItem(uri, prompt); -item.metadata = { workingDirectoryPath: '/Users/me/my-project' }; -``` - -**Cloud sessions:** Set `owner` and `name` for GitHub repository identification. - -```typescript -item.metadata = { owner: 'microsoft', name: 'vscode' }; -``` - -**Remote agent host sessions:** Set `remoteAgentHost` and `workingDirectoryPath` for composite labels. - -```typescript -item.metadata = { remoteAgentHost: 'dev-box', workingDirectoryPath: '/home/user/project' }; -``` - -### Checklist for New Controller Implementations - -When implementing a `ChatSessionItemController`, ensure metadata is set in **all** code paths that create `ChatSessionItem` objects: - -1. **`newChatSessionItemHandler`** — Called when the user sends the first message from the welcome chat. Resolve the workspace folder from `context.inputState` (look for the folder option group) and set `item.metadata`. -2. **`forkHandler`** — Called when forking an existing session. Copy or resolve metadata from the parent session. -3. **Refresh/sync handlers** — When rebuilding items from persisted sessions (e.g., `_refreshItems`), set metadata from the session's stored state. - -Missing metadata in **any** of these paths causes the session to appear under "Unknown" in the sessions list. - ---- - -## Context Keys - -### Session Provider Context Keys (managed by `SessionsManagementService`) - -| Context Key | Type | Description | -|-------------|------|-------------| -| `isNewChatSession` | `boolean` | `true` when viewing the new-session form (no established session selected) | -| `activeSessionProviderId` | `string` | Provider ID of the active session (e.g., `'default-copilot'`) | -| `activeSessionType` | `string` | Session type of the active session (e.g., `'copilot-cli'`, `'copilot-cloud'`) | -| `isActiveSessionBackgroundProvider` | `boolean` | Whether the active session uses the background agent provider | -| `isActiveSessionArchived` | `boolean` | Whether the active session is archived (marked as done) | -| `activeSessionSupportsMultiChat` | `boolean` | Whether the active session's provider supports multiple chats per session | - -### Other Session Context Keys (defined in `common/contextkeys.ts`) - -| Context Key | Type | Description | -|-------------|------|-------------| -| `activeSessionHasGitRepository` | `boolean` | Whether the active session has an associated git repository | -| `chatSessionProviderId` | `string` | The provider ID of a session in context menu overlays | - ---- - -## Adding a New Provider - -To add a new sessions provider: - -1. **Implement `ISessionsProvider`** with a unique `id`, supported `sessionTypes`, `capabilities`, and `browseActions` -2. **Create session data classes** implementing `ISession` with observable properties for the new session type -3. **Register via a workbench contribution** at `WorkbenchPhase.AfterRestored`: - ```typescript - class MyProviderContribution extends Disposable implements IWorkbenchContribution { - constructor( - @IInstantiationService instantiationService: IInstantiationService, - @ISessionsProvidersService sessionsProvidersService: ISessionsProvidersService, - ) { - super(); - const provider = this._register(instantiationService.createInstance(MyProvider)); - this._register(sessionsProvidersService.registerProvider(provider)); - } - } - registerWorkbenchContribution2(MyProviderContribution.ID, MyProviderContribution, WorkbenchPhase.AfterRestored); - ``` -4. Session IDs must use the format `${providerId}:${localId}` so the management service can route actions correctly -5. Fire `onDidChangeSessions` when sessions are added, removed, or updated -6. Fire `onDidReplaceSession` when a temporary session is atomically replaced by a committed one -7. The provider's `browseActions` will automatically appear in the workspace picker -8. The provider's `sessionTypes` will be available for session type selection diff --git a/src/vs/sessions/browser/accountTitleBarState.ts b/src/vs/sessions/browser/accountTitleBarState.ts deleted file mode 100644 index 2ba44ecf..00000000 --- a/src/vs/sessions/browser/accountTitleBarState.ts +++ /dev/null @@ -1,220 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { Codicon } from '../../base/common/codicons.js'; -import { ThemeIcon } from '../../base/common/themables.js'; -import { localize } from '../../nls.js'; -import { ChatEntitlement, IChatSentiment, IQuotaSnapshot } from '../../workbench/services/chat/common/chatEntitlementService.js'; -import { IDefaultAccountService } from '../../platform/defaultAccount/common/defaultAccount.js'; -import { IAuthenticationService } from '../../workbench/services/authentication/common/authentication.js'; - -export interface IResolvedAccountInfo { - readonly accountName: string; - readonly accountProviderId: string; - readonly accountProviderLabel: string; -} - -/** - * Resolves the current account info by trying the default account service - * first, then falling back to raw GitHub sessions from the authentication - * service. The fallback covers the window between session creation and - * {@link IDefaultAccountService} initialization. - */ -export async function resolveAccountInfo( - defaultAccountService: IDefaultAccountService, - authenticationService: IAuthenticationService, -): Promise { - const account = await defaultAccountService.getDefaultAccount(); - if (account) { - return { - accountName: account.accountName, - accountProviderId: account.authenticationProvider.id, - accountProviderLabel: account.authenticationProvider.name, - }; - } - - try { - const sessions = await authenticationService.getSessions('github'); - if (sessions.length > 0) { - return { - accountName: sessions[0].account.label, - accountProviderId: 'github', - accountProviderLabel: 'GitHub', - }; - } - } catch { - // Provider not available yet - } - - return undefined; -} - -export type AccountTitleBarStateSource = 'account' | 'copilot'; -export type AccountTitleBarStateKind = 'default' | 'accent' | 'warning' | 'prominent'; - -export interface IAccountTitleBarStateContext { - readonly isAccountLoading: boolean; - readonly accountName?: string; - readonly accountProviderLabel?: string; - readonly entitlement: ChatEntitlement; - readonly sentiment: IChatSentiment; - readonly quotas: { - readonly chat?: IQuotaSnapshot; - readonly completions?: IQuotaSnapshot; - }; -} - -export interface IAccountTitleBarState { - readonly source: AccountTitleBarStateSource; - readonly kind: AccountTitleBarStateKind; - readonly icon: ThemeIcon; - readonly label: string; - readonly ariaLabel: string; - readonly badge?: string; - readonly dotBadge?: 'warning' | 'error'; - readonly revealLabelOnHover?: boolean; -} - -export function getAccountProfileImageUrl(accountProviderId: string | undefined, accountName: string | undefined): string | undefined { - if (accountProviderId !== 'github' || !accountName?.trim()) { - return undefined; - } - - return `https://github.com/${encodeURIComponent(accountName.trim())}.png?size=64`; -} - -export function getAccountTitleBarBadgeKey(state: IAccountTitleBarState): string | undefined { - if (!state.dotBadge) { - return undefined; - } - - return `${state.source}:${state.dotBadge}:${state.badge ?? ''}`; -} - -export function getAccountTitleBarState(context: IAccountTitleBarStateContext): IAccountTitleBarState { - if (context.isAccountLoading) { - return { - source: 'account', - kind: 'default', - icon: ThemeIcon.modify(Codicon.loading, 'spin'), - label: localize('loadingAccount', "Loading Account..."), - ariaLabel: localize('loadingAccountAria', "Loading account"), - revealLabelOnHover: true, - }; - } - - const copilotState = getCopilotPresentation(context.entitlement, context.sentiment, context.quotas); - if (copilotState) { - return copilotState; - } - - if (context.accountName) { - return { - source: 'account', - kind: 'default', - icon: Codicon.account, - label: context.accountName, - revealLabelOnHover: true, - ariaLabel: context.accountProviderLabel - ? localize('accountSignedInAria', "Signed in as {0} with {1}", context.accountName, context.accountProviderLabel) - : localize('accountSignedInAriaNameOnly', "Signed in as {0}", context.accountName), - }; - } - - return { - source: 'account', - kind: 'prominent', - icon: Codicon.account, - label: localize('signInLabel', "Sign In"), - ariaLabel: localize('signInAria', "Sign in to your account"), - }; -} - -function getCopilotPresentation( - entitlement: ChatEntitlement, - sentiment: IChatSentiment, - quotas: { readonly chat?: IQuotaSnapshot; readonly completions?: IQuotaSnapshot } -): IAccountTitleBarState | undefined { - if (sentiment.hidden) { - return undefined; - } - - if (entitlement === ChatEntitlement.Unknown) { - return { - source: 'copilot', - kind: 'prominent', - icon: Codicon.account, - label: localize('agentsSignedOut', "Agents Signed Out"), - ariaLabel: localize('agentsSignedOutAria', "Agents is signed out"), - }; - } - - if (sentiment.disabled || sentiment.untrusted) { - return { - source: 'copilot', - kind: 'warning', - icon: Codicon.account, - label: localize('copilotUnavailable', "Copilot Unavailable"), - ariaLabel: sentiment.untrusted - ? localize('copilotUnavailableUntrustedAria', "GitHub Copilot is unavailable in untrusted workspaces") - : localize('copilotUnavailableDisabledAria', "GitHub Copilot is disabled"), - }; - } - - const chatQuotaExceeded = quotas.chat?.percentRemaining === 0; - const completionsQuotaExceeded = quotas.completions?.percentRemaining === 0; - if (entitlement === ChatEntitlement.Free && (chatQuotaExceeded || completionsQuotaExceeded)) { - return { - source: 'copilot', - kind: 'warning', - icon: Codicon.account, - label: localize('copilotQuotaReached', "Quota Reached"), - dotBadge: 'error', - ariaLabel: getQuotaReachedAriaLabel(chatQuotaExceeded, completionsQuotaExceeded), - }; - } - - const remainingPercent = getLowestPositivePercent(quotas.chat, quotas.completions); - if (entitlement === ChatEntitlement.Free && typeof remainingPercent === 'number' && remainingPercent <= 25) { - return { - source: 'copilot', - kind: remainingPercent <= 10 ? 'warning' : 'accent', - icon: Codicon.account, - label: localize('copilotTokensRemaining', "Tokens Remaining"), - badge: `${remainingPercent}%`, - dotBadge: remainingPercent <= 10 ? 'error' : 'warning', - ariaLabel: localize('copilotTokensRemainingAria', "{0}% GitHub Copilot tokens remaining", remainingPercent), - }; - } - - return undefined; -} - -function getLowestPositivePercent(...quotas: Array): number | undefined { - let lowest: number | undefined; - for (const quota of quotas) { - if (typeof quota?.percentRemaining !== 'number' || quota.percentRemaining <= 0) { - continue; - } - - lowest = typeof lowest === 'number' - ? Math.min(lowest, quota.percentRemaining) - : quota.percentRemaining; - } - - return lowest; -} - -function getQuotaReachedAriaLabel(chatQuotaExceeded: boolean, completionsQuotaExceeded: boolean): string { - if (chatQuotaExceeded && completionsQuotaExceeded) { - return localize('copilotAllQuotaReachedAria', "GitHub Copilot chat and inline suggestion quota reached"); - } - - if (chatQuotaExceeded) { - return localize('copilotChatQuotaReachedAria', "GitHub Copilot chat quota reached"); - } - - return localize('copilotCompletionsQuotaReachedAria', "GitHub Copilot inline suggestion quota reached"); -} diff --git a/src/vs/sessions/browser/chatDashboardService.ts b/src/vs/sessions/browser/chatDashboardService.ts deleted file mode 100644 index e2735374..00000000 --- a/src/vs/sessions/browser/chatDashboardService.ts +++ /dev/null @@ -1,27 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { DisposableStore } from '../../base/common/lifecycle.js'; -import { InstantiationType, registerSingleton } from '../../platform/instantiation/common/extensions.js'; -import { createDecorator } from '../../platform/instantiation/common/instantiation.js'; - -export const IChatDashboardService = createDecorator('chatDashboardService'); - -export interface IChatDashboardService { - readonly _serviceBrand: undefined; - - /** - * Creates a chat status dashboard element embedded in a container div. - * Returns `undefined` if the dashboard is not available. - */ - createDashboardElement(store: DisposableStore): HTMLElement | undefined; -} - -class NullChatDashboardService implements IChatDashboardService { - readonly _serviceBrand: undefined; - createDashboardElement(): HTMLElement | undefined { return undefined; } -} - -registerSingleton(IChatDashboardService, NullChatDashboardService, InstantiationType.Delayed); diff --git a/src/vs/sessions/browser/layoutActions.ts b/src/vs/sessions/browser/layoutActions.ts deleted file mode 100644 index b4178d11..00000000 --- a/src/vs/sessions/browser/layoutActions.ts +++ /dev/null @@ -1,163 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { alert } from '../../base/browser/ui/aria/aria.js'; -import { Codicon } from '../../base/common/codicons.js'; -import { KeyCode, KeyMod } from '../../base/common/keyCodes.js'; -import { localize, localize2 } from '../../nls.js'; -import { Categories } from '../../platform/action/common/actionCommonCategories.js'; -import { Action2, MenuRegistry, registerAction2 } from '../../platform/actions/common/actions.js'; -import { ContextKeyExpr } from '../../platform/contextkey/common/contextkey.js'; -import { Menus } from './menus.js'; -import { ServicesAccessor } from '../../platform/instantiation/common/instantiation.js'; -import { KeybindingWeight } from '../../platform/keybinding/common/keybindingsRegistry.js'; -import { registerIcon } from '../../platform/theme/common/iconRegistry.js'; -import { IsAuxiliaryWindowContext, IsWindowAlwaysOnTopContext, SideBarVisibleContext } from '../../workbench/common/contextkeys.js'; -import { IWorkbenchLayoutService, Parts } from '../../workbench/services/layout/browser/layoutService.js'; -import { SessionsWelcomeVisibleContext } from '../common/contextkeys.js'; - -// Register Icons -const panelCloseIcon = registerIcon('agent-panel-close', Codicon.close, localize('agentPanelCloseIcon', "Icon to close the panel.")); -const sidebarToggleClosedIcon = registerIcon('agent-sidebar-toggle-closed', Codicon.layoutSidebarLeftOff, localize('agentSidebarToggleClosedIcon', "Icon for the sessions sidebar when closed.")); -const sidebarToggleOpenIcon = registerIcon('agent-sidebar-toggle-open', Codicon.layoutSidebarLeft, localize('agentSidebarToggleOpenIcon', "Icon for the sessions sidebar when open.")); - -class ToggleSidebarVisibilityAction extends Action2 { - - static readonly ID = 'workbench.action.agentToggleSidebarVisibility'; - - constructor() { - super({ - id: ToggleSidebarVisibilityAction.ID, - title: localize2('toggleSidebar', 'Toggle Primary Side Bar Visibility'), - icon: sidebarToggleClosedIcon, - toggled: { - condition: SideBarVisibleContext, - icon: sidebarToggleOpenIcon, - }, - metadata: { - description: localize('openAndCloseSidebar', 'Open/Show and Close/Hide Sidebar'), - }, - category: Categories.View, - f1: true, - keybinding: { - weight: KeybindingWeight.WorkbenchContrib, - primary: KeyMod.CtrlCmd | KeyCode.KeyB - }, - menu: [ - { - id: Menus.TitleBarLeftLayout, - group: 'navigation', - order: 0, - when: ContextKeyExpr.and(IsAuxiliaryWindowContext.toNegated(), SessionsWelcomeVisibleContext.toNegated()) - }, - { - id: Menus.TitleBarContext, - group: 'navigation', - order: 0, - when: ContextKeyExpr.and(IsAuxiliaryWindowContext.toNegated(), SessionsWelcomeVisibleContext.toNegated()) - } - ] - }); - } - - run(accessor: ServicesAccessor): void { - const layoutService = accessor.get(IWorkbenchLayoutService); - const isCurrentlyVisible = layoutService.isVisible(Parts.SIDEBAR_PART); - - layoutService.setPartHidden(isCurrentlyVisible, Parts.SIDEBAR_PART); - - // Announce visibility change to screen readers - const alertMessage = isCurrentlyVisible - ? localize('sidebarHidden', "Primary Side Bar hidden") - : localize('sidebarVisible', "Primary Side Bar shown"); - alert(alertMessage); - } -} - -class ToggleSecondarySidebarVisibilityAction extends Action2 { - - static readonly ID = 'workbench.action.agentToggleSecondarySidebarVisibility'; - - constructor() { - super({ - id: ToggleSecondarySidebarVisibilityAction.ID, - title: localize2('toggleSecondarySidebar', 'Toggle Secondary Side Bar Visibility'), - icon: panelCloseIcon, - metadata: { - description: localize('openAndCloseSecondarySidebar', 'Open/Show and Close/Hide Secondary Side Bar'), - }, - category: Categories.View, - f1: true, - menu: [ - { - id: Menus.TitleBarContext, - order: 1, - when: ContextKeyExpr.and(IsAuxiliaryWindowContext.toNegated(), SessionsWelcomeVisibleContext.toNegated()) - } - ] - }); - } - - run(accessor: ServicesAccessor): void { - const layoutService = accessor.get(IWorkbenchLayoutService); - const isCurrentlyVisible = layoutService.isVisible(Parts.AUXILIARYBAR_PART); - - layoutService.setPartHidden(isCurrentlyVisible, Parts.AUXILIARYBAR_PART); - - // Announce visibility change to screen readers - const alertMessage = isCurrentlyVisible - ? localize('secondarySidebarHidden', "Secondary Side Bar hidden") - : localize('secondarySidebarVisible', "Secondary Side Bar shown"); - alert(alertMessage); - } -} - -class TogglePanelVisibilityAction extends Action2 { - - static readonly ID = 'workbench.action.agentTogglePanelVisibility'; - - constructor() { - super({ - id: TogglePanelVisibilityAction.ID, - title: localize2('togglePanel', 'Toggle Panel Visibility'), - category: Categories.View, - f1: true, - icon: panelCloseIcon, - menu: [ - { - id: Menus.PanelTitle, - group: 'navigation', - order: 2, - when: IsAuxiliaryWindowContext.toNegated() - } - ] - }); - } - - run(accessor: ServicesAccessor): void { - const layoutService = accessor.get(IWorkbenchLayoutService); - layoutService.setPartHidden(layoutService.isVisible(Parts.PANEL_PART), Parts.PANEL_PART); - } -} - -registerAction2(ToggleSidebarVisibilityAction); -registerAction2(ToggleSecondarySidebarVisibilityAction); -registerAction2(TogglePanelVisibilityAction); - -// Floating window controls: always-on-top -MenuRegistry.appendMenuItem(Menus.TitleBarRightLayout, { - command: { - id: 'workbench.action.toggleWindowAlwaysOnTop', - title: localize('toggleWindowAlwaysOnTop', "Toggle Always on Top"), - icon: Codicon.pin, - toggled: { - condition: IsWindowAlwaysOnTopContext, - icon: Codicon.pinned, - }, - }, - when: IsAuxiliaryWindowContext, - group: 'navigation', - order: 0 -}); diff --git a/src/vs/sessions/browser/layoutPolicy.ts b/src/vs/sessions/browser/layoutPolicy.ts deleted file mode 100644 index ded7d705..00000000 --- a/src/vs/sessions/browser/layoutPolicy.ts +++ /dev/null @@ -1,159 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { Disposable } from '../../base/common/lifecycle.js'; -import { observableValue, derived, IObservable } from '../../base/common/observable.js'; -import { isIOS, isMobile } from '../../base/common/platform.js'; -import { isAndroid } from '../../base/browser/browser.js'; -import { Gesture } from '../../base/browser/touch.js'; - -/** Viewport classification based on container width. */ -export type ViewportClass = 'phone' | 'tablet' | 'desktop'; - -/** Default visibility for each workbench part. */ -export interface IPartVisibilityDefaults { - readonly sidebar: boolean; - readonly auxiliaryBar: boolean; - readonly panel: boolean; - readonly chatBar: boolean; - readonly editor: boolean; -} - -/** Default sizes (in pixels) for each workbench part. */ -export interface IPartSizeDefaults { - readonly sideBarSize: number; - readonly auxiliaryBarSize: number; - readonly panelSize: number; - readonly chatBarWidth: number; -} - -const PHONE_MAX_WIDTH = 640; -const TABLET_MAX_WIDTH = 1024; - -/** - * Whether the current platform is a phone/tablet OS. The phone layout is - * only applied on actual mobile devices so that resizing a desktop window - * below 640px does not switch the agents workbench into phone mode. - */ -const isMobilePlatform = isMobile; - -/** - * Classifies the viewport into one of three classes based on width. - * Phone and tablet classifications are gated on a mobile OS; desktop - * browsers and Electron always report `desktop` regardless of width. - */ -function classifyViewport(width: number): ViewportClass { - if (!isMobilePlatform) { - return 'desktop'; - } - if (width < PHONE_MAX_WIDTH) { - return 'phone'; - } - if (width < TABLET_MAX_WIDTH) { - return 'tablet'; - } - return 'desktop'; -} - -/** - * Observable-based viewport classification and layout policy for - * the Sessions workbench. Consumed by `SessionsWorkbench` to drive - * part visibility, sizing, and behavior based on viewport dimensions - * and platform. - */ -export class SessionsLayoutPolicy extends Disposable { - - // --- Platform flags (static, read once) --- - - /** Whether the current platform is iOS. */ - readonly isIOS: boolean; - - /** Whether the current platform is Android. */ - readonly isAndroid: boolean; - - /** Whether the current device supports touch input. */ - readonly isTouchDevice: boolean; - - // --- Observables --- - - private readonly _viewportClass = observableValue(this, 'desktop'); - - /** Current viewport class derived from the most recent `update()` call. */ - readonly viewportClass: IObservable = this._viewportClass; - - /** `true` when the viewport class is `phone`. */ - readonly isPhoneLayout: IObservable = derived(this, reader => { - return this._viewportClass.read(reader) === 'phone'; - }); - - constructor() { - super(); - - this.isIOS = isIOS; - this.isAndroid = isAndroid; - this.isTouchDevice = Gesture.isTouchDevice(); - } - - /** - * Update the viewport classification. Call this from the workbench - * `layout()` method whenever the container dimensions change. - * - * @param width Container width in pixels. - * @param height Container height in pixels (reserved for future use). - */ - update(width: number, _height: number): void { - const next = classifyViewport(width); - if (this._viewportClass.get() !== next) { - this._viewportClass.set(next, undefined); - } - } - - /** - * Returns the default part visibility for the given viewport class. - * If no class is supplied the current observed class is used. - */ - getPartVisibilityDefaults(viewportClass?: ViewportClass): IPartVisibilityDefaults { - const vc = viewportClass ?? this._viewportClass.get(); - switch (vc) { - case 'phone': - return { sidebar: false, auxiliaryBar: false, panel: false, chatBar: true, editor: false }; - case 'tablet': - case 'desktop': - // Tablet and desktop share the standard multi-part workbench defaults. - // A dedicated tablet layout has not been designed yet. - return { sidebar: true, auxiliaryBar: true, panel: false, chatBar: true, editor: false }; - } - } - - /** - * Returns the default part sizes for the given viewport dimensions. - * If no viewport class is supplied the current observed class is used. - * - * @param width Container width in pixels. - * @param height Container height in pixels (reserved for future use). - * @param viewportClass Optional explicit viewport class override. - */ - getPartSizes(width: number, _height: number, viewportClass?: ViewportClass): IPartSizeDefaults { - const vc = viewportClass ?? this._viewportClass.get(); - switch (vc) { - case 'phone': - return { - sideBarSize: 0, - auxiliaryBarSize: 0, - panelSize: 0, - chatBarWidth: width, - }; - case 'tablet': - case 'desktop': - // Tablet currently falls back to desktop sizing. - return { - sideBarSize: 300, - auxiliaryBarSize: 340, - panelSize: 300, - chatBarWidth: width - 300, - }; - } - } -} diff --git a/src/vs/sessions/browser/media/sessions-icon.svg b/src/vs/sessions/browser/media/sessions-icon.svg deleted file mode 100644 index d1eb57cf..00000000 --- a/src/vs/sessions/browser/media/sessions-icon.svg +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/vs/sessions/browser/media/sessions-logo-dark.svg b/src/vs/sessions/browser/media/sessions-logo-dark.svg deleted file mode 100644 index fdbb8745..00000000 --- a/src/vs/sessions/browser/media/sessions-logo-dark.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/src/vs/sessions/browser/media/sessions-logo-light.svg b/src/vs/sessions/browser/media/sessions-logo-light.svg deleted file mode 100644 index 29dfd545..00000000 --- a/src/vs/sessions/browser/media/sessions-logo-light.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/src/vs/sessions/browser/media/sidebarActionButton.css b/src/vs/sessions/browser/media/sidebarActionButton.css deleted file mode 100644 index eba2e2fc..00000000 --- a/src/vs/sessions/browser/media/sidebarActionButton.css +++ /dev/null @@ -1,49 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -.sidebar-action-list .actions-container { - gap: 4px; -} - -.sidebar-action > .action-label { - /* Hide the default action-label rendered by ActionViewItem */ - display: none; -} - -/* Shared styling for interactive sidebar action buttons (account widget, customization links, etc.) */ -.sidebar-action-button { - display: flex; - align-items: center; - border: none; - padding: 4px 8px; - margin: 0; - font-size: 12px; - height: auto; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - background: transparent; - color: var(--vscode-agentsPanel-foreground, var(--vscode-foreground)); - width: 100%; - text-align: left; - justify-content: flex-start; - text-decoration: none; - border-radius: 4px; - cursor: pointer; - gap: 10px; - display: flex; -} - -.sidebar-action-button:hover { - background-color: var(--vscode-toolbar-hoverBackground); -} - -.sidebar-action-button.monaco-text-button:focus { - outline-offset: -1px !important; -} - -.sidebar-action-button.monaco-text-button .codicon { - margin: 0; -} diff --git a/src/vs/sessions/browser/media/style.css b/src/vs/sessions/browser/media/style.css deleted file mode 100644 index 7adb9988..00000000 --- a/src/vs/sessions/browser/media/style.css +++ /dev/null @@ -1,933 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -/* ---- Sidebar & Auxiliary Bar Card Appearance ---- */ - -.agent-sessions-workbench .part.sidebar { - background: var(--vscode-agents-background); -} - -.agent-sessions-workbench > .monaco-grid-view { - background-color: var(--vscode-agents-background); -} - -.agent-sessions-workbench.shell-gradient-background { - position: relative; - isolation: isolate; - background: var(--vscode-agents-background); -} - -.agent-sessions-workbench.shell-gradient-background::before { - content: ''; - position: absolute; - inset: 0; - z-index: 0; - pointer-events: none; - background: var(--vscode-agents-background); -} - -@supports (background: color-mix(in srgb, black 0%, white)) { - .agent-sessions-workbench.shell-gradient-background::before { - background: - linear-gradient( - to bottom right, - transparent 0%, - color-mix(in srgb, var(--vscode-agentsGradient-tintColor) 5%, transparent) 58%, - color-mix(in srgb, var(--vscode-agentsGradient-tintColor) 7%, transparent) 82%, - color-mix(in srgb, var(--vscode-agentsGradient-tintColor) 9%, transparent) 100% - ), - var(--vscode-agents-background); - } - - .monaco-workbench.vs.agent-sessions-workbench.shell-gradient-background::before { - background: - linear-gradient( - to bottom right, - transparent 0%, - color-mix(in srgb, var(--vscode-agentsGradient-tintColor) 1%, transparent) 45%, - color-mix(in srgb, var(--vscode-agentsGradient-tintColor) 6%, transparent) 75%, - color-mix(in srgb, var(--vscode-agentsGradient-tintColor) 12%, transparent) 100% - ), - var(--vscode-agents-background); - } - - .monaco-workbench.vs-dark.agent-sessions-workbench.shell-gradient-background::before { - background: linear-gradient( - to bottom right, - var(--vscode-agents-background) 0%, - color-mix(in srgb, var(--vscode-agentsGradient-tintColor) 7%, var(--vscode-agents-background)) 56%, - color-mix(in srgb, var(--vscode-agentsGradient-tintColor) 10%, var(--vscode-agents-background)) 82%, - color-mix(in srgb, var(--vscode-agentsGradient-tintColor) 13%, var(--vscode-agents-background)) 100% - ); - } -} - -.monaco-workbench.hc-black.agent-sessions-workbench.shell-gradient-background::before, -.monaco-workbench.hc-light.agent-sessions-workbench.shell-gradient-background::before { - display: none; -} - -.monaco-workbench.agent-sessions-workbench > .notifications-center { - right: 15px; - bottom: 15px; -} - -.monaco-workbench.agent-sessions-workbench > .notifications-toasts { - right: 15px; - bottom: 15px; -} - -.monaco-workbench.agent-sessions-workbench.nostatusbar > .notifications-center { - bottom: 15px; -} - -.monaco-workbench.agent-sessions-workbench.nostatusbar > .notifications-toasts { - bottom: 15px; -} - -.monaco-workbench.agent-sessions-workbench > .notifications-center.bottom-left { - right: auto; - left: 15px; - bottom: 15px; -} - -.monaco-workbench.agent-sessions-workbench > .notifications-toasts.bottom-left { - right: auto; - left: 15px; - bottom: 15px; -} - -.monaco-workbench.agent-sessions-workbench > .notifications-center.top-right { - top: 40px; - right: 15px; - bottom: auto; -} - -.agent-sessions-workbench.shell-gradient-background > .monaco-grid-view { - position: relative; - z-index: 1; - background: transparent; -} - -/** - * The auxiliary bar and panel use a card-like appearance with margins and border-radius - * applied directly on the .part element. The grid allocates full space; CSS margin shrinks - * the part within its split-view-view wrapper. Layout dimensions are reduced in code - * (AgenticAuxiliaryBarPart) to keep internal layout correct. - * - * No z-index or stacking-context changes - sashes render naturally on top. - * - * Margin values (must match the constants in the Part classes): - * Sidebar: no card (flush, spans full height) - * Auxiliary bar: top=16, bottom=18, right=16 - * Panel: bottom=18, left=16, right=16 - */ - -.agent-sessions-workbench.shell-gradient-background .part.titlebar, -.agent-sessions-workbench.shell-gradient-background .part.sidebar, -.agent-sessions-workbench.shell-gradient-background .part.titlebar > .content, -.agent-sessions-workbench.shell-gradient-background .part.titlebar > .titlebar-container, -.agent-sessions-workbench.shell-gradient-background .part.titlebar > .sessions-titlebar-container, -.agent-sessions-workbench.shell-gradient-background .part.sidebar > .composite.title, -.agent-sessions-workbench.shell-gradient-background .part.sidebar > .content, -.agent-sessions-workbench.shell-gradient-background .part.sidebar > .sidebar-footer { - background: transparent !important; -} - -.agent-sessions-workbench .part.sidebar.mobile-overlay-sidebar { - background: var(--vscode-agents-background) !important; -} - -.agent-sessions-workbench .part.chatbar { - margin: 0 10px 0px 10px; - background: var(--part-background); - border: 1px solid var(--part-border-color, transparent); - border-radius: 8px; - box-sizing: border-box; -} - -.agent-sessions-workbench .part.editor:not(.modal-editor-part) { - margin: 0 0 0 0; - background: var(--vscode-agentsPanel-background); - border: 1px solid var(--vscode-agentsPanel-border, transparent); - border-right-width: 0; - border-top-right-radius: 0; - border-bottom-right-radius: 0; - border-top-left-radius: 8px; - border-bottom-left-radius: 8px; - box-sizing: border-box; - overflow: hidden; -} - -.agent-sessions-workbench .part.editor:not(.modal-editor-part)::after { - box-shadow: none; -} - -.agent-sessions-workbench .part.editor .multiDiffEntry { - padding: 6px 8px 0; - box-sizing: border-box; -} - -.agent-sessions-workbench .part.editor .multiDiffEntry .header-content { - border-radius: 6px; - border: none; - background: var(--vscode-sideBarSectionHeader-background); - margin: 0; - align-items: center; -} - -.agent-sessions-workbench .part.editor .multiDiffEntry .header:not(.collapsed) .header-content { - border-bottom: none; -} - -.agent-sessions-workbench .part.editor .multiDiffEntry .header-content .file-path .title { - font-size: 13px; - line-height: 22px; -} - -.agent-sessions-workbench .part.editor .multiDiffEntry .header { - cursor: pointer; -} - -.agent-sessions-workbench .part.editor .multiDiffEntry .header:focus, -.agent-sessions-workbench .part.editor .multiDiffEntry .header:focus-visible { - outline: none; -} - -.agent-sessions-workbench .part.editor .multiDiffEntry .header:focus .header-content, -.agent-sessions-workbench .part.editor .multiDiffEntry .header:focus-visible .header-content { - box-shadow: inset 0 0 0 1px var(--vscode-focusBorder); -} - -.agent-sessions-workbench .part.editor .multiDiffEntry .header:hover .header-content { - background: color-mix(in srgb, var(--vscode-sideBarSectionHeader-background) 95%, var(--vscode-foreground)); -} - -.agent-sessions-workbench .part.editor .multiDiffEntry .header-content .actions { - opacity: 0; - pointer-events: none; - padding: 0 0 0 8px; -} - -.agent-sessions-workbench .part.editor .multiDiffEntry .header:hover .header-content .actions, -.agent-sessions-workbench .part.editor .multiDiffEntry .header:focus-within .header-content .actions { - opacity: 1; - pointer-events: auto; -} - -.agent-sessions-workbench .part.editor .multiDiffEntry .collapse-button a { - border-radius: 4px; -} - -.agent-sessions-workbench .part.editor .multiDiffEntry .editorParent { - border-bottom: none; -} - -.agent-sessions-workbench .part.editor .diff-hidden-lines .center { - box-shadow: none; -} - -.agent-sessions-workbench .part.editor .multiDiffEntry .header-content .status { - display: inline-flex; - align-items: center; - justify-content: center; - width: 16px; - min-width: 16px; - font-size: 11px; - font-weight: 600; - opacity: 0.9; -} - -.agent-sessions-workbench .part.editor .multiDiffEntry .header-content .status.added { - color: var(--vscode-gitDecoration-addedResourceForeground); -} - -.agent-sessions-workbench .part.editor .multiDiffEntry .header-content .status.deleted { - color: var(--vscode-gitDecoration-deletedResourceForeground); -} - -.agent-sessions-workbench .part.editor .multiDiffEntry .header-content .status.renamed { - color: var(--vscode-gitDecoration-modifiedResourceForeground); -} - -.agent-sessions-workbench .part.editor .title.tabs { - background-color: transparent !important; - --editor-group-tab-height: 26px !important; -} - -/* Editor tabs: match chat composite bar tab style */ -.agent-sessions-workbench .part.editor .tabs-container > .tab { - background-color: transparent !important; - border-right: none !important; - border-radius: 4px; - font-size: 12px; - font-weight: 500; - box-shadow: none !important; - padding: 0 0 0 4px !important; - --tab-border-top-color: transparent !important; -} - -/* Allow tabs to shrink in narrow viewports so the close button stays reachable. - * The default `sizing-fit` rule sets `min-width: fit-content; flex-shrink: 0;` - * which prevents the tab from shrinking below its label width and pushes the - * close button out of view. */ -.agent-sessions-workbench .part.editor .tabs-container > .tab.sizing-fit { - min-width: 0 !important; - flex-shrink: 1 !important; -} - -.agent-sessions-workbench .part.editor .tabs-container > .tab.sizing-fit .monaco-icon-label, -.agent-sessions-workbench .part.editor .tabs-container > .tab.sizing-fit .monaco-icon-label > .monaco-icon-label-container { - overflow: hidden; - text-overflow: ellipsis; - min-width: 0; -} - -/* Keep the close button reserved within the tab so it remains accessible when - * the tab shrinks. Without this the action sits in an `overflow: hidden` - * container that only reveals on hover, which combined with the smaller tab - * makes the close target hard to hit. */ -.agent-sessions-workbench .part.editor .tabs-container > .tab > .tab-actions { - flex: 0 0 auto; - overflow: visible; -} - -.agent-sessions-workbench .part.editor .tabs-and-actions-container { - --tabs-border-bottom-color: transparent !important; - align-items: center; - padding: 4px; -} - -.agent-sessions-workbench .part.editor .tabs-container > .tab.active { - background-color: color-mix(in srgb, var(--vscode-foreground) 5%, transparent) !important; -} - -.agent-sessions-workbench .part.editor .tabs-container > .tab .tab-border-top-container, -.agent-sessions-workbench .part.editor .tabs-container > .tab .tab-border-bottom-container { - display: none !important; -} - -.agent-sessions-workbench:not(.nosidebar) .part.chatbar { - margin-left: 0; -} - -.agent-sessions-workbench .part.auxiliarybar { - margin: 0 10px 0px 0; - background: var(--part-background); - border: 1px solid var(--part-border-color, transparent); - border-top-left-radius: 0; - border-bottom-left-radius: 0; - border-top-right-radius: 8px; - border-bottom-right-radius: 8px; - box-sizing: border-box; -} - -.agent-sessions-workbench.nomaineditorarea .part.auxiliarybar { - border-top-left-radius: 8px; - border-bottom-left-radius: 8px; -} - -.agent-sessions-workbench .part.auxiliarybar > .content .pane-body { - background-color: var(--vscode-agentsPanel-background); -} - -.agent-sessions-workbench .part.panel { - margin: 0 10px 10px 10px; - background: var(--part-background); - border: 1px solid var(--part-border-color, transparent); - border-radius: 8px; - box-sizing: border-box; -} - -.agent-sessions-workbench:not(.nosidebar) .part.panel { - margin-left: 0; -} - -.agent-sessions-workbench .monaco-sash:before { - border-radius: calc(var(--vscode-sash-hover-size) / 2); -} - -.agent-sessions-workbench .monaco-sash:not(.disabled) > .orthogonal-drag-handle { - border-radius: var(--vscode-sash-size); -} - -/* ---- Scrollbars ---- */ - -/* Thinner, rounded scrollbar sliders scoped to the agents app. - * The scrollbar widget sets the slider's size inline to match the full - * track. We hide that default background and paint a narrower, rounded - * slider with an inset ::before pseudo-element so all four corners round - * consistently and the full hit area is preserved. */ -.agent-sessions-workbench .monaco-scrollable-element > .scrollbar > .slider { - background: transparent !important; -} - -.agent-sessions-workbench .monaco-scrollable-element > .scrollbar.vertical > .slider::before, -.agent-sessions-workbench .monaco-scrollable-element > .scrollbar.horizontal > .slider::before { - content: ''; - position: absolute; - inset: 1px; - border-radius: 999px; - background: var(--vscode-scrollbarSlider-background); -} - -.agent-sessions-workbench .monaco-scrollable-element > .scrollbar > .slider:hover::before { - background: var(--vscode-scrollbarSlider-hoverBackground); -} - -.agent-sessions-workbench .monaco-scrollable-element > .scrollbar > .slider.active::before { - background: var(--vscode-scrollbarSlider-activeBackground); -} - -.monaco-workbench.vs.agent-sessions-workbench .part.chatbar, -.monaco-workbench.vs.agent-sessions-workbench .part.auxiliarybar, -.monaco-workbench.vs.agent-sessions-workbench .part.panel { - border-color: var(--vscode-editorWidget-border, var(--vscode-widget-border, transparent)); -} - -/* ---- Chat Layout ---- */ - -/* Remove max-width from the session container so the scrollbar extends full width */ -.agent-sessions-workbench .interactive-session { - max-width: none; -} - -.monaco-workbench.vs.agent-sessions-workbench .part.chatbar .interactive-list > .monaco-list > .monaco-scrollable-element > .monaco-list-rows { - background: transparent !important; -} - -.monaco-workbench.vs.agent-sessions-workbench .part.chatbar .interactive-list > .monaco-list > .monaco-scrollable-element > .monaco-list-rows > .monaco-list-row.request:hover, -.monaco-workbench.vs.agent-sessions-workbench .part.chatbar .interactive-list > .monaco-list > .monaco-scrollable-element > .monaco-list-rows > .monaco-list-row.response:hover, -.monaco-workbench.vs.agent-sessions-workbench .part.chatbar .interactive-list > .monaco-list > .monaco-scrollable-element > .monaco-list-rows > .monaco-list-row.focused.request, -.monaco-workbench.vs.agent-sessions-workbench .part.chatbar .interactive-list > .monaco-list > .monaco-scrollable-element > .monaco-list-rows > .monaco-list-row.focused.response, -.monaco-workbench.vs.agent-sessions-workbench .part.chatbar .interactive-list > .monaco-list > .monaco-scrollable-element > .monaco-list-rows > .monaco-list-row.selected.request, -.monaco-workbench.vs.agent-sessions-workbench .part.chatbar .interactive-list > .monaco-list > .monaco-scrollable-element > .monaco-list-rows > .monaco-list-row.selected.response { - background: transparent !important; -} - -/* Constrain content items to the same max-width, centered */ -.agent-sessions-workbench .interactive-session .interactive-item-container { - max-width: 950px; - margin: 0 auto; - padding-left: 12px !important; - padding-right: 12px !important; - box-sizing: border-box; -} - -.agent-sessions-workbench .interactive-session > .chat-suggest-next-widget { - max-width: 950px; - margin: 0 auto; - padding-left: 12px !important; - padding-right: 12px !important; - box-sizing: border-box; -} - -/* ---- Chat Output ---- */ - -/* Top fade overlay: dims content scrolled near the top edge of the chat bar card. - * Use the Monaco scroll shadow element, which is only shown when scrollTop > 0. */ -.agent-sessions-workbench .part.chatbar .monaco-scrollable-element > .shadow.top { - height: 16px; - background: linear-gradient(to bottom, var(--part-background), transparent); - box-shadow: none; -} - -/* Same fade overlay for the chat renderer inside an editor (e.g. floating aux window). */ -.agent-sessions-workbench .chat-editor-relative .interactive-list .monaco-scrollable-element > .shadow.top { - height: 16px; - background: linear-gradient(to bottom, var(--vscode-editor-background), transparent); - box-shadow: none; -} - -/* ---- Chat Input ---- */ - -.agent-sessions-workbench .interactive-session .chat-input-container { - border-radius: var(--vscode-cornerRadius-large); - border-color: var(--vscode-agentsChatInput-border) !important; - background-color: var(--vscode-agentsChatInput-background); - color: var(--vscode-agentsChatInput-foreground); -} - -.agent-sessions-workbench .interactive-session .chat-input-container.focused { - border-color: var(--vscode-agentsChatInput-focusBorder, var(--vscode-focusBorder)) !important; -} - -/* While the developer-joy animated border is active, keep a faint - persistent ring so the input still has a visible boundary throughout - the comet animation (improves visuals + accessibility). The spinning - gradient ring overlays this. When focused, the regular focus border - is blended/dimmed during the animation to avoid competing visually. */ -.agent-sessions-workbench .interactive-session .chat-input-container.working:not(.focused) { - border-color: var(--vscode-agentsChatInput-border) !important; -} - -/* Dim the focus border while the comet is animating so the bright focus - ring doesn't visually fight with the spinning gradient. */ -.agent-sessions-workbench .interactive-session .chat-input-container.working.focused { - border-color: color-mix(in srgb, var(--vscode-agentsChatInput-focusBorder, var(--vscode-focusBorder)) 40%, transparent) !important; -} - -/* Make the Monaco editor inside the chat input transparent so it inherits the chatInput.background */ -.agent-sessions-workbench .interactive-session .interactive-input-part .chat-editor-container .interactive-input-editor .monaco-editor, -.agent-sessions-workbench .interactive-session .interactive-input-part .chat-editor-container .interactive-input-editor .monaco-editor .monaco-editor-background { - background-color: transparent !important; -} - -.agent-sessions-workbench .interactive-session .interactive-input-part { - width: 100%; - max-width: 950px; - margin: 0 auto !important; - display: inherit !important; - /* Align with panel (terminal) card margin */ - padding: 4px 10px !important; - box-sizing: border-box; -} - -/* Hide shared chat-session option-group pickers in the sessions app active chat UI. - * The sessions workbench provides its own new-session configuration controls and - * should not surface the shared workbench chat session pickers here. */ -.agent-sessions-workbench .interactive-session .chat-input-toolbars .chat-sessionPicker-container { - display: none; -} - -/* ---- Sessions Action Widget Pickers ---- */ - -.agent-sessions-workbench .action-widget { - background-color: var(--vscode-agentsPanel-background); -} - -.agent-sessions-workbench .action-widget .action-widget-action-bar, -.agent-sessions-workbench .action-widget .action-list-filter { - background-color: var(--vscode-agentsPanel-background); -} - -.agent-sessions-workbench .action-widget .action-list-filter-row { - padding-right: 0; -} - -.agent-sessions-workbench .action-widget .monaco-scrollable-element > .monaco-list-rows { - background-color: var(--vscode-agentsPanel-background) !important; -} - -/* ---- Sessions Quick Picks ---- */ - -.agent-sessions-workbench .quick-input-widget, -.agent-sessions-workbench .quick-input-titlebar { - background-color: var(--vscode-agentsPanel-background) !important; -} - -.agent-sessions-workbench .quick-input-header, -.agent-sessions-workbench .quick-input-html-widget, -.agent-sessions-workbench .quick-input-list .monaco-scrollable-element > .monaco-list-rows, -.agent-sessions-workbench .quick-input-tree .monaco-scrollable-element > .monaco-list-rows { - background-color: var(--vscode-agentsPanel-background) !important; -} - -.agent-sessions-workbench .quick-input-header { - border-radius: var(--vscode-cornerRadius-xLarge); -} - -/* ---- Sessions Context Menus ---- */ - -.agent-sessions-workbench .context-view.monaco-menu-container, -.agent-sessions-workbench .context-view.monaco-menu-container .monaco-menu, -.agent-sessions-workbench .context-view.monaco-menu-container .monaco-scrollable-element { - background-color: var(--vscode-agentsPanel-background) !important; -} - -/* Keep code block backgrounds stable across themes; add separation in older light themes via border only */ -.agent-sessions-workbench .interactive-session .interactive-response .interactive-result-code-block { - border-color: var(--vscode-editorWidget-border, var(--vscode-widget-border)); -} -/* ---- Modal Editor Block ---- */ - -.agent-sessions-workbench .monaco-modal-editor-block { - background: rgba(0, 0, 0, 0.5); -} - -.agent-sessions-workbench .monaco-modal-editor-block .modal-editor-sidebar .monaco-tl-twistie.force-no-twistie { - width: 0 !important; - padding-left: 0 !important; - padding-right: 0 !important; -} - - -/* ---- Customization Empty State ---- */ - -/* Icon + title side by side in a row, description underneath */ -.agent-sessions-workbench .ai-customization-list-widget .list-empty-state .empty-state-header { - display: flex; - flex-direction: row; - align-items: center; - gap: 6px; -} - -.agent-sessions-workbench .ai-customization-list-widget .list-empty-state .empty-state-header > .empty-state-icon { - font-size: 16px; - margin-bottom: 0; - flex-shrink: 0; -} - -/* MCP / Plugin empty state: icon + title side by side */ -.agent-sessions-workbench .mcp-empty-state .empty-state-header { - display: flex; - flex-direction: row; - align-items: center; - gap: 6px; -} - -.agent-sessions-workbench .mcp-empty-state .empty-state-header > .empty-icon { - font-size: 16px; - margin-bottom: 0; - flex-shrink: 0; -} - -/* ---- Part Appear Transitions ---- */ - -/* - * Subtle appear animation when parts transition from display:none → visible - * (via split-view-view .visible class). - * - * Auxiliary bar, panel, and chat bar animate their container via opacity, - * margin, border-color, and background. The sidebar reveal animation applies - * only to `.part.sidebar > .content`, using opacity + transform so the - * sidebar container, header, and footer stay visually fixed. - * - * Opacity transiently creates a stacking context while it animates from 0 to 1 - * over 250ms — once settled at opacity: 1, no additional stacking context is - * introduced by this animation. Margin and transform shifts are purely visual - * within the grid-allocated space. - */ - -.agent-sessions-workbench .part.auxiliarybar, -.agent-sessions-workbench .part.panel, -.agent-sessions-workbench .part.chatbar { - transition: - opacity 250ms ease-out, - margin-top 250ms ease-out, - margin-right 250ms ease-out, - margin-bottom 250ms ease-out; -} - -/* Auxiliary bar also transitions horizontal margin */ -.agent-sessions-workbench .part.auxiliarybar { - transition: - opacity 250ms ease-out, - margin 250ms ease-out; -} - -.agent-sessions-workbench .part.sidebar > .content { - transition: - opacity 250ms ease-out, - transform 250ms ease-out; -} - -@starting-style { - /* Shared starting values */ - .agent-sessions-workbench .part.auxiliarybar, - .agent-sessions-workbench .part.panel, - .agent-sessions-workbench .part.chatbar { - opacity: 0; - border-color: transparent; - } - - /* Card parts: blend from surrounding background */ - .agent-sessions-workbench .part.auxiliarybar, - .agent-sessions-workbench .part.panel, - .agent-sessions-workbench .part.chatbar { - background: color-mix(in srgb, var(--part-background) 60%, var(--vscode-sideBar-background)); - } - - .agent-sessions-workbench .part.sidebar > .content { - opacity: 0; - transform: translateX(-6px); - } - - /* Panel (bottom): slides down from 6px above → margin: 0 10px 10px 10px */ - .agent-sessions-workbench .part.panel { - margin: 6px 16px 16px 16px; - } - - /* Auxiliary bar (right): slides in from 6px right → margin: 0 10px 0px 0 */ - .agent-sessions-workbench .part.auxiliarybar { - margin: 0 16px 0px 6px; - } - - /* Chat bar (center-bottom): slides up from 6px below → margin: 0 10px 0px 10px */ - .agent-sessions-workbench .part.chatbar { - margin: 6px 16px 0px 16px; - } -} - -@media (prefers-reduced-motion: reduce) { - .agent-sessions-workbench .part.auxiliarybar, - .agent-sessions-workbench .part.panel, - .agent-sessions-workbench .part.chatbar { - transition: none; - } - - .agent-sessions-workbench .part.sidebar > .content { - transition: none; - } -} - -/* ---- Widget Customizations ---- */ - -/* Action Widget */ -.agent-sessions-workbench .action-widget .monaco-list .monaco-list-row { - padding-right: 0; -} - -/* Badge */ -.agent-sessions-workbench .badge > .badge-content { - border-radius: 4px !important; -} - -/* Phone-layout rules for parts, sashes, max-width constraints, and grid - background live in mobileChatShell.css — do not duplicate them here. */ - -/* - * Phone layout (< 640px) styles. Currently the only mobile form factor - * supported by the sessions workbench; tablet/larger viewports fall back - * to the desktop layout. - */ - -/* ---- Phone Layout: Overscroll Containment ---- */ - -/* Prevent body rubber-band on iOS and Chrome pull-to-refresh on Android */ -.agent-sessions-workbench.phone-layout .monaco-scrollable-element > .scrollable-element { - overscroll-behavior: contain; -} - -.agent-sessions-workbench.phone-layout .interactive-session { - overscroll-behavior: contain; -} - -.agent-sessions-workbench.phone-layout .monaco-list { - overscroll-behavior: contain; -} - -/* ---- Phone Layout: Touch Target Sizing ---- */ - -/* Ensure interactive elements meet 44px minimum touch target. - Excludes: - - Quick pick toolbars: 44x44 icons crowd the small popup header. - - Chat input toolbars: a dense row of picker buttons (Local, Auto, - agent, model, tools, send...). Enforcing 44px makes items 50px tall - and shows a huge hover/active background next to smaller adjacent - labels, making the bar feel unbalanced. */ -.agent-sessions-workbench.phone-layout .action-item > .action-label { - min-height: 44px; - min-width: 44px; -} - -.agent-sessions-workbench.phone-layout .quick-input-widget .action-item > .action-label, -.agent-sessions-workbench.phone-layout .chat-input-toolbars .action-item > .action-label { - min-height: 0; - min-width: 0; -} - -/* Touch action for tap responsiveness */ -.agent-sessions-workbench.phone-layout .action-item, -.agent-sessions-workbench.phone-layout button { - touch-action: manipulation; -} - -/* Disable text selection callout on interactive elements */ -.agent-sessions-workbench.phone-layout .action-item, -.agent-sessions-workbench.phone-layout .monaco-toolbar, -.agent-sessions-workbench.phone-layout .sidebar-footer { - -webkit-touch-callout: none; - user-select: none; - -webkit-user-select: none; -} - -/* Titlebar safe-area inset lives in mobileChatShell.css */ - -/* ---- Phone Layout: Mobile Quick Picks ---- */ - -/* On phone, expand quick picks to use the viewport with safe-area gutters - rather than the desktop-default narrow popup near the trigger. Position - is left untouched so the picker stays anchored where the user invoked - it (avoids feeling modal/jumpy). */ -.agent-sessions-workbench.phone-layout .quick-input-widget { - left: 8px !important; - right: 8px !important; - width: auto !important; - max-width: none !important; -} - -.agent-sessions-workbench.phone-layout .quick-input-widget .quick-input-list, -.agent-sessions-workbench.phone-layout .quick-input-widget .quick-input-tree { - max-height: 50vh; -} - -.agent-sessions-workbench.phone-layout .quick-input-widget .quick-input-list .monaco-list-row, -.agent-sessions-workbench.phone-layout .quick-input-widget .quick-input-tree .monaco-list-row { - min-height: 44px; -} - -/* ---- Phone Layout: Mobile Dialogs ---- */ - -/* Make dialogs near-full-width with larger buttons on phone */ -.agent-sessions-workbench.phone-layout .monaco-dialog-box { - width: calc(100% - 32px); - max-width: calc(100% - 32px); -} - -.agent-sessions-workbench.phone-layout .monaco-dialog-box .dialog-buttons-row .monaco-button { - min-height: 44px; - font-size: 16px; -} - -/* ---- Phone Layout: Mobile Notifications ---- */ - -/* Full-width notification toasts at top of screen */ -.agent-sessions-workbench.phone-layout .notifications-toasts { - left: 8px !important; - right: 8px !important; - bottom: auto !important; - top: calc(env(safe-area-inset-top) + 48px) !important; - width: auto !important; -} - -.agent-sessions-workbench.phone-layout .notifications-toasts .notification-toast { - width: 100%; - max-width: 100%; -} - -.agent-sessions-workbench.phone-layout .notifications-toasts .notification-toast .notification-toast-container { - border-radius: 12px; -} - -/* ---- Phone Layout: Hover Cards ---- */ - -/* Disable delayed hover cards on touch devices — they never trigger */ -.agent-sessions-workbench.phone-layout .monaco-hover { - display: none; -} - -/* Exception: keep hovers that are explicitly triggered (e.g., info buttons) */ -.agent-sessions-workbench.phone-layout .monaco-hover.visible-on-mobile { - display: block; -} - -/* ---- Phone Layout: Mobile Editor Modal ---- */ - -/* Full-screen editor modal on phone — no margins, covers entire viewport */ -.agent-sessions-workbench.phone-layout .monaco-modal-editor-part { - top: 0 !important; - left: 0 !important; - right: 0 !important; - bottom: 0 !important; - width: 100% !important; - height: 100% !important; - border-radius: 0 !important; - margin: 0 !important; - animation: editor-slide-up 250ms ease-out; -} - -@keyframes editor-slide-up { - from { - transform: translateY(30%); - opacity: 0.5; - } - to { - transform: translateY(0); - opacity: 1; - } -} - -/* Make the modal backdrop fully cover the screen on phone */ -.agent-sessions-workbench.phone-layout .monaco-modal-editor-block { - background: rgba(0, 0, 0, 0.7); -} - -/* Safe area padding for editor titlebar on phone */ -.agent-sessions-workbench.phone-layout .monaco-modal-editor-part .title { - padding-top: env(safe-area-inset-top); -} - -/* ---- Phone Layout: Input Auto-Zoom Prevention ---- */ - -/* iOS Safari zooms in on input focus when font-size < 16px. - Force minimum 16px on all input elements on phone. */ -.agent-sessions-workbench.phone-layout input, -.agent-sessions-workbench.phone-layout textarea, -.agent-sessions-workbench.phone-layout .monaco-inputbox input, -.agent-sessions-workbench.phone-layout .chat-input-container textarea { - font-size: 16px; -} - -/* ---- Phone Layout: Native Scroll Preservation ---- */ - -/* Ensure chat content uses momentum scrolling on phone. - The -webkit-overflow-scrolling property is needed for older iOS. */ -.agent-sessions-workbench.phone-layout .interactive-session .monaco-scrollable-element { - -webkit-overflow-scrolling: touch; -} - -/* ---- Phone Layout: Bottom Sheet Panel ---- */ - -/* Panel slides up from bottom as a sheet on phone */ -.agent-sessions-workbench.phone-layout .split-view-view:has(> .part.panel) { - position: absolute !important; - bottom: 0 !important; - left: 0 !important; - right: 0 !important; - height: 60vh !important; - z-index: 200; - animation: panel-slide-up 250ms ease-out; - border-radius: 16px 16px 0 0; - overflow: hidden; -} - -@keyframes panel-slide-up { - from { - transform: translateY(100%); - opacity: 0.5; - } - to { - transform: translateY(0); - opacity: 1; - } -} - -/* Panel drag handle visual indicator */ -.agent-sessions-workbench.phone-layout .part.panel::before { - content: ''; - display: block; - width: 36px; - height: 5px; - background: var(--vscode-foreground); - opacity: 0.3; - border-radius: 3px; - margin: 8px auto 4px auto; -} - -/* ---- Phone Layout: Auxiliary Bar Overlay ---- */ - -/* Auxiliary bar slides in from the right as a full-height overlay on phone */ -.agent-sessions-workbench.phone-layout .split-view-view:has(> .part.auxiliarybar) { - position: absolute !important; - top: 0 !important; - right: 0 !important; - bottom: 0 !important; - width: 85vw !important; - max-width: 400px; - z-index: 200; - animation: auxbar-slide-in 250ms ease-out; -} - -@keyframes auxbar-slide-in { - from { - transform: translateX(100%); - opacity: 0; - } - to { - transform: translateX(0); - opacity: 1; - } -} diff --git a/src/vs/sessions/browser/menus.ts b/src/vs/sessions/browser/menus.ts deleted file mode 100644 index f6f3929a..00000000 --- a/src/vs/sessions/browser/menus.ts +++ /dev/null @@ -1,34 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { MenuId } from '../../platform/actions/common/actions.js'; - -/** - * Menu IDs for the Agent Sessions workbench layout. - */ -export const Menus = { - ChatBarTitle: new MenuId('ChatBarTitle'), - CommandCenter: new MenuId('SessionsCommandCenter'), - CommandCenterCenter: new MenuId('SessionsCommandCenterCenter'), - TitleBarContext: new MenuId('SessionsTitleBarContext'), - TitleBarLeftLayout: new MenuId('SessionsTitleBarLeftLayout'), - TitleBarSessionTitle: new MenuId('SessionsTitleBarSessionTitle'), - TitleBarSessionMenu: new MenuId('SessionsTitleBarSessionMenu'), - TitleBarRightLayout: new MenuId('SessionsTitleBarRightLayout'), - MobileTitleBarCenter: new MenuId('SessionsMobileTitleBarCenter'), - PanelTitle: new MenuId('SessionsPanelTitle'), - SidebarTitle: new MenuId('SessionsSidebarTitle'), - SidebarSessionsHeader: new MenuId('SessionsSidebarSessionsHeader'), - AuxiliaryBarTitle: new MenuId('SessionsAuxiliaryBarTitle'), - SidebarFooter: new MenuId('SessionsSidebarFooter'), - SidebarCustomizations: new MenuId('SessionsSidebarCustomizations'), - AccountMenu: new MenuId('SessionsAccountMenu'), - AgentFeedbackEditorContent: new MenuId('AgentFeedbackEditorContent'), - - NewSessionConfig: new MenuId('NewSessions.SessionConfigMenu'), - NewSessionControl: new MenuId('NewSessions.SessionControlMenu'), - NewSessionRepositoryConfig: new MenuId('NewSessions.RepositoryConfigMenu'), - SessionWorkspaceManage: new MenuId('Sessions.SessionWorkspaceManage'), -} as const; diff --git a/src/vs/sessions/browser/mobileNavigationStack.ts b/src/vs/sessions/browser/mobileNavigationStack.ts deleted file mode 100644 index 020022bf..00000000 --- a/src/vs/sessions/browser/mobileNavigationStack.ts +++ /dev/null @@ -1,113 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { Disposable } from '../../base/common/lifecycle.js'; -import { Emitter, Event } from '../../base/common/event.js'; -import { mainWindow } from '../../base/browser/window.js'; - -export type MobileNavigationLayer = 'sidebar' | 'editor' | 'panel' | 'auxbar'; - -interface MobileNavigationEntry { - readonly layer: MobileNavigationLayer; - readonly id: number; -} - -/** - * Manages a stack of open overlay layers (sidebar, editor modal, panel sheet, - * aux bar) and integrates with `history.pushState` / `popstate` so that the - * Android back button dismisses overlays in LIFO order. - */ -export class MobileNavigationStack extends Disposable { - - private readonly _stack: MobileNavigationEntry[] = []; - private _nextId = 0; - - private readonly _onDidPop = this._register(new Emitter()); - readonly onDidPop: Event = this._onDidPop.event; - - constructor() { - super(); - - this._register(Event.fromDOMEventEmitter(mainWindow, 'popstate')(e => { - this._onPopState(e); - })); - } - - push(layer: MobileNavigationLayer): void { - const id = this._nextId++; - this._stack.push({ layer, id }); - mainWindow.history.pushState({ layer, id }, ''); - } - - pop(): MobileNavigationLayer | undefined { - const entry = this._stack.pop(); - if (entry) { - this._onDidPop.fire(entry.layer); - } - return entry?.layer; - } - - peek(): MobileNavigationLayer | undefined { - return this._stack.length > 0 - ? this._stack[this._stack.length - 1].layer - : undefined; - } - - has(layer: MobileNavigationLayer): boolean { - return this._stack.some(e => e.layer === layer); - } - - clear(): void { - this._stack.length = 0; - } - - /** - * Removes the topmost entry matching `layer` from the stack (without - * firing {@link onDidPop}) and rewinds the browser history by one entry. - * Use this when a layer is closed by UI interaction (e.g., backdrop click) - * so the history and stack stay in sync without recursing back into - * close handlers. - * - * Concurrent silent pops are handled via a counter: each call increments - * {@link _pendingSilentPops} and the matching {@link _onPopState} decrements - * it, so rapid back-button taps or multiple overlay closes cannot leak - * suppression state across unrelated pops. - */ - popSilently(layer: MobileNavigationLayer): void { - for (let i = this._stack.length - 1; i >= 0; i--) { - if (this._stack[i].layer === layer) { - this._stack.splice(i, 1); - this._pendingSilentPops++; - mainWindow.history.back(); - return; - } - } - } - - private _pendingSilentPops = 0; - - private _onPopState(e: PopStateEvent): void { - if (this._pendingSilentPops > 0) { - this._pendingSilentPops--; - return; - } - - if (this._stack.length === 0) { - return; - } - - const top = this._stack[this._stack.length - 1]; - const state = e.state as { layer?: string; id?: number } | null; - - // Only pop if the event's state id matches expectations — - // the popstate must correspond to a state *before* our top entry, - // meaning the top entry's push was just undone. - if (state && typeof state.id === 'number' && state.id >= top.id) { - return; - } - - this.pop(); - } -} diff --git a/src/vs/sessions/browser/paneCompositePartService.ts b/src/vs/sessions/browser/paneCompositePartService.ts deleted file mode 100644 index 1c41eb90..00000000 --- a/src/vs/sessions/browser/paneCompositePartService.ts +++ /dev/null @@ -1,115 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { Emitter } from '../../base/common/event.js'; -import { assertReturnsDefined } from '../../base/common/types.js'; -import { IInstantiationService } from '../../platform/instantiation/common/instantiation.js'; -import { IProgressIndicator } from '../../platform/progress/common/progress.js'; -import { IPaneComposite } from '../../workbench/common/panecomposite.js'; -import { ViewContainerLocation } from '../../workbench/common/views.js'; -import { IPaneCompositePartService } from '../../workbench/services/panecomposite/browser/panecomposite.js'; -import { Disposable } from '../../base/common/lifecycle.js'; -import { PaneCompositeDescriptor } from '../../workbench/browser/panecomposite.js'; -import { IPaneCompositePart } from '../../workbench/browser/parts/paneCompositePart.js'; -import { SINGLE_WINDOW_PARTS } from '../../workbench/services/layout/browser/layoutService.js'; -import { PanelPart } from './parts/panelPart.js'; -import { SidebarPart } from './parts/sidebarPart.js'; -import { AuxiliaryBarPart } from './parts/auxiliaryBarPart.js'; -import { ChatBarPart } from './parts/chatBarPart.js'; -import { MobilePanelPart } from './parts/mobile/mobilePanelPart.js'; -import { MobileSidebarPart } from './parts/mobile/mobileSidebarPart.js'; -import { MobileAuxiliaryBarPart } from './parts/mobile/mobileAuxiliaryBarPart.js'; -import { MobileChatBarPart } from './parts/mobile/mobileChatBarPart.js'; -import { getClientArea } from '../../base/browser/dom.js'; -import { mainWindow } from '../../base/browser/window.js'; -import { InstantiationType, registerSingleton } from '../../platform/instantiation/common/extensions.js'; - -export class AgenticPaneCompositePartService extends Disposable implements IPaneCompositePartService { - - declare readonly _serviceBrand: undefined; - - private readonly _onDidPaneCompositeOpen = this._register(new Emitter<{ composite: IPaneComposite; viewContainerLocation: ViewContainerLocation }>()); - readonly onDidPaneCompositeOpen = this._onDidPaneCompositeOpen.event; - - private readonly _onDidPaneCompositeClose = this._register(new Emitter<{ composite: IPaneComposite; viewContainerLocation: ViewContainerLocation }>()); - readonly onDidPaneCompositeClose = this._onDidPaneCompositeClose.event; - - private readonly paneCompositeParts = new Map(); - - constructor( - @IInstantiationService instantiationService: IInstantiationService - ) { - super(); - - const { width } = getClientArea(mainWindow.document.body); - const isPhoneLayout = width < 640; - - this.registerPart(ViewContainerLocation.Panel, instantiationService.createInstance(isPhoneLayout ? MobilePanelPart : PanelPart)); - this.registerPart(ViewContainerLocation.Sidebar, instantiationService.createInstance(isPhoneLayout ? MobileSidebarPart : SidebarPart)); - this.registerPart(ViewContainerLocation.AuxiliaryBar, instantiationService.createInstance(isPhoneLayout ? MobileAuxiliaryBarPart : AuxiliaryBarPart)); - this.registerPart(ViewContainerLocation.ChatBar, instantiationService.createInstance(isPhoneLayout ? MobileChatBarPart : ChatBarPart)); - } - - private registerPart(location: ViewContainerLocation, part: IPaneCompositePart): void { - this.paneCompositeParts.set(location, part); - this._register(part.onDidPaneCompositeOpen(composite => this._onDidPaneCompositeOpen.fire({ composite, viewContainerLocation: location }))); - this._register(part.onDidPaneCompositeClose(composite => this._onDidPaneCompositeClose.fire({ composite, viewContainerLocation: location }))); - } - - getRegistryId(viewContainerLocation: ViewContainerLocation): string { - return this.getPartByLocation(viewContainerLocation).registryId; - } - - getPartId(viewContainerLocation: ViewContainerLocation): SINGLE_WINDOW_PARTS { - return this.getPartByLocation(viewContainerLocation).partId; - } - - openPaneComposite(id: string | undefined, viewContainerLocation: ViewContainerLocation, focus?: boolean): Promise { - return this.getPartByLocation(viewContainerLocation).openPaneComposite(id, focus); - } - - getActivePaneComposite(viewContainerLocation: ViewContainerLocation): IPaneComposite | undefined { - return this.getPartByLocation(viewContainerLocation).getActivePaneComposite(); - } - - getPaneComposite(id: string, viewContainerLocation: ViewContainerLocation): PaneCompositeDescriptor | undefined { - return this.getPartByLocation(viewContainerLocation).getPaneComposite(id); - } - - getPaneComposites(viewContainerLocation: ViewContainerLocation): PaneCompositeDescriptor[] { - return this.getPartByLocation(viewContainerLocation).getPaneComposites(); - } - - getPinnedPaneCompositeIds(viewContainerLocation: ViewContainerLocation): string[] { - return this.getPartByLocation(viewContainerLocation).getPinnedPaneCompositeIds(); - } - - getVisiblePaneCompositeIds(viewContainerLocation: ViewContainerLocation): string[] { - return this.getPartByLocation(viewContainerLocation).getVisiblePaneCompositeIds(); - } - - getPaneCompositeIds(viewContainerLocation: ViewContainerLocation): string[] { - return this.getPartByLocation(viewContainerLocation).getPaneCompositeIds(); - } - - getProgressIndicator(id: string, viewContainerLocation: ViewContainerLocation): IProgressIndicator | undefined { - return this.getPartByLocation(viewContainerLocation).getProgressIndicator(id); - } - - hideActivePaneComposite(viewContainerLocation: ViewContainerLocation): void { - this.getPartByLocation(viewContainerLocation).hideActivePaneComposite(); - } - - getLastActivePaneCompositeId(viewContainerLocation: ViewContainerLocation): string { - return this.getPartByLocation(viewContainerLocation).getLastActivePaneCompositeId(); - } - - private getPartByLocation(viewContainerLocation: ViewContainerLocation): IPaneCompositePart { - return assertReturnsDefined(this.paneCompositeParts.get(viewContainerLocation)); - } - -} - -registerSingleton(IPaneCompositePartService, AgenticPaneCompositePartService, InstantiationType.Delayed); diff --git a/src/vs/sessions/browser/parts/auxiliaryBarPart.ts b/src/vs/sessions/browser/parts/auxiliaryBarPart.ts deleted file mode 100644 index fe0b787b..00000000 --- a/src/vs/sessions/browser/parts/auxiliaryBarPart.ts +++ /dev/null @@ -1,305 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import '../../../workbench/browser/parts/auxiliarybar/media/auxiliaryBarPart.css'; -import './media/auxiliaryBarPart.css'; -import { localize } from '../../../nls.js'; -import { IContextKeyService } from '../../../platform/contextkey/common/contextkey.js'; -import { IContextMenuService } from '../../../platform/contextview/browser/contextView.js'; -import { IInstantiationService } from '../../../platform/instantiation/common/instantiation.js'; -import { IKeybindingService } from '../../../platform/keybinding/common/keybinding.js'; -import { INotificationService } from '../../../platform/notification/common/notification.js'; -import { IStorageService } from '../../../platform/storage/common/storage.js'; -import { IThemeService } from '../../../platform/theme/common/themeService.js'; -import { ActiveAuxiliaryContext, AuxiliaryBarFocusContext } from '../../../workbench/common/contextkeys.js'; -import { ACTIVITY_BAR_TOP_ACTIVE_BORDER, ACTIVITY_BAR_TOP_DRAG_AND_DROP_BORDER, ACTIVITY_BAR_TOP_FOREGROUND, ACTIVITY_BAR_TOP_INACTIVE_FOREGROUND, PANEL_ACTIVE_TITLE_BORDER, PANEL_ACTIVE_TITLE_FOREGROUND, PANEL_DRAG_AND_DROP_BORDER, PANEL_INACTIVE_TITLE_FOREGROUND, SIDE_BAR_TITLE_BORDER } from '../../../workbench/common/theme.js'; -import { agentsPanelBackground, agentsPanelBorder, agentsPanelForeground, agentsBadgeBackground, agentsBadgeForeground } from '../../common/theme.js'; -import { IViewDescriptorService, ViewContainerLocation } from '../../../workbench/common/views.js'; -import { IExtensionService } from '../../../workbench/services/extensions/common/extensions.js'; -import { IWorkbenchLayoutService, Parts } from '../../../workbench/services/layout/browser/layoutService.js'; -import { HoverPosition } from '../../../base/browser/ui/hover/hoverWidget.js'; -import { IAction } from '../../../base/common/actions.js'; -import { assertReturnsDefined } from '../../../base/common/types.js'; -import { LayoutPriority } from '../../../base/browser/ui/splitview/splitview.js'; -import { AbstractPaneCompositePart, CompositeBarPosition } from '../../../workbench/browser/parts/paneCompositePart.js'; -import { Part } from '../../../workbench/browser/part.js'; -import { ActionsOrientation, IActionViewItem } from '../../../base/browser/ui/actionbar/actionbar.js'; -import { IPaneCompositeBarOptions } from '../../../workbench/browser/parts/paneCompositeBar.js'; -import { IMenuService, IMenu, MenuId, MenuItemAction } from '../../../platform/actions/common/actions.js'; -import { Menus } from '../menus.js'; -import { IHoverService } from '../../../platform/hover/browser/hover.js'; -import { DropdownWithPrimaryActionViewItem } from '../../../platform/actions/browser/dropdownWithPrimaryActionViewItem.js'; -import { IBaseActionViewItemOptions } from '../../../base/browser/ui/actionbar/actionViewItems.js'; -import { getFlatContextMenuActions } from '../../../platform/actions/browser/menuEntryActionViewItem.js'; -import { IDisposable, MutableDisposable } from '../../../base/common/lifecycle.js'; -import { Extensions } from '../../../workbench/browser/panecomposite.js'; -import { mainWindow } from '../../../base/browser/window.js'; - -/** - * Auxiliary bar part specifically for agent sessions workbench. - * This is a simplified version of the AuxiliaryBarPart for agent session contexts. - */ -export class AuxiliaryBarPart extends AbstractPaneCompositePart { - - static readonly activeViewSettingsKey = 'workbench.agentsession.auxiliarybar.activepanelid'; - static readonly pinnedViewsKey = 'workbench.agentsession.auxiliarybar.pinnedPanels'; - static readonly placeholderViewContainersKey = 'workbench.agentsession.auxiliarybar.placeholderPanels'; - static readonly viewContainersWorkspaceStateKey = 'workbench.agentsession.auxiliarybar.viewContainersWorkspaceState'; - - /** Visual margin values for the card-like appearance */ - static readonly MARGIN_TOP = 10; - static readonly MARGIN_BOTTOM = 0; - static readonly MARGIN_RIGHT = 10; - - // Action ID for run script - defined here to avoid layering issues - private static readonly RUN_SCRIPT_ACTION_ID = 'workbench.action.agentSessions.runScript'; - private static readonly RUN_SCRIPT_DROPDOWN_MENU_ID = MenuId.for('AgentSessionsRunScriptDropdown'); - private static readonly DEFAULT_MINIMUM_WIDTH = 270; - - // Run script dropdown management - private readonly _runScriptDropdown = this._register(new MutableDisposable()); - private readonly _runScriptMenu = this._register(new MutableDisposable()); - private readonly _runScriptMenuListener = this._register(new MutableDisposable()); - - // Sessions-specific auxiliary bar dimensions (intentionally not tied to the sessions SidebarPart values) - override get minimumWidth(): number { - return AuxiliaryBarPart.DEFAULT_MINIMUM_WIDTH; - } - override readonly maximumWidth: number = Number.POSITIVE_INFINITY; - override readonly minimumHeight: number = 0; - override readonly maximumHeight: number = Number.POSITIVE_INFINITY; - override get snap(): boolean { - return this.hasAttachedEditorRequiringSidebarSpace() ? false : super.snap; - } - - get preferredHeight(): number | undefined { - return this.layoutService.mainContainerDimension.height * 0.4; - } - - get preferredWidth(): number | undefined { - const activeComposite = this.getActivePaneComposite(); - - if (!activeComposite) { - return undefined; - } - - const width = activeComposite.getOptimalWidth(); - if (typeof width !== 'number') { - return undefined; - } - - return Math.max(width, 340); - } - - readonly priority = LayoutPriority.Low; - - constructor( - @INotificationService notificationService: INotificationService, - @IStorageService storageService: IStorageService, - @IContextMenuService contextMenuService: IContextMenuService, - @IWorkbenchLayoutService layoutService: IWorkbenchLayoutService, - @IKeybindingService keybindingService: IKeybindingService, - @IHoverService hoverService: IHoverService, - @IInstantiationService instantiationService: IInstantiationService, - @IThemeService themeService: IThemeService, - @IViewDescriptorService viewDescriptorService: IViewDescriptorService, - @IContextKeyService contextKeyService: IContextKeyService, - @IExtensionService extensionService: IExtensionService, - @IMenuService menuService: IMenuService, - ) { - super( - Parts.AUXILIARYBAR_PART, - { - hasTitle: true, - trailingSeparator: false, - borderWidth: () => 0, - }, - AuxiliaryBarPart.activeViewSettingsKey, - ActiveAuxiliaryContext.bindTo(contextKeyService), - AuxiliaryBarFocusContext.bindTo(contextKeyService), - 'auxiliarybar', - 'auxiliarybar', - undefined, - SIDE_BAR_TITLE_BORDER, - ViewContainerLocation.AuxiliaryBar, - Extensions.Auxiliary, - Menus.AuxiliaryBarTitle, - notificationService, - storageService, - contextMenuService, - layoutService, - keybindingService, - hoverService, - instantiationService, - themeService, - viewDescriptorService, - contextKeyService, - extensionService, - menuService, - ); - - this._register(this.layoutService.onDidChangePartVisibility(e => { - if (e.partId === Parts.AUXILIARYBAR_PART || e.partId === Parts.EDITOR_PART) { - this._onDidChange.fire(undefined); - } - })); - } - - override create(parent: HTMLElement): void { - super.create(parent); - parent.setAttribute('role', 'complementary'); - parent.setAttribute('aria-label', localize('auxiliaryBarAriaLabel', "Session Details")); - } - - override updateStyles(): void { - super.updateStyles(); - - const container = assertReturnsDefined(this.getContainer()); - - // Store background and border as CSS variables for the card styling on .part - container.style.setProperty('--part-background', this.getColor(agentsPanelBackground) || ''); - container.style.setProperty('--part-border-color', this.getColor(agentsPanelBorder) || 'transparent'); - container.style.setProperty('--part-foreground', this.getColor(agentsPanelForeground) || ''); - container.style.backgroundColor = this.getColor(agentsPanelBackground) || ''; - - // Clear borders - the card appearance uses border-radius instead - container.style.borderLeftColor = ''; - container.style.borderRightColor = ''; - container.style.borderLeftStyle = ''; - container.style.borderRightStyle = ''; - container.style.borderLeftWidth = ''; - container.style.borderRightWidth = ''; - } - - protected getCompositeBarOptions(): IPaneCompositeBarOptions { - const $this = this; - return { - partContainerClass: 'auxiliarybar', - pinnedViewContainersKey: AuxiliaryBarPart.pinnedViewsKey, - placeholderViewContainersKey: AuxiliaryBarPart.placeholderViewContainersKey, - viewContainersWorkspaceStateKey: AuxiliaryBarPart.viewContainersWorkspaceStateKey, - icon: false, - orientation: ActionsOrientation.HORIZONTAL, - recomputeSizes: true, - activityHoverOptions: { - position: () => this.getCompositeBarPosition() === CompositeBarPosition.BOTTOM ? HoverPosition.ABOVE : HoverPosition.BELOW, - }, - fillExtraContextMenuActions: actions => this.fillExtraContextMenuActions(actions), - compositeSize: 0, - iconSize: 16, - get overflowActionSize() { return $this.getCompositeBarPosition() === CompositeBarPosition.TITLE ? 40 : 30; }, - colors: theme => ({ - activeBackgroundColor: theme.getColor(agentsPanelBackground), - inactiveBackgroundColor: theme.getColor(agentsPanelBackground), - get activeBorderBottomColor() { return $this.getCompositeBarPosition() === CompositeBarPosition.TITLE ? theme.getColor(PANEL_ACTIVE_TITLE_BORDER) : theme.getColor(ACTIVITY_BAR_TOP_ACTIVE_BORDER); }, - get activeForegroundColor() { return $this.getCompositeBarPosition() === CompositeBarPosition.TITLE ? theme.getColor(PANEL_ACTIVE_TITLE_FOREGROUND) : theme.getColor(ACTIVITY_BAR_TOP_FOREGROUND); }, - get inactiveForegroundColor() { return $this.getCompositeBarPosition() === CompositeBarPosition.TITLE ? theme.getColor(PANEL_INACTIVE_TITLE_FOREGROUND) : theme.getColor(ACTIVITY_BAR_TOP_INACTIVE_FOREGROUND); }, - badgeBackground: theme.getColor(agentsBadgeBackground), - badgeForeground: theme.getColor(agentsBadgeForeground), - get dragAndDropBorder() { return $this.getCompositeBarPosition() === CompositeBarPosition.TITLE ? theme.getColor(PANEL_DRAG_AND_DROP_BORDER) : theme.getColor(ACTIVITY_BAR_TOP_DRAG_AND_DROP_BORDER); } - }), - compact: true - }; - } - - protected override actionViewItemProvider(action: IAction, options: IBaseActionViewItemOptions): IActionViewItem | undefined { - // Create a DropdownWithPrimaryActionViewItem for the run script action - if (action.id === AuxiliaryBarPart.RUN_SCRIPT_ACTION_ID && action instanceof MenuItemAction) { - // Create and store the menu so we can listen for changes - if (!this._runScriptMenu.value) { - this._runScriptMenu.value = this.menuService.createMenu(AuxiliaryBarPart.RUN_SCRIPT_DROPDOWN_MENU_ID, this.contextKeyService); - this._runScriptMenuListener.value = this._runScriptMenu.value.onDidChange(() => this._updateRunScriptDropdown()); - } - - const dropdownActions = this._getRunScriptDropdownActions(); - - const dropdownAction: IAction = { - id: 'runScriptDropdown', - label: '', - tooltip: '', - class: undefined, - enabled: true, - run: () => { } - }; - - this._runScriptDropdown.value = this.instantiationService.createInstance( - DropdownWithPrimaryActionViewItem, - action, - dropdownAction, - dropdownActions, - '', - { - hoverDelegate: options.hoverDelegate, - getKeyBinding: (action: IAction) => this.keybindingService.lookupKeybinding(action.id, this.contextKeyService) - } - ); - - return this._runScriptDropdown.value; - } - - return super.actionViewItemProvider(action, options); - } - - private _getRunScriptDropdownActions(): IAction[] { - if (!this._runScriptMenu.value) { - return []; - } - return getFlatContextMenuActions(this._runScriptMenu.value.getActions({ shouldForwardArgs: true })); - } - - private _updateRunScriptDropdown(): void { - if (this._runScriptDropdown.value) { - const dropdownActions = this._getRunScriptDropdownActions(); - const dropdownAction: IAction = { - id: 'runScriptDropdown', - label: '', - tooltip: '', - class: undefined, - enabled: true, - run: () => { } - }; - this._runScriptDropdown.value.update(dropdownAction, dropdownActions); - } - } - - private hasAttachedEditorRequiringSidebarSpace(): boolean { - return this.layoutService.isVisible(Parts.AUXILIARYBAR_PART) - && this.layoutService.isVisible(Parts.EDITOR_PART, mainWindow); - } - - private fillExtraContextMenuActions(_actions: IAction[]): void { } - - protected shouldShowCompositeBar(): boolean { - return true; - } - - protected getCompositeBarPosition(): CompositeBarPosition { - return CompositeBarPosition.TITLE; - } - - override layout(width: number, height: number, top: number, left: number): void { - if (!this.layoutService.isVisible(Parts.AUXILIARYBAR_PART)) { - return; - } - - // Layout content with reduced dimensions to account for visual margins and border - const borderTotal = 2; // 1px border on each side - super.layout( - width - AuxiliaryBarPart.MARGIN_RIGHT - borderTotal, - height - AuxiliaryBarPart.MARGIN_TOP - AuxiliaryBarPart.MARGIN_BOTTOM - borderTotal, - top, left - ); - - // Restore the full grid-allocated dimensions so that Part.relayout() works correctly. - // Part.layout() only stores _dimension and _contentPosition - no other side effects. - Part.prototype.layout.call(this, width, height, top, left); - } - - override toJSON(): object { - return { - type: Parts.AUXILIARYBAR_PART - }; - } -} diff --git a/src/vs/sessions/browser/parts/chatBarPart.ts b/src/vs/sessions/browser/parts/chatBarPart.ts deleted file mode 100644 index 273b79fc..00000000 --- a/src/vs/sessions/browser/parts/chatBarPart.ts +++ /dev/null @@ -1,208 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import './media/chatBarPart.css'; -import { IContextKeyService } from '../../../platform/contextkey/common/contextkey.js'; -import { IContextMenuService } from '../../../platform/contextview/browser/contextView.js'; -import { IInstantiationService } from '../../../platform/instantiation/common/instantiation.js'; -import { IKeybindingService } from '../../../platform/keybinding/common/keybinding.js'; -import { INotificationService } from '../../../platform/notification/common/notification.js'; -import { IStorageService } from '../../../platform/storage/common/storage.js'; -import { IThemeService } from '../../../platform/theme/common/themeService.js'; -import { PANEL_ACTIVE_TITLE_BORDER, PANEL_ACTIVE_TITLE_FOREGROUND, PANEL_DRAG_AND_DROP_BORDER, PANEL_INACTIVE_TITLE_FOREGROUND, SIDE_BAR_TITLE_BORDER } from '../../../workbench/common/theme.js'; -import { agentsPanelBackground, agentsPanelBorder, agentsPanelForeground, agentsBadgeBackground, agentsBadgeForeground } from '../../common/theme.js'; -import { IViewDescriptorService, ViewContainerLocation } from '../../../workbench/common/views.js'; -import { IExtensionService } from '../../../workbench/services/extensions/common/extensions.js'; -import { IWorkbenchLayoutService, Parts } from '../../../workbench/services/layout/browser/layoutService.js'; -import { HoverPosition } from '../../../base/browser/ui/hover/hoverWidget.js'; -import { assertReturnsDefined } from '../../../base/common/types.js'; -import { LayoutPriority } from '../../../base/browser/ui/splitview/splitview.js'; -import { AbstractPaneCompositePart, CompositeBarPosition } from '../../../workbench/browser/parts/paneCompositePart.js'; -import { Part } from '../../../workbench/browser/part.js'; -import { ActionsOrientation } from '../../../base/browser/ui/actionbar/actionbar.js'; -import { IPaneCompositeBarOptions } from '../../../workbench/browser/parts/paneCompositeBar.js'; -import { IMenuService } from '../../../platform/actions/common/actions.js'; -import { IHoverService } from '../../../platform/hover/browser/hover.js'; -import { Extensions } from '../../../workbench/browser/panecomposite.js'; -import { Menus } from '../menus.js'; -import { ActiveChatBarContext, ChatBarFocusContext } from '../../common/contextkeys.js'; -import { ChatCompositeBar } from './chatCompositeBar.js'; -import { prepend } from '../../../base/browser/dom.js'; - -export class ChatBarPart extends AbstractPaneCompositePart { // TODO: should not be a AbstractPaneCompositePart but instead a custom Part with a CompositeBar - - static readonly activeViewSettingsKey = 'workbench.chatbar.activepanelid'; - static readonly pinnedViewsKey = 'workbench.chatbar.pinnedPanels'; - static readonly placeholderViewContainersKey = 'workbench.chatbar.placeholderPanels'; - static readonly viewContainersWorkspaceStateKey = 'workbench.chatbar.viewContainersWorkspaceState'; - - override readonly minimumWidth: number = 300; - override readonly maximumWidth: number = Number.POSITIVE_INFINITY; - override readonly minimumHeight: number = 0; - override readonly maximumHeight: number = Number.POSITIVE_INFINITY; - override get snap(): boolean { return false; } - - /** Visual margin values for the card-like appearance */ - static readonly MARGIN_TOP = 10; - static readonly MARGIN_LEFT = 10; - static readonly MARGIN_RIGHT = 10; - static readonly MARGIN_BOTTOM = 0; - - /** Border width on the card (1px each side) */ - static readonly BORDER_WIDTH = 1; - - /** Height of the session composite bar when visible */ - private static readonly SESSION_BAR_HEIGHT = 35; - - private _sessionCompositeBar: ChatCompositeBar | undefined; - - protected _lastLayout: { readonly width: number; readonly height: number; readonly top: number; readonly left: number } | undefined; - - get preferredHeight(): number | undefined { - return this.layoutService.mainContainerDimension.height * 0.4; - } - - readonly priority = LayoutPriority.High; - - constructor( - @INotificationService notificationService: INotificationService, - @IStorageService storageService: IStorageService, - @IContextMenuService contextMenuService: IContextMenuService, - @IWorkbenchLayoutService layoutService: IWorkbenchLayoutService, - @IKeybindingService keybindingService: IKeybindingService, - @IHoverService hoverService: IHoverService, - @IInstantiationService instantiationService: IInstantiationService, - @IThemeService themeService: IThemeService, - @IViewDescriptorService viewDescriptorService: IViewDescriptorService, - @IContextKeyService contextKeyService: IContextKeyService, - @IExtensionService extensionService: IExtensionService, - @IMenuService menuService: IMenuService - ) { - super( - Parts.CHATBAR_PART, - { - hasTitle: false, - trailingSeparator: true, - borderWidth: () => 0, - }, - ChatBarPart.activeViewSettingsKey, - ActiveChatBarContext.bindTo(contextKeyService), - ChatBarFocusContext.bindTo(contextKeyService), - 'chatbar', - 'chatbar', - undefined, - SIDE_BAR_TITLE_BORDER, - ViewContainerLocation.ChatBar, - Extensions.ChatBar, - Menus.ChatBarTitle, - notificationService, - storageService, - contextMenuService, - layoutService, - keybindingService, - hoverService, - instantiationService, - themeService, - viewDescriptorService, - contextKeyService, - extensionService, - menuService, - ); - } - - override create(parent: HTMLElement): void { - super.create(parent); - - // Create the session composite bar and prepend it before the content area - this._sessionCompositeBar = this._register(this.instantiationService.createInstance(ChatCompositeBar)); - prepend(parent, this._sessionCompositeBar.element); - - // Relayout when session bar visibility changes - this._register(this._sessionCompositeBar.onDidChangeVisibility(() => { - if (this._lastLayout) { - this.layout(this._lastLayout.width, this._lastLayout.height, this._lastLayout.top, this._lastLayout.left); - } - })); - } - - override updateStyles(): void { - super.updateStyles(); - - const container = assertReturnsDefined(this.getContainer()); - - // Store background and border as CSS variables for the card styling on .part - container.style.setProperty('--part-background', this.getColor(agentsPanelBackground) || ''); - container.style.setProperty('--part-border-color', this.getColor(agentsPanelBorder) || 'transparent'); - container.style.setProperty('--part-foreground', this.getColor(agentsPanelForeground) || ''); - container.style.backgroundColor = this.getColor(agentsPanelBackground) || ''; - } - - override layout(width: number, height: number, top: number, left: number): void { - if (!this.layoutService.isVisible(Parts.CHATBAR_PART)) { - return; - } - - this._lastLayout = { width, height, top, left }; - - // Account for the session composite bar height when visible - const sessionBarHeight = this._sessionCompositeBar?.visible ? ChatBarPart.SESSION_BAR_HEIGHT : 0; - - // Layout content with reduced dimensions to account for visual margins and border - const borderTotal = ChatBarPart.BORDER_WIDTH * 2; - const marginLeft = this.layoutService.isVisible(Parts.SIDEBAR_PART) ? 0 : ChatBarPart.MARGIN_LEFT; - super.layout( - width - marginLeft - ChatBarPart.MARGIN_RIGHT - borderTotal, - height - ChatBarPart.MARGIN_TOP - ChatBarPart.MARGIN_BOTTOM - borderTotal - sessionBarHeight, - top, left - ); - - // Restore the full grid-allocated dimensions so that Part.relayout() works correctly. - Part.prototype.layout.call(this, width, height, top, left); - } - - protected getCompositeBarOptions(): IPaneCompositeBarOptions { - return { - partContainerClass: 'chatbar', - pinnedViewContainersKey: ChatBarPart.pinnedViewsKey, - placeholderViewContainersKey: ChatBarPart.placeholderViewContainersKey, - viewContainersWorkspaceStateKey: ChatBarPart.viewContainersWorkspaceStateKey, - icon: false, - orientation: ActionsOrientation.HORIZONTAL, - recomputeSizes: true, - activityHoverOptions: { - position: () => HoverPosition.BELOW, - }, - fillExtraContextMenuActions: () => { }, - compositeSize: 0, - iconSize: 16, - overflowActionSize: 30, - colors: theme => ({ - activeBackgroundColor: theme.getColor(agentsPanelBackground), - inactiveBackgroundColor: theme.getColor(agentsPanelBackground), - activeBorderBottomColor: theme.getColor(PANEL_ACTIVE_TITLE_BORDER), - activeForegroundColor: theme.getColor(PANEL_ACTIVE_TITLE_FOREGROUND), - inactiveForegroundColor: theme.getColor(PANEL_INACTIVE_TITLE_FOREGROUND), - badgeBackground: theme.getColor(agentsBadgeBackground), - badgeForeground: theme.getColor(agentsBadgeForeground), - dragAndDropBorder: theme.getColor(PANEL_DRAG_AND_DROP_BORDER) - }), - compact: true - }; - } - - protected shouldShowCompositeBar(): boolean { - return false; - } - - protected getCompositeBarPosition(): CompositeBarPosition { - return CompositeBarPosition.TITLE; - } - - override toJSON(): object { - return { - type: Parts.CHATBAR_PART - }; - } -} diff --git a/src/vs/sessions/browser/parts/chatCompositeBar.ts b/src/vs/sessions/browser/parts/chatCompositeBar.ts deleted file mode 100644 index c7df14a3..00000000 --- a/src/vs/sessions/browser/parts/chatCompositeBar.ts +++ /dev/null @@ -1,236 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import './media/chatCompositeBar.css'; -import { Disposable, DisposableStore } from '../../../base/common/lifecycle.js'; -import { Emitter, Event } from '../../../base/common/event.js'; -import { $, addDisposableListener, DisposableResizeObserver, EventType, getWindow, reset } from '../../../base/browser/dom.js'; -import { autorun } from '../../../base/common/observable.js'; -import { IThemeService } from '../../../platform/theme/common/themeService.js'; -import { PANEL_ACTIVE_TITLE_BORDER, PANEL_ACTIVE_TITLE_FOREGROUND, PANEL_INACTIVE_TITLE_FOREGROUND } from '../../../workbench/common/theme.js'; -import { agentsPanelBackground } from '../../common/theme.js'; -import { Action } from '../../../base/common/actions.js'; -import { ActionBar } from '../../../base/browser/ui/actionbar/actionbar.js'; -import { Codicon } from '../../../base/common/codicons.js'; -import { ThemeIcon } from '../../../base/common/themables.js'; -import { IContextMenuService } from '../../../platform/contextview/browser/contextView.js'; -import { StandardMouseEvent } from '../../../base/browser/mouseEvent.js'; -import { localize } from '../../../nls.js'; -import { IQuickInputService } from '../../../platform/quickinput/common/quickInput.js'; -import { IChat, SessionStatus } from '../../services/sessions/common/session.js'; -import { ISessionsManagementService } from '../../services/sessions/common/sessionsManagement.js'; - -interface IChatTab { - readonly chat: IChat; - readonly element: HTMLElement; -} - -/** - * A composite bar that displays chats within the active agent session as tabs. - * Selecting a tab loads that chat in the chat view pane instead of switching view containers. - * - * The bar auto-hides when there is only one chat in the active session and shows when there are multiple. - */ -export class ChatCompositeBar extends Disposable { - - private readonly _container: HTMLElement; - private readonly _tabsContainer: HTMLElement; - private readonly _tabs: IChatTab[] = []; - private readonly _tabDisposables = this._register(new DisposableStore()); - - private readonly _onDidChangeVisibility = this._register(new Emitter()); - readonly onDidChangeVisibility: Event = this._onDidChangeVisibility.event; - - private _visible = false; - - get element(): HTMLElement { - return this._container; - } - - get visible(): boolean { - return this._visible; - } - - constructor( - @IThemeService private readonly _themeService: IThemeService, - @ISessionsManagementService private readonly _sessionsManagementService: ISessionsManagementService, - @IContextMenuService private readonly _contextMenuService: IContextMenuService, - @IQuickInputService private readonly _quickInputService: IQuickInputService, - ) { - super(); - - this._container = $('.chat-composite-bar'); - this._tabsContainer = $('.chat-composite-bar-tabs'); - this._container.appendChild(this._tabsContainer); - - // Track active session changes - this._register(autorun(reader => { - const activeSession = this._sessionsManagementService.activeSession.read(reader); - if (!activeSession) { - this._rebuildTabs([], '', undefined); - return; - } - - const chats = activeSession.chats.read(reader); - const activeChatUri = activeSession.activeChat.read(reader)?.resource.toString() ?? ''; - const mainChatUri = activeSession.mainChat.resource.toString(); - this._rebuildTabs(chats, activeChatUri, mainChatUri); - })); - - // Scroll active tab into view on resize - const resizeObserver = this._register(new DisposableResizeObserver(() => this._revealActiveTab())); - this._register(resizeObserver.observe(this._tabsContainer)); - - - this._updateStyles(); - this._register(this._themeService.onDidColorThemeChange(() => this._updateStyles())); - } - - private _rebuildTabs(chats: readonly IChat[], activeChatId: string, mainChatId?: string): void { - this._tabDisposables.clear(); - this._tabs.length = 0; - reset(this._tabsContainer); - - for (const chat of chats) { - this._createTab(chat, chat.resource.toString() === mainChatId); - } - - this._updateActiveTab(activeChatId); - this._updateVisibility(); - } - - private _createTab(chat: IChat, isMainChat: boolean): void { - const tab = $('.chat-composite-bar-tab'); - tab.tabIndex = 0; - tab.setAttribute('role', 'tab'); - - const labelEl = $('.chat-composite-bar-tab-label'); - this._tabDisposables.add(autorun(reader => { - const title = chat.title.read(reader); - labelEl.textContent = title; - })); - tab.appendChild(labelEl); - - // Track untitled state for styling (dirty dot + close button) - this._tabDisposables.add(autorun(reader => { - const status = chat.status.read(reader); - tab.classList.toggle('untitled', status === SessionStatus.Untitled); - })); - - // Remove action bar — only for non-main chats, visible on hover - if (!isMainChat) { - const closeAction = this._tabDisposables.add(new Action( - 'chatCompositeBar.closeChat', - localize('closeChat', "Close"), - ThemeIcon.asClassName(Codicon.close), - true, - async () => { - const session = this._sessionsManagementService.activeSession.get(); - if (session) { - await this._sessionsManagementService.deleteChat(session, chat.resource); - } - }, - )); - const actionBar = this._tabDisposables.add(new ActionBar(tab, { actionViewItemProvider: undefined })); - actionBar.push(closeAction, { icon: true, label: false }); - actionBar.getContainer().classList.add('chat-composite-bar-tab-actions'); - } - - const indicator = $('.chat-composite-bar-tab-indicator'); - tab.appendChild(indicator); - - this._tabsContainer.appendChild(tab); - - this._tabDisposables.add(addDisposableListener(tab, EventType.CLICK, () => { - this._onTabClicked(chat); - })); - - this._tabDisposables.add(addDisposableListener(tab, EventType.KEY_DOWN, (e: KeyboardEvent) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - this._onTabClicked(chat); - } - })); - - const renameAction = this._tabDisposables.add(new Action('sessionCompositeBar.renameChat', localize('renameChat', "Rename"), undefined, true, async () => { - const newTitle = await this._quickInputService.input({ - value: chat.title.get(), - prompt: localize('renameChat.prompt', "Rename Chat"), - }); - if (newTitle) { - const session = this._sessionsManagementService.activeSession.get(); - if (session) { - await this._sessionsManagementService.renameChat(session, chat.resource, newTitle); - } - } - })); - - this._tabDisposables.add(addDisposableListener(tab, EventType.CONTEXT_MENU, (e: MouseEvent) => { - // No context menu for untitled chats - if (chat.status.get() === SessionStatus.Untitled) { - e.preventDefault(); - return; - } - e.preventDefault(); - e.stopPropagation(); - const event = new StandardMouseEvent(getWindow(tab), e); - this._contextMenuService.showContextMenu({ - getAnchor: () => event, - getActions: () => [ - renameAction, - ] - }); - })); - - this._tabs.push({ chat: chat, element: tab }); - } - - private _onTabClicked(chat: IChat): void { - const session = this._sessionsManagementService.activeSession.get(); - if (session) { - this._sessionsManagementService.openChat(session, chat.resource); - } - } - - private _updateActiveTab(activeChatId: string): void { - for (const tab of this._tabs) { - const isActive = tab.chat.resource.toString() === activeChatId; - tab.element.classList.toggle('active', isActive); - tab.element.setAttribute('aria-selected', String(isActive)); - if (isActive) { - tab.element.scrollIntoView({ block: 'nearest', inline: 'nearest' }); - } - } - } - - private _revealActiveTab(): void { - const activeTab = this._tabs.find(t => t.element.classList.contains('active')); - activeTab?.element.scrollIntoView({ block: 'nearest', inline: 'nearest' }); - } - - private _updateVisibility(): void { - // Show when there are multiple sessions, hide when there is only one (or none) - const wasVisible = this._visible; - this._visible = this._tabs.length > 1; - this._container.style.display = this._visible ? '' : 'none'; - if (wasVisible !== this._visible) { - this._onDidChangeVisibility.fire(this._visible); - } - } - - private _updateStyles(): void { - const theme = this._themeService.getColorTheme(); - - const bg = theme.getColor(agentsPanelBackground); - const activeFg = theme.getColor(PANEL_ACTIVE_TITLE_FOREGROUND); - const inactiveFg = theme.getColor(PANEL_INACTIVE_TITLE_FOREGROUND); - const activeBorder = theme.getColor(PANEL_ACTIVE_TITLE_BORDER); - - this._container.style.setProperty('--chat-bar-background', bg?.toString() ?? ''); - this._container.style.setProperty('--chat-tab-active-foreground', activeFg?.toString() ?? ''); - this._container.style.setProperty('--chat-tab-inactive-foreground', inactiveFg?.toString() ?? ''); - this._container.style.setProperty('--chat-tab-active-border', activeBorder?.toString() ?? ''); - } -} diff --git a/src/vs/sessions/browser/parts/editorPart.ts b/src/vs/sessions/browser/parts/editorPart.ts deleted file mode 100644 index 638d8033..00000000 --- a/src/vs/sessions/browser/parts/editorPart.ts +++ /dev/null @@ -1,29 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { mainWindow } from '../../../base/browser/window.js'; -import { MainEditorPart as MainEditorPartBase } from '../../../workbench/browser/parts/editor/editorPart.js'; -import { Parts } from '../../../workbench/services/layout/browser/layoutService.js'; - -export class MainEditorPart extends MainEditorPartBase { - static readonly MARGIN_TOP = 0; - static readonly MARGIN_LEFT = 10; - static readonly MARGIN_BOTTOM = 10; - - override layout(width: number, height: number, top: number, left: number): void { - if (!this.layoutService.isVisible(Parts.EDITOR_PART, mainWindow)) { - return; - } - - const adjustedMargin = this.layoutService.isVisible(Parts.SIDEBAR_PART) || - this.layoutService.isVisible(Parts.CHATBAR_PART) - ? 0 - : MainEditorPart.MARGIN_LEFT; - const adjustedWidth = width - adjustedMargin - 2 /* border width */; - const adjustedHeight = height - MainEditorPart.MARGIN_TOP - MainEditorPart.MARGIN_BOTTOM - 2 /* border width */; - - super.layout(adjustedWidth, adjustedHeight, top, left); - } -} diff --git a/src/vs/sessions/browser/parts/editorParts.ts b/src/vs/sessions/browser/parts/editorParts.ts deleted file mode 100644 index 709e4d19..00000000 --- a/src/vs/sessions/browser/parts/editorParts.ts +++ /dev/null @@ -1,18 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import './media/editorPart.css'; -import { InstantiationType, registerSingleton } from '../../../platform/instantiation/common/extensions.js'; -import { EditorParts as EditorPartsBase } from '../../../workbench/browser/parts/editor/editorParts.js'; -import { IEditorGroupsService } from '../../../workbench/services/editor/common/editorGroupsService.js'; -import { MainEditorPart } from './editorPart.js'; - -export class EditorParts extends EditorPartsBase { - protected override createMainEditorPart(): MainEditorPart { - return this.instantiationService.createInstance(MainEditorPart, this); - } -} - -registerSingleton(IEditorGroupsService, EditorParts, InstantiationType.Eager); diff --git a/src/vs/sessions/browser/parts/media/auxiliaryBarPart.css b/src/vs/sessions/browser/parts/media/auxiliaryBarPart.css deleted file mode 100644 index f9875f20..00000000 --- a/src/vs/sessions/browser/parts/media/auxiliaryBarPart.css +++ /dev/null @@ -1,53 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -/* ===== Modern action label styling for sessions auxiliary bar ===== */ - -/* Base label: lowercase text + heavier weight + pill padding */ -.agent-sessions-workbench .part.auxiliarybar > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item:not(.icon) .action-label { - text-transform: capitalize; - font-weight: 500; - border-radius: 4px; - padding: 0px 8px; - font-size: 12px; - line-height: 22px; -} - -.agent-sessions-workbench .part.auxiliarybar > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item { - padding-left: 0; - padding-right: 0; -} - -.agent-sessions-workbench .part.auxiliarybar > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .badge { - margin-left: -4px; - padding-right: 6px; -} - -.agent-sessions-workbench .part.auxiliarybar > .title { - padding-left: 4px; - padding-right: 2px; - background-color: var(--vscode-agentsPanel-background); -} - -/* Hide the underline indicator for non-focused items, but keep it for keyboard focus */ -.agent-sessions-workbench .part.auxiliarybar > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item:not(:focus-visible) .active-item-indicator:before { - display: none !important; -} -.agent-sessions-workbench .part.auxiliarybar > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item:focus-visible .active-item-indicator:before { - display: block !important; -} - -/* Active/checked state: background container instead of underline */ -.agent-sessions-workbench .part.auxiliarybar > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.checked { - background-color: color-mix(in srgb, var(--vscode-agentsPanel-foreground, var(--vscode-foreground)) 5%, transparent) !important; - border-radius: 4px; -} - -/* Override base workbench monaco editor background in auxiliary bar content */ -.agent-sessions-workbench .part.auxiliarybar > .content .monaco-editor, -.agent-sessions-workbench .part.auxiliarybar > .content .monaco-editor .margin, -.agent-sessions-workbench .part.auxiliarybar > .content .monaco-editor .monaco-editor-background { - background-color: var(--vscode-agentsPanel-background); -} diff --git a/src/vs/sessions/browser/parts/media/chatBarPart.css b/src/vs/sessions/browser/parts/media/chatBarPart.css deleted file mode 100644 index 30e6ec20..00000000 --- a/src/vs/sessions/browser/parts/media/chatBarPart.css +++ /dev/null @@ -1,88 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -.monaco-workbench.nochatbar .part.chatbar { - display: none !important; - visibility: hidden !important; -} - -.monaco-workbench .part.chatbar > .content .monaco-editor, -.monaco-workbench .part.chatbar > .content .monaco-editor .margin, -.monaco-workbench .part.chatbar > .content .monaco-editor .monaco-editor-background { - background-color: var(--vscode-agentsPanel-background); -} - -.monaco-workbench .part.chatbar > .content > .monaco-progress-container { - top: 0; -} - -.monaco-workbench .part.chatbar .title-actions .actions-container { - justify-content: flex-end; -} - -.monaco-workbench .part.chatbar .title-actions .action-item { - margin-right: 4px; -} - -.monaco-workbench .part.chatbar > .title { - background-color: var(--vscode-agentsPanel-background); -} - -.monaco-workbench .part.chatbar > .title > .title-label { - flex: 1; -} - -.monaco-workbench .part.chatbar > .title > .title-label h2 { - text-transform: uppercase; -} - -.monaco-workbench .part.chatbar > .title > .composite-bar-container { - flex: 1; -} - -.monaco-workbench .part.chatbar > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item:focus { - outline: 0 !important; /* activity bar indicates focus custom */ -} - -.monaco-workbench .part.chatbar > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item .action-label { - outline-offset: 2px; -} - -.hc-black .monaco-workbench .part.chatbar > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item .action-label, -.hc-light .monaco-workbench .part.chatbar > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item .action-label { - border-radius: 0px; -} - -.monaco-workbench .part.chatbar > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item .action-label::before { - position: absolute; - left: 5px; /* place icon in center */ -} - -.monaco-workbench .part.chatbar > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.checked:not(:focus) .active-item-indicator:before, -.monaco-workbench .part.chatbar > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.checked.clicked:focus .active-item-indicator:before { - border-top-color: var(--vscode-panelTitle-activeBorder) !important; -} - -.monaco-workbench .part.chatbar > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item:hover .action-label, -.monaco-workbench .part.chatbar > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item:focus .action-label { - color: var(--vscode-agentsPanel-foreground) !important; -} - -.monaco-workbench .part.chatbar > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.checked .action-label, -.monaco-workbench .part.chatbar > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item:hover .action-label { - outline: var(--vscode-contrastActiveBorder, unset) solid 1px !important; -} - -.monaco-workbench .part.chatbar > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item:not(.checked):hover .action-label { - outline: var(--vscode-contrastActiveBorder, unset) dashed 1px !important; -} - -.monaco-workbench .chatbar.part.pane-composite-part > .composite.title > .title-actions { - flex: inherit; -} - -.monaco-workbench .chatbar.pane-composite-part > .title.has-composite-bar > .title-actions .monaco-action-bar .action-item { - max-width: 150px; -} diff --git a/src/vs/sessions/browser/parts/media/chatCompositeBar.css b/src/vs/sessions/browser/parts/media/chatCompositeBar.css deleted file mode 100644 index 257e50c3..00000000 --- a/src/vs/sessions/browser/parts/media/chatCompositeBar.css +++ /dev/null @@ -1,94 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -.chat-composite-bar { - display: flex; - align-items: center; - background-color: var(--chat-bar-background); - padding: 0 4px; - height: 35px; - flex-shrink: 0; - overflow: hidden; -} - -.chat-composite-bar-tabs { - display: flex; - align-items: center; - height: 100%; - gap: 0; - overflow-x: auto; - overflow-y: hidden; -} - -.chat-composite-bar-tabs::-webkit-scrollbar { - display: none; -} - -/* Base tab: preserve chat title casing while keeping the same pill treatment */ -.chat-composite-bar-tab { - display: flex; - align-items: center; - position: relative; - padding: 0 8px; - cursor: pointer; - white-space: nowrap; - color: var(--chat-tab-inactive-foreground); - font-weight: 500; - font-size: 12px; - line-height: 22px; - height: 26px; - border-radius: 4px; - user-select: none; -} - -.chat-composite-bar-tab:hover { - color: var(--chat-tab-active-foreground); -} - -/* Active state: background container instead of underline — mirrors auxiliarybar checked */ -.chat-composite-bar-tab.active { - color: var(--chat-tab-active-foreground); - background-color: color-mix(in srgb, var(--vscode-agentsPanel-foreground, var(--vscode-foreground)) 5%, transparent); -} - -/* Reduce right padding when close button is present to avoid excess spacing */ -.chat-composite-bar-tab:has(.chat-composite-bar-tab-actions) { - padding-right: 2px; -} - -/* Hide the underline indicator — mirrors auxiliarybar active-item-indicator hiding */ -.chat-composite-bar-tab-indicator { - display: none; -} - -.chat-composite-bar-tab:focus-visible { - outline: 1px solid var(--vscode-focusBorder); - outline-offset: -1px; -} - -/* Remove action bar — always occupies space; opacity toggled like editor tabs */ -.chat-composite-bar-tab-actions { - display: flex; - margin-left: 2px; -} - -.chat-composite-bar-tab-actions .action-item .action-label { - width: 16px; - height: 16px; - border-radius: 4px; - color: var(--chat-tab-inactive-foreground, currentColor); - opacity: 0; -} - -.chat-composite-bar-tab.active .chat-composite-bar-tab-actions .action-label, -.chat-composite-bar-tab:hover .chat-composite-bar-tab-actions .action-label, -.chat-composite-bar-tab-actions .action-label:focus { - opacity: 1; -} - -.chat-composite-bar-tab-actions .action-item .action-label:hover { - background-color: var(--vscode-toolbar-hoverBackground); - color: var(--chat-tab-active-foreground); -} diff --git a/src/vs/sessions/browser/parts/media/editorPart.css b/src/vs/sessions/browser/parts/media/editorPart.css deleted file mode 100644 index 978d095f..00000000 --- a/src/vs/sessions/browser/parts/media/editorPart.css +++ /dev/null @@ -1,71 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -.agent-sessions-workbench .part.editor:not(.modal-editor-part) { - margin: 0 0 0 0; - background: var(--vscode-editor-background); - border: 1px solid var(--vscode-editorWidget-border, var(--vscode-widget-border, transparent)); - border-right-width: 0; - border-top-right-radius: 0; - border-bottom-right-radius: 0; - border-top-left-radius: 8px; - border-bottom-left-radius: 8px; - box-sizing: border-box; - overflow: hidden; -} - -.agent-sessions-workbench.nosidebar.nochatbar .part.editor:not(.modal-editor-part) { - margin-left: 10px; -} - -/* Editor Layout Actions Toolbar */ - -.agent-sessions-workbench .part.editor > .content .editor-group-container > .title .editor-actions { - padding-right: 0; -} - -.agent-sessions-workbench .part.editor > .content .editor-group-container > .title .editor-actions-separator { - display: block; - align-self: center; - width: 1px; - height: 16px; - margin: 0 4px; - background-color: var(--vscode-titleBar-activeForeground); - opacity: 0.3; -} - -.agent-sessions-workbench .part.editor > .content .editor-group-container > .title .editor-layout-actions { - display: block; - cursor: default; - flex: initial; - padding: 0 0 0 4px; - height: var(--editor-group-tab-height); -} - -.agent-sessions-workbench .part.editor > .content .editor-group-container > .title .editor-layout-actions.hidden { - display: none; -} - -.agent-sessions-workbench .part.editor > .content .editor-group-container > .title .editor-layout-actions .action-item { - margin-right: 4px; -} - -.agent-sessions-workbench .part.editor > .content .editor-group-container > .title > .tabs-and-actions-container.wrapping .editor-layout-actions { - /* When tabs are wrapped, position the trailing editor actions at the end of the very last row */ - position: absolute; - bottom: 0; - right: 0; -} - -.agent-sessions-workbench .part.editor > .content .editor-group-container > .title.two-tab-bars > .tabs-and-actions-container:first-child .editor-layout-actions { - /* When multiple tab bars are visible, only show editor actions for the last tab bar */ - display: none; -} - -/* Modal Editor Part */ - -.agent-sessions-workbench .part.editor.modal-editor-part .modal-editor-action-container .action-label.codicon.codicon-open-in-window { - transform: rotate(180deg); -} diff --git a/src/vs/sessions/browser/parts/media/panelPart.css b/src/vs/sessions/browser/parts/media/panelPart.css deleted file mode 100644 index 48e33b8d..00000000 --- a/src/vs/sessions/browser/parts/media/panelPart.css +++ /dev/null @@ -1,68 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -/* Hide the top title border because we draw our own */ -.monaco-workbench .part.panel.bottom .composite.title { - border-top-width: 0; -} - -/* ===== Modern action label styling for sessions panel ===== */ - -/* Hide the underline indicator for non-focused, non-checked items; keep it for focus-visible and checked */ -.agent-sessions-workbench .part.panel > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item:not(:focus-visible):not(.checked) .active-item-indicator:before { - display: none !important; -} -.agent-sessions-workbench .part.panel > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item:focus-visible .active-item-indicator:before { - display: block !important; -} -.agent-sessions-workbench .part.panel > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.checked .active-item-indicator:before { - display: block !important; -} - -/* Make icon action items 24px tall */ -.agent-sessions-workbench .part.panel > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.icon { - height: 24px; -} - -.agent-sessions-workbench .part.panel > .title { - padding-left: 6px; - padding-right: 6px; - background-color: var(--vscode-agentsPanel-background); -} - -/* Active/checked state: background container instead of underline */ -.agent-sessions-workbench .part.panel > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.checked { - background-color: color-mix(in srgb, var(--vscode-agentsPanel-foreground, var(--vscode-foreground)) 5%, transparent) !important; - border-radius: 4px; -} - -/* Override base workbench panel content background for terminal */ -.agent-sessions-workbench .part.panel > .content .monaco-editor, -.agent-sessions-workbench .part.panel > .content .monaco-editor .margin, -.agent-sessions-workbench .part.panel > .content .monaco-editor .monaco-editor-background { - background-color: var(--vscode-agentsPanel-background); -} - -.agent-sessions-workbench .part.panel > .content .pane-body, -.agent-sessions-workbench .part.panel > .content .chat-debug-editor, -.agent-sessions-workbench .part.panel > .content .chat-debug-home, -.agent-sessions-workbench .part.panel > .content .chat-debug-home-content, -.agent-sessions-workbench .part.panel > .content .chat-debug-overview, -.agent-sessions-workbench .part.panel > .content .chat-debug-overview-content { - background-color: var(--vscode-agentsPanel-background); -} - -.monaco-workbench.vs.agent-sessions-workbench .part.panel > .content #workbench\.sessions\.panel\.chatDebugContainer { - --vscode-panel-background: var(--vscode-agentsPanel-background); -} - -.monaco-workbench.vs.agent-sessions-workbench .part.panel > .content #workbench\.sessions\.panel\.chatDebugContainer .monaco-list-rows { - background: var(--vscode-agentsPanel-background) !important; -} - -/* Terminal body background */ -.agent-sessions-workbench .part.panel .terminal-wrapper { - background-color: var(--vscode-agentsPanel-background); -} diff --git a/src/vs/sessions/browser/parts/media/projectBarPart.css b/src/vs/sessions/browser/parts/media/projectBarPart.css deleted file mode 100644 index 70576594..00000000 --- a/src/vs/sessions/browser/parts/media/projectBarPart.css +++ /dev/null @@ -1,263 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -.monaco-workbench .part.projectbar { - width: 48px; - height: 100%; -} - -.monaco-workbench .projectbar.bordered::before { - content: ''; - float: left; - position: absolute; - box-sizing: border-box; - height: 100%; - width: 0px; - border-color: inherit; -} - -.monaco-workbench .projectbar.left.bordered::before { - right: 0; - border-right-style: solid; - border-right-width: 1px; -} - -.monaco-workbench .projectbar > .content { - height: 100%; - display: flex; - flex-direction: column; - justify-content: space-between; -} - -.monaco-workbench .projectbar > .content > .actions-container { - display: flex; - flex-direction: column; - align-items: center; - width: 100%; - margin-bottom: auto; -} - -/* Action items (both add button and workspace entries) */ -.monaco-workbench .projectbar .action-item { - position: relative; - display: flex; - align-items: center; - justify-content: center; - width: 48px; - height: 48px; - cursor: pointer; -} - -.monaco-workbench .projectbar .action-item:focus { - outline: 0 !important; /* project bar indicates focus custom */ -} - -.monaco-workbench .projectbar .action-item:focus-visible::before { - content: ''; - position: absolute; - inset: 6px; - border: 1px solid var(--vscode-focusBorder); - border-radius: 4px; - pointer-events: none; -} - -.monaco-workbench .projectbar .action-item .action-label { - display: flex; - align-items: center; - justify-content: center; - width: 24px; - height: 24px; - font-size: 16px; - border-radius: 4px; - color: var(--vscode-activityBar-inactiveForeground); -} - -.monaco-workbench .projectbar .action-item:hover .action-label { - color: var(--vscode-activityBar-foreground); -} - -/* Add folder button */ -.monaco-workbench .projectbar .action-item.add-folder { - margin-bottom: 4px; -} - -.monaco-workbench .projectbar .action-item.add-folder .action-label { - font-size: 20px; -} - -/* Workspace entry icon - shows first letter */ -.monaco-workbench .projectbar .action-item.workspace-entry .action-label.workspace-icon { - font-weight: 600; - font-size: 18px; - text-transform: uppercase; - background-color: var(--vscode-activityBar-inactiveForeground); - color: var(--vscode-activityBar-background); - border-radius: 6px; -} - -.monaco-workbench .projectbar .action-item.workspace-entry:hover .action-label.workspace-icon { - background-color: var(--vscode-activityBar-foreground); -} - -/* Workspace entry with codicon icon */ -.monaco-workbench .projectbar .action-item.workspace-entry .action-label.workspace-icon.codicon-icon { - font-weight: normal; - font-size: 24px; - text-transform: none; - background-color: transparent; - color: var(--vscode-activityBar-inactiveForeground); - border-radius: 0; -} - -.monaco-workbench .projectbar .action-item.workspace-entry:hover .action-label.workspace-icon.codicon-icon { - background-color: transparent; - color: var(--vscode-activityBar-foreground); -} - -.monaco-workbench .projectbar .action-item.workspace-entry.checked .action-label.workspace-icon.codicon-icon { - background-color: transparent; - color: var(--vscode-activityBar-foreground); -} - -/* Selected/checked state */ -.monaco-workbench .projectbar .action-item.workspace-entry.checked .action-label.workspace-icon { - background-color: var(--vscode-activityBar-foreground); - color: var(--vscode-activityBar-background); -} - -/* Active item indicator (vertical bar on the left) */ -.monaco-workbench .projectbar .action-item .active-item-indicator { - position: absolute; - left: 0; - width: 2px; - height: 24px; - background-color: transparent; - border-radius: 0 2px 2px 0; -} - -.monaco-workbench .projectbar .action-item.workspace-entry.checked .active-item-indicator { - background-color: var(--vscode-activityBar-activeBorder, var(--vscode-activityBar-foreground)); -} - -/* Active background for checked items */ -.monaco-workbench .projectbar .action-item.workspace-entry.checked { - background-color: var(--vscode-activityBar-activeBackground); -} - -/* High contrast styling */ -.monaco-workbench.hc-black .projectbar .action-item .action-label, -.monaco-workbench.hc-light .projectbar .action-item .action-label { - padding: 6px; -} - -.monaco-workbench.hc-black .projectbar .action-item.checked .action-label::before, -.monaco-workbench.hc-light .projectbar .action-item.checked .action-label::before { - outline: 1px solid var(--vscode-contrastActiveBorder); -} - -.monaco-workbench.hc-black .projectbar .action-item:hover .action-label::before, -.monaco-workbench.hc-light .projectbar .action-item:hover .action-label::before { - outline: 1px dashed var(--vscode-contrastActiveBorder); -} - -/* ===== Global Composite Bar (Accounts, Settings) at bottom ===== */ - -.monaco-workbench .projectbar > .content > .monaco-action-bar { - text-align: center; - background-color: inherit; -} - -.monaco-workbench .projectbar > .content > .monaco-action-bar .action-item { - display: block; - position: relative; -} - -.monaco-workbench .projectbar > .content > .monaco-action-bar .action-label { - position: relative; - z-index: 1; - display: flex; - overflow: hidden; - width: 48px; - height: 48px; - margin-right: 0; - box-sizing: border-box; -} - -.monaco-workbench .projectbar > .content > .monaco-action-bar .action-label.codicon { - font-size: 24px; - align-items: center; - justify-content: center; - color: var(--vscode-activityBar-inactiveForeground); -} - -.monaco-workbench .projectbar > .content > .monaco-action-bar .action-item.active .action-label.codicon, -.monaco-workbench .projectbar > .content > .monaco-action-bar .action-item:focus .action-label.codicon, -.monaco-workbench .projectbar > .content > .monaco-action-bar .action-item:hover .action-label.codicon { - color: var(--vscode-activityBar-foreground) !important; -} - -.monaco-workbench .projectbar > .content > .monaco-action-bar .action-item:focus { - outline: 0 !important; -} - -.monaco-workbench .projectbar > .content > .monaco-action-bar .profile-badge, -.monaco-workbench .projectbar > .content > .monaco-action-bar .active-item-indicator, -.monaco-workbench .projectbar > .content > .monaco-action-bar .badge { - position: absolute; - top: 0; - bottom: 0; - margin: auto; - left: 0; - overflow: hidden; - width: 100%; - height: 100%; -} - -.monaco-workbench .projectbar > .content > .monaco-action-bar .active-item-indicator, -.monaco-workbench .projectbar > .content > .monaco-action-bar .badge { - z-index: 2; -} - -.monaco-workbench .projectbar > .content > .monaco-action-bar .profile-badge { - z-index: 1; -} - -.monaco-workbench .projectbar > .content > .monaco-action-bar .active-item-indicator { - pointer-events: none; -} - -.monaco-workbench .projectbar > .content > .monaco-action-bar .badge .badge-content { - position: absolute; - top: 24px; - right: 8px; - font-size: 9px; - font-weight: 600; - min-width: 8px; - height: 16px; - line-height: 16px; - padding: 0 4px; - border-radius: 20px; - text-align: center; -} - -.monaco-workbench .projectbar > .content > .monaco-action-bar .profile-badge .profile-text-overlay { - position: absolute; - font-weight: 600; - font-size: 9px; - line-height: 10px; - top: 24px; - right: 6px; - padding: 2px 3px; - border-radius: 7px; - background-color: var(--vscode-profileBadge-background); - color: var(--vscode-profileBadge-foreground); - border: 2px solid var(--vscode-activityBar-background); -} - -.monaco-workbench .projectbar > .content > .monaco-action-bar .action-item:active .profile-text-overlay, -.monaco-workbench .projectbar > .content > .monaco-action-bar .action-item:focus .profile-text-overlay, -.monaco-workbench .projectbar > .content > .monaco-action-bar .action-item:hover .profile-text-overlay { - color: var(--vscode-activityBar-foreground); -} diff --git a/src/vs/sessions/browser/parts/media/sidebarPart.css b/src/vs/sessions/browser/parts/media/sidebarPart.css deleted file mode 100644 index 28f63b4d..00000000 --- a/src/vs/sessions/browser/parts/media/sidebarPart.css +++ /dev/null @@ -1,127 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -/* Hide traffic light spacer in fullscreen (traffic lights are not shown) */ -.monaco-workbench.fullscreen .part.sidebar .window-controls-container { - display: none; -} - -/* Make the sidebar title area draggable to move the window */ -.agent-sessions-workbench .part.sidebar > .composite.title { - position: relative; -} - -.agent-sessions-workbench .part.sidebar > .composite.title > .titlebar-drag-region { - top: 0; - left: 0; - display: block; - position: absolute; - width: 100%; - height: 100%; - -webkit-app-region: drag; -} - -/* Interactive elements in the title area must not be draggable */ -.agent-sessions-workbench .part.sidebar > .composite.title .action-item { - -webkit-app-region: no-drag; -} - -/* Toggled state for the sidebar toggle button in the sidebar title area */ -.agent-sessions-workbench .part.sidebar > .composite.title > .global-actions-left .action-label.checked { - background: var(--vscode-toolbar-activeBackground); - border-radius: var(--vscode-cornerRadius-medium); -} - -/* Preserve toggled background when the toggle button is hovered or focused */ -.agent-sessions-workbench .part.sidebar > .composite.title > .global-actions-left .action-label.checked:hover, -.agent-sessions-workbench .part.sidebar > .composite.title > .global-actions-left .action-label.checked:focus { - background: var(--vscode-toolbar-activeBackground); -} -/* Sidebar Footer Container */ -.agent-sessions-workbench .part.sidebar > .sidebar-footer { - display: flex; - align-items: stretch; - gap: 4px; - padding: 6px 0 0 0; - margin: 0 10px 2px 10px; - flex-shrink: 0; -} - -/* Make the toolbar fill the footer width and stack actions vertically */ -.monaco-workbench .part.sidebar > .sidebar-footer .monaco-toolbar, -.monaco-workbench .part.sidebar > .sidebar-footer .monaco-action-bar, - -.monaco-workbench .part.sidebar > .sidebar-footer .actions-container { - width: 100%; - max-width: 100%; - display: flex; - flex-direction: column; - align-items: stretch; - cursor: default; -} - -.monaco-workbench .part.sidebar > .sidebar-footer .action-item { - width: 100%; - max-width: 100%; - cursor: default; -} - -/* ---- Phone Layout: Sidebar Drawer Overlay ---- */ - -/* On phone, the sidebar is a full-width drawer that slides over the chat. - It covers the full viewport below the mobile top bar; the top bar's - sidebar toggle button remains visible and is used to dismiss it. - - The drawer slides in/out with a transition (not a keyframe animation) so - that interrupted toggles retarget smoothly from the current position - rather than restarting. The split-view wrapper toggles `display: none` - via a `.visible` class; `transition-behavior: allow-discrete` defers - the discrete `display` change until the slide-out completes, and - `@starting-style` provides the offscreen origin for the slide-in. */ -.agent-sessions-workbench.phone-layout .split-view-view:has(> .part.sidebar) { - position: absolute !important; - top: 0 !important; - left: 0 !important; - bottom: 0 !important; - width: 100% !important; - height: 100% !important; - z-index: 250; - transform: translateX(0); - transition: - transform 260ms cubic-bezier(0.32, 0.72, 0, 1), - display 260ms allow-discrete; -} - -/* Slide-in starting state (applies on each transition into .visible) */ -@starting-style { - .agent-sessions-workbench.phone-layout .split-view-view.visible:has(> .part.sidebar) { - transform: translateX(-100%); - } -} - -/* Slide-out target: when `.visible` is removed, splitview's own rule sets - `display: none`. With `allow-discrete` above, the transform animates first - and the discrete `display` swap happens at the end of the transition. */ -.agent-sessions-workbench.phone-layout .split-view-view:not(.visible):has(> .part.sidebar) { - transform: translateX(-100%); -} - -/* The sidebar Part inside fills its container */ -.agent-sessions-workbench.phone-layout .part.sidebar { - width: 100%; - height: 100%; -} - -@media (prefers-reduced-motion: reduce) { - .agent-sessions-workbench.phone-layout .split-view-view:has(> .part.sidebar) { - transition: none; - } -} - -/* Increase sidebar footer action button height for touch */ -.agent-sessions-workbench.phone-layout .part.sidebar > .sidebar-footer .sidebar-action-button { - min-height: 44px; - padding: 8px 12px; -} diff --git a/src/vs/sessions/browser/parts/media/titlebarpart.css b/src/vs/sessions/browser/parts/media/titlebarpart.css deleted file mode 100644 index 098a41d9..00000000 --- a/src/vs/sessions/browser/parts/media/titlebarpart.css +++ /dev/null @@ -1,174 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -.monaco-workbench .part.titlebar > .sessions-titlebar-container { - justify-content: initial; -} - -.monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-left { - display: flex; - height: 100%; - align-items: center; - order: 0; - flex-grow: 0; - flex-shrink: 0; - width: auto; - justify-content: flex-start; -} - -.monaco-workbench .part.titlebar > .sessions-titlebar-container.has-center > .titlebar-center { - order: 1; - width: auto; - flex-grow: 0; - flex-shrink: 1; - min-width: 0px; - margin: 0; - justify-content: flex-start; -} - -.agent-sessions-workbench.monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-left { - width: fit-content; - flex-grow: 0; -} - -.agent-sessions-workbench.monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-center { - flex: 1; - max-width: none; -} - -.agent-sessions-workbench.monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-center .window-title { - margin: unset; -} - -.agent-sessions-workbench.monaco-workbench.mac .part.titlebar > .sessions-titlebar-container > .titlebar-right { - order: 2; - width: fit-content; - flex-grow: 0; - justify-content: flex-end; - margin-right: 12px; -} - -/* Session Title Actions Container (before right toolbar) */ -.monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-right > .titlebar-actions-container { - display: none; - flex-shrink: 0; - -webkit-app-region: no-drag; - height: 100%; -} - -.monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-right > .titlebar-actions-container:not(.has-no-actions) { - display: flex; - align-items: center; -} - -.monaco-workbench .part.titlebar > .titlebar-container.sessions-titlebar-container .monaco-toolbar .actions-container { - gap: 1px; -} - -.agent-sessions-workbench.monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-right > .titlebar-session-actions-container .monaco-toolbar .actions-container > .action-item.open-in-vscode-titlebar-widget + .action-item { - margin-left: 5px; -} - -/* Add spacing between the session action group and the right layout actions. */ -.monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-right > .titlebar-session-actions-container:not(.has-no-actions) + .titlebar-right-layout-container:not(.has-no-actions) { - margin-left: 4px; -} - -/* Toggled action buttons in session actions toolbar */ -.monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-right > .titlebar-session-actions-container .action-label.checked { - background: var(--vscode-toolbar-activeBackground); - border-radius: var(--vscode-cornerRadius-medium); -} - -/* Secondary sidebar toggle uses icon variants for toggle state — no background needed */ -.monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-right > .titlebar-session-actions-container .action-label.codicon-agent-secondary-sidebar-toggle-open.checked, -.monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-right > .titlebar-session-actions-container .action-label.codicon-agent-secondary-sidebar-toggle-closed.checked { - background: none; -} - -.monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-right > .titlebar-session-actions-container .action-label.codicon-agent-secondary-sidebar-toggle-open.checked:hover, -.monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-right > .titlebar-session-actions-container .action-label.codicon-agent-secondary-sidebar-toggle-closed.checked:hover { - background: var(--vscode-toolbar-hoverBackground); -} - -.monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-right > .titlebar-actions-container .monaco-action-bar .action-item:not(.disabled) .codicon { - color: var(--vscode-icon-foreground); -} - -.monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-right > .titlebar-actions-container .monaco-action-bar .action-item { - display: flex; -} - -/* Left Tool Bar Container */ -.monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-left > .left-toolbar-container { - display: none; - padding-left: 8px; - flex-grow: 0; - flex-shrink: 0; - text-align: center; - position: relative; - z-index: 2500; - -webkit-app-region: no-drag; - height: 100%; - order: 2; -} - -.monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-left > .left-toolbar-container:not(.has-no-actions) { - display: flex; - justify-content: center; - align-items: center; -} - -.monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-left > .left-toolbar-container .monaco-action-bar .action-item:not(.disabled) .codicon { - color: var(--vscode-icon-foreground); -} - -.monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-left > .left-toolbar-container .monaco-action-bar .action-item { - display: flex; -} - -/* Remove the titlebar shadow in agent sessions */ -.agent-sessions-workbench.monaco-workbench .part.titlebar { - box-shadow: none; -} - -/* macOS native: the spacer uses window-controls-container but should not block dragging */ -.agent-sessions-workbench.mac .part.titlebar .window-controls-container { - -webkit-app-region: drag; -} - -/* ---- Phone Layout: Minimal Titlebar ---- */ - -/* On phone, ensure the titlebar left is visible (it holds the hamburger area) - even when sidebar is hidden. Override the nosidebar rule. */ -.agent-sessions-workbench.phone-layout.nosidebar .part.titlebar > .sessions-titlebar-container > .titlebar-left { - display: flex !important; -} - -/* But hide the toolbar content inside it — only structural element remains */ -.agent-sessions-workbench.phone-layout .part.titlebar > .sessions-titlebar-container > .titlebar-left > .left-toolbar-container { - display: none !important; -} - -/* Hide the window controls spacer on phone (no native traffic lights on mobile) */ -.agent-sessions-workbench.phone-layout .part.titlebar > .sessions-titlebar-container > .titlebar-left > .window-controls-container { - display: none !important; -} - -/* Keep the center (session title) visible and full-width on phone */ -.agent-sessions-workbench.phone-layout .part.titlebar > .sessions-titlebar-container > .titlebar-center { - flex: 1; - min-width: 0; -} - -/* On phone, hide ALL right-side action containers (session actions + layout actions) */ -.agent-sessions-workbench.phone-layout .part.titlebar > .sessions-titlebar-container > .titlebar-right > .titlebar-actions-container { - display: none !important; -} - -/* Ensure safe area padding on top for notch */ -.agent-sessions-workbench.phone-layout .part.titlebar > .sessions-titlebar-container { - padding-top: env(safe-area-inset-top); -} diff --git a/src/vs/sessions/browser/parts/menubar.contribution.ts b/src/vs/sessions/browser/parts/menubar.contribution.ts deleted file mode 100644 index b42ea4db..00000000 --- a/src/vs/sessions/browser/parts/menubar.contribution.ts +++ /dev/null @@ -1,69 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { localize } from '../../../nls.js'; -import { MenuId, MenuRegistry } from '../../../platform/actions/common/actions.js'; -import { IsMacNativeContext } from '../../../platform/contextkey/common/contextkeys.js'; - -MenuRegistry.appendMenuItem(MenuId.MenubarMainMenu, { - submenu: MenuId.MenubarFileMenu, - title: { - value: 'File', - original: 'File', - mnemonicTitle: localize({ key: 'mFile', comment: ['&& denotes a mnemonic'] }, "&&File"), - }, - order: 1 -}); - -MenuRegistry.appendMenuItem(MenuId.MenubarMainMenu, { - submenu: MenuId.MenubarEditMenu, - title: { - value: 'Edit', - original: 'Edit', - mnemonicTitle: localize({ key: 'mEdit', comment: ['&& denotes a mnemonic'] }, "&&Edit") - }, - order: 2 -}); - -MenuRegistry.appendMenuItem(MenuId.MenubarMainMenu, { - submenu: MenuId.MenubarViewMenu, - title: { - value: 'View', - original: 'View', - mnemonicTitle: localize({ key: 'mView', comment: ['&& denotes a mnemonic'] }, "&&View") - }, - order: 4 -}); - -MenuRegistry.appendMenuItem(MenuId.MenubarMainMenu, { - submenu: MenuId.MenubarTerminalMenu, - title: { - value: 'Terminal', - original: 'Terminal', - mnemonicTitle: localize({ key: 'mTerminal', comment: ['&& denotes a mnemonic'] }, "&&Terminal") - }, - order: 7 -}); - -MenuRegistry.appendMenuItem(MenuId.MenubarMainMenu, { - submenu: MenuId.MenubarHelpMenu, - title: { - value: 'Help', - original: 'Help', - mnemonicTitle: localize({ key: 'mHelp', comment: ['&& denotes a mnemonic'] }, "&&Help") - }, - order: 8 -}); - -MenuRegistry.appendMenuItem(MenuId.MenubarMainMenu, { - submenu: MenuId.MenubarPreferencesMenu, - title: { - value: 'Preferences', - original: 'Preferences', - mnemonicTitle: localize({ key: 'mPreferences', comment: ['&& denotes a mnemonic'] }, "&&Preferences") - }, - when: IsMacNativeContext, - order: 9 -}); diff --git a/src/vs/sessions/browser/parts/mobile/contributions/media/mobileOverlayViews.css b/src/vs/sessions/browser/parts/mobile/contributions/media/mobileOverlayViews.css deleted file mode 100644 index 1944b381..00000000 --- a/src/vs/sessions/browser/parts/mobile/contributions/media/mobileOverlayViews.css +++ /dev/null @@ -1,293 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -/* Shared base for full-screen mobile overlay views (terminal, diff). */ - -.mobile-overlay-view { - position: fixed; - inset: 0; - z-index: 1000; - display: flex; - flex-direction: column; - background: var(--vscode-editor-background); - color: var(--vscode-editor-foreground, var(--vscode-foreground)); - touch-action: manipulation; - -webkit-touch-callout: none; - user-select: none; - -webkit-user-select: none; -} - -/* -- Header ------------------------------------------------------------------------------------- */ - -.mobile-overlay-header { - display: flex; - align-items: center; - gap: 4px; - height: 56px; - min-height: 56px; - padding: 0 8px; - padding-top: env(safe-area-inset-top); - border-bottom: 1px solid var(--vscode-panel-border, var(--vscode-editorWidget-border, transparent)); - flex-shrink: 0; - box-sizing: content-box; -} - -.mobile-overlay-back-btn { - display: flex; - align-items: center; - gap: 4px; - height: 44px; - min-width: 44px; - padding: 0 8px; - border: none; - background: none; - color: var(--vscode-foreground); - cursor: pointer; - border-radius: 4px; - font-size: 14px; - white-space: nowrap; - touch-action: manipulation; -} - -.mobile-overlay-back-btn:active { - background: var(--vscode-toolbar-hoverBackground); -} - -.mobile-overlay-back-btn .back-btn-label { - font-size: 14px; - color: var(--vscode-foreground); -} - -.mobile-overlay-header-info { - flex: 1; - min-width: 0; - display: flex; - flex-direction: column; - justify-content: center; -} - -.mobile-overlay-header-title { - font-size: 14px; - font-weight: 500; - color: var(--vscode-foreground); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.mobile-overlay-header-subtitle { - font-size: 12px; - color: var(--vscode-descriptionForeground); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.mobile-overlay-header-badge { - display: inline-flex; - align-items: center; - gap: 3px; - padding: 1px 6px; - border-radius: 10px; - font-size: 11px; - font-weight: 500; - white-space: nowrap; - flex-shrink: 0; -} - -.mobile-overlay-header-badge.success { - background-color: color-mix(in srgb, var(--vscode-testing-iconPassed) 20%, transparent); - color: var(--vscode-testing-iconPassed); -} - -.mobile-overlay-header-badge.error { - background-color: color-mix(in srgb, var(--vscode-errorForeground) 20%, transparent); - color: var(--vscode-errorForeground); -} - -.mobile-overlay-header-badge.running { - background-color: color-mix(in srgb, var(--vscode-textLink-foreground) 20%, transparent); - color: var(--vscode-textLink-foreground); -} - -/* -- Body --------------------------------------------------------------------------------------- */ - -.mobile-overlay-body { - flex: 1; - overflow: hidden; - display: flex; - flex-direction: column; -} - -.mobile-overlay-scroll { - flex: 1; - overflow-y: auto; - overflow-x: auto; - -webkit-overflow-scrolling: touch; - overscroll-behavior: contain; - padding-bottom: env(safe-area-inset-bottom); -} - -/* -- Footer toolbar --------------------------------------------------------------------- */ - -.mobile-overlay-footer { - display: flex; - align-items: center; - gap: 8px; - padding: 8px 12px; - padding-bottom: calc(8px + env(safe-area-inset-bottom)); - border-top: 1px solid var(--vscode-panel-border, transparent); - background: var(--vscode-editor-background); - flex-shrink: 0; -} - -.mobile-overlay-footer-btn { - display: flex; - align-items: center; - gap: 6px; - height: 36px; - padding: 0 14px; - border-radius: 6px; - border: 1px solid var(--vscode-widget-border, color-mix(in srgb, var(--vscode-foreground) 15%, transparent)); - background: transparent; - color: var(--vscode-foreground); - font-size: 13px; - cursor: pointer; - touch-action: manipulation; - white-space: nowrap; -} - -.mobile-overlay-footer-btn:active { - background: var(--vscode-toolbar-hoverBackground); -} - -/* -- Terminal-specific ---------------------------------------------------------------- */ - -.mobile-terminal-output { - font-family: var(--monaco-monospace-font, 'SF Mono', Menlo, Monaco, Consolas, monospace); - font-size: 13px; - line-height: 1.5; - white-space: pre; /* preserve columns; horizontal scroll for long lines */ - color: var(--vscode-editor-foreground, var(--vscode-foreground)); - padding: 12px 16px; - min-height: 100%; - box-sizing: border-box; -} - -.mobile-terminal-output.truncated::after { - content: '\A[output truncated]'; - display: block; - color: var(--vscode-descriptionForeground); - font-style: italic; -} - -.mobile-terminal-jump-btn { - position: absolute; - bottom: 64px; - right: 16px; - right: calc(16px + env(safe-area-inset-right)); - height: 36px; - padding: 0 14px; - border-radius: 18px; - border: 1px solid var(--vscode-widget-border, transparent); - background: var(--vscode-editor-background); - color: var(--vscode-foreground); - font-size: 13px; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); - cursor: pointer; - touch-action: manipulation; - transition: opacity 0.15s ease; -} - -.mobile-terminal-jump-btn.hidden { - opacity: 0; - pointer-events: none; -} - -/* -- Diff-specific ------------------------------------------------------------------------ */ - -.mobile-diff-output { - font-family: var(--monaco-monospace-font, 'SF Mono', Menlo, Monaco, Consolas, monospace); - font-size: 12px; - line-height: 1.6; - white-space: pre; - padding: 8px 0; - min-height: 100%; - box-sizing: border-box; -} - -.mobile-diff-hunk-header { - display: block; - color: var(--vscode-descriptionForeground); - background: color-mix(in srgb, var(--vscode-foreground) 6%, transparent); - padding: 4px 16px; - font-size: 11px; - user-select: none; - -webkit-user-select: none; -} - -.mobile-diff-line { - display: flex; - gap: 0; -} - -.mobile-diff-line-num { - flex-shrink: 0; - width: 40px; - padding: 0 8px; - color: var(--vscode-editorLineNumber-foreground); - text-align: right; - user-select: none; - -webkit-user-select: none; -} - -.mobile-diff-gutter { - flex-shrink: 0; - width: 16px; - text-align: center; - padding: 0 2px; - user-select: none; - -webkit-user-select: none; -} - -.mobile-diff-content { - flex: 1; - padding: 0 16px 0 4px; - user-select: text; - -webkit-user-select: text; -} - -/* Added lines — soft green tint on the row, vibrant green text. The tint is intentionally very light (8%) so the text remains the loudest visual signal, matching the mockup. */ -.mobile-diff-line.added { - background: color-mix(in srgb, var(--vscode-gitDecoration-addedResourceForeground) 8%, transparent); -} - -.mobile-diff-line.added .mobile-diff-line-num, -.mobile-diff-line.added .mobile-diff-gutter, -.mobile-diff-line.added .mobile-diff-content { - color: var(--vscode-gitDecoration-addedResourceForeground); -} - -/* Removed lines — same treatment with the deletion color. */ -.mobile-diff-line.removed { - background: color-mix(in srgb, var(--vscode-gitDecoration-deletedResourceForeground) 8%, transparent); -} - -.mobile-diff-line.removed .mobile-diff-line-num, -.mobile-diff-line.removed .mobile-diff-gutter, -.mobile-diff-line.removed .mobile-diff-content { - color: var(--vscode-gitDecoration-deletedResourceForeground); -} - -.mobile-diff-empty-state { - display: flex; - align-items: center; - justify-content: center; - flex: 1; - color: var(--vscode-descriptionForeground); - font-size: 14px; - padding: 32px; - text-align: center; -} diff --git a/src/vs/sessions/browser/parts/mobile/contributions/mobileDiffView.ts b/src/vs/sessions/browser/parts/mobile/contributions/mobileDiffView.ts deleted file mode 100644 index baec0a09..00000000 --- a/src/vs/sessions/browser/parts/mobile/contributions/mobileDiffView.ts +++ /dev/null @@ -1,282 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import './media/mobileOverlayViews.css'; -import * as DOM from '../../../../../base/browser/dom.js'; -import { Disposable, DisposableStore, toDisposable } from '../../../../../base/common/lifecycle.js'; -import { Gesture, EventType as TouchEventType } from '../../../../../base/browser/touch.js'; -import { Codicon } from '../../../../../base/common/codicons.js'; -import { ThemeIcon } from '../../../../../base/common/themables.js'; -import { localize } from '../../../../../nls.js'; -import { ITextFileService } from '../../../../../workbench/services/textfile/common/textfiles.js'; -import { URI } from '../../../../../base/common/uri.js'; -import { basename } from '../../../../../base/common/resources.js'; -import { linesDiffComputers } from '../../../../../editor/common/diff/linesDiffComputers.js'; - -const $ = DOM.$; - -/** - * Command ID for opening the {@link MobileDiffView}. - * - * Accepts {@link IFileDiffViewData} as the single argument. Phone-only. - */ -export const MOBILE_OPEN_DIFF_VIEW_COMMAND_ID = 'sessions.mobile.openDiffView'; - -/** - * Minimal subset of diff entry fields consumed by the mobile diff view. - * Defined locally to avoid importing from vs/workbench/contrib in vs/sessions/browser. - */ -export interface IFileDiffViewData { - /** - * URI of the file before the change. `undefined` when the file is - * newly added by the agent and there is no prior content; the diff - * is rendered against an empty original (all lines as additions). - */ - readonly originalURI: URI | undefined; - readonly modifiedURI: URI; - readonly identical: boolean; - readonly added: number; - readonly removed: number; -} - -/** - * Data passed to {@link MobileDiffView} when opening a diff view. - */ -export interface IMobileDiffViewData { - readonly diff: IFileDiffViewData; -} - -/** - * Full-screen overlay for viewing file changes produced by a coding agent - * session on phone viewports. - * - * Renders a unified diff with coloured +/- gutters and line numbers. Text is - * read from the file service via the modified/original URIs stored in - * {@link IFileDiffViewData}. This keeps the view lightweight — it avoids - * embedding a full Monaco diff editor while still giving users a readable - * view of what changed. - * - * Follows the account-sheet overlay pattern: appends to the workbench - * container, disposes on back-button tap. - */ -export class MobileDiffView extends Disposable { - - private readonly viewStore = this._register(new DisposableStore()); - private disposed = false; - - constructor( - workbenchContainer: HTMLElement, - data: IMobileDiffViewData, - private readonly textFileService: ITextFileService, - ) { - super(); - this.render(workbenchContainer, data); - } - - private render(workbenchContainer: HTMLElement, data: IMobileDiffViewData): void { - const { diff } = data; - const fileName = basename(diff.modifiedURI); - - // -- Root overlay ----------------------------------------- - const overlay = DOM.append(workbenchContainer, $('div.mobile-overlay-view')); - this.viewStore.add(DOM.addDisposableListener(overlay, DOM.EventType.CONTEXT_MENU, e => e.preventDefault())); - this.viewStore.add(toDisposable(() => overlay.remove())); - - // -- Header ----------------------------------------------- - const header = DOM.append(overlay, $('div.mobile-overlay-header')); - - const backBtn = DOM.append(header, $('button.mobile-overlay-back-btn', { type: 'button' })) as HTMLButtonElement; - backBtn.setAttribute('aria-label', localize('diffView.back', "Back")); - DOM.append(backBtn, $('span')).classList.add(...ThemeIcon.asClassNameArray(Codicon.chevronLeft)); - DOM.append(backBtn, $('span.back-btn-label')).textContent = localize('diffView.backLabel', "Back"); - this.viewStore.add(Gesture.addTarget(backBtn)); - this.viewStore.add(DOM.addDisposableListener(backBtn, DOM.EventType.CLICK, () => this.dispose())); - this.viewStore.add(DOM.addDisposableListener(backBtn, TouchEventType.Tap, () => this.dispose())); - - const info = DOM.append(header, $('div.mobile-overlay-header-info')); - DOM.append(info, $('div.mobile-overlay-header-title')).textContent = fileName; - - if (!diff.identical) { - const sub = DOM.append(info, $('div.mobile-overlay-header-subtitle')); - const parts: string[] = []; - if (diff.added) { - parts.push(`+${diff.added}`); - } - if (diff.removed) { - parts.push(`-${diff.removed}`); - } - sub.textContent = parts.join(' '); - } - - // -- Body ------------------------------------------------- - const body = DOM.append(overlay, $('div.mobile-overlay-body')); - const scrollWrapper = DOM.append(body, $('div.mobile-overlay-scroll')); - const contentArea = DOM.append(scrollWrapper, $('div.mobile-diff-output')); - - this.loadDiffContent(contentArea, diff); - } - - private loadDiffContent(container: HTMLElement, diff: IFileDiffViewData): void { - if (diff.identical) { - const empty = DOM.append(container, $('div.mobile-diff-empty-state')); - empty.textContent = localize('diffView.noChanges', "No changes in this file."); - return; - } - - const loadingEl = DOM.append(container, $('div.mobile-diff-empty-state')); - loadingEl.textContent = localize('diffView.loading', "Loading…"); - - Promise.all([ - diff.originalURI - ? this.textFileService.read(diff.originalURI, { acceptTextOnly: true }).then(m => m.value).catch(() => '') - : Promise.resolve(''), - this.textFileService.read(diff.modifiedURI, { acceptTextOnly: true }).then(m => m.value).catch(() => ''), - ]).then(([originalText, modifiedText]) => { - if (this.disposed) { - return; - } - DOM.clearNode(container); - const hunks = computeUnifiedDiff(originalText, modifiedText); - if (hunks.length === 0) { - const empty = DOM.append(container, $('div.mobile-diff-empty-state')); - empty.textContent = localize('diffView.noChanges', "No changes in this file."); - return; - } - this.renderHunks(container, hunks); - }); - } - - private renderHunks(container: HTMLElement, hunks: IDiffHunk[]): void { - for (const hunk of hunks) { - // Hunk header - const headerEl = DOM.append(container, $('span.mobile-diff-hunk-header')); - headerEl.textContent = hunk.header; - - // Lines - for (const line of hunk.lines) { - const row = DOM.append(container, $('div.mobile-diff-line')); - row.classList.add(line.type); - - const numEl = DOM.append(row, $('span.mobile-diff-line-num')); - numEl.textContent = line.lineNum !== undefined ? String(line.lineNum) : ''; - - const gutter = DOM.append(row, $('span.mobile-diff-gutter')); - gutter.textContent = line.type === 'added' ? '+' : line.type === 'removed' ? '-' : ' '; - - const content = DOM.append(row, $('span.mobile-diff-content')); - content.textContent = line.text; - } - } - } - - override dispose(): void { - this.disposed = true; - this.viewStore.dispose(); - super.dispose(); - } -} - -// -- Unified diff hunk rendering --------------------------------------------- -// Uses the workbench's `linesDiffComputers` so we get the same diff quality as -// the diff editor — no in-tree diff algorithm to maintain. - -interface IDiffLine { - type: 'context' | 'added' | 'removed'; - lineNum?: number; - text: string; -} - -interface IDiffHunk { - header: string; - lines: IDiffLine[]; -} - -const CONTEXT_LINES = 3; - -function computeUnifiedDiff(original: string, modified: string): IDiffHunk[] { - const origLines = original.split(/\r?\n/); - const modLines = modified.split(/\r?\n/); - - const result = linesDiffComputers.getDefault().computeDiff(origLines, modLines, { - ignoreTrimWhitespace: false, - maxComputationTimeMs: 1000, - computeMoves: false, - }); - - if (result.changes.length === 0) { - return []; - } - - // Merge changes that are within 2*CONTEXT_LINES of each other into a - // single hunk so consecutive edits aren't visually fragmented. - type Group = { origStart: number; origEnd: number; modStart: number; modEnd: number }; - const groups: Group[] = []; - for (const change of result.changes) { - const g: Group = { - origStart: change.original.startLineNumber, - origEnd: change.original.endLineNumberExclusive, - modStart: change.modified.startLineNumber, - modEnd: change.modified.endLineNumberExclusive, - }; - const last = groups[groups.length - 1]; - if (last && g.origStart - last.origEnd <= CONTEXT_LINES * 2) { - last.origEnd = g.origEnd; - last.modEnd = g.modEnd; - } else { - groups.push(g); - } - } - - const hunks: IDiffHunk[] = []; - for (const group of groups) { - const origLeading = Math.max(1, group.origStart - CONTEXT_LINES); - const modLeading = Math.max(1, group.modStart - CONTEXT_LINES); - const origTrailing = Math.min(origLines.length + 1, group.origEnd + CONTEXT_LINES); - const modTrailing = Math.min(modLines.length + 1, group.modEnd + CONTEXT_LINES); - - const lines: IDiffLine[] = []; - - // Leading context (taken from original — same as modified in unchanged regions). - for (let i = origLeading; i < group.origStart; i++) { - lines.push({ type: 'context', lineNum: i, text: origLines[i - 1] ?? '' }); - } - - // Removed lines (from original). - for (let i = group.origStart; i < group.origEnd; i++) { - lines.push({ type: 'removed', lineNum: i, text: origLines[i - 1] ?? '' }); - } - - // Added lines (from modified). - for (let i = group.modStart; i < group.modEnd; i++) { - lines.push({ type: 'added', lineNum: i, text: modLines[i - 1] ?? '' }); - } - - // Trailing context. - for (let i = group.origEnd; i < origTrailing; i++) { - lines.push({ type: 'context', lineNum: i, text: origLines[i - 1] ?? '' }); - } - - const origCount = origTrailing - origLeading; - const modCount = modTrailing - modLeading; - hunks.push({ - header: `@@ -${origLeading},${origCount} +${modLeading},${modCount} @@`, - lines, - }); - } - - return hunks; -} - -/** - * Opens a {@link MobileDiffView} for the given file diff. - * Returns the view instance; dispose it to close. - */ -export function openMobileDiffView( - workbenchContainer: HTMLElement, - data: IMobileDiffViewData, - textFileService: ITextFileService, -): MobileDiffView { - return new MobileDiffView(workbenchContainer, data, textFileService); -} diff --git a/src/vs/sessions/browser/parts/mobile/media/mobileSessionFilterChips.css b/src/vs/sessions/browser/parts/mobile/media/mobileSessionFilterChips.css deleted file mode 100644 index e067ce36..00000000 --- a/src/vs/sessions/browser/parts/mobile/media/mobileSessionFilterChips.css +++ /dev/null @@ -1,84 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -/* Outer chip row. - * Layout: [horizontally-scrolling status + sort/filter chips] [pinned Find chip] - * The Find chip lives outside the scroll area so it's always reachable on - * narrow viewports without horizontal scrolling. A right-edge fade on the - * scroll container hints that more chips exist when content overflows. */ -.mobile-session-filter-chips { - display: flex; - align-items: center; - gap: 6px; - padding: 4px 10px 8px; - flex-shrink: 0; -} - -.mobile-session-filter-chips-scroll { - flex: 1 1 auto; - min-width: 0; - display: flex; - align-items: center; - gap: 6px; - overflow-x: auto; - overflow-y: hidden; - -webkit-overflow-scrolling: touch; - scrollbar-width: none; - /* Mask-based fade on the trailing edge so chips that scroll under the - * pinned Find chip get visually softened instead of clipped abruptly. */ - -webkit-mask-image: linear-gradient(to right, black calc(100% - 16px), transparent); - mask-image: linear-gradient(to right, black calc(100% - 16px), transparent); -} - -.mobile-session-filter-chips-scroll::-webkit-scrollbar { - display: none; -} - -.mobile-session-filter-chip { - display: inline-flex; - align-items: center; - gap: 4px; - padding: 4px 10px; - border-radius: 14px; - font-size: 12px; - line-height: 18px; - white-space: nowrap; - cursor: pointer; - -webkit-user-select: none; - user-select: none; - touch-action: manipulation; - min-height: 28px; - border: 1px solid var(--vscode-widget-border, color-mix(in srgb, var(--vscode-foreground) 15%, transparent)); - background-color: transparent; - color: var(--vscode-foreground); - transition: background-color 0.1s ease, border-color 0.1s ease, color 0.1s ease; - flex-shrink: 0; -} - -.mobile-session-filter-chip:active { - opacity: 0.7; -} - -.mobile-session-filter-chip.active { - background-color: var(--vscode-badge-background); - color: var(--vscode-badge-foreground); - border-color: var(--vscode-badge-background); -} - -.mobile-session-filter-chip.icon-only { - min-width: 32px; - padding: 4px 8px; - justify-content: center; -} - -.mobile-session-filter-chip .chip-icon { - font-size: 14px; - display: flex; - align-items: center; -} - -.mobile-session-filter-chip .chip-label { - flex-shrink: 0; -} diff --git a/src/vs/sessions/browser/parts/mobile/media/mobileSortGroupSheet.css b/src/vs/sessions/browser/parts/mobile/media/mobileSortGroupSheet.css deleted file mode 100644 index 42a14513..00000000 --- a/src/vs/sessions/browser/parts/mobile/media/mobileSortGroupSheet.css +++ /dev/null @@ -1,216 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -/* Bottom sheet for mobile sort/group selection. - * Docked at the bottom of the viewport, with a translucent backdrop and a - * native-feeling slide-up animation. Used by the sessions filter chips on - * phone. */ - -.mobile-sort-group-sheet-overlay { - position: fixed; - inset: 0; - z-index: 2000; - display: flex; - flex-direction: column; - justify-content: flex-end; - pointer-events: none; -} - -.mobile-sort-group-sheet-overlay > * { - pointer-events: auto; -} - -.mobile-sort-group-sheet-backdrop { - position: absolute; - inset: 0; - background: rgba(0, 0, 0, 0.4); - animation: mobile-sort-group-sheet-fade-in 180ms ease-out; -} - -.mobile-sort-group-sheet-backdrop.closing { - animation: mobile-sort-group-sheet-fade-out 180ms ease-in forwards; -} - -.mobile-sort-group-sheet { - position: relative; - width: 100%; - max-height: 80vh; - display: flex; - flex-direction: column; - background: var(--vscode-menu-background, var(--vscode-editor-background)); - color: var(--vscode-menu-foreground, var(--vscode-foreground)); - border-top-left-radius: 16px; - border-top-right-radius: 16px; - padding-bottom: env(safe-area-inset-bottom); - box-shadow: 0 -8px 24px rgba(0, 0, 0, 0.25); - animation: mobile-sort-group-sheet-slide-up 220ms cubic-bezier(0.2, 0.9, 0.3, 1); - overflow: hidden; -} - -.mobile-sort-group-sheet.closing { - animation: mobile-sort-group-sheet-slide-down 180ms ease-in forwards; -} - -/* iOS-style drag handle hint at the top of the sheet. Decorative only — - * actual drag-to-dismiss is not wired up. */ -.mobile-sort-group-sheet-handle { - width: 36px; - height: 4px; - margin: 8px auto 4px; - border-radius: 2px; - background: color-mix(in srgb, var(--vscode-foreground) 30%, transparent); - flex-shrink: 0; -} - -.mobile-sort-group-sheet-header { - display: flex; - align-items: center; - gap: 8px; - padding: 8px 16px 12px; - flex-shrink: 0; -} - -.mobile-sort-group-sheet-title { - flex: 1; - min-width: 0; - font-size: 16px; - font-weight: 600; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.mobile-sort-group-sheet-close { - display: flex; - align-items: center; - justify-content: center; - width: 36px; - height: 36px; - border: none; - background: transparent; - color: var(--vscode-foreground); - cursor: pointer; - border-radius: 50%; - flex-shrink: 0; - font-size: 16px; - padding: 0; - touch-action: manipulation; -} - -.mobile-sort-group-sheet-close:active { - background: var(--vscode-toolbar-hoverBackground); -} - -.mobile-sort-group-sheet-list { - flex: 1 1 auto; - overflow-y: auto; - -webkit-overflow-scrolling: touch; - padding: 4px 0 8px; -} - -.mobile-sort-group-sheet-section-title { - padding: 12px 16px 4px; - font-size: 11px; - font-weight: 500; - text-transform: uppercase; - letter-spacing: 0.05em; - color: var(--vscode-descriptionForeground); -} - -.mobile-sort-group-sheet-divider { - height: 1px; - margin: 8px 16px; - background: color-mix(in srgb, var(--vscode-foreground) 12%, transparent); -} - -.mobile-sort-group-sheet-item { - display: flex; - align-items: center; - gap: 12px; - width: 100%; - min-height: 48px; - padding: 0 16px; - border: none; - background: transparent; - color: inherit; - font-size: 15px; - text-align: left; - cursor: pointer; - touch-action: manipulation; - font-family: inherit; -} - -.mobile-sort-group-sheet-item:focus { - outline: none; -} - -.mobile-sort-group-sheet-item:focus-visible { - outline: 2px solid var(--vscode-focusBorder); - outline-offset: -2px; -} - -.mobile-sort-group-sheet-item:active { - background: var(--vscode-toolbar-hoverBackground); -} - -.mobile-sort-group-sheet-item.checked { - color: var(--vscode-menu-selectionForeground, var(--vscode-foreground)); - font-weight: 600; -} - -.mobile-sort-group-sheet-check { - width: 20px; - height: 20px; - display: flex; - align-items: center; - justify-content: center; - color: var(--vscode-textLink-foreground, var(--vscode-button-background)); - font-size: 14px; - flex-shrink: 0; -} - -.mobile-sort-group-sheet-label { - flex: 1; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -@keyframes mobile-sort-group-sheet-slide-up { - from { - transform: translateY(100%); - } - to { - transform: translateY(0); - } -} - -@keyframes mobile-sort-group-sheet-slide-down { - from { - transform: translateY(0); - } - to { - transform: translateY(100%); - } -} - -@keyframes mobile-sort-group-sheet-fade-in { - from { - opacity: 0; - } - to { - opacity: 1; - } -} - -@keyframes mobile-sort-group-sheet-fade-out { - from { - opacity: 1; - } - to { - opacity: 0; - } -} diff --git a/src/vs/sessions/browser/parts/mobile/mobileAuxiliaryBarPart.ts b/src/vs/sessions/browser/parts/mobile/mobileAuxiliaryBarPart.ts deleted file mode 100644 index fa402bc6..00000000 --- a/src/vs/sessions/browser/parts/mobile/mobileAuxiliaryBarPart.ts +++ /dev/null @@ -1,54 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { Parts } from '../../../../workbench/services/layout/browser/layoutService.js'; -import { AbstractPaneCompositePart } from '../../../../workbench/browser/parts/paneCompositePart.js'; -import { AuxiliaryBarPart } from '../auxiliaryBarPart.js'; -import { isPhoneLayout } from './mobileLayout.js'; - -/** - * Mobile variant of AuxiliaryBarPart. - * - * On phone-sized viewports the auxiliary bar fills the full grid cell - * without card margins or border insets. On tablet/desktop it falls - * back to the desktop behavior so runtime viewport transitions keep - * working. - */ -export class MobileAuxiliaryBarPart extends AuxiliaryBarPart { - - override updateStyles(): void { - // Always run the desktop implementation first so inline card styles - // are set on tablet/desktop transitions. In phone mode we then - // clear them so CSS can take over (inline styles have the highest - // specificity). - super.updateStyles(); - - if (!isPhoneLayout(this.layoutService)) { - return; - } - - const container = this.getContainer(); - if (container) { - container.style.backgroundColor = ''; - container.style.removeProperty('--part-background'); - container.style.removeProperty('--part-border-color'); - } - } - - override layout(width: number, height: number, top: number, left: number): void { - if (!isPhoneLayout(this.layoutService)) { - super.layout(width, height, top, left); - return; - } - - if (!this.layoutService.isVisible(Parts.AUXILIARYBAR_PART)) { - return; - } - - // Full dimensions — no card margins or border subtraction. - // AbstractPaneCompositePart.layout internally calls Part.layout. - AbstractPaneCompositePart.prototype.layout.call(this, width, height, top, left); - } -} diff --git a/src/vs/sessions/browser/parts/mobile/mobileChatBarPart.ts b/src/vs/sessions/browser/parts/mobile/mobileChatBarPart.ts deleted file mode 100644 index f4f82dcd..00000000 --- a/src/vs/sessions/browser/parts/mobile/mobileChatBarPart.ts +++ /dev/null @@ -1,58 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { Parts } from '../../../../workbench/services/layout/browser/layoutService.js'; -import { AbstractPaneCompositePart } from '../../../../workbench/browser/parts/paneCompositePart.js'; -import { ChatBarPart } from '../chatBarPart.js'; -import { isPhoneLayout } from './mobileLayout.js'; - -/** - * Mobile variant of ChatBarPart. - * - * On phone-sized viewports the chat bar fills the full grid cell without - * card margins, border insets, or session-bar height adjustments. When - * the viewport transitions to tablet/desktop (e.g., device rotation - * crossing the phone breakpoint) this delegates to the desktop - * implementation so layout math stays correct. - */ -export class MobileChatBarPart extends ChatBarPart { - - override updateStyles(): void { - // Always run the desktop implementation first so inline styles are - // set on tablet/desktop transitions. In phone mode we then clear - // the card-specific inline styles so CSS can take over. - super.updateStyles(); - - if (!isPhoneLayout(this.layoutService)) { - return; - } - - const container = this.getContainer(); - if (container) { - container.style.backgroundColor = ''; - container.style.removeProperty('--part-background'); - container.style.removeProperty('--part-border-color'); - container.style.color = ''; - } - } - - override layout(width: number, height: number, top: number, left: number): void { - if (!isPhoneLayout(this.layoutService)) { - super.layout(width, height, top, left); - return; - } - - if (!this.layoutService.isVisible(Parts.CHATBAR_PART)) { - return; - } - - this._lastLayout = { width, height, top, left }; - - // Full dimensions — no card margins or session-bar subtraction. - // AbstractPaneCompositePart.layout internally calls Part.layout so - // there is no need to invoke Part.prototype.layout separately. - AbstractPaneCompositePart.prototype.layout.call(this, width, height, top, left); - } -} diff --git a/src/vs/sessions/browser/parts/mobile/mobileChatShell.css b/src/vs/sessions/browser/parts/mobile/mobileChatShell.css deleted file mode 100644 index 955835f9..00000000 --- a/src/vs/sessions/browser/parts/mobile/mobileChatShell.css +++ /dev/null @@ -1,762 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -/* - * `!important` policy for this file: - * - * Only use `!important` when fighting one of: - * 1. SplitView.View.layoutContainer — sets inline position/top/left/width/height - * on every `.split-view-view` (src/vs/base/browser/ui/splitview/splitview.ts). - * 2. Part.layoutContents — inlines width/height on `.part > .content` via size(). - * 3. An equal-specificity desktop rule in the main workbench stylesheet. - * - * Rules that only face lower-specificity desktop CSS (e.g. a single `.part.X` - * selector) do NOT need `!important` — the `.phone-layout` class on the - * workbench root already wins. When adding a new rule, omit `!important` - * first and only add it if DevTools shows an actual override. - */ - -/* ---- Mobile Top Bar ---- */ - -.mobile-top-bar { - display: flex; - align-items: center; - height: 48px; - min-height: 48px; - padding: 0 4px; - padding-top: env(safe-area-inset-top); - background: var(--vscode-editor-background); - flex-shrink: 0; - -webkit-touch-callout: none; - user-select: none; - -webkit-user-select: none; - touch-action: manipulation; - z-index: 10; -} - -.mobile-top-bar .mobile-top-bar-button { - display: flex; - align-items: center; - justify-content: center; - width: 44px; - height: 44px; - border: none; - background: none; - color: var(--vscode-foreground); - cursor: pointer; - border-radius: 50%; - flex-shrink: 0; - touch-action: manipulation; - font-size: 18px; - padding: 0; -} - -.monaco-workbench .mobile-top-bar .mobile-top-bar-button:focus { - outline: none !important; -} - -.mobile-top-bar .mobile-top-bar-button:focus-visible { - outline: 1px solid var(--vscode-focusBorder); - outline-offset: -1px; -} - -.mobile-top-bar .mobile-top-bar-button:active { - background: var(--vscode-toolbar-hoverBackground); -} - -/* -- Mobile titlebar: Changes pill ------------------------------------- */ -/* Pill-shaped button showing total insertions/deletions for the active session. Tapping opens a quick pick of changed files; selecting one opens the mobile diff view. Hidden when the session has no changes or when on the welcome / new-chat screen. */ - -.mobile-top-bar .mobile-top-bar-button.mobile-changes-pill { - width: auto; - height: 28px; - padding: 0 10px; - border-radius: 14px; - gap: 4px; - font-size: 12px; - line-height: 18px; - font-variant-numeric: tabular-nums; - border: 1px solid var(--vscode-widget-border, color-mix(in srgb, var(--vscode-foreground) 15%, transparent)); - background: transparent; - color: var(--vscode-foreground); -} - -.mobile-top-bar .mobile-changes-pill .mobile-changes-pill-icon { - font-size: 14px; - color: var(--vscode-descriptionForeground); - display: flex; - align-items: center; -} - -.mobile-top-bar .mobile-changes-pill .mobile-changes-pill-added { - color: var(--vscode-gitDecoration-addedResourceForeground, var(--vscode-charts-green)); -} - -.mobile-top-bar .mobile-changes-pill .mobile-changes-pill-removed { - color: var(--vscode-gitDecoration-deletedResourceForeground, var(--vscode-charts-red)); -} - -.mobile-top-bar .mobile-changes-pill:active { - background: var(--vscode-toolbar-hoverBackground); -} - -.mobile-top-bar .mobile-top-bar-center { - flex: 1; - min-width: 0; - display: flex; - align-items: center; - height: 100%; -} - -.mobile-top-bar .mobile-session-title { - flex: 1; - min-width: 0; - text-align: center; - font-size: 16px; - font-weight: 500; - color: var(--vscode-foreground); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - padding: 0 4px; - cursor: pointer; - border: none; - background: none; - touch-action: manipulation; - font-family: inherit; -} - -.monaco-workbench .mobile-top-bar .mobile-session-title:focus { - outline: none !important; -} - -.mobile-top-bar .mobile-session-title:focus-visible { - outline: 1px solid var(--vscode-focusBorder); - outline-offset: -1px; -} - -.mobile-top-bar .mobile-session-title:active { - opacity: 0.7; -} - -.mobile-top-bar .mobile-top-bar-actions { - display: none; - flex: 1; - min-width: 0; - align-items: center; - height: 100%; -} - -/* When the welcome screen is visible and the center menu has contributed - items (e.g., the web host filter on the home screen) the title is - hidden and the toolbar takes its place. */ -.mobile-top-bar.show-actions .mobile-session-title { - display: none; -} - -.mobile-top-bar.show-actions .mobile-top-bar-actions { - display: flex; - flex-direction: column; -} - -/* ---- Phone Layout: Full-screen chat ---- */ - -/* On phone, stack the mobile top bar and grid vertically */ -.agent-sessions-workbench.phone-layout { - display: flex; - flex-direction: column; - overflow: hidden; -} - -/* On phone, all split-view-view wrappers inside the grid — both those - wrapping parts AND those wrapping nested grid branch nodes — fill the - full grid area. This collapses the multi-axis grid into a single - full-screen viewport so parts overlap rather than share horizontal - space. The sidebar uses its own z-index + transform to slide over - the chat (see sidebarPart.css). The :has() conditions scope strictly - to grid wrappers so splitviews used inside individual parts' content - (e.g. a sidebar's view list) are not affected. */ -.agent-sessions-workbench.phone-layout .split-view-view:has(> .part), -.agent-sessions-workbench.phone-layout .split-view-view:has(> .monaco-grid-branch-node) { - position: absolute !important; - top: 0 !important; - left: 0 !important; - width: 100% !important; - height: 100% !important; -} - -/* All grid branch nodes fill their parent. `.monaco-grid-branch-node` is - specific to the grid widget, so this descendant selector won't hit - splitviews used inside individual parts' content. The grid widget - does not write inline geometry to branch nodes (only to the wrapping - `.split-view-view`), so plain CSS suffices here. */ -.agent-sessions-workbench.phone-layout .monaco-grid-branch-node { - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; -} - -/* Remove card appearance from ALL parts on phone. - Specificity wins over the desktop card rule in style.css without !important; - width/height match what the mobile Part.layout() already inlines. */ -.agent-sessions-workbench.phone-layout .part.chatbar, -.agent-sessions-workbench.phone-layout .part.sidebar, -.agent-sessions-workbench.phone-layout .part.auxiliarybar, -.agent-sessions-workbench.phone-layout .part.panel { - margin: 0; - border: none; - border-radius: 0; - box-shadow: none; - --part-border-color: transparent; - width: 100%; - height: 100%; -} - -/* Pin Part content to the wrapper's full width on phone. - `!important` is required because `Part.layoutContents()` inlines the - width on `.part > .content` based on the splitview size (rule #2 in the - policy above). Without this, opening the sidebar — which makes the - splitview share space between sidebar and chatbar — would shrink the - chatbar's content during the drawer slide animation. */ -.agent-sessions-workbench.phone-layout .part.chatbar > .content, -.agent-sessions-workbench.phone-layout .part.sidebar > .content, -.agent-sessions-workbench.phone-layout .part.auxiliarybar > .content, -.agent-sessions-workbench.phone-layout .part.panel > .content { - width: 100% !important; - height: 100% !important; -} - -/* Hide the session composite bar (Copilot CLI / Approvals / Branch) on phone */ -.agent-sessions-workbench.phone-layout .session-composite-bar { - display: none; -} - -/* Ensure the grid view element doesn't overflow — flex child must shrink */ -.agent-sessions-workbench.phone-layout > .monaco-grid-view { - flex: 1 1 0% !important; - min-height: 0 !important; - overflow: hidden !important; - height: auto !important; - background-color: var(--vscode-editor-background); -} - -/* Remove max-width constraint on chat content */ -.agent-sessions-workbench.phone-layout .interactive-session .interactive-item-container { - max-width: none !important; -} - -.agent-sessions-workbench.phone-layout .interactive-session > .chat-suggest-next-widget { - max-width: none !important; -} - -.agent-sessions-workbench.phone-layout .interactive-session .interactive-input-part { - max-width: none !important; /* fights equal-specificity rule in style.css */ - padding-bottom: calc(10px + env(safe-area-inset-bottom)); -} - -/* Chat input minimum font size to prevent iOS auto-zoom */ -.agent-sessions-workbench.phone-layout .interactive-session .chat-input-container textarea, -.agent-sessions-workbench.phone-layout .interactive-session .chat-input-container input { - font-size: 16px !important; -} - -/* Hide the desktop titlebar on phone — replaced by mobile top bar */ -.agent-sessions-workbench.phone-layout .part.titlebar { - display: none !important; -} - -/* Sidebar content and customization toolbar should stack and scroll */ -.agent-sessions-workbench.phone-layout .part.sidebar { - display: flex !important; - flex-direction: column !important; - overflow: hidden !important; -} - -.agent-sessions-workbench.phone-layout .part.sidebar > .composite.title { - display: none; -} - -.agent-sessions-workbench.phone-layout .part.sidebar > .content { - top: 0 !important; - flex: 1 !important; - min-height: 0 !important; - overflow-y: auto !important; - -webkit-overflow-scrolling: touch; -} - -/* Customization toolbar: hidden on phone (opens editors, not mobile-compatible) */ -.agent-sessions-workbench.phone-layout .part.sidebar .ai-customization-toolbar { - display: none; -} - -/* Make sidebar footer touch-friendly */ -.agent-sessions-workbench.phone-layout .part.sidebar > .sidebar-footer .sidebar-action-button { - min-height: 44px; - padding: 8px 12px; -} - -/* Hide the "+ Session" button in the sidebar on phone — replaced by top bar + button */ -.agent-sessions-workbench.phone-layout .agent-sessions-new-button-container { - display: none; -} - -/* Hide sashes on phone */ -.agent-sessions-workbench.phone-layout .monaco-sash { - display: none !important; - pointer-events: none !important; -} - -/* Overscroll containment */ -.agent-sessions-workbench.phone-layout .interactive-session { - overscroll-behavior: contain; -} - -.agent-sessions-workbench.phone-layout .monaco-list { - overscroll-behavior: contain; -} - -/* On phone, push the chat input to the bottom of the chat area */ -.agent-sessions-workbench.phone-layout .interactive-session .interactive-input-and-execute-toolbar { - margin-top: auto; -} - -/* ---- Phone Layout: Chat Welcome Page ---- */ - -/* Make the welcome page a flex column that fills the chat area */ -.agent-sessions-workbench.phone-layout .new-chat-widget-container { - display: flex !important; - flex-direction: column !important; - height: 100% !important; - padding: 8px 8px 0 8px !important; -} - -.agent-sessions-workbench.phone-layout .new-chat-widget-content { - display: flex !important; - flex-direction: column !important; - flex: 1 !important; - min-height: 0 !important; - max-width: 100% !important; - padding-bottom: 20px !important; -} - -/* Workspace picker centered vertically with icon above */ -.agent-sessions-workbench.phone-layout .new-session-workspace-picker-container { - flex: 1 !important; - display: flex !important; - flex-direction: column !important; - align-items: center !important; - justify-content: center !important; - max-width: 100% !important; -} - -/* Show the sessions logo above the workspace picker — same asset as the auth page */ -.agent-sessions-workbench.phone-layout .new-session-workspace-picker-container::before { - content: ''; - display: block; - width: 64px; - height: 64px; - margin-bottom: 16px; - background-image: url('../../media/sessions-logo-light.svg'); - background-size: contain; - background-repeat: no-repeat; - background-position: center; -} - -.vs .agent-sessions-workbench.phone-layout .new-session-workspace-picker-container::before, -.hc-light .agent-sessions-workbench.phone-layout .new-session-workspace-picker-container::before { - background-image: url('../../media/sessions-logo-dark.svg'); -} - -/* Center the picker text */ -.agent-sessions-workbench.phone-layout .session-workspace-picker { - display: flex !important; - flex-direction: column !important; - align-items: center !important; - gap: 8px !important; - font-size: 16px !important; -} - -.agent-sessions-workbench.phone-layout .session-workspace-picker-label { - font-size: 18px; - opacity: 0.6; -} - -/* Input slot pinned to the bottom */ -.agent-sessions-workbench.phone-layout .new-chat-input-container { - flex-shrink: 0 !important; - padding: 0 0 8px 0 !important; - max-width: 100% !important; -} - -/* Make the chat input full-width and edge-to-edge styled */ -.agent-sessions-workbench.phone-layout .sessions-chat-input-area { - border-radius: 16px !important; - max-width: 100% !important; -} - -/* Hide the local mode bar (Copilot CLI / Default Approvals / Branch) on phone */ -.agent-sessions-workbench.phone-layout .new-chat-bottom-container { - display: none; -} - -/* Also hide the sessions-chat-widget's DnD overlay on phone */ -.agent-sessions-workbench.phone-layout .sessions-chat-dnd-overlay { - display: none; -} - -/* Chat widget fills full width on phone */ -.agent-sessions-workbench.phone-layout .sessions-chat-widget { - width: 100% !important; -} - -/* allow-any-unicode-next-line */ -/* Compact chat toolbar on phone */ -.agent-sessions-workbench.phone-layout .sessions-chat-toolbar { - padding: 0 6px 0 6px !important; - max-height: 32px !important; - gap: 4px !important; -} - -/* Prevent card transitions from flashing on phone */ -.agent-sessions-workbench.phone-layout .part.chatbar, -.agent-sessions-workbench.phone-layout .part.sidebar, -.agent-sessions-workbench.phone-layout .part.auxiliarybar, -.agent-sessions-workbench.phone-layout .part.panel { - transition: none !important; -} - -/* ---- Mobile Account Indicator ---- */ - -.mobile-top-bar .mobile-account-indicator { - position: relative; -} - -.mobile-top-bar .mobile-account-avatar { - display: none; - width: 28px; - height: 28px; - border-radius: 50%; - object-fit: cover; - border: 1px solid var(--vscode-commandCenter-border, transparent); - box-sizing: border-box; -} - -.mobile-top-bar .mobile-account-avatar.visible { - display: block; -} - -/* Hide the codicon when the avatar is loaded */ -.mobile-top-bar .mobile-account-indicator .codicon.hidden { - display: none; -} - -.mobile-top-bar .mobile-account-badge { - position: absolute; - top: 8px; - right: 8px; - width: 8px; - height: 8px; - border-radius: 50%; - border: 1.5px solid var(--vscode-editor-background); - background: var(--vscode-editorWarning-foreground); - pointer-events: none; -} - -.mobile-top-bar .mobile-account-badge.dot-badge-warning { - background: var(--vscode-editorWarning-foreground); -} - -.mobile-top-bar .mobile-account-badge.dot-badge-error { - background: var(--vscode-editorError-foreground); -} - -/* ---- Mobile Account Sheet (full-screen bottom sheet) ---- */ - -.mobile-account-sheet { - position: fixed; - inset: 0; - z-index: 1000; - display: flex; - flex-direction: column; - background: var(--vscode-editor-background); - color: var(--vscode-foreground); - touch-action: manipulation; - -webkit-touch-callout: none; - user-select: none; - -webkit-user-select: none; -} - -.mobile-account-sheet-header { - display: flex; - align-items: center; - justify-content: space-between; - height: 56px; - min-height: 56px; - padding: 0 16px; - padding-top: env(safe-area-inset-top); - border-bottom: 1px solid var(--vscode-panel-border, var(--vscode-editorWidget-border, transparent)); - flex-shrink: 0; -} - -.mobile-account-sheet-title { - font-size: 18px; - font-weight: 600; - margin: 0; - color: var(--vscode-foreground); -} - -.mobile-account-sheet-close { - display: flex; - align-items: center; - justify-content: center; - width: 44px; - height: 44px; - border: none; - background: none; - color: var(--vscode-foreground); - cursor: pointer; - border-radius: 50%; - font-size: 18px; - padding: 0; - touch-action: manipulation; -} - -.mobile-account-sheet-close:active { - background: var(--vscode-toolbar-hoverBackground); -} - -.mobile-account-sheet-content { - flex: 1; - overflow-y: auto; - -webkit-overflow-scrolling: touch; - overscroll-behavior: contain; - padding: 0 16px; - padding-bottom: calc(16px + env(safe-area-inset-bottom)); -} - -/* Profile card */ - -.mobile-account-sheet-profile { - display: flex; - align-items: center; - gap: 16px; - padding: 24px 0 20px; -} - -.mobile-account-sheet-avatar { - width: 56px; - height: 56px; - border-radius: 50%; - object-fit: cover; - flex-shrink: 0; -} - -.mobile-account-sheet-avatar-placeholder { - display: flex; - align-items: center; - justify-content: center; - width: 56px; - height: 56px; - border-radius: 50%; - background: var(--vscode-badge-background); - color: var(--vscode-badge-foreground); - font-size: 28px; - flex-shrink: 0; -} - -.mobile-account-sheet-profile-info { - min-width: 0; - flex: 1; -} - -.mobile-account-sheet-name { - font-size: 18px; - font-weight: 600; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.mobile-account-sheet-provider { - font-size: 14px; - color: var(--vscode-descriptionForeground); - margin-top: 2px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -/* Dashboard section */ - -.mobile-account-sheet-section { - padding: 8px 0 16px; - border-top: 1px solid var(--vscode-panel-border, var(--vscode-editorWidget-border, transparent)); -} - -/* Action rows */ - -.mobile-account-sheet-actions { - display: flex; - flex-direction: column; - border-top: 1px solid var(--vscode-panel-border, var(--vscode-editorWidget-border, transparent)); - padding: 8px 0; -} - -.mobile-account-sheet-action { - display: flex; - align-items: center; - gap: 16px; - height: 52px; - min-height: 52px; - padding: 0 4px; - border: none; - border-radius: 12px; - background: none; - color: var(--vscode-foreground); - font-size: 16px; - text-align: left; - cursor: pointer; - touch-action: manipulation; - font-family: inherit; -} - -.mobile-account-sheet-action:active { - background: var(--vscode-list-hoverBackground); -} - -.mobile-account-sheet-action:disabled { - opacity: 0.5; - cursor: default; -} - -.mobile-account-sheet-action-icon { - font-size: 20px; - width: 24px; - text-align: center; - flex-shrink: 0; - color: var(--vscode-descriptionForeground); -} - -.mobile-account-sheet-action-label { - flex: 1; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.mobile-account-sheet-separator { - height: 1px; - margin: 4px 0; - background: var(--vscode-panel-border, var(--vscode-editorWidget-border, transparent)); -} - -.agent-sessions-workbench.phone-layout .sessions-account-titlebar-panel-action { - min-height: 44px; - touch-action: manipulation; -} - -.agent-sessions-workbench.phone-layout .sessions-account-titlebar-panel-header-action { - min-width: 44px; - min-height: 44px; - touch-action: manipulation; -} - -/* -- Phone: Sessions list header --------------------------------------- */ -/* The desktop header row (Sessions label + New + Filter + Find toolbar) is hidden on phone in favor of the filter-chips row and the (+) button in MobileTitlebarPart. The row container itself is still rendered because the tree's find widget mounts inside it; the `phone-layout-empty` class is removed when the find widget is open so the row reappears for searching. */ - -.agent-sessions-workbench.phone-layout .agent-sessions-header-row.phone-layout-empty { - display: none; -} - -/* On phone the label / actions slots are intentionally left empty (the - * desktop header content is replaced by the mobile filter chips and the - * (+) button in MobileTitlebarPart). When the find widget opens the row - * becomes visible, but these two empty flex children would still occupy - * leading space because of `gap` between siblings. Collapse them so the - * find input sits flush with the row's leading edge. */ -.agent-sessions-workbench.phone-layout .agent-sessions-header-row > .agent-sessions-header-label, -.agent-sessions-workbench.phone-layout .agent-sessions-header-row > .agent-sessions-header-actions { - display: none; -} - -/* -- Phone: Find widget mobile-friendly UX ----------------------------- */ -/* When the user opens find on phone, the desktop type-filter widget is normally a tiny floating input with a 24px tall input box and a 22x22 close button — designed for a mouse cursor. On phone, expand it to a full-width row with thumb-sized targets and a single visible search input. */ - -.agent-sessions-workbench.phone-layout .agent-sessions-header-row { - /* The find widget commandeers the row when open. Use minimal horizontal padding so the input stretches as close to the screen edges as possible — feels right for a phone search field. */ - padding: 6px 4px 6px 8px; - min-height: 52px; - gap: 4px; -} - -.agent-sessions-workbench.phone-layout .agent-sessions-find-widget-container { - /* Take the full width on phone — there's no label/toolbar competing for space because they're hidden in this mode. */ - flex: 1 1 auto; - min-width: 0; -} - -.agent-sessions-workbench.phone-layout - .agent-sessions-find-widget-container > .monaco-tree-type-filter { - /* Reset desktop offsets — the desktop widget tries to align with the tree-element rows above it; on phone it lives in its own row. */ - width: 100%; - gap: 4px; -} - -.agent-sessions-workbench.phone-layout - .agent-sessions-find-widget-container > .monaco-tree-type-filter - .monaco-tree-type-filter-input { - flex: 1 1 auto; - min-width: 0; -} - -.agent-sessions-workbench.phone-layout - .agent-sessions-find-widget-container > .monaco-tree-type-filter - .monaco-tree-type-filter-input > .monaco-findInput { - display: block; - width: 100%; -} - -.agent-sessions-workbench.phone-layout - .agent-sessions-find-widget-container > .monaco-tree-type-filter - .monaco-tree-type-filter-input > .monaco-findInput > .monaco-inputbox { - min-height: 36px; - border-radius: 18px; - padding: 0 12px; - font-size: 14px; - width: 100%; -} - -.agent-sessions-workbench.phone-layout - .agent-sessions-find-widget-container > .monaco-tree-type-filter - .monaco-tree-type-filter-input > .monaco-findInput > .monaco-inputbox input { - font-size: 14px; - line-height: 22px; -} - -/* Hide the per-input "filter mode" toggle (matches whole word, etc.) on phone — it's tiny and rarely useful for a sessions list lookup; the primary action is "type to filter". */ -.agent-sessions-workbench.phone-layout - .agent-sessions-find-widget-container > .monaco-tree-type-filter - .monaco-tree-type-filter-input > .monaco-findInput > .controls { - display: none; -} - -/* Close button: thumb-sized, hugs the right edge. Override the desktop actionbar's natural left-margin so there is no extra empty gap between the input and the X. */ -.agent-sessions-workbench.phone-layout - .agent-sessions-find-widget-container > .monaco-tree-type-filter - > .monaco-tree-type-filter-actionbar { - margin: 0; - flex-shrink: 0; -} - -.agent-sessions-workbench.phone-layout - .agent-sessions-find-widget-container > .monaco-tree-type-filter - > .monaco-tree-type-filter-actionbar .monaco-action-bar .action-label { - width: 36px; - height: 36px; - padding: 0; -} diff --git a/src/vs/sessions/browser/parts/mobile/mobileLayout.ts b/src/vs/sessions/browser/parts/mobile/mobileLayout.ts deleted file mode 100644 index 3d7ca98d..00000000 --- a/src/vs/sessions/browser/parts/mobile/mobileLayout.ts +++ /dev/null @@ -1,26 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { IWorkbenchLayoutService } from '../../../../workbench/services/layout/browser/layoutService.js'; - -/** - * CSS class applied to the sessions workbench main container when the - * viewport is classified as phone. Must stay in sync with - * `LayoutClasses.PHONE_LAYOUT` in `workbench.ts`. - */ -const PHONE_LAYOUT_CLASS = 'phone-layout'; - -/** - * Returns true when the sessions workbench currently has the phone - * layout class on its main container. - * - * Mobile Part subclasses are chosen once at construction time, but the - * viewport class can change at runtime (e.g., device rotation crossing - * the phone breakpoint). Parts use this to decide whether to apply - * mobile-specific layout math or defer to the desktop implementation. - */ -export function isPhoneLayout(layoutService: IWorkbenchLayoutService): boolean { - return layoutService.mainContainer.classList.contains(PHONE_LAYOUT_CLASS); -} diff --git a/src/vs/sessions/browser/parts/mobile/mobilePanelPart.ts b/src/vs/sessions/browser/parts/mobile/mobilePanelPart.ts deleted file mode 100644 index 2891360e..00000000 --- a/src/vs/sessions/browser/parts/mobile/mobilePanelPart.ts +++ /dev/null @@ -1,50 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { Parts } from '../../../../workbench/services/layout/browser/layoutService.js'; -import { AbstractPaneCompositePart } from '../../../../workbench/browser/parts/paneCompositePart.js'; -import { PanelPart } from '../panelPart.js'; -import { isPhoneLayout } from './mobileLayout.js'; - -/** - * Mobile variant of PanelPart. - * - * On phone-sized viewports the panel fills the full grid cell - * without card margins or border insets. On tablet/desktop it falls - * back to the desktop behavior so runtime viewport transitions keep - * working. - */ -export class MobilePanelPart extends PanelPart { - - override updateStyles(): void { - super.updateStyles(); - - if (!isPhoneLayout(this.layoutService)) { - return; - } - - const container = this.getContainer(); - if (container) { - container.style.backgroundColor = ''; - container.style.removeProperty('--part-background'); - container.style.removeProperty('--part-border-color'); - } - } - - override layout(width: number, height: number, top: number, left: number): void { - if (!isPhoneLayout(this.layoutService)) { - super.layout(width, height, top, left); - return; - } - - if (!this.layoutService.isVisible(Parts.PANEL_PART)) { - return; - } - - // Full dimensions — no card margins or border subtraction. - // AbstractPaneCompositePart.layout internally calls Part.layout. - AbstractPaneCompositePart.prototype.layout.call(this, width, height, top, left); - } -} diff --git a/src/vs/sessions/browser/parts/mobile/mobileSessionFilterChips.ts b/src/vs/sessions/browser/parts/mobile/mobileSessionFilterChips.ts deleted file mode 100644 index da89fe24..00000000 --- a/src/vs/sessions/browser/parts/mobile/mobileSessionFilterChips.ts +++ /dev/null @@ -1,299 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import './media/mobileSessionFilterChips.css'; -import * as DOM from '../../../../base/browser/dom.js'; -import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; -import { Emitter, Event } from '../../../../base/common/event.js'; -import { Gesture, EventType as TouchEventType } from '../../../../base/browser/touch.js'; -import { EventType } from '../../../../base/browser/dom.js'; -import { Codicon } from '../../../../base/common/codicons.js'; -import { ThemeIcon } from '../../../../base/common/themables.js'; -import { localize } from '../../../../nls.js'; -import { SessionStatus } from '../../../services/sessions/common/session.js'; - -const $ = DOM.$; - -/** - * Chip definition for a status filter in the mobile session list. - */ -interface IFilterChipDef { - readonly label: string; - /** Status values that this chip toggles (e.g., "In Progress" covers both InProgress and NeedsInput). */ - readonly statuses: readonly SessionStatus[]; -} - -/** - * Callback interface for driving the filter state from chips. - * This mirrors the filter API on {@link ISessionsList} so the chip widget - * doesn't depend on the tree control directly. - */ -export interface IMobileSessionFilterChipHost { - isStatusExcluded(status: SessionStatus): boolean; - setStatusExcluded(status: SessionStatus, excluded: boolean): void; - readonly onDidUpdate: Event; -} - -/** - * Horizontally scrollable chip row for quick session status filtering on - * phone-sized viewports. Shows three fixed status chips, a "Sort" - * chip that opens the full filter context menu, and a "Find" chip that - * opens the find widget. - * - * Architecture note: this widget is phone-only and created conditionally - * in {@link SessionsView}. It does NOT branch on IsPhoneLayoutContext - * internally — the conditional instantiation happens at the call site. - */ -export class MobileSessionFilterChips extends Disposable { - - private readonly container: HTMLElement; - /** - * Inner horizontally scrollable region that hosts the status filter - * chips and the "Sort" chip. The "Find" chip lives OUTSIDE - * this scroll area, pinned to the right edge of {@link container} so - * it's always reachable on phone-width viewports without scrolling. - */ - private readonly scrollContainer: HTMLElement; - private readonly chipElements = new Map(); - private readonly chipDisposables = this._register(new DisposableStore()); - - private readonly _onDidRequestSortGroup = this._register(new Emitter()); - /** - * Fired when the user taps the "Sort" chip. The argument is - * the chip's DOM element so the host can anchor a sheet/menu to it. - */ - readonly onDidRequestSortGroup: Event = this._onDidRequestSortGroup.event; - - private readonly _onDidRequestFind = this._register(new Emitter()); - /** - * Fired when the user taps the "Find" chip. The host should open the - * sessions find widget. - */ - readonly onDidRequestFind: Event = this._onDidRequestFind.event; - - private static readonly CHIP_DEFS: readonly IFilterChipDef[] = [ - { - label: localize('chipCompleted', "Completed"), - statuses: [SessionStatus.Completed], - }, - { - label: localize('chipInProgress', "In Progress"), - statuses: [SessionStatus.InProgress, SessionStatus.NeedsInput], - }, - { - label: localize('chipFailed', "Failed"), - statuses: [SessionStatus.Error], - }, - ]; - - constructor( - parent: HTMLElement, - private readonly host: IMobileSessionFilterChipHost, - ) { - super(); - - this.container = DOM.append(parent, $('.mobile-session-filter-chips')); - this.container.setAttribute('role', 'toolbar'); - this.container.setAttribute('aria-label', localize('filterChipsLabel', "Session status filters")); - - this.scrollContainer = DOM.append(this.container, $('.mobile-session-filter-chips-scroll')); - - this.renderChips(); - - // Re-sync active state when the list updates (filters may have - // changed via the full filter menu or programmatic reset). - this._register(this.host.onDidUpdate(() => this.syncActiveStates())); - } - - private renderChips(): void { - this.chipDisposables.clear(); - this.chipElements.clear(); - DOM.clearNode(this.scrollContainer); - // The Find chip lives directly on `container` (sibling of - // `scrollContainer`) so we need to remove it too on re-render. - // `scrollContainer` itself must be preserved. - for (const child of Array.from(this.container.children)) { - if (child !== this.scrollContainer) { - child.remove(); - } - } - - for (const def of MobileSessionFilterChips.CHIP_DEFS) { - this.createStatusChip(def); - } - - this.createSortGroupChip(); - this.createFindChip(); - this.syncActiveStates(); - } - - private createStatusChip(def: IFilterChipDef): void { - const chip = DOM.append(this.scrollContainer, $('.mobile-session-filter-chip')); - chip.setAttribute('role', 'button'); - chip.setAttribute('tabindex', '0'); - chip.setAttribute('aria-pressed', 'false'); - - const label = DOM.append(chip, $('span.chip-label')); - label.textContent = def.label; - - this.chipElements.set(def.label, chip); - - // Touch + click handling (iOS requires both per sessions instructions) - this.chipDisposables.add(Gesture.addTarget(chip)); - this.chipDisposables.add(DOM.addDisposableListener(chip, EventType.CLICK, (e) => { - e.preventDefault(); - this.toggleStatusChip(def); - })); - this.chipDisposables.add(DOM.addDisposableListener(chip, TouchEventType.Tap, () => { - this.toggleStatusChip(def); - })); - - // Keyboard activation - this.chipDisposables.add(DOM.addDisposableListener(chip, EventType.KEY_DOWN, (e: KeyboardEvent) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - this.toggleStatusChip(def); - } - })); - } - - private createSortGroupChip(): void { - const chip = DOM.append(this.scrollContainer, $('.mobile-session-filter-chip.mobile-session-filter-chip-action')); - chip.setAttribute('role', 'button'); - chip.setAttribute('tabindex', '0'); - chip.setAttribute('aria-label', localize('sortGroupAriaLabel', "Sort and group options")); - - const icon = DOM.append(chip, $('span.chip-icon')); - icon.classList.add(...ThemeIcon.asClassNameArray(Codicon.listFilter)); - - const label = DOM.append(chip, $('span.chip-label')); - label.textContent = localize('sortGroup', "Sort"); - - const fire = () => this._onDidRequestSortGroup.fire(chip); - - this.chipDisposables.add(Gesture.addTarget(chip)); - this.chipDisposables.add(DOM.addDisposableListener(chip, EventType.CLICK, (e) => { - e.preventDefault(); - fire(); - })); - this.chipDisposables.add(DOM.addDisposableListener(chip, TouchEventType.Tap, () => { - fire(); - })); - - this.chipDisposables.add(DOM.addDisposableListener(chip, EventType.KEY_DOWN, (e: KeyboardEvent) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - fire(); - } - })); - } - - private createFindChip(): void { - const chip = DOM.append(this.container, $('.mobile-session-filter-chip.mobile-session-filter-chip-action.icon-only')); - chip.setAttribute('role', 'button'); - chip.setAttribute('tabindex', '0'); - chip.setAttribute('aria-label', localize('findAriaLabel', "Find session")); - - const icon = DOM.append(chip, $('span.chip-icon')); - icon.classList.add(...ThemeIcon.asClassNameArray(Codicon.search)); - - const fire = () => this._onDidRequestFind.fire(); - - this.chipDisposables.add(Gesture.addTarget(chip)); - this.chipDisposables.add(DOM.addDisposableListener(chip, EventType.CLICK, (e) => { - e.preventDefault(); - fire(); - })); - this.chipDisposables.add(DOM.addDisposableListener(chip, TouchEventType.Tap, () => { - fire(); - })); - - this.chipDisposables.add(DOM.addDisposableListener(chip, EventType.KEY_DOWN, (e: KeyboardEvent) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - fire(); - } - })); - } - - /** - * Toggle a status filter chip. The chip applies an _inclusive_ filter: - * - * - When a chip is activated, only sessions matching the chip's statuses - * are shown (all other statuses become excluded). - * - Tapping the same chip again deactivates it, restoring the default - * (no status exclusions). - * - When multiple chips are active, sessions matching ANY active chip - * are shown. - */ - private toggleStatusChip(def: IFilterChipDef): void { - const isCurrentlyActive = this.isChipActive(def); - - if (isCurrentlyActive) { - // Deactivate: clear all status exclusions (show everything) - for (const chipDef of MobileSessionFilterChips.CHIP_DEFS) { - for (const status of chipDef.statuses) { - this.host.setStatusExcluded(status, false); - } - } - } else { - // Activate: exclude all statuses NOT covered by any active chip - // First, compute the new set of active chips (current + this one) - const willBeActive = new Set(); - willBeActive.add(def); - for (const otherDef of MobileSessionFilterChips.CHIP_DEFS) { - if (otherDef !== def && this.isChipActive(otherDef)) { - willBeActive.add(otherDef); - } - } - - const includedStatuses = new Set(); - for (const activeDef of willBeActive) { - for (const status of activeDef.statuses) { - includedStatuses.add(status); - } - } - - // Exclude every status not in the included set, - // include every status in the included set - for (const chipDef of MobileSessionFilterChips.CHIP_DEFS) { - for (const status of chipDef.statuses) { - this.host.setStatusExcluded(status, !includedStatuses.has(status)); - } - } - } - - this.syncActiveStates(); - } - - /** - * A chip is considered "active" when ALL of its statuses are NOT excluded - * AND at least one other status IS excluded (meaning the user is - * filtering). - */ - private isChipActive(def: IFilterChipDef): boolean { - const allStatuses = MobileSessionFilterChips.CHIP_DEFS.flatMap(d => [...d.statuses]); - const hasAnyExclusion = allStatuses.some(s => this.host.isStatusExcluded(s)); - if (!hasAnyExclusion) { - return false; // no filters active → no chip is "active" - } - return def.statuses.every(s => !this.host.isStatusExcluded(s)); - } - - private syncActiveStates(): void { - for (const def of MobileSessionFilterChips.CHIP_DEFS) { - const chip = this.chipElements.get(def.label); - if (chip) { - const active = this.isChipActive(def); - chip.classList.toggle('active', active); - chip.setAttribute('aria-pressed', String(active)); - } - } - } - - get element(): HTMLElement { - return this.container; - } -} diff --git a/src/vs/sessions/browser/parts/mobile/mobileSidebarPart.ts b/src/vs/sessions/browser/parts/mobile/mobileSidebarPart.ts deleted file mode 100644 index f062ddfd..00000000 --- a/src/vs/sessions/browser/parts/mobile/mobileSidebarPart.ts +++ /dev/null @@ -1,37 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { AbstractPaneCompositePart } from '../../../../workbench/browser/parts/paneCompositePart.js'; -import { SidebarPart } from '../sidebarPart.js'; -import { isPhoneLayout } from './mobileLayout.js'; - -/** - * Mobile variant of SidebarPart. - * - * On phone-sized viewports the sidebar skips card-specific inline styles - * so that CSS-only theming takes over. On tablet/desktop it falls back - * to the desktop behavior so runtime viewport transitions keep working. - */ -export class MobileSidebarPart extends SidebarPart { - - override updateStyles(): void { - // Run base theme wiring; this also cascades to AbstractPaneCompositePart. - super.updateStyles(); - - if (!isPhoneLayout(this.layoutService)) { - return; - } - - // Skip SidebarPart's card / title-area inline styles on phone. - AbstractPaneCompositePart.prototype.updateStyles.call(this); - - const container = this.getContainer(); - if (container) { - container.style.backgroundColor = ''; - container.style.color = ''; - container.style.outlineColor = ''; - } - } -} diff --git a/src/vs/sessions/browser/parts/mobile/mobileSortGroupSheet.ts b/src/vs/sessions/browser/parts/mobile/mobileSortGroupSheet.ts deleted file mode 100644 index 3dba3888..00000000 --- a/src/vs/sessions/browser/parts/mobile/mobileSortGroupSheet.ts +++ /dev/null @@ -1,173 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import './media/mobileSortGroupSheet.css'; -import * as DOM from '../../../../base/browser/dom.js'; -import { Codicon } from '../../../../base/common/codicons.js'; -import { ThemeIcon } from '../../../../base/common/themables.js'; -import { Gesture, EventType as TouchEventType } from '../../../../base/browser/touch.js'; -import { localize } from '../../../../nls.js'; - -const $ = DOM.$; - -/** - * One row in the {@link showMobileSortGroupSheet} bottom sheet. - */ -export interface IMobileSortGroupSheetItem { - readonly id: string; - readonly label: string; - readonly checked: boolean; - /** - * Logical group used for visually separating sort vs. group toggles - * with a divider. Items with the same `group` value render together. - */ - readonly group: string; - /** - * Optional section title rendered above this group's first item. Only - * the first item of a `group` should set `groupTitle` — subsequent - * items reuse the existing section. - */ - readonly groupTitle?: string; -} - -/** - * Show a phone-friendly bottom sheet of single-select toggle items. - * - * Renders as a fixed-position overlay docked at the bottom of the - * viewport with a translucent backdrop. Tapping a row resolves with that - * row's `id`; tapping the backdrop, the close button, or pressing Escape - * resolves with `undefined`. - * - * The sheet is purpose-built for mobile UX (large touch targets, native - * sheet feel, safe-area-aware) — it is intentionally simpler than a - * generic context menu / quick pick. - * - * @param workbenchContainer The workbench root element. The overlay is - * appended here and removed on dismiss. - * @param title Sheet title displayed in the header. - * @param items Items to render. Items grouped by `group` are visually - * separated. - * @returns A promise that resolves with the selected item id, or - * `undefined` if dismissed without selection. - */ -export function showMobileSortGroupSheet( - workbenchContainer: HTMLElement, - title: string, - items: readonly IMobileSortGroupSheetItem[], -): Promise { - return new Promise(resolve => { - const disposables: (() => void)[] = []; - let resolved = false; - - const finish = (id: string | undefined) => { - if (resolved) { - return; - } - resolved = true; - // Animate sheet out before removal for a more native feel. - sheet.classList.add('closing'); - backdrop.classList.add('closing'); - DOM.getWindow(workbenchContainer).setTimeout(() => { - for (const d of disposables) { - try { d(); } catch { /* ignore */ } - } - overlay.remove(); - resolve(id); - }, 180); - }; - - // -- DOM: backdrop + sheet ------------------------------------- - const overlay = DOM.append(workbenchContainer, $('div.mobile-sort-group-sheet-overlay')); - const backdrop = DOM.append(overlay, $('div.mobile-sort-group-sheet-backdrop')); - const sheet = DOM.append(overlay, $('div.mobile-sort-group-sheet')); - sheet.setAttribute('role', 'dialog'); - sheet.setAttribute('aria-modal', 'true'); - sheet.setAttribute('aria-label', title); - - // -- Header (handle bar + title + close) ----------------------- - DOM.append(sheet, $('div.mobile-sort-group-sheet-handle')); - const header = DOM.append(sheet, $('div.mobile-sort-group-sheet-header')); - DOM.append(header, $('div.mobile-sort-group-sheet-title')).textContent = title; - const closeBtn = DOM.append(header, $('button.mobile-sort-group-sheet-close', { type: 'button' })) as HTMLButtonElement; - closeBtn.setAttribute('aria-label', localize('sortGroupSheet.close', "Close")); - DOM.append(closeBtn, $('span')).classList.add(...ThemeIcon.asClassNameArray(Codicon.close)); - const closeGesture = Gesture.addTarget(closeBtn); - disposables.push(() => closeGesture.dispose()); - const closeClick = DOM.addDisposableListener(closeBtn, DOM.EventType.CLICK, (e: MouseEvent) => { - e.preventDefault(); - finish(undefined); - }); - disposables.push(() => closeClick.dispose()); - const closeTap = DOM.addDisposableListener(closeBtn, TouchEventType.Tap, () => finish(undefined)); - disposables.push(() => closeTap.dispose()); - - // -- Items list ------------------------------------------------ - const list = DOM.append(sheet, $('div.mobile-sort-group-sheet-list')); - list.setAttribute('role', 'listbox'); - - let lastGroup: string | undefined; - let firstRow: HTMLButtonElement | undefined; - let firstCheckedRow: HTMLButtonElement | undefined; - for (const item of items) { - if (item.group !== lastGroup) { - if (lastGroup !== undefined) { - DOM.append(list, $('div.mobile-sort-group-sheet-divider')); - } - if (item.groupTitle) { - const sectionTitle = DOM.append(list, $('div.mobile-sort-group-sheet-section-title')); - sectionTitle.textContent = item.groupTitle; - } - lastGroup = item.group; - } - - const row = DOM.append(list, $('button.mobile-sort-group-sheet-item', { type: 'button' })) as HTMLButtonElement; - row.setAttribute('role', 'option'); - row.setAttribute('aria-selected', String(item.checked)); - if (item.checked) { - row.classList.add('checked'); - } - firstRow ??= row; - if (item.checked && !firstCheckedRow) { - firstCheckedRow = row; - } - - const checkSlot = DOM.append(row, $('span.mobile-sort-group-sheet-check')); - if (item.checked) { - checkSlot.classList.add(...ThemeIcon.asClassNameArray(Codicon.check)); - } - DOM.append(row, $('span.mobile-sort-group-sheet-label')).textContent = item.label; - - const rowGesture = Gesture.addTarget(row); - disposables.push(() => rowGesture.dispose()); - const rowClick = DOM.addDisposableListener(row, DOM.EventType.CLICK, (e: MouseEvent) => { - e.preventDefault(); - finish(item.id); - }); - disposables.push(() => rowClick.dispose()); - const rowTap = DOM.addDisposableListener(row, TouchEventType.Tap, () => finish(item.id)); - disposables.push(() => rowTap.dispose()); - } - - // -- Dismissal: backdrop + Escape ------------------------------ - const backdropClick = DOM.addDisposableListener(backdrop, DOM.EventType.CLICK, () => finish(undefined)); - disposables.push(() => backdropClick.dispose()); - const backdropGesture = Gesture.addTarget(backdrop); - disposables.push(() => backdropGesture.dispose()); - const backdropTap = DOM.addDisposableListener(backdrop, TouchEventType.Tap, () => finish(undefined)); - disposables.push(() => backdropTap.dispose()); - - const keyHandler = DOM.addDisposableListener(DOM.getWindow(workbenchContainer), DOM.EventType.KEY_DOWN, (e: KeyboardEvent) => { - if (e.key === 'Escape') { - e.preventDefault(); - e.stopPropagation(); - finish(undefined); - } - }, true); - disposables.push(() => keyHandler.dispose()); - - // Focus the first checked row (or the first row) for keyboard users. - (firstCheckedRow ?? firstRow)?.focus(); - }); -} diff --git a/src/vs/sessions/browser/parts/mobile/mobileTitlebarPart.ts b/src/vs/sessions/browser/parts/mobile/mobileTitlebarPart.ts deleted file mode 100644 index caec6ecf..00000000 --- a/src/vs/sessions/browser/parts/mobile/mobileTitlebarPart.ts +++ /dev/null @@ -1,593 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import './mobileChatShell.css'; -import { Disposable, DisposableStore, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; -import { $, addDisposableListener, append, EventType } from '../../../../base/browser/dom.js'; -import { Emitter, Event } from '../../../../base/common/event.js'; -import { ThemeIcon } from '../../../../base/common/themables.js'; -import { Codicon } from '../../../../base/common/codicons.js'; -import { IAction, Separator } from '../../../../base/common/actions.js'; -import { localize } from '../../../../nls.js'; -import { autorun } from '../../../../base/common/observable.js'; -import { URI } from '../../../../base/common/uri.js'; -import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; -import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; -import { HiddenItemStrategy, MenuWorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js'; -import { IMenuService } from '../../../../platform/actions/common/actions.js'; -import { fillInActionBarActions } from '../../../../platform/actions/browser/menuEntryActionViewItem.js'; -import { ICommandService } from '../../../../platform/commands/common/commands.js'; -import { IQuickInputService, IQuickPickItem } from '../../../../platform/quickinput/common/quickInput.js'; -import { IDefaultAccountService } from '../../../../platform/defaultAccount/common/defaultAccount.js'; -import { IAuthenticationService } from '../../../../workbench/services/authentication/common/authentication.js'; -import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; -import { ISessionFileChange } from '../../../services/sessions/common/session.js'; -import { IsNewChatSessionContext } from '../../../common/contextkeys.js'; -import { SideBarVisibleContext } from '../../../../workbench/common/contextkeys.js'; -import { Menus } from '../../menus.js'; -import { ChatEntitlement, ChatEntitlementService, IChatEntitlementService } from '../../../../workbench/services/chat/common/chatEntitlementService.js'; -import { getAccountTitleBarState, getAccountProfileImageUrl, getAccountTitleBarBadgeKey, resolveAccountInfo } from '../../accountTitleBarState.js'; -import { IChatDashboardService } from '../../chatDashboardService.js'; -import { basename } from '../../../../base/common/resources.js'; -import { IFileDiffViewData, MOBILE_OPEN_DIFF_VIEW_COMMAND_ID } from './contributions/mobileDiffView.js'; - -/** - * Mobile titlebar — prepended above the workbench grid on phone viewports - * in place of the desktop titlebar. - * - * Layout (contextual right slot): - * - * - **In a chat session** → `[toggle sidebar] [session title] [changes pill] [+]` - * - **Welcome / new session** → `[toggle sidebar] [host widget | title] [account]` - * - * The center slot switches content based on whether the sessions welcome - * (home/empty) screen is visible: - * - * - **Welcome hidden** → shows the active session title (live, from - * {@link ISessionsManagementService.activeSession}). - * - **Welcome visible** → shows whatever is contributed to the - * {@link Menus.MobileTitleBarCenter} menu. On web, the host filter - * contribution appends its host dropdown + connection button there. - * - * The switch is driven entirely by the menu: when the toolbar has no - * items the title is shown; as soon as it has items the title is hidden - * and the toolbar fills the slot. - * - * The right slot swaps between the new-session (+) button (in a chat) - * and the account indicator (on welcome / new session). The account - * indicator shows the user's avatar or a person icon with an optional - * dot badge for quota/status warnings. Tapping it opens a panel with - * account info, copilot status dashboard, and sign-in/sign-out actions. - */ -export class MobileTitlebarPart extends Disposable { - - readonly element: HTMLElement; - - private readonly sessionTitleElement: HTMLElement; - private readonly actionsContainer: HTMLElement; - - private readonly _onDidClickHamburger = this._register(new Emitter()); - readonly onDidClickHamburger: Event = this._onDidClickHamburger.event; - - private readonly _onDidClickNewSession = this._register(new Emitter()); - readonly onDidClickNewSession: Event = this._onDidClickNewSession.event; - - private readonly _onDidClickTitle = this._register(new Emitter()); - readonly onDidClickTitle: Event = this._onDidClickTitle.event; - - // Account indicator state - private readonly accountButton: HTMLElement; - private readonly accountAvatarElement: HTMLImageElement; - private readonly accountIconElement: HTMLElement; - private readonly accountBadgeElement: HTMLElement; - private accountName: string | undefined; - private accountProviderId: string | undefined; - private accountProviderLabel: string | undefined; - private isAccountLoading = true; - private accountRequestCounter = 0; - private avatarRequestCounter = 0; - private currentAvatarUrl: string | undefined; - private loadedAvatarUrl: string | undefined; - private isAccountMenuVisible = false; - private lastBadgeKey: string | undefined; - private dismissedBadgeKey: string | undefined; - private readonly accountPanelDisposable = this._register(new MutableDisposable()); - private readonly avatarLoadDisposable = this._register(new MutableDisposable()); - private readonly copilotDashboardStore = this._register(new MutableDisposable()); - - // Changes pill state — kept here so the click handler can read the - // latest set without re-deriving it on each tap. - private latestChanges: readonly ISessionFileChange[] = []; - - constructor( - parent: HTMLElement, - @IInstantiationService instantiationService: IInstantiationService, - @ISessionsManagementService private readonly sessionsManagementService: ISessionsManagementService, - @IContextKeyService private readonly contextKeyService: IContextKeyService, - @IDefaultAccountService private readonly defaultAccountService: IDefaultAccountService, - @IAuthenticationService private readonly authenticationService: IAuthenticationService, - @IChatEntitlementService private readonly chatEntitlementService: ChatEntitlementService, - @IMenuService private readonly menuService: IMenuService, - @IChatDashboardService private readonly chatDashboardService: IChatDashboardService, - @ICommandService private readonly commandService: ICommandService, - @IQuickInputService private readonly quickInputService: IQuickInputService, - ) { - super(); - - this.element = document.createElement('div'); - this.element.className = 'mobile-top-bar'; - - // Register DOM removal before appending so that any exception - // between this point and the end of the constructor still cleans - // up the element via disposal. - this._register(toDisposable(() => this.element.remove())); - parent.prepend(this.element); - - // Sidebar toggle button. Uses the same icon as the desktop/web - // agents-app sidebar toggle and reflects open/closed state via the - // SideBarVisibleContext key. - const hamburger = append(this.element, $('button.mobile-top-bar-button')); - hamburger.setAttribute('aria-label', localize('mobileTopBar.openSessions', "Open sessions")); - const hamburgerIcon = append(hamburger, $('span')); - const closedIconClasses = ThemeIcon.asClassNameArray(Codicon.layoutSidebarLeftOff); - const openIconClasses = ThemeIcon.asClassNameArray(Codicon.layoutSidebarLeft); - hamburgerIcon.classList.add(...closedIconClasses); - this._register(addDisposableListener(hamburger, EventType.CLICK, () => this._onDidClickHamburger.fire())); - - const sidebarVisibleKeySet = new Set([SideBarVisibleContext.key]); - const updateSidebarIcon = () => { - const isOpen = !!SideBarVisibleContext.getValue(contextKeyService); - hamburgerIcon.classList.remove(...closedIconClasses, ...openIconClasses); - hamburgerIcon.classList.add(...(isOpen ? openIconClasses : closedIconClasses)); - hamburger.setAttribute('aria-label', isOpen - ? localize('mobileTopBar.closeSessions', "Close sessions") - : localize('mobileTopBar.openSessions', "Open sessions")); - }; - updateSidebarIcon(); - - // Center slot: title and/or actions container (mutually exclusive) - const center = append(this.element, $('div.mobile-top-bar-center')); - - this.sessionTitleElement = append(center, $('button.mobile-session-title')); - this.sessionTitleElement.setAttribute('type', 'button'); - this.sessionTitleElement.textContent = localize('mobileTopBar.newSession', "New Session"); - this._register(addDisposableListener(this.sessionTitleElement, EventType.CLICK, () => this._onDidClickTitle.fire())); - - this.actionsContainer = append(center, $('div.mobile-top-bar-actions')); - - // Right slot — laid out left-to-right in DOM order. The new-session - // (+) button is appended LAST so it always sits at the right edge, - // even when the changes pill is visible. - - // Changes pill — shown when in a chat that has produced changes. - // Tap → opens a file picker; selecting a file invokes the - // `sessions.mobile.openDiffView` command for that file's diff. - const changesPill = append(this.element, $('button.mobile-top-bar-button.mobile-changes-pill', { type: 'button' })) as HTMLButtonElement; - changesPill.setAttribute('aria-label', localize('mobileTopBar.changes', "View changes")); - changesPill.style.display = 'none'; - const changesIcon = append(changesPill, $('span.mobile-changes-pill-icon')); - changesIcon.classList.add(...ThemeIcon.asClassNameArray(Codicon.diffMultiple)); - const changesAddedEl = append(changesPill, $('span.mobile-changes-pill-added')); - const changesRemovedEl = append(changesPill, $('span.mobile-changes-pill-removed')); - this._register(addDisposableListener(changesPill, EventType.CLICK, () => this.showChangesPicker())); - - // New session button (+) — shown when in a chat, hidden on welcome. - // Always rightmost when in a chat. - const newSessionButton = append(this.element, $('button.mobile-top-bar-button.mobile-new-session-button')); - newSessionButton.setAttribute('aria-label', localize('mobileTopBar.newSessionAria', "New session")); - const newSessionIcon = append(newSessionButton, $('span')); - newSessionIcon.classList.add(...ThemeIcon.asClassNameArray(Codicon.plus)); - this._register(addDisposableListener(newSessionButton, EventType.CLICK, () => this._onDidClickNewSession.fire())); - - // Account indicator — shown on welcome/new session, hidden in a chat - this.accountButton = append(this.element, $('button.mobile-top-bar-button.mobile-account-indicator')); - this.accountButton.setAttribute('aria-label', localize('mobileTopBar.account', "Account")); - this.accountAvatarElement = append(this.accountButton, $('img.mobile-account-avatar', { alt: '', draggable: 'false' })) as HTMLImageElement; - this.accountAvatarElement.decoding = 'async'; - this.accountAvatarElement.referrerPolicy = 'no-referrer'; - this.accountIconElement = append(this.accountButton, $('span')); - this.accountBadgeElement = append(this.accountButton, $('span.mobile-account-badge')); - this._register(addDisposableListener(this.accountButton, EventType.CLICK, () => this.showAccountPanel())); - - // Track account state — listen to multiple sources to catch - // updates regardless of service initialization ordering. - this._register(this.defaultAccountService.onDidChangeDefaultAccount(() => this.refreshAccount())); - this._register(this.authenticationService.onDidChangeSessions(() => this.refreshAccount())); - this._register(this.chatEntitlementService.onDidChangeEntitlement(() => this.renderAccountState())); - this._register(this.chatEntitlementService.onDidChangeSentiment(() => this.renderAccountState())); - this._register(this.chatEntitlementService.onDidChangeQuotaExceeded(() => this.renderAccountState())); - this._register(this.chatEntitlementService.onDidChangeQuotaRemaining(() => this.renderAccountState())); - this.refreshAccount(); - - // Keep the title in sync with the active session - this._register(autorun(reader => { - const session = this.sessionsManagementService.activeSession.read(reader); - const title = session?.title.read(reader); - this.sessionTitleElement.textContent = title || localize('mobileTopBar.newSession', "New Session"); - })); - - // Keep the changes pill in sync with the active session's changes. - // Hidden when there are no changes (counts are zero and list is empty). - const isNewChatRef = { value: !!IsNewChatSessionContext.getValue(contextKeyService) }; - const renderChangesPill = () => { - const changes = this.latestChanges; - let added = 0; - let removed = 0; - for (const c of changes) { - added += c.insertions; - removed += c.deletions; - } - const hasChanges = changes.length > 0 && (added > 0 || removed > 0); - // Hide on welcome / new-chat — no session changes to view there. - const visible = hasChanges && !isNewChatRef.value; - changesPill.style.display = visible ? '' : 'none'; - if (visible) { - changesAddedEl.textContent = `+${added}`; - changesRemovedEl.textContent = `-${removed}`; - changesPill.title = localize('mobileTopBar.changesTooltip', "{0} files changed (+{1} -{2})", changes.length, added, removed); - } - }; - this._register(autorun(reader => { - const session = this.sessionsManagementService.activeSession.read(reader); - this.latestChanges = session?.changes.read(reader) ?? []; - renderChangesPill(); - })); - - // Mount the center toolbar (host filter widget on web welcome, etc.) - const toolbar = this._register(instantiationService.createInstance(MenuWorkbenchToolBar, this.actionsContainer, Menus.MobileTitleBarCenter, { - hiddenItemStrategy: HiddenItemStrategy.NoHide, - telemetrySource: 'mobileTitlebar.center', - toolbarOptions: { primaryGroup: () => true }, - })); - - // Switch between title and toolbar based on whether a new (empty) - // chat session is active AND whether the toolbar has anything to - // show. The latter is important because on desktop/electron or - // when no agent hosts are configured the toolbar can be empty — - // in that case we keep the title visible. - const newChatKeySet = new Set([IsNewChatSessionContext.key]); - const updateCenterMode = () => { - const isNewChat = !!IsNewChatSessionContext.getValue(contextKeyService); - const hasActions = toolbar.getItemsLength() > 0; - this.element.classList.toggle('show-actions', isNewChat && hasActions); - - // Right slot: swap between [+] (in-chat) and [account] (welcome) - newSessionButton.style.display = isNewChat ? 'none' : ''; - this.accountButton.style.display = isNewChat ? '' : 'none'; - - // Changes pill follows the in-chat state — hidden on welcome. - isNewChatRef.value = isNewChat; - renderChangesPill(); - }; - updateCenterMode(); - this._register(contextKeyService.onDidChangeContext(e => { - if (e.affectsSome(newChatKeySet)) { - updateCenterMode(); - } - if (e.affectsSome(sidebarVisibleKeySet)) { - updateSidebarIcon(); - } - })); - this._register(toolbar.onDidChangeMenuItems(() => updateCenterMode())); - } - - /** - * Explicitly set the title shown in the center slot. Called only when - * overriding the live session title (tests, placeholders). The live - * subscription will overwrite this on the next session change. - */ - setTitle(title: string): void { - this.sessionTitleElement.textContent = title; - } - - // --- Changes Pill --- // - - /** - * Open a quick pick listing the files changed in the active session. - * Selecting one invokes {@link MOBILE_OPEN_DIFF_VIEW_COMMAND_ID} with - * the corresponding {@link IFileDiffViewData}. - */ - private showChangesPicker(): void { - const changes = this.latestChanges; - if (!changes.length) { - return; - } - - type Item = IQuickPickItem & { readonly diff: IFileDiffViewData }; - const items: Item[] = changes.map(change => { - // IChatSessionFileChange2 carries `uri` (and may also have `modifiedUri`); - // the legacy IChatSessionFileChange only has `modifiedUri`. The discriminator - // is the presence of `uri` (required on the v2 shape, absent on v1). - const v2 = (change as { uri?: URI }).uri; - const modifiedURI = v2 ? (change.modifiedUri ?? v2) : change.modifiedUri!; - // `originalURI` may legitimately be undefined for newly-added files; - // MobileDiffView treats that as an empty original (all-added diff). - // Do NOT fall back to modifiedURI here — that would self-diff and - // render "No changes in this file." for added files. - const originalURI = change.originalUri; - const added = change.insertions; - const removed = change.deletions; - return { - label: basename(modifiedURI), - description: `+${added} -${removed}`, - detail: modifiedURI.path, - diff: { - originalURI, - modifiedURI, - identical: added === 0 && removed === 0, - added, - removed, - }, - }; - }); - - const picker = this.quickInputService.createQuickPick(); - picker.title = localize('mobileTopBar.changesPickerTitle', "Session Changes"); - picker.placeholder = localize('mobileTopBar.changesPickerPlaceholder', "Select a file to view its diff"); - picker.matchOnDescription = true; - picker.items = items; - const store = new DisposableStore(); - store.add(picker.onDidAccept(() => { - const selected = picker.selectedItems[0]; - if (selected) { - this.commandService.executeCommand(MOBILE_OPEN_DIFF_VIEW_COMMAND_ID, selected.diff); - } - picker.hide(); - })); - store.add(picker.onDidHide(() => { - store.dispose(); - picker.dispose(); - })); - picker.show(); - } - - // --- Account Indicator --- // - - private async refreshAccount(): Promise { - const requestId = ++this.accountRequestCounter; - this.isAccountLoading = true; - this.renderAccountState(); - - const info = await resolveAccountInfo(this.defaultAccountService, this.authenticationService); - if (requestId !== this.accountRequestCounter) { - return; - } - - this.accountName = info?.accountName; - this.accountProviderId = info?.accountProviderId; - this.accountProviderLabel = info?.accountProviderLabel; - this.isAccountLoading = false; - this.refreshAvatar(); - this.renderAccountState(); - } - - private renderAccountState(): void { - // When we have a session from the auth service but the entitlement - // service hasn't resolved yet (still Unknown), treat it as the - // account being available rather than signed out. This avoids - // showing "Sign In" right after the walkthrough completes. - const entitlement = this.accountName && this.chatEntitlementService.entitlement === ChatEntitlement.Unknown - ? ChatEntitlement.Unresolved - : this.chatEntitlementService.entitlement; - - const state = getAccountTitleBarState({ - isAccountLoading: this.isAccountLoading, - accountName: this.accountName, - accountProviderLabel: this.accountProviderLabel, - entitlement, - sentiment: this.chatEntitlementService.sentiment, - quotas: this.chatEntitlementService.quotas, - }); - - // Avatar - const hasAvatar = !!this.loadedAvatarUrl && !this.isAccountLoading; - this.accountAvatarElement.classList.toggle('visible', hasAvatar); - if (hasAvatar && this.accountAvatarElement.src !== this.loadedAvatarUrl) { - this.accountAvatarElement.src = this.loadedAvatarUrl!; - } else if (!hasAvatar) { - this.accountAvatarElement.removeAttribute('src'); - } - - // Codicon fallback - const titleBarIcon = state.dotBadge ? Codicon.account : state.icon; - this.accountIconElement.className = ThemeIcon.asClassName(titleBarIcon); - this.accountIconElement.classList.toggle('hidden', hasAvatar); - - // Dot badge - const badgeKey = getAccountTitleBarBadgeKey(state); - if (badgeKey !== this.lastBadgeKey) { - this.lastBadgeKey = badgeKey; - this.dismissedBadgeKey = undefined; - } - const showBadge = !!badgeKey && badgeKey !== this.dismissedBadgeKey; - this.accountBadgeElement.style.display = showBadge ? '' : 'none'; - this.accountBadgeElement.classList.toggle('dot-badge-warning', showBadge && state.dotBadge === 'warning'); - this.accountBadgeElement.classList.toggle('dot-badge-error', showBadge && state.dotBadge === 'error'); - - // ARIA - this.accountButton.setAttribute('aria-label', state.ariaLabel); - } - - private refreshAvatar(): void { - const avatarUrl = getAccountProfileImageUrl(this.accountProviderId, this.accountName); - if (avatarUrl === this.currentAvatarUrl) { - return; - } - - this.currentAvatarUrl = avatarUrl; - this.loadedAvatarUrl = undefined; - this.avatarLoadDisposable.clear(); - const requestId = ++this.avatarRequestCounter; - - if (!avatarUrl) { - this.renderAccountState(); - return; - } - - const image = new Image(); - image.referrerPolicy = 'no-referrer'; - const clearHandlers = () => { image.onload = null; image.onerror = null; }; - image.onload = () => { - if (requestId !== this.avatarRequestCounter) { return; } - this.loadedAvatarUrl = avatarUrl; - this.renderAccountState(); - clearHandlers(); - }; - image.onerror = () => { - if (requestId !== this.avatarRequestCounter) { return; } - this.loadedAvatarUrl = undefined; - this.renderAccountState(); - clearHandlers(); - }; - this.avatarLoadDisposable.value = toDisposable(() => { clearHandlers(); image.src = ''; }); - image.src = avatarUrl; - } - - // --- Account Sheet --- // - - private showAccountPanel(): void { - if (this.isAccountMenuVisible) { - this.accountPanelDisposable.clear(); - return; - } - - this.accountPanelDisposable.clear(); - - const panelStore = new DisposableStore(); - this.accountPanelDisposable.value = panelStore; - - const badgeKey = getAccountTitleBarBadgeKey(getAccountTitleBarState({ - isAccountLoading: this.isAccountLoading, - accountName: this.accountName, - accountProviderLabel: this.accountProviderLabel, - entitlement: this.chatEntitlementService.entitlement, - sentiment: this.chatEntitlementService.sentiment, - quotas: this.chatEntitlementService.quotas, - })); - if (badgeKey) { - this.dismissedBadgeKey = badgeKey; - } - - this.isAccountMenuVisible = true; - this.renderAccountState(); - panelStore.add({ - dispose: () => { - this.isAccountMenuVisible = false; - this.copilotDashboardStore.clear(); - this.renderAccountState(); - } - }); - - const closeSheet = () => this.accountPanelDisposable.clear(); - - // Full-screen sheet inside the workbench container - const workbenchContainer = this.element.parentElement!; - const sheet = append(workbenchContainer, $('div.mobile-account-sheet')); - panelStore.add(toDisposable(() => sheet.remove())); - - // Header: title + close button - const header = append(sheet, $('div.mobile-account-sheet-header')); - const headerTitle = append(header, $('h2.mobile-account-sheet-title')); - headerTitle.textContent = localize('mobileAccount.title', "Account"); - const closeButton = append(header, $('button.mobile-account-sheet-close', { type: 'button' })) as HTMLButtonElement; - closeButton.setAttribute('aria-label', localize('mobileAccount.close', "Close")); - append(closeButton, $('span')).classList.add(...ThemeIcon.asClassNameArray(Codicon.close)); - panelStore.add(addDisposableListener(closeButton, EventType.CLICK, closeSheet)); - - // Scrollable content - const content = append(sheet, $('div.mobile-account-sheet-content')); - - // Profile section - const profile = append(content, $('div.mobile-account-sheet-profile')); - if (this.loadedAvatarUrl) { - const avatar = append(profile, $('img.mobile-account-sheet-avatar', { alt: '', draggable: 'false' })) as HTMLImageElement; - avatar.src = this.loadedAvatarUrl; - avatar.referrerPolicy = 'no-referrer'; - avatar.decoding = 'async'; - } else { - const avatarPlaceholder = append(profile, $('div.mobile-account-sheet-avatar-placeholder')); - append(avatarPlaceholder, $('span')).classList.add(...ThemeIcon.asClassNameArray(Codicon.account)); - } - const profileInfo = append(profile, $('div.mobile-account-sheet-profile-info')); - if (this.isAccountLoading) { - append(profileInfo, $('div.mobile-account-sheet-name')).textContent = localize('mobileAccount.loading', "Loading..."); - } else if (this.accountName) { - append(profileInfo, $('div.mobile-account-sheet-name')).textContent = this.accountName; - if (this.accountProviderLabel) { - append(profileInfo, $('div.mobile-account-sheet-provider')).textContent = this.accountProviderLabel; - } - } else { - append(profileInfo, $('div.mobile-account-sheet-name')).textContent = localize('mobileAccount.signedOut', "Not signed in"); - } - - // Copilot status dashboard — only when signed in AND entitlements - // have resolved. When entitlement is Unknown or Available (setup - // pending), the dashboard shows a "Set up Copilot" prompt that - // doesn't apply in the agents app. - const entitlement = this.chatEntitlementService.entitlement; - const showDashboard = !this.chatEntitlementService.sentiment.hidden - && !!this.accountName - && entitlement !== ChatEntitlement.Unknown - && entitlement !== ChatEntitlement.Available; - if (showDashboard) { - const dashboardSection = append(content, $('div.mobile-account-sheet-section')); - const store = new DisposableStore(); - this.copilotDashboardStore.value = store; - const dashboardElement = this.chatDashboardService.createDashboardElement(store); - if (dashboardElement) { - append(dashboardSection, dashboardElement); - } - } - - // Actions list - const actionsSection = append(content, $('div.mobile-account-sheet-actions')); - const allActions = this.getSheetActions(); - for (const action of allActions) { - if (action instanceof Separator) { - append(actionsSection, $('div.mobile-account-sheet-separator')); - continue; - } - const row = append(actionsSection, $('button.mobile-account-sheet-action', { type: 'button' })) as HTMLButtonElement; - row.disabled = !action.enabled; - row.setAttribute('aria-label', action.tooltip || action.label); - const icon = this.getActionIcon(action); - if (icon) { - append(row, $('span.mobile-account-sheet-action-icon')).classList.add(...ThemeIcon.asClassNameArray(icon)); - } - append(row, $('span.mobile-account-sheet-action-label')).textContent = action.label; - panelStore.add(addDisposableListener(row, EventType.CLICK, async event => { - event.preventDefault(); - event.stopPropagation(); - closeSheet(); - await Promise.resolve(action.run()); - })); - } - } - - private getSheetActions(): IAction[] { - const menu = this.menuService.createMenu(Menus.AccountMenu, this.contextKeyService); - const rawActions: IAction[] = []; - fillInActionBarActions(menu.getActions(), rawActions); - menu.dispose(); - return rawActions.filter(action => { - if (action instanceof Separator) { - return true; - } - if (this.isAccountLoading && action.id === 'workbench.action.agenticSignIn') { - return false; - } - return !action.id.startsWith('update.'); - }); - } - - private getActionIcon(action: IAction): ThemeIcon | undefined { - switch (action.id) { - case 'workbench.action.openSettings': return Codicon.settingsGear; - case 'workbench.action.agenticSignOut': return Codicon.signOut; - case 'workbench.action.agenticSignIn': return Codicon.signIn; - default: return undefined; - } - } -} diff --git a/src/vs/sessions/browser/parts/panelPart.ts b/src/vs/sessions/browser/parts/panelPart.ts deleted file mode 100644 index 8db79950..00000000 --- a/src/vs/sessions/browser/parts/panelPart.ts +++ /dev/null @@ -1,204 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import '../../../workbench/browser/parts/panel/media/panelpart.css'; -import './media/panelPart.css'; -import { IAction } from '../../../base/common/actions.js'; -import { ActionsOrientation } from '../../../base/browser/ui/actionbar/actionbar.js'; -import { ActivePanelContext, PanelFocusContext } from '../../../workbench/common/contextkeys.js'; -import { IWorkbenchLayoutService, Parts, Position } from '../../../workbench/services/layout/browser/layoutService.js'; -import { IStorageService } from '../../../platform/storage/common/storage.js'; -import { IContextMenuService } from '../../../platform/contextview/browser/contextView.js'; -import { IKeybindingService } from '../../../platform/keybinding/common/keybinding.js'; -import { IInstantiationService } from '../../../platform/instantiation/common/instantiation.js'; -import { IThemeService } from '../../../platform/theme/common/themeService.js'; -import { PANEL_TITLE_BORDER, PANEL_ACTIVE_TITLE_FOREGROUND, PANEL_INACTIVE_TITLE_FOREGROUND, PANEL_ACTIVE_TITLE_BORDER, PANEL_DRAG_AND_DROP_BORDER } from '../../../workbench/common/theme.js'; -import { agentsBadgeBackground, agentsBadgeForeground, agentsPanelBackground, agentsPanelBorder, agentsPanelForeground } from '../../common/theme.js'; -import { INotificationService } from '../../../platform/notification/common/notification.js'; -import { IContextKeyService } from '../../../platform/contextkey/common/contextkey.js'; -import { assertReturnsDefined } from '../../../base/common/types.js'; -import { IExtensionService } from '../../../workbench/services/extensions/common/extensions.js'; -import { IViewDescriptorService, ViewContainerLocation } from '../../../workbench/common/views.js'; -import { HoverPosition } from '../../../base/browser/ui/hover/hoverWidget.js'; -import { IMenuService } from '../../../platform/actions/common/actions.js'; -import { Menus } from '../menus.js'; -import { AbstractPaneCompositePart, CompositeBarPosition } from '../../../workbench/browser/parts/paneCompositePart.js'; -import { Part } from '../../../workbench/browser/part.js'; -import { IPaneCompositeBarOptions } from '../../../workbench/browser/parts/paneCompositeBar.js'; -import { IHoverService } from '../../../platform/hover/browser/hover.js'; -import { IConfigurationService } from '../../../platform/configuration/common/configuration.js'; -import { Extensions } from '../../../workbench/browser/panecomposite.js'; - -/** - * Panel part specifically for agent sessions workbench. - * This is a simplified version of the PanelPart for agent session contexts. - */ -export class PanelPart extends AbstractPaneCompositePart { - - //#region IView - - readonly minimumWidth: number = 300; - readonly maximumWidth: number = Number.POSITIVE_INFINITY; - readonly minimumHeight: number = 77; - readonly maximumHeight: number = Number.POSITIVE_INFINITY; - - get preferredHeight(): number | undefined { - return this.layoutService.mainContainerDimension.height * 0.4; - } - - get preferredWidth(): number | undefined { - const activeComposite = this.getActivePaneComposite(); - - if (!activeComposite) { - return undefined; - } - - const width = activeComposite.getOptimalWidth(); - if (typeof width !== 'number') { - return undefined; - } - - return Math.max(width, 300); - } - - //#endregion - - static readonly activePanelSettingsKey = 'workbench.agentsession.panelpart.activepanelid'; - - /** Visual margin values for the card-like appearance */ - static readonly MARGIN_BOTTOM = 10; - static readonly MARGIN_LEFT = 10; - static readonly MARGIN_RIGHT = 10; - - constructor( - @INotificationService notificationService: INotificationService, - @IStorageService storageService: IStorageService, - @IContextMenuService contextMenuService: IContextMenuService, - @IWorkbenchLayoutService layoutService: IWorkbenchLayoutService, - @IKeybindingService keybindingService: IKeybindingService, - @IHoverService hoverService: IHoverService, - @IInstantiationService instantiationService: IInstantiationService, - @IThemeService themeService: IThemeService, - @IViewDescriptorService viewDescriptorService: IViewDescriptorService, - @IContextKeyService contextKeyService: IContextKeyService, - @IExtensionService extensionService: IExtensionService, - @IMenuService menuService: IMenuService, - @IConfigurationService private readonly configurationService: IConfigurationService - ) { - super( - Parts.PANEL_PART, - { hasTitle: true, trailingSeparator: true }, - PanelPart.activePanelSettingsKey, - ActivePanelContext.bindTo(contextKeyService), - PanelFocusContext.bindTo(contextKeyService), - 'panel', - 'panel', - undefined, - PANEL_TITLE_BORDER, - ViewContainerLocation.Panel, - Extensions.Panels, - Menus.PanelTitle, - notificationService, - storageService, - contextMenuService, - layoutService, - keybindingService, - hoverService, - instantiationService, - themeService, - viewDescriptorService, - contextKeyService, - extensionService, - menuService, - ); - - this._register(this.configurationService.onDidChangeConfiguration(e => { - if (e.affectsConfiguration('workbench.panel.showLabels')) { - this.updateCompositeBar(true); - } - })); - } - - override updateStyles(): void { - super.updateStyles(); - - const container = assertReturnsDefined(this.getContainer()); - - // Store background and border as CSS variables for the card styling on .part - container.style.setProperty('--part-background', this.getColor(agentsPanelBackground) || ''); - container.style.setProperty('--part-border-color', this.getColor(agentsPanelBorder) || 'transparent'); - container.style.setProperty('--part-foreground', this.getColor(agentsPanelForeground) || ''); - container.style.backgroundColor = this.getColor(agentsPanelBackground) || ''; - - // Clear inline borders - the card appearance uses CSS border-radius instead - container.style.borderTopColor = ''; - container.style.borderTopStyle = ''; - container.style.borderTopWidth = ''; - } - - protected getCompositeBarOptions(): IPaneCompositeBarOptions { - return { - partContainerClass: 'panel', - pinnedViewContainersKey: 'workbench.agentsession.panel.pinnedPanels', - placeholderViewContainersKey: 'workbench.agentsession.panel.placeholderPanels', - viewContainersWorkspaceStateKey: 'workbench.agentsession.panel.viewContainersWorkspaceState', - icon: this.configurationService.getValue('workbench.panel.showLabels') === false, - orientation: ActionsOrientation.HORIZONTAL, - recomputeSizes: true, - activityHoverOptions: { - position: () => this.layoutService.getPanelPosition() === Position.BOTTOM && !this.layoutService.isPanelMaximized() ? HoverPosition.ABOVE : HoverPosition.BELOW, - }, - fillExtraContextMenuActions: actions => this.fillExtraContextMenuActions(actions), - compositeSize: 0, - iconSize: 16, - compact: true, - overflowActionSize: 44, - colors: theme => ({ - activeBackgroundColor: theme.getColor(agentsPanelBackground), - inactiveBackgroundColor: theme.getColor(agentsPanelBackground), - activeBorderBottomColor: theme.getColor(PANEL_ACTIVE_TITLE_BORDER), - activeForegroundColor: theme.getColor(PANEL_ACTIVE_TITLE_FOREGROUND), - inactiveForegroundColor: theme.getColor(PANEL_INACTIVE_TITLE_FOREGROUND), - badgeBackground: theme.getColor(agentsBadgeBackground), - badgeForeground: theme.getColor(agentsBadgeForeground), - dragAndDropBorder: theme.getColor(PANEL_DRAG_AND_DROP_BORDER) - }) - }; - } - - private fillExtraContextMenuActions(_actions: IAction[]): void { } - - override layout(width: number, height: number, top: number, left: number): void { - if (!this.layoutService.isVisible(Parts.PANEL_PART)) { - return; - } - - // Layout content with reduced dimensions to account for visual margins and border - const borderTotal = 2; // 1px border on each side - const marginLeft = this.layoutService.isVisible(Parts.SIDEBAR_PART) ? 0 : PanelPart.MARGIN_LEFT; - super.layout( - width - marginLeft - PanelPart.MARGIN_RIGHT - borderTotal, - height - PanelPart.MARGIN_BOTTOM - borderTotal, - top, left - ); - - // Restore the full grid-allocated dimensions so that Part.relayout() works correctly. - Part.prototype.layout.call(this, width, height, top, left); - } - - protected override shouldShowCompositeBar(): boolean { - return true; - } - - protected getCompositeBarPosition(): CompositeBarPosition { - return CompositeBarPosition.TITLE; - } - - toJSON(): object { - return { - type: Parts.PANEL_PART - }; - } -} diff --git a/src/vs/sessions/browser/parts/parts.ts b/src/vs/sessions/browser/parts/parts.ts deleted file mode 100644 index 3a1ed5af..00000000 --- a/src/vs/sessions/browser/parts/parts.ts +++ /dev/null @@ -1,8 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -export const enum AgenticParts { - PROJECTBAR_PART = 'workbench.parts.projectbar', -} diff --git a/src/vs/sessions/browser/parts/projectBarPart.ts b/src/vs/sessions/browser/parts/projectBarPart.ts deleted file mode 100644 index e902c52e..00000000 --- a/src/vs/sessions/browser/parts/projectBarPart.ts +++ /dev/null @@ -1,584 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import './media/projectBarPart.css'; -import { Part } from '../../../workbench/browser/part.js'; -import { IWorkbenchLayoutService, Position } from '../../../workbench/services/layout/browser/layoutService.js'; -import { IColorTheme, IThemeService } from '../../../platform/theme/common/themeService.js'; -import { IStorageService, StorageScope, StorageTarget } from '../../../platform/storage/common/storage.js'; -import { IWorkspaceContextService } from '../../../platform/workspace/common/workspace.js'; -import { IHoverService } from '../../../platform/hover/browser/hover.js'; -import { DisposableStore, MutableDisposable } from '../../../base/common/lifecycle.js'; -import { $, addDisposableListener, append, clearNode, Dimension, EventType, getActiveDocument, getWindow } from '../../../base/browser/dom.js'; -import { Emitter, Event } from '../../../base/common/event.js'; -import { ACTIVITY_BAR_BACKGROUND, ACTIVITY_BAR_BADGE_BACKGROUND, ACTIVITY_BAR_BADGE_FOREGROUND, ACTIVITY_BAR_BORDER, ACTIVITY_BAR_FOREGROUND, ACTIVITY_BAR_INACTIVE_FOREGROUND } from '../../../workbench/common/theme.js'; -import { contrastBorder } from '../../../platform/theme/common/colorRegistry.js'; -import { assertReturnsDefined } from '../../../base/common/types.js'; -import { ThemeIcon } from '../../../base/common/themables.js'; -import { Codicon } from '../../../base/common/codicons.js'; -import { codiconsLibrary } from '../../../base/common/codiconsLibrary.js'; -import { Lazy } from '../../../base/common/lazy.js'; -import { HoverPosition } from '../../../base/browser/ui/hover/hoverWidget.js'; -import { GlobalCompositeBar } from '../../../workbench/browser/parts/globalCompositeBar.js'; -import { IInstantiationService } from '../../../platform/instantiation/common/instantiation.js'; -import { IAction, Action, Separator } from '../../../base/common/actions.js'; -import { URI } from '../../../base/common/uri.js'; -import { IFileDialogService } from '../../../platform/dialogs/common/dialogs.js'; -import { IPathService } from '../../../workbench/services/path/common/pathService.js'; -import { IWorkspaceEditingService } from '../../../workbench/services/workspaces/common/workspaceEditing.js'; -import { ILabelService } from '../../../platform/label/common/label.js'; -import { basename } from '../../../base/common/resources.js'; -import { IContextMenuService } from '../../../platform/contextview/browser/contextView.js'; -import { StandardMouseEvent } from '../../../base/browser/mouseEvent.js'; -import { IQuickInputService, IQuickPickItem } from '../../../platform/quickinput/common/quickInput.js'; -import { getIconRegistry, IconContribution } from '../../../platform/theme/common/iconRegistry.js'; -import { defaultInputBoxStyles } from '../../../platform/theme/browser/defaultStyles.js'; -import { WorkbenchIconSelectBox } from '../../../workbench/services/userDataProfile/browser/iconSelectBox.js'; -import { localize } from '../../../nls.js'; -import { AgenticParts } from './parts.js'; - -const HOVER_GROUP_ID = 'projectbar'; -const PROJECT_BAR_FOLDERS_KEY = 'workbench.agentsession.projectbar.folders'; - -type ProjectBarEntryDisplayType = 'letter' | 'icon'; - -interface IProjectBarEntryData { - readonly uri: string; - readonly displayType?: ProjectBarEntryDisplayType; - readonly iconId?: string; -} - -interface IProjectBarEntry { - readonly uri: URI; - readonly name: string; - displayType: ProjectBarEntryDisplayType; - iconId?: string; -} - -const icons = new Lazy(() => { - const iconDefinitions = getIconRegistry().getIcons(); - const includedChars = new Set(); - const dedupedIcons = iconDefinitions.filter(e => { - if (e.id === codiconsLibrary.blank.id) { - return false; - } - if (ThemeIcon.isThemeIcon(e.defaults)) { - return false; - } - if (includedChars.has(e.defaults.fontCharacter)) { - return false; - } - includedChars.add(e.defaults.fontCharacter); - return true; - }); - return dedupedIcons; -}); - -/** - * ProjectBarPart displays project folder entries stored in workspace storage and allows selection between them. - * When a folder is selected, the workspace editing service is used to replace the current workspace folder - * with the selected one. It is positioned to the left of the sidebar and has the same visual style as the activity bar. - * Also includes global activities (accounts, settings) at the bottom. - */ -export class ProjectBarPart extends Part { - - static readonly ACTION_HEIGHT = 48; - - //#region IView - - readonly minimumWidth: number = 48; - readonly maximumWidth: number = 48; - readonly minimumHeight: number = 0; - readonly maximumHeight: number = Number.POSITIVE_INFINITY; - - //#endregion - - private content: HTMLElement | undefined; - private actionsContainer: HTMLElement | undefined; - private addFolderButton: HTMLElement | undefined; - private entries: IProjectBarEntry[] = []; - private _selectedFolderUri: URI | undefined; - private readonly globalCompositeBar: GlobalCompositeBar; - - private readonly workspaceEntryDisposables = this._register(new MutableDisposable()); - - private readonly _onDidSelectWorkspace = this._register(new Emitter()); - readonly onDidSelectWorkspace: Event = this._onDidSelectWorkspace.event; - - constructor( - @IWorkbenchLayoutService layoutService: IWorkbenchLayoutService, - @IThemeService themeService: IThemeService, - @IStorageService private readonly storageService: IStorageService, - @IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService, - @IFileDialogService private readonly fileDialogService: IFileDialogService, - @IPathService private readonly pathService: IPathService, - @IWorkspaceEditingService private readonly workspaceEditingService: IWorkspaceEditingService, - @ILabelService private readonly labelService: ILabelService, - @IHoverService private readonly hoverService: IHoverService, - @IContextMenuService private readonly contextMenuService: IContextMenuService, - @IQuickInputService private readonly quickInputService: IQuickInputService, - @IInstantiationService private readonly instantiationService: IInstantiationService, - ) { - super(AgenticParts.PROJECTBAR_PART, { hasTitle: false }, themeService, storageService, layoutService); - - // Create the global composite bar for accounts and settings at the bottom - this.globalCompositeBar = this._register(instantiationService.createInstance( - GlobalCompositeBar, - () => this.getContextMenuActions(), - (theme: IColorTheme) => ({ - activeForegroundColor: theme.getColor(ACTIVITY_BAR_FOREGROUND), - inactiveForegroundColor: theme.getColor(ACTIVITY_BAR_INACTIVE_FOREGROUND), - badgeBackground: theme.getColor(ACTIVITY_BAR_BADGE_BACKGROUND), - badgeForeground: theme.getColor(ACTIVITY_BAR_BADGE_FOREGROUND), - activeBackgroundColor: undefined, - inactiveBackgroundColor: undefined, - activeBorderBottomColor: undefined, - }), - { - position: () => this.layoutService.getSideBarPosition() === Position.LEFT ? HoverPosition.RIGHT : HoverPosition.LEFT, - } - )); - - // Load entries from storage - this.loadEntriesFromStorage(); - } - - private getContextMenuActions(): IAction[] { - return this.globalCompositeBar.getContextMenuActions(); - } - - private loadEntriesFromStorage(): void { - const raw = this.storageService.get(PROJECT_BAR_FOLDERS_KEY, StorageScope.WORKSPACE); - if (raw) { - try { - const data: (string | IProjectBarEntryData)[] = JSON.parse(raw); - this.entries = data.map(item => { - // Support legacy format (just URIs as strings) and new format (objects with display settings) - if (typeof item === 'string') { - const uri = URI.parse(item); - return { uri, name: basename(uri), displayType: 'letter' as ProjectBarEntryDisplayType }; - } else { - const uri = URI.parse(item.uri); - return { - uri, - name: basename(uri), - displayType: item.displayType ?? 'letter', - iconId: item.iconId - }; - } - }); - } catch { - this.entries = []; - } - } else { - this.entries = []; - } - - // The selected folder is always the first workspace folder - const currentFolders = this.workspaceContextService.getWorkspace().folders; - this._selectedFolderUri = currentFolders.length > 0 ? currentFolders[0].uri : undefined; - } - - private saveEntriesToStorage(): void { - const data: IProjectBarEntryData[] = this.entries.map(e => ({ - uri: e.uri.toString(), - displayType: e.displayType, - iconId: e.iconId - })); - this.storageService.store(PROJECT_BAR_FOLDERS_KEY, JSON.stringify(data), StorageScope.WORKSPACE, StorageTarget.MACHINE); - } - - private addFolderEntry(uri: URI): void { - // Don't add duplicates - if (this.entries.some(e => e.uri.toString() === uri.toString())) { - return; - } - - this.entries.push({ uri, name: basename(uri), displayType: 'letter' }); - this.saveEntriesToStorage(); - - // Select the newly added folder - this._selectedFolderUri = uri; - this.saveEntriesToStorage(); - this.applySelectedFolder(); - this._onDidSelectWorkspace.fire(this._selectedFolderUri); - - this.renderContent(); - } - - private async applySelectedFolder(): Promise { - if (!this._selectedFolderUri) { - return; - } - - const currentFolders = this.workspaceContextService.getWorkspace().folders; - const foldersToRemove = currentFolders.map(f => f.uri); - - // Remove existing workspace folders and add the selected one - await this.workspaceEditingService.updateFolders( - 0, - foldersToRemove.length, - [{ uri: this._selectedFolderUri }] - ); - } - - protected override createContentArea(parent: HTMLElement): HTMLElement { - this.element = parent; - this.content = append(this.element, $('.content')); - - // Create actions container for workspace folders and add button - this.actionsContainer = append(this.content, $('.actions-container')); - - // Create the UI for workspace folders - this.renderContent(); - - // Create global composite bar at the bottom (accounts, settings) - this.globalCompositeBar.create(this.content); - - return this.content; - } - - private renderContent(): void { - if (!this.actionsContainer) { - return; - } - - // Clear existing content - clearNode(this.actionsContainer); - this.workspaceEntryDisposables.value = new DisposableStore(); - - // Create add folder button - this.createAddFolderButton(this.actionsContainer); - - // Create workspace folder entries - this.createWorkspaceEntries(this.actionsContainer); - } - - private createAddFolderButton(container: HTMLElement): void { - this.addFolderButton = append(container, $('.action-item.add-folder')); - const actionLabel = append(this.addFolderButton, $('span.action-label')); - - // Add the plus icon using codicon - actionLabel.classList.add(...ThemeIcon.asClassNameArray(Codicon.add)); - - // Add hover tooltip - this.workspaceEntryDisposables.value?.add( - this.hoverService.setupDelayedHover( - this.addFolderButton, - { - appearance: { showPointer: true }, - position: { hoverPosition: HoverPosition.RIGHT }, - content: 'Add Folder to Project' - }, - { groupId: HOVER_GROUP_ID } - ) - ); - - // Click handler to add folder - this.workspaceEntryDisposables.value?.add( - addDisposableListener(this.addFolderButton, EventType.CLICK, () => { - this.pickAndAddFolder(); - }) - ); - - // Keyboard support - this.addFolderButton.setAttribute('tabindex', '0'); - this.addFolderButton.setAttribute('role', 'button'); - this.addFolderButton.setAttribute('aria-label', 'Add Folder to Project'); - this.workspaceEntryDisposables.value?.add( - addDisposableListener(this.addFolderButton, EventType.KEY_DOWN, (e: KeyboardEvent) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - this.pickAndAddFolder(); - } - }) - ); - } - - private async pickAndAddFolder(): Promise { - const folders = await this.fileDialogService.showOpenDialog({ - openLabel: 'Add', - title: 'Add Folder to Project', - canSelectFolders: true, - canSelectMany: false, - defaultUri: await this.fileDialogService.defaultFolderPath(), - availableFileSystems: [this.pathService.defaultUriScheme] - }); - - if (folders?.length) { - this.addFolderEntry(folders[0]); - } - } - - private createWorkspaceEntries(container: HTMLElement): void { - for (let i = 0; i < this.entries.length; i++) { - this.createWorkspaceEntry(container, this.entries[i], i); - } - - // Auto-select first entry if available and none selected - if (this.entries.length > 0 && this._selectedFolderUri) { - this._onDidSelectWorkspace.fire(this._selectedFolderUri); - } - } - - private createWorkspaceEntry(container: HTMLElement, entry: IProjectBarEntry, index: number): void { - const entryDisposables = this.workspaceEntryDisposables.value!; - - const entryElement = append(container, $('.action-item.workspace-entry')); - const actionLabel = append(entryElement, $('span.action-label.workspace-icon')); - append(entryElement, $('span.active-item-indicator')); - - // Render based on display type - const folderName = entry.name; - if (entry.displayType === 'icon' && entry.iconId) { - // Render codicon - const icon = ThemeIcon.fromId(entry.iconId); - actionLabel.classList.add(...ThemeIcon.asClassNameArray(icon)); - actionLabel.classList.add('codicon-icon'); - actionLabel.textContent = ''; - } else { - // Default: render first letter of folder name - const firstLetter = folderName.charAt(0).toUpperCase(); - actionLabel.textContent = firstLetter; - } - - // Set selected state - const isSelected = this._selectedFolderUri?.toString() === entry.uri.toString(); - if (isSelected) { - entryElement.classList.add('checked'); - } - - // Build hover content with full path - const folderPath = this.labelService.getUriLabel(entry.uri, { relative: false }); - - // Add hover tooltip with folder name - entryDisposables.add( - this.hoverService.setupDelayedHover( - entryElement, - { - appearance: { showPointer: true }, - position: { hoverPosition: HoverPosition.RIGHT }, - content: folderPath - }, - { groupId: HOVER_GROUP_ID } - ) - ); - - // Click handler to select workspace - entryDisposables.add( - addDisposableListener(entryElement, EventType.CLICK, () => { - this.selectWorkspace(index); - }) - ); - - // Keyboard support - entryElement.setAttribute('tabindex', '0'); - entryElement.setAttribute('role', 'button'); - entryElement.setAttribute('aria-label', folderName); - entryElement.setAttribute('aria-pressed', isSelected ? 'true' : 'false'); - entryDisposables.add( - addDisposableListener(entryElement, EventType.KEY_DOWN, (e: KeyboardEvent) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - this.selectWorkspace(index); - } - }) - ); - - // Context menu with customize and remove actions - entryDisposables.add( - addDisposableListener(entryElement, EventType.CONTEXT_MENU, (e: MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - const event = new StandardMouseEvent(getWindow(entryElement), e); - this.contextMenuService.showContextMenu({ - getAnchor: () => event, - getActions: () => [ - new Action('projectbar.customize', localize('projectbar.customize', "Customize"), undefined, true, () => this.showCustomizeQuickPick(index)), - new Separator(), - new Action('projectbar.removeFolder', localize('projectbar.removeFolder', "Remove Folder"), undefined, true, () => this.removeFolderEntry(index)) - ] - }); - }) - ); - } - - private selectWorkspace(index: number): void { - if (index < 0 || index >= this.entries.length) { - return; - } - - const entry = this.entries[index]; - if (this._selectedFolderUri?.toString() === entry.uri.toString()) { - return; // Already selected - } - - this._selectedFolderUri = entry.uri; - this.saveEntriesToStorage(); - - // Re-render to update visual state - this.renderContent(); - - // Apply the selected folder as the workspace folder - this.applySelectedFolder(); - - // Fire selection event - this._onDidSelectWorkspace.fire(this._selectedFolderUri); - } - - private removeFolderEntry(index: number): void { - if (index < 0 || index >= this.entries.length) { - return; - } - - const removedUri = this.entries[index].uri; - this.entries.splice(index, 1); - this.saveEntriesToStorage(); - - // If the removed entry was the selected one, select the first remaining entry - if (this._selectedFolderUri?.toString() === removedUri.toString()) { - if (this.entries.length > 0) { - this._selectedFolderUri = this.entries[0].uri; - this.applySelectedFolder(); - this._onDidSelectWorkspace.fire(this._selectedFolderUri); - } else { - this._selectedFolderUri = undefined; - this._onDidSelectWorkspace.fire(undefined); - } - } - - this.renderContent(); - } - - private async showCustomizeQuickPick(index: number): Promise { - if (index < 0 || index >= this.entries.length) { - return; - } - - const entry = this.entries[index]; - - interface ICustomizeQuickPickItem extends IQuickPickItem { - customType: 'letter' | 'icon'; - } - - const items: ICustomizeQuickPickItem[] = [ - { - customType: 'letter', - label: localize('projectbar.customize.letter', "Letter"), - description: localize('projectbar.customize.letter.description', "Show the first letter of the workspace name") - }, - { - customType: 'icon', - label: localize('projectbar.customize.icon', "Icon"), - description: localize('projectbar.customize.icon.description', "Choose a codicon to represent the workspace") - } - ]; - - const picked = await this.quickInputService.pick(items, { - placeHolder: localize('projectbar.customize.placeholder', "Choose how to display the workspace in the project bar"), - title: localize('projectbar.customize.title', "Customize Workspace Appearance") - }); - - if (!picked) { - return; - } - - if (picked.customType === 'letter') { - entry.displayType = 'letter'; - entry.iconId = undefined; - this.saveEntriesToStorage(); - this.renderContent(); - } else if (picked.customType === 'icon') { - const icon = await this.pickIcon(); - if (icon) { - entry.displayType = 'icon'; - entry.iconId = icon.id; - this.saveEntriesToStorage(); - this.renderContent(); - } - } - } - - private async pickIcon(): Promise { - const iconSelectBox = this.instantiationService.createInstance(WorkbenchIconSelectBox, { - icons: icons.value, - inputBoxStyles: defaultInputBoxStyles - }); - - const dimension = new Dimension(486, 260); - return new Promise(resolve => { - const disposables = new DisposableStore(); - - disposables.add(iconSelectBox.onDidSelect(e => { - resolve(e); - disposables.dispose(); - iconSelectBox.dispose(); - })); - - iconSelectBox.clearInput(); - const body = getActiveDocument().body; - const bodyRect = body.getBoundingClientRect(); - const hoverWidget = this.hoverService.showInstantHover({ - content: iconSelectBox.domNode, - target: { - targetElements: [body], - x: bodyRect.left + (bodyRect.width - dimension.width) / 2, - y: bodyRect.top + this.layoutService.activeContainerOffset.top - }, - position: { - hoverPosition: HoverPosition.BELOW, - }, - persistence: { - sticky: true, - }, - }, true); - - if (hoverWidget) { - disposables.add(hoverWidget); - } - - iconSelectBox.layout(dimension); - iconSelectBox.focus(); - }); - } - - get selectedWorkspaceFolder(): URI | undefined { - return this._selectedFolderUri; - } - - override updateStyles(): void { - super.updateStyles(); - - const container = assertReturnsDefined(this.getContainer()); - const background = this.getColor(ACTIVITY_BAR_BACKGROUND) || ''; - container.style.backgroundColor = background; - - const borderColor = this.getColor(ACTIVITY_BAR_BORDER) || this.getColor(contrastBorder) || ''; - container.classList.toggle('bordered', !!borderColor); - container.style.borderColor = borderColor ? borderColor : ''; - } - - focus(): void { - // Focus the add folder button (first focusable element) - this.addFolderButton?.focus(); - } - - focusGlobalCompositeBar(): void { - this.globalCompositeBar.focus(); - } - - override layout(width: number, height: number): void { - super.layout(width, height, 0, 0); - - // The global composite bar takes some height at the bottom - // The actions container will take the remaining space due to CSS flex layout - } - - toJSON(): object { - return { - type: AgenticParts.PROJECTBAR_PART - }; - } -} diff --git a/src/vs/sessions/browser/parts/sidebarPart.ts b/src/vs/sessions/browser/parts/sidebarPart.ts deleted file mode 100644 index ca6bd416..00000000 --- a/src/vs/sessions/browser/parts/sidebarPart.ts +++ /dev/null @@ -1,333 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import '../../../workbench/browser/parts/sidebar/media/sidebarpart.css'; -import './media/sidebarPart.css'; -import { IWorkbenchLayoutService, Parts, Position as SideBarPosition } from '../../../workbench/services/layout/browser/layoutService.js'; -import { SidebarFocusContext, ActiveViewletContext } from '../../../workbench/common/contextkeys.js'; -import { IStorageService } from '../../../platform/storage/common/storage.js'; -import { IContextMenuService } from '../../../platform/contextview/browser/contextView.js'; -import { IKeybindingService } from '../../../platform/keybinding/common/keybinding.js'; -import { IInstantiationService } from '../../../platform/instantiation/common/instantiation.js'; -import { IThemeService } from '../../../platform/theme/common/themeService.js'; -import { SIDE_BAR_TITLE_FOREGROUND, SIDE_BAR_TITLE_BORDER, SIDE_BAR_FOREGROUND, SIDE_BAR_DRAG_AND_DROP_BACKGROUND, ACTIVITY_BAR_BADGE_BACKGROUND, ACTIVITY_BAR_BADGE_FOREGROUND, ACTIVITY_BAR_TOP_FOREGROUND, ACTIVITY_BAR_TOP_ACTIVE_BORDER, ACTIVITY_BAR_TOP_INACTIVE_FOREGROUND, ACTIVITY_BAR_TOP_DRAG_AND_DROP_BORDER } from '../../../workbench/common/theme.js'; -import { agentsPanelForeground } from '../../common/theme.js'; -import { INotificationService } from '../../../platform/notification/common/notification.js'; -import { IContextKeyService } from '../../../platform/contextkey/common/contextkey.js'; -import { AnchorAlignment } from '../../../base/browser/ui/contextview/contextview.js'; -import { IExtensionService } from '../../../workbench/services/extensions/common/extensions.js'; -import { LayoutPriority } from '../../../base/browser/ui/grid/grid.js'; -import { assertReturnsDefined } from '../../../base/common/types.js'; -import { IViewDescriptorService, ViewContainerLocation } from '../../../workbench/common/views.js'; -import { AbstractPaneCompositePart, CompositeBarPosition } from '../../../workbench/browser/parts/paneCompositePart.js'; -import { ICompositeTitleLabel } from '../../../workbench/browser/parts/compositePart.js'; -import { Part } from '../../../workbench/browser/part.js'; -import { ActionsOrientation } from '../../../base/browser/ui/actionbar/actionbar.js'; -import { HoverPosition } from '../../../base/browser/ui/hover/hoverWidget.js'; -import { IPaneCompositeBarOptions } from '../../../workbench/browser/parts/paneCompositeBar.js'; -import { IMenuService } from '../../../platform/actions/common/actions.js'; -import { Separator } from '../../../base/common/actions.js'; -import { IHoverService } from '../../../platform/hover/browser/hover.js'; -import { Extensions } from '../../../workbench/browser/panecomposite.js'; -import { Menus } from '../menus.js'; -import { $, append, getWindowId, prepend } from '../../../base/browser/dom.js'; -import { HiddenItemStrategy, MenuWorkbenchToolBar } from '../../../platform/actions/browser/toolbar.js'; -import { isFullscreen, onDidChangeFullscreen } from '../../../base/browser/browser.js'; -import { mainWindow } from '../../../base/browser/window.js'; -import { IConfigurationService } from '../../../platform/configuration/common/configuration.js'; -import { hasNativeTitlebar, getTitleBarStyle } from '../../../platform/window/common/window.js'; -import { isMacintosh, isNative, isWeb } from '../../../base/common/platform.js'; - -/** - * Sidebar part specifically for agent sessions workbench. - * This is a simplified version of the SidebarPart for agent session contexts. - */ -export class SidebarPart extends AbstractPaneCompositePart { - - static readonly activeViewletSettingsKey = 'workbench.agentsession.sidebar.activeviewletid'; - static readonly pinnedViewContainersKey = 'workbench.agentsession.pinnedViewlets2'; - static readonly placeholderViewContainersKey = 'workbench.agentsession.placeholderViewlets'; - static readonly viewContainersWorkspaceStateKey = 'workbench.agentsession.viewletsWorkspaceState'; - - /** Visual margin values - sidebar is flush (no card appearance) */ - static readonly MARGIN_TOP = 0; - static readonly MARGIN_BOTTOM = 0; - static readonly MARGIN_LEFT = 0; - private static readonly FOOTER_ITEM_HEIGHT = 26; - private static readonly FOOTER_ITEM_GAP = 4; - private static readonly FOOTER_VERTICAL_PADDING = 6; - private static readonly FOOTER_BOTTOM_MARGIN = 2; - private static readonly FOOTER_BORDER_TOP = 1; - - private footerContainer: HTMLElement | undefined; - private sideBarTitleArea: HTMLElement | undefined; - private footerToolbar: MenuWorkbenchToolBar | undefined; - private previousLayoutDimensions: { width: number; height: number; top: number; left: number } | undefined; - - //#region IView - - // On web the titlebar hosts an additional host filter combo alongside the - // sidebar toggle; use a wider minimum so those controls always fit within - // the sidebar's rendered area (below this the sidebar snaps closed). - readonly minimumWidth: number = isWeb ? 270 : 170; - readonly maximumWidth: number = Number.POSITIVE_INFINITY; - readonly minimumHeight: number = 0; - readonly maximumHeight: number = Number.POSITIVE_INFINITY; - override get snap(): boolean { return true; } - - readonly priority: LayoutPriority = LayoutPriority.Low; - - get preferredWidth(): number | undefined { - const viewlet = this.getActivePaneComposite(); - - if (!viewlet) { - return undefined; - } - - const width = viewlet.getOptimalWidth(); - if (typeof width !== 'number') { - return undefined; - } - - return Math.max(width, 300); - } - - //#endregion - - constructor( - @INotificationService notificationService: INotificationService, - @IStorageService storageService: IStorageService, - @IContextMenuService contextMenuService: IContextMenuService, - @IWorkbenchLayoutService layoutService: IWorkbenchLayoutService, - @IKeybindingService keybindingService: IKeybindingService, - @IHoverService hoverService: IHoverService, - @IInstantiationService instantiationService: IInstantiationService, - @IThemeService themeService: IThemeService, - @IViewDescriptorService viewDescriptorService: IViewDescriptorService, - @IContextKeyService contextKeyService: IContextKeyService, - @IExtensionService extensionService: IExtensionService, - @IMenuService menuService: IMenuService, - @IConfigurationService private readonly configurationService: IConfigurationService, - ) { - super( - Parts.SIDEBAR_PART, - { hasTitle: false, trailingSeparator: false, borderWidth: () => 0 }, - SidebarPart.activeViewletSettingsKey, - ActiveViewletContext.bindTo(contextKeyService), - SidebarFocusContext.bindTo(contextKeyService), - 'sideBar', - 'viewlet', - SIDE_BAR_TITLE_FOREGROUND, - SIDE_BAR_TITLE_BORDER, - ViewContainerLocation.Sidebar, - Extensions.Viewlets, - Menus.SidebarTitle, - notificationService, - storageService, - contextMenuService, - layoutService, - keybindingService, - hoverService, - instantiationService, - themeService, - viewDescriptorService, - contextKeyService, - extensionService, - menuService, - ); - } - - override create(parent: HTMLElement): void { - super.create(parent); - this.createFooter(parent); - } - - protected override createTitleArea(parent: HTMLElement): HTMLElement | undefined { - const titleArea = super.createTitleArea(parent); - this.sideBarTitleArea = titleArea; - - if (titleArea) { - // Add a drag region so the sidebar title area can be used to move the window, - // matching the titlebar's drag behavior. - prepend(titleArea, $('div.titlebar-drag-region')); - } - - // macOS native: the sidebar spans full height and the traffic lights - // overlay the top-left corner. Add a fixed-width spacer inside the - // title area to push content horizontally past the traffic lights. - if (titleArea && isMacintosh && isNative && !hasNativeTitlebar(this.configurationService, getTitleBarStyle(this.configurationService))) { - const spacer = $('div.window-controls-container'); - spacer.style.width = '70px'; - spacer.style.height = '100%'; - spacer.style.flexShrink = '0'; - spacer.style.order = '-1'; // match global-actions-left order so DOM order is respected - prepend(titleArea, spacer); - - // Hide spacer in fullscreen (traffic lights are not shown) - const updateSpacerVisibility = () => { - spacer.style.display = isFullscreen(mainWindow) ? 'none' : ''; - }; - updateSpacerVisibility(); - this._register(onDidChangeFullscreen(windowId => { - if (windowId === getWindowId(mainWindow)) { - updateSpacerVisibility(); - } - })); - } - - return titleArea; - } - - private createFooter(parent: HTMLElement): void { - const footer = append(parent, $('.sidebar-footer.sidebar-action-list')); - this.footerContainer = footer; - - this.footerToolbar = this._register(this.instantiationService.createInstance(MenuWorkbenchToolBar, footer, Menus.SidebarFooter, { - hiddenItemStrategy: HiddenItemStrategy.NoHide, - toolbarOptions: { primaryGroup: () => true }, - telemetrySource: 'sidebarFooter', - })); - - this._register(this.footerToolbar.onDidChangeMenuItems(() => { - if (this.previousLayoutDimensions) { - const { width, height, top, left } = this.previousLayoutDimensions; - this.layout(width, height, top, left); - } - })); - } - - private getFooterHeight(): number { - const actionCount = this.footerToolbar?.getItemsLength() ?? 0; - if (actionCount === 0) { - return 0; - } - - return SidebarPart.FOOTER_VERTICAL_PADDING * 2 - + (actionCount * SidebarPart.FOOTER_ITEM_HEIGHT) - + ((actionCount - 1) * SidebarPart.FOOTER_ITEM_GAP) - + SidebarPart.FOOTER_BOTTOM_MARGIN - + SidebarPart.FOOTER_BORDER_TOP; - } - - private updateFooterVisibility(): void { - const footer = this.footerContainer; - if (!footer) { - return; - } - - footer.style.display = this.getFooterHeight() > 0 ? '' : 'none'; - } - - override updateStyles(): void { - super.updateStyles(); - - const container = assertReturnsDefined(this.getContainer()); - - container.style.backgroundColor = 'transparent'; - container.style.color = this.getColor(SIDE_BAR_FOREGROUND) || ''; - container.style.outlineColor = this.getColor(SIDE_BAR_DRAG_AND_DROP_BACKGROUND) ?? ''; - - // No right border in sessions sidebar - container.style.borderRightWidth = ''; - container.style.borderRightStyle = ''; - container.style.borderRightColor = ''; - - if (this.sideBarTitleArea) { - this.sideBarTitleArea.style.backgroundColor = 'transparent'; - this.sideBarTitleArea.style.color = this.getColor(agentsPanelForeground) || ''; - } - } - - override layout(width: number, height: number, top: number, left: number): void { - this.previousLayoutDimensions = { width, height, top, left }; - - if (!this.layoutService.isVisible(Parts.SIDEBAR_PART)) { - return; - } - - this.updateFooterVisibility(); - const footerHeight = Math.min(height, this.getFooterHeight()); - - // Layout content with reduced height to account for footer - super.layout( - width, - height - footerHeight, - top, left - ); - - // Restore the full grid-allocated dimensions so that Part.relayout() works correctly. - Part.prototype.layout.call(this, width, height, top, left); - } - - protected override getTitleAreaDropDownAnchorAlignment(): AnchorAlignment { - return this.layoutService.getSideBarPosition() === SideBarPosition.LEFT ? AnchorAlignment.LEFT : AnchorAlignment.RIGHT; - } - - protected override createTitleLabel(_parent: HTMLElement): ICompositeTitleLabel { - // No title label in agent sessions sidebar - return { - updateTitle: () => { }, - updateStyles: () => { } - }; - } - - protected getCompositeBarOptions(): IPaneCompositeBarOptions { - return { - partContainerClass: 'sidebar', - pinnedViewContainersKey: SidebarPart.pinnedViewContainersKey, - placeholderViewContainersKey: SidebarPart.placeholderViewContainersKey, - viewContainersWorkspaceStateKey: SidebarPart.viewContainersWorkspaceStateKey, - icon: false, - orientation: ActionsOrientation.HORIZONTAL, - recomputeSizes: true, - activityHoverOptions: { - position: () => this.getCompositeBarPosition() === CompositeBarPosition.BOTTOM ? HoverPosition.ABOVE : HoverPosition.BELOW, - }, - fillExtraContextMenuActions: actions => { - if (this.getCompositeBarPosition() === CompositeBarPosition.TITLE) { - const viewsSubmenuAction = this.getViewsSubmenuAction(); - if (viewsSubmenuAction) { - actions.push(new Separator()); - actions.push(viewsSubmenuAction); - } - } - }, - compositeSize: 0, - iconSize: 16, - overflowActionSize: 30, - colors: theme => ({ - activeBackgroundColor: undefined, - inactiveBackgroundColor: undefined, - activeBorderBottomColor: theme.getColor(ACTIVITY_BAR_TOP_ACTIVE_BORDER), - activeForegroundColor: theme.getColor(ACTIVITY_BAR_TOP_FOREGROUND), - inactiveForegroundColor: theme.getColor(ACTIVITY_BAR_TOP_INACTIVE_FOREGROUND), - badgeBackground: theme.getColor(ACTIVITY_BAR_BADGE_BACKGROUND), - badgeForeground: theme.getColor(ACTIVITY_BAR_BADGE_FOREGROUND), - dragAndDropBorder: theme.getColor(ACTIVITY_BAR_TOP_DRAG_AND_DROP_BORDER) - }), - compact: true - }; - } - - protected shouldShowCompositeBar(): boolean { - return false; - } - - protected getCompositeBarPosition(): CompositeBarPosition { - return CompositeBarPosition.TITLE; - } - - async focusActivityBar(): Promise { - if (this.shouldShowCompositeBar()) { - this.focusCompositeBar(); - } - } - - toJSON(): object { - return { - type: Parts.SIDEBAR_PART - }; - } -} diff --git a/src/vs/sessions/browser/parts/titlebarPart.ts b/src/vs/sessions/browser/parts/titlebarPart.ts deleted file mode 100644 index cf1a434d..00000000 --- a/src/vs/sessions/browser/parts/titlebarPart.ts +++ /dev/null @@ -1,470 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import '../../../workbench/browser/parts/titlebar/media/titlebarpart.css'; -import './media/titlebarpart.css'; -import { MultiWindowParts, Part } from '../../../workbench/browser/part.js'; -import { ITitleService } from '../../../workbench/services/title/browser/titleService.js'; -import { getZoomFactor, isWCOEnabled, getWCOTitlebarAreaRect, isFullscreen, onDidChangeFullscreen } from '../../../base/browser/browser.js'; -import { hasCustomTitlebar, hasNativeTitlebar, DEFAULT_CUSTOM_TITLEBAR_HEIGHT, TitlebarStyle, getTitleBarStyle, getWindowControlsStyle, WindowControlsStyle } from '../../../platform/window/common/window.js'; -import { IContextMenuService } from '../../../platform/contextview/browser/contextView.js'; -import { StandardMouseEvent } from '../../../base/browser/mouseEvent.js'; -import { IConfigurationService } from '../../../platform/configuration/common/configuration.js'; -import { DisposableStore } from '../../../base/common/lifecycle.js'; -import { IThemeService } from '../../../platform/theme/common/themeService.js'; -import { agentsPanelForeground } from '../../common/theme.js'; -import { isMacintosh, isWeb, isNative, platformLocale } from '../../../base/common/platform.js'; -import { EventType, EventHelper, append, $, addDisposableListener, prepend, getWindow, getWindowId, getContentWidth } from '../../../base/browser/dom.js'; -import { IInstantiationService } from '../../../platform/instantiation/common/instantiation.js'; -import { Emitter, Event } from '../../../base/common/event.js'; -import { IStorageService } from '../../../platform/storage/common/storage.js'; -import { Parts, IWorkbenchLayoutService } from '../../../workbench/services/layout/browser/layoutService.js'; - -import { IContextKeyService } from '../../../platform/contextkey/common/contextkey.js'; -import { IHostService } from '../../../workbench/services/host/browser/host.js'; -import { HiddenItemStrategy, MenuWorkbenchToolBar } from '../../../platform/actions/browser/toolbar.js'; -import { IEditorGroupsContainer } from '../../../workbench/services/editor/common/editorGroupsService.js'; -import { CodeWindow, mainWindow } from '../../../base/browser/window.js'; -import { safeIntl } from '../../../base/common/date.js'; -import { ITitlebarPart, ITitleProperties, ITitleVariable, IAuxiliaryTitlebarPart } from '../../../workbench/browser/parts/titlebar/titlebarPart.js'; -import { Menus } from '../menus.js'; - -/** - * Simplified agent sessions titlebar part. - * - * Three sections driven entirely by menus: - * - **Left**: `Menus.TitleBarLeft` toolbar - * - **Center**: `Menus.CommandCenter` toolbar (renders session picker via IActionViewItemService) - * - **Right**: `Menus.TitleBarRight` toolbar (includes account submenu) - * - * No menubar, no editor actions, no layout controls, no WindowTitle dependency. - */ -export class TitlebarPart extends Part implements ITitlebarPart { - - //#region IView - - readonly minimumWidth: number = 0; - readonly maximumWidth: number = Number.POSITIVE_INFINITY; - - get minimumHeight(): number { - const wcoEnabled = isWeb && isWCOEnabled(); - let value = DEFAULT_CUSTOM_TITLEBAR_HEIGHT; - if (wcoEnabled) { - value = Math.max(value, getWCOTitlebarAreaRect(getWindow(this.element))?.height ?? 0); - } - - return value / (this.preventZoom ? getZoomFactor(getWindow(this.element)) : 1); - } - - get maximumHeight(): number { return this.minimumHeight; } - - //#endregion - - //#region Events - - private readonly _onMenubarVisibilityChange = this._register(new Emitter()); - readonly onMenubarVisibilityChange = this._onMenubarVisibilityChange.event; - - private readonly _onWillDispose = this._register(new Emitter()); - readonly onWillDispose = this._onWillDispose.event; - - //#endregion - - private rootContainer!: HTMLElement; - private windowControlsContainer: HTMLElement | undefined; - - private leftContent!: HTMLElement; - private leftToolbarContainer!: HTMLElement; - private centerContent!: HTMLElement; - private rightContent!: HTMLElement; - - get leftContainer(): HTMLElement { return this.leftContent; } - get rightContainer(): HTMLElement { return this.rightContent; } - get rightWindowControlsContainer(): HTMLElement | undefined { return this.windowControlsContainer; } - - private sideBarPartResizeObserver: ResizeObserver | undefined; - private leftToolbarContentWidth: number = 0; - private lastSideBarWidth: number = 0; - private leftSpacerWidth: number = 0; - - private readonly titleBarStyle: TitlebarStyle; - private isInactive: boolean = false; - - constructor( - id: string, - targetWindow: CodeWindow, - @IContextMenuService private readonly contextMenuService: IContextMenuService, - @IConfigurationService protected readonly configurationService: IConfigurationService, - @IInstantiationService protected readonly instantiationService: IInstantiationService, - @IThemeService themeService: IThemeService, - @IStorageService storageService: IStorageService, - @IWorkbenchLayoutService layoutService: IWorkbenchLayoutService, - @IContextKeyService private readonly contextKeyService: IContextKeyService, - @IHostService private readonly hostService: IHostService, - ) { - super(id, { hasTitle: false }, themeService, storageService, layoutService); - - this.titleBarStyle = getTitleBarStyle(this.configurationService); - - this.registerListeners(getWindowId(targetWindow)); - } - - private registerListeners(targetWindowId: number): void { - this._register(this.hostService.onDidChangeFocus(focused => focused ? this.onFocus() : this.onBlur())); - this._register(this.hostService.onDidChangeActiveWindow(windowId => windowId === targetWindowId ? this.onFocus() : this.onBlur())); - } - - private onBlur(): void { - this.isInactive = true; - this.updateStyles(); - } - - private onFocus(): void { - this.isInactive = false; - this.updateStyles(); - } - - updateProperties(_properties: ITitleProperties): void { - // No window title to update in simplified titlebar - } - - registerVariables(_variables: ITitleVariable[]): void { - // No window title variables in simplified titlebar - } - - updateOptions(_options: { compact: boolean }): void { - // No compact mode support in agent sessions titlebar - } - - protected override createContentArea(parent: HTMLElement): HTMLElement { - this.element = parent; - this.rootContainer = append(parent, $('.titlebar-container.sessions-titlebar-container.has-center')); - - // Draggable region - prepend(this.rootContainer, $('div.titlebar-drag-region')); - - this.leftContent = append(this.rootContainer, $('.titlebar-left')); - this.centerContent = append(this.rootContainer, $('.titlebar-center')); - this.rightContent = append(this.rootContainer, $('.titlebar-right')); - - // Window Controls Container (must be before left toolbar for correct ordering) - if (!hasNativeTitlebar(this.configurationService, this.titleBarStyle)) { - let primaryWindowControlsLocation = isMacintosh ? 'left' : 'right'; - if (isMacintosh && isNative) { - const localeInfo = safeIntl.Locale(platformLocale).value; - const textInfo = (localeInfo as { textInfo?: { direction?: string } }).textInfo; - if (textInfo?.direction === 'rtl') { - primaryWindowControlsLocation = 'right'; - } - } - - if (isMacintosh && isNative && primaryWindowControlsLocation === 'left') { - // macOS native: traffic lights are rendered by the OS at the top-left corner. - // Add a fixed-width spacer to push content past the traffic lights. - const spacer = append(this.leftContent, $('div.window-controls-container')); - - // Hide spacer in fullscreen (traffic lights are not shown) - const updateSpacerVisibility = () => { - const fullscreen = isFullscreen(mainWindow); - spacer.style.display = fullscreen ? 'none' : ''; - this.leftSpacerWidth = fullscreen ? 0 : 70; - }; - updateSpacerVisibility(); - spacer.style.width = `${this.leftSpacerWidth}px`; - spacer.style.flexShrink = '0'; - this._register(onDidChangeFullscreen(windowId => { - if (windowId === getWindowId(mainWindow)) { - updateSpacerVisibility(); - this.updateLeftContentWidth(); - } - })); - } else if (getWindowControlsStyle(this.configurationService) === WindowControlsStyle.HIDDEN) { - // controls explicitly disabled - } else { - this.windowControlsContainer = append(primaryWindowControlsLocation === 'left' ? this.leftContent : this.rightContent, $('div.window-controls-container')); - if (isWeb) { - append(primaryWindowControlsLocation === 'left' ? this.rightContent : this.leftContent, $('div.window-controls-container')); - } - - if (isWCOEnabled()) { - this.windowControlsContainer.classList.add('wco-enabled'); - } - } - } - - // Left toolbar (driven by Menus.TitleBarLeft, rendered after window controls via CSS order) - this.leftToolbarContainer = append(this.leftContent, $('div.left-toolbar-container')); - const leftToolbar = this._register(this.instantiationService.createInstance(MenuWorkbenchToolBar, this.leftToolbarContainer, Menus.TitleBarLeftLayout, { - contextMenu: Menus.TitleBarContext, - telemetrySource: 'titlePart.left', - hiddenItemStrategy: HiddenItemStrategy.NoHide, - toolbarOptions: { primaryGroup: () => true }, - })); - this.leftToolbarContentWidth = getContentWidth(this.leftToolbarContainer); - this.updateLeftContentWidth(); - this._register(leftToolbar.onDidChangeMenuItems(() => { - this.leftToolbarContentWidth = getContentWidth(this.leftToolbarContainer); - this.updateLeftContentWidth(); - })); - - // Center toolbar - command center (renders session picker via IActionViewItemService) - // Uses .window-title > .command-center nesting to match default workbench CSS selectors - const windowTitle = append(this.centerContent, $('div.window-title')); - const centerToolbarContainer = append(windowTitle, $('div.command-center')); - this._register(this.instantiationService.createInstance(MenuWorkbenchToolBar, centerToolbarContainer, Menus.CommandCenter, { - contextMenu: Menus.TitleBarContext, - hiddenItemStrategy: HiddenItemStrategy.NoHide, - telemetrySource: 'commandCenter', - toolbarOptions: { primaryGroup: () => true }, - })); - - // Right toolbar (driven by Menus.TitleBarRightLayout - includes layout actions) - const rightToolbarContainer = prepend(this.rightContent, $('div.titlebar-actions-container.titlebar-right-layout-container')); - this._register(this.instantiationService.createInstance(MenuWorkbenchToolBar, rightToolbarContainer, Menus.TitleBarRightLayout, { - contextMenu: Menus.TitleBarContext, - hiddenItemStrategy: HiddenItemStrategy.NoHide, - telemetrySource: 'titlePart.right', - toolbarOptions: { primaryGroup: () => true }, - })); - - // Session title actions toolbar (before right toolbar) - const sessionActionsContainer = prepend(this.rightContent, $('div.titlebar-actions-container.titlebar-session-actions-container')); - this._register(this.instantiationService.createInstance(MenuWorkbenchToolBar, sessionActionsContainer, Menus.TitleBarSessionMenu, { - contextMenu: Menus.TitleBarContext, - hiddenItemStrategy: HiddenItemStrategy.NoHide, - telemetrySource: 'titlePart.sessionActions', - toolbarOptions: { primaryGroup: () => true }, - })); - - // Context menu on the titlebar - this._register(addDisposableListener(this.rootContainer, EventType.CONTEXT_MENU, e => { - EventHelper.stop(e); - this.onContextMenu(e); - })); - - this.updateStyles(); - - return this.element; - } - - override updateStyles(): void { - super.updateStyles(); - - if (this.element) { - this.element.classList.toggle('inactive', this.isInactive); - - // Titlebar is transparent — it inherits the sidebar/gradient background via CSS. - // Only set foreground color for text/icon contrast. - this.element.style.backgroundColor = ''; - - const titleForeground = this.getColor(agentsPanelForeground); - this.element.style.color = titleForeground || ''; - } - } - - private onContextMenu(e: MouseEvent): void { - const event = new StandardMouseEvent(getWindow(this.element), e); - this.contextMenuService.showContextMenu({ - getAnchor: () => event, - menuId: Menus.TitleBarContext, - contextKeyService: this.contextKeyService, - domForShadowRoot: isMacintosh && isNative ? event.target : undefined - }); - } - - get hasZoomableElements(): boolean { - return true; // sessions titlebar always has command center and toolbar actions - } - - get preventZoom(): boolean { - // Prevent zooming behavior if any of the following conditions are met: - // 1. Shrinking below the window control size (zoom < 1) - // 2. No custom items are present in the title bar - return getZoomFactor(getWindow(this.element)) < 1 || !this.hasZoomableElements; - } - - override layout(width: number, height: number): void { - this.updateLayout(); - super.layoutContents(width, height); - this.installSideBarPartResizeObserver(); - } - - private installSideBarPartResizeObserver(): void { - if (this.sideBarPartResizeObserver) { - return; - } - - const sideBarContainer = this.layoutService.getContainer(getWindow(this.element), Parts.SIDEBAR_PART); - if (!sideBarContainer) { - return; - } - - this.sideBarPartResizeObserver = new ResizeObserver(entries => { - this.lastSideBarWidth = entries[0].contentRect.width; - this.updateLeftContentWidth(); - }); - this.sideBarPartResizeObserver.observe(sideBarContainer); - this._register({ dispose: () => this.sideBarPartResizeObserver?.disconnect() }); - } - - private getLeftContentWidth(): number { - if (this.leftToolbarContentWidth === 0) { - this.leftToolbarContentWidth = getContentWidth(this.leftToolbarContainer); - } - return this.leftToolbarContentWidth + this.leftSpacerWidth; - } - - private updateLeftContentWidth(): void { - this.leftContent.style.width = `${Math.max(this.getLeftContentWidth(), this.lastSideBarWidth)}px`; - } - - private updateLayout(): void { - if (!hasCustomTitlebar(this.configurationService, this.titleBarStyle)) { - return; - } - - const zoomFactor = getZoomFactor(getWindow(this.element)); - this.element.style.setProperty('--zoom-factor', zoomFactor.toString()); - this.rootContainer.classList.toggle('counter-zoom', this.preventZoom); - } - - focus(): void { - // eslint-disable-next-line no-restricted-syntax - (this.element.querySelector('[tabindex]:not([tabindex="-1"])') as HTMLElement | null)?.focus(); - } - - toJSON(): object { - return { type: Parts.TITLEBAR_PART }; - } - - override dispose(): void { - this._onWillDispose.fire(); - super.dispose(); - } -} - -/** - * Main agent sessions titlebar part (for the main window). - */ -export class MainTitlebarPart extends TitlebarPart { - - constructor( - @IContextMenuService contextMenuService: IContextMenuService, - @IConfigurationService configurationService: IConfigurationService, - @IInstantiationService instantiationService: IInstantiationService, - @IThemeService themeService: IThemeService, - @IStorageService storageService: IStorageService, - @IWorkbenchLayoutService layoutService: IWorkbenchLayoutService, - @IContextKeyService contextKeyService: IContextKeyService, - @IHostService hostService: IHostService, - ) { - super(Parts.TITLEBAR_PART, mainWindow, contextMenuService, configurationService, instantiationService, themeService, storageService, layoutService, contextKeyService, hostService); - } -} - -/** - * Auxiliary agent sessions titlebar part (for auxiliary windows). - */ -export class AuxiliaryTitlebarPart extends TitlebarPart implements IAuxiliaryTitlebarPart { - - private static COUNTER = 1; - - get height() { return this.minimumHeight; } - - constructor( - readonly container: HTMLElement, - private readonly mainTitlebar: TitlebarPart, - @IContextMenuService contextMenuService: IContextMenuService, - @IConfigurationService configurationService: IConfigurationService, - @IInstantiationService instantiationService: IInstantiationService, - @IThemeService themeService: IThemeService, - @IStorageService storageService: IStorageService, - @IWorkbenchLayoutService layoutService: IWorkbenchLayoutService, - @IContextKeyService contextKeyService: IContextKeyService, - @IHostService hostService: IHostService, - ) { - const id = AuxiliaryTitlebarPart.COUNTER++; - super(`workbench.parts.auxiliaryTitle.${id}`, getWindow(container), contextMenuService, configurationService, instantiationService, themeService, storageService, layoutService, contextKeyService, hostService); - } - - override get preventZoom(): boolean { - // Prevent zooming behavior if any of the following conditions are met: - // 1. Shrinking below the window control size (zoom < 1) - // 2. No custom items are present in the main title bar - // The auxiliary title bar never contains any zoomable items itself, - // but we want to match the behavior of the main title bar. - return getZoomFactor(getWindow(this.element)) < 1 || !this.mainTitlebar.hasZoomableElements; - } -} - -/** - * Agent Sessions title service - manages the titlebar parts. - */ -export class TitleService extends MultiWindowParts implements ITitleService { - - declare _serviceBrand: undefined; - - readonly mainPart: TitlebarPart; - - constructor( - @IInstantiationService protected readonly instantiationService: IInstantiationService, - @IStorageService storageService: IStorageService, - @IThemeService themeService: IThemeService - ) { - super('workbench.agentSessionsTitleService', themeService, storageService); - - this.mainPart = this._register(this.createMainTitlebarPart()); - this.onMenubarVisibilityChange = this.mainPart.onMenubarVisibilityChange; - this._register(this.registerPart(this.mainPart)); - } - - protected createMainTitlebarPart(): TitlebarPart { - return this.instantiationService.createInstance(MainTitlebarPart); - } - - //#region Auxiliary Titlebar Parts - - createAuxiliaryTitlebarPart(container: HTMLElement, editorGroupsContainer: IEditorGroupsContainer, instantiationService: IInstantiationService): IAuxiliaryTitlebarPart { - const titlebarPartContainer = $('.part.titlebar', { role: 'none' }); - titlebarPartContainer.style.position = 'relative'; - container.insertBefore(titlebarPartContainer, container.firstChild); - - const disposables = new DisposableStore(); - - const titlebarPart = this.doCreateAuxiliaryTitlebarPart(titlebarPartContainer, editorGroupsContainer, instantiationService); - disposables.add(this.registerPart(titlebarPart)); - - disposables.add(Event.runAndSubscribe(titlebarPart.onDidChange, () => titlebarPartContainer.style.height = `${titlebarPart.height}px`)); - titlebarPart.create(titlebarPartContainer); - - Event.once(titlebarPart.onWillDispose)(() => disposables.dispose()); - - return titlebarPart; - } - - protected doCreateAuxiliaryTitlebarPart(container: HTMLElement, _editorGroupsContainer: IEditorGroupsContainer, instantiationService: IInstantiationService): TitlebarPart & IAuxiliaryTitlebarPart { - return instantiationService.createInstance(AuxiliaryTitlebarPart, container, this.mainPart); - } - - //#endregion - - //#region Service Implementation - - readonly onMenubarVisibilityChange: Event; - - updateProperties(properties: ITitleProperties): void { - for (const part of this.parts) { - part.updateProperties(properties); - } - } - - registerVariables(variables: ITitleVariable[]): void { - for (const part of this.parts) { - part.registerVariables(variables); - } - } - - //#endregion -} diff --git a/src/vs/sessions/browser/web.factory.ts b/src/vs/sessions/browser/web.factory.ts deleted file mode 100644 index 595d27fa..00000000 --- a/src/vs/sessions/browser/web.factory.ts +++ /dev/null @@ -1,49 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { IWorkbench, IWorkbenchConstructionOptions } from '../../workbench/browser/web.api.js'; -import { SessionsBrowserMain } from './web.main.js'; -import { IDisposable, toDisposable } from '../../base/common/lifecycle.js'; -import { mark } from '../../base/common/performance.js'; -import { DeferredPromise } from '../../base/common/async.js'; -import { CommandsRegistry } from '../../platform/commands/common/commands.js'; -import { MenuRegistry, MenuId } from '../../platform/actions/common/actions.js'; - -const workbenchPromise = new DeferredPromise(); - -/** - * Creates the Sessions workbench with the provided options in the provided container. - */ -export function create(domElement: HTMLElement, options: IWorkbenchConstructionOptions): IDisposable { - - mark('code/didLoadWorkbenchMain'); - - // Register embedder commands (same as workbench/browser/web.factory.ts) - if (Array.isArray(options.commands)) { - for (const command of options.commands) { - CommandsRegistry.registerCommand(command.id, (_accessor, ...args) => { - return command.handler(...args); - }); - - if (command.label) { - MenuRegistry.appendMenuItem(MenuId.CommandPalette, { command: { id: command.id, title: command.label } }); - } - } - } - - let instantiatedWorkbench: IWorkbench | undefined = undefined; - new SessionsBrowserMain(domElement, options).open().then(workbench => { - instantiatedWorkbench = workbench; - workbenchPromise.complete(workbench); - }); - - return toDisposable(() => { - if (instantiatedWorkbench) { - instantiatedWorkbench.shutdown(); - } else { - workbenchPromise.p.then(w => w.shutdown()); - } - }); -} diff --git a/src/vs/sessions/browser/web.main.ts b/src/vs/sessions/browser/web.main.ts deleted file mode 100644 index 4dcabc35..00000000 --- a/src/vs/sessions/browser/web.main.ts +++ /dev/null @@ -1,94 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { joinPath } from '../../base/common/resources.js'; -import { onUnexpectedError } from '../../base/common/errors.js'; -import { ServiceCollection } from '../../platform/instantiation/common/serviceCollection.js'; -import { ILogService } from '../../platform/log/common/log.js'; -import { IRemoteAuthorityResolverService } from '../../platform/remote/common/remoteAuthorityResolver.js'; -import { IStorageService } from '../../platform/storage/common/storage.js'; -import { IUriIdentityService } from '../../platform/uriIdentity/common/uriIdentity.js'; -import { IAnyWorkspaceIdentifier, IWorkspaceContextService } from '../../platform/workspace/common/workspace.js'; -import { IWorkspaceTrustEnablementService, IWorkspaceTrustManagementService } from '../../platform/workspace/common/workspaceTrust.js'; -import { IBrowserWorkbenchEnvironmentService } from '../../workbench/services/environment/browser/environmentService.js'; -import { IWorkbenchConfigurationService } from '../../workbench/services/configuration/common/configuration.js'; -import { IUserDataProfileService } from '../../workbench/services/userDataProfile/common/userDataProfile.js'; -import { BrowserUserDataProfilesService } from '../../platform/userDataProfile/browser/userDataProfile.js'; -import { FileService } from '../../platform/files/common/fileService.js'; -import { IPolicyService } from '../../platform/policy/common/policy.js'; -import { IRemoteAgentService } from '../../workbench/services/remote/common/remoteAgentService.js'; -import { IWorkspaceEditingService } from '../../workbench/services/workspaces/common/workspaceEditing.js'; -import { WorkspaceTrustEnablementService, WorkspaceTrustManagementService } from '../../workbench/services/workspaces/common/workspaceTrust.js'; -import { BrowserMain, IBrowserMainWorkbench } from '../../workbench/browser/web.main.js'; -import { getWorkspaceIdentifier } from '../../workbench/services/workspaces/browser/workspaces.js'; -import { SessionsWorkspaceContextService } from '../services/workspace/browser/workspaceContextService.js'; -import { ConfigurationService } from '../services/configuration/browser/configurationService.js'; -import { Workbench as SessionsWorkbench } from './workbench.js'; - -export class SessionsBrowserMain extends BrowserMain { - - protected override createWorkbench(domElement: HTMLElement, serviceCollection: ServiceCollection, logService: ILogService): IBrowserMainWorkbench { - return new SessionsWorkbench(domElement, undefined, serviceCollection, logService); - } - - protected override async createWorkspaceConfigAndStorageServices( - serviceCollection: ServiceCollection, - _workspace: IAnyWorkspaceIdentifier, - environmentService: IBrowserWorkbenchEnvironmentService, - userDataProfileService: IUserDataProfileService, - _userDataProfilesService: BrowserUserDataProfilesService, - fileService: FileService, - _remoteAgentService: IRemoteAgentService, - uriIdentityService: IUriIdentityService, - policyService: IPolicyService, - logService: ILogService, - remoteAuthorityResolverService: IRemoteAuthorityResolverService, - ): Promise<{ configurationService: IWorkbenchConfigurationService; storageService: IStorageService }> { - // Use sessions workspace/configuration services instead of the standard - // WorkspaceService. This mirrors what SessionsMain does on desktop: - // the agents window manages workspace folders in-memory without creating - // workspace file watchers or other resources. - - // Workspace — use a stable synthetic workspace identifier for agents - const sessionsWorkspaceUri = joinPath(environmentService.userRoamingDataHome, 'agent-sessions.code-workspace'); - const workspaceIdentifier = getWorkspaceIdentifier(sessionsWorkspaceUri); - const workspaceContextService = new SessionsWorkspaceContextService(workspaceIdentifier, uriIdentityService); - - serviceCollection.set(IWorkspaceContextService, workspaceContextService); - serviceCollection.set(IWorkspaceEditingService, workspaceContextService); - - // Configuration — the sessions ConfigurationService works against the - // in-memory workspace model rather than a real .code-workspace file on disk. - const configurationService = new ConfigurationService( - userDataProfileService, - workspaceContextService, - uriIdentityService, - fileService, - policyService, - logService - ); - - try { - await configurationService.initialize(); - } catch (error) { - onUnexpectedError(error); - } - - serviceCollection.set(IWorkbenchConfigurationService, configurationService); - - // Storage - const storageService = await this.createStorageService(workspaceIdentifier, logService, userDataProfileService); - serviceCollection.set(IStorageService, storageService); - - // Workspace Trust Service - const workspaceTrustEnablementService = new WorkspaceTrustEnablementService(configurationService, environmentService); - serviceCollection.set(IWorkspaceTrustEnablementService, workspaceTrustEnablementService); - - const workspaceTrustManagementService = new WorkspaceTrustManagementService(configurationService, remoteAuthorityResolverService, storageService, uriIdentityService, environmentService, workspaceContextService, workspaceTrustEnablementService, fileService); - serviceCollection.set(IWorkspaceTrustManagementService, workspaceTrustManagementService); - - return { configurationService, storageService }; - } -} diff --git a/src/vs/sessions/browser/widget/AGENTS_CHAT_WIDGET.md b/src/vs/sessions/browser/widget/AGENTS_CHAT_WIDGET.md deleted file mode 100644 index 25e56685..00000000 --- a/src/vs/sessions/browser/widget/AGENTS_CHAT_WIDGET.md +++ /dev/null @@ -1,503 +0,0 @@ -# Agent Sessions Chat Widget Architecture - -This document describes the architecture of the **Agent Sessions Chat Widget** (`AgentSessionsChatWidget`), a new extensible chat widget designed for the agent sessions window. It replaces the tightly-coupled agent session logic inside `ChatWidget` and `ChatInputPart` with a clean, composable system built around the wrapper pattern. - ---- - -## 1. Motivation: Why a New Architecture? - -### The Problem with Patching Core Widgets - -The original approach to supporting agent sessions involved adding agent-specific logic directly into the core `ChatWidget` and `ChatInputPart`. Over time, this led to significant coupling and code complexity: - -**Inside `ChatWidget` (~100+ lines of agent-specific code):** -- `ChatFullWelcomePart` is directly instantiated inside `ChatWidget.render()`, with the widget reaching into the welcome part's DOM to reparent the input element between `fullWelcomePart.inputSlot` and `mainInputContainer` -- `showFullWelcome` creates a forked rendering path — 5+ conditional branches in `render()`, `updateChatViewVisibility()`, and `renderWelcomeViewContentIfNeeded()` -- `lockToCodingAgent()` / `unlockFromCodingAgent()` add ~55 lines of method code plus ~20 lines of scattered `_lockedAgent`-gated logic throughout `clear()`, `forcedAgent` computation, welcome content generation, and scroll lock behavior -- The `_lockedToCodingAgentContextKey` context key is set/read in many places, creating implicit coupling between agent session state and widget rendering - -**Inside `ChatInputPart` (~50+ lines of agent-specific code):** -- Imports `AgentSessionProviders`, `getAgentSessionProvider`, `getAgentSessionProviderName` directly -- Manages `_pendingDelegationTarget`, `agentSessionTypeKey` context key, and `sessionTargetWidget` -- Has ~15 call sites checking `sessionTypePickerDelegate?.getActiveSessionProvider()` to determine option groups, picker rendering, and session type handling -- `getEffectiveSessionType()` resolves session type through a delegate → session context → fallback chain - -**Consequences:** -- **Fragile changes** — modifying agent session behavior requires touching core `ChatWidget` internals, risking regressions in the standard chat experience -- **Testing difficulty** — agent session logic is interleaved with general chat logic, making it hard to test either in isolation -- **Feature creep** — every new agent session feature (target restriction, deferred creation, cached option groups) adds more conditional branches to shared code -- **Unclear ownership** — it's hard to tell where "chat widget" ends and "agent sessions" begins - -### The Solution: Composition Over Modification - -The `AgentSessionsChatWidget` wraps `ChatWidget` instead of patching it. Agent-specific behavior lives in self-contained components that compose with the core widget through well-defined interfaces (`submitHandler`, `hiddenPickerIds`, `excludeOptionGroup`, `ISessionTypePickerDelegate` bridge). The core `ChatWidget` requires no agent-specific modifications. - ---- - -## 2. Overview - -The Agent Sessions Chat Widget provides: - -- **Deferred session creation** — the UI is fully interactive before any session resource exists -- **Target configuration** — users select which agent provider (Local, Cloud, etc.) to use -- **Welcome view** — a branded empty-state experience with mascot, target buttons, and option pickers -- **Initial session options** — option selections travel atomically with the first request to the extension -- **Configurable picker placement** — pickers can be rendered in the welcome view, input toolbar, or both - -```mermaid -graph TD - A[AgentSessionsChatWidget] --> B[ChatWidget] - A --> C[AgentSessionsChatWelcomePart] - A --> D[AgentSessionsChatTargetConfig] - - B --> F[ChatInputPart] - C --> G[Target Buttons] - C --> H[Option Pickers] - C --> I[Input Slot] - D --> J[Observable Target State] - E[AgentSessionsChatInputPart] -.->|standalone adapter| F - E -.-> D -``` - -> **Note:** `AgentSessionsChatInputPart` is a standalone adapter that bridges `IAgentChatTargetConfig` to `ChatInputPart`. It is available for consumers that need a `ChatInputPart` outside of a full `ChatWidget`, but `AgentSessionsChatWidget` itself creates the bridge delegate inline and passes it through `wrappedViewOptions` to the `ChatWidget`'s own `ChatInputPart`. - ---- - -## 3. Key Components - -### 3.1 `AgentSessionsChatWidget` - -**Location:** `src/vs/sessions/browser/widget/agentSessionsChatWidget.ts` - -The main wrapper around `ChatWidget`. It: - -1. **Owns the target config** — creates `AgentSessionsChatTargetConfig` from provided options -2. **Intercepts submission via two mechanisms** — uses `submitHandler` to create the session on first send, and monkey-patches `acceptInput` to attach `initialSessionOptions` to the session context -3. **Manages the welcome view** — shows `AgentSessionsChatWelcomePart` when the chat is empty -4. **Gathers initial options** — collects all option selections and attaches them to the session context -5. **Hides duplicate pickers** — uses `hiddenPickerIds` and `excludeOptionGroup` to avoid showing pickers in both the welcome view and input toolbar -6. **Caches option groups** — persists extension-contributed option groups to `StorageService` so pickers render immediately on next load before extensions activate - -#### Submission Interception: Two Mechanisms - -The widget uses two complementary interception points: - -- **`submitHandler`** (in `wrappedViewOptions`): Called by `ChatWidget._acceptInput()` before the normal send flow. If the session hasn't been created yet, it calls `_createSessionForCurrentTarget()`, restores the input text (which gets cleared by `setModel()`), and returns `false` to let the normal flow continue. -- **Monkey-patched `acceptInput`**: Called when `ChatSubmitAction` directly invokes `chatWidget.acceptInput()`. This captures the input text, creates the session if needed, then calls `_gatherAllOptionSelections()` to merge all option picks and attaches them to `contributedChatSession.initialSessionOptions` before delegating to the original `acceptInput`. - -Both paths converge on the same session creation and option gathering logic. The `submitHandler` handles the ChatWidget-internal send path, while the monkey-patch handles external callers (like `ChatSubmitAction`). - -```mermaid -sequenceDiagram - participant User - participant Widget as AgentSessionsChatWidget - participant Welcome as AgentSessionsChatWelcomePart - participant ChatWidget - participant Extension - - User->>Widget: Opens agent sessions window - Widget->>ChatWidget: render() with wrappedViewOptions - Widget->>Welcome: Create welcome view - Welcome-->>User: Shows mascot + target buttons + input - - User->>Welcome: Selects "Cloud" target - Welcome->>Widget: targetConfig.setSelectedTarget(Cloud) - Widget->>ChatWidget: Bridge to sessionTypePickerDelegate - - User->>Welcome: Picks repository - Welcome-->>Welcome: Stores in _selectedOptions - - User->>ChatWidget: Types message + clicks Send - Note over ChatWidget: ChatSubmitAction calls acceptInput() - ChatWidget->>Widget: Monkey-patched acceptInput() - Widget->>Widget: Capture input text - Widget->>Widget: _createSessionForCurrentTarget() - Widget->>Widget: _gatherAllOptionSelections() - Widget->>ChatWidget: setContributedChatSession({...initialSessionOptions}) - Widget->>ChatWidget: originalAcceptInput(capturedInput) - Note over ChatWidget: _acceptInput() → submitHandler check (already created, returns false) - ChatWidget->>Extension: $invokeAgent(request, {chatSessionContext: {initialSessionOptions}}) -``` - -### 3.2 `AgentSessionsChatTargetConfig` - -**Location:** `src/vs/sessions/browser/widget/agentSessionsChatTargetConfig.ts` - -A reactive configuration object that tracks: - -- **Allowed targets** — which agent providers are available (e.g., `[Background, Cloud]`) -- **Selected target** — which provider the user has chosen -- **Events** — fires when the target or allowed set changes - -```typescript -interface IAgentChatTargetConfig { - readonly allowedTargets: IObservable>; - readonly selectedTarget: IObservable; - readonly onDidChangeSelectedTarget: Event; - readonly onDidChangeAllowedTargets: Event>; - setSelectedTarget(target: AgentSessionProviders): void; - addAllowedTarget(target: AgentSessionProviders): void; - removeAllowedTarget(target: AgentSessionProviders): void; - setAllowedTargets(targets: AgentSessionProviders[]): void; -} -``` - -The target config is **purely UI state** — changing targets does NOT create sessions or resources. - -### 3.3 `AgentSessionsChatWelcomePart` - -**Location:** `src/vs/sessions/browser/parts/agentSessionsChatWelcomePart.ts` - -Renders the welcome view when the chat is empty: - -- **Mascot** — product branding image -- **Target buttons** — Local / Cloud toggle with sliding indicator -- **Option pickers** — extension-contributed option groups (repository, folder, etc.) -- **Input slot** — where the chat input is placed when in welcome mode - -The welcome part reads from `IAgentChatTargetConfig` and the `IChatSessionsService` for option groups. When the shared `ChatInputPart` is rendered in this slot, the sessions window preserves the core `.chat-input-container.focused` focus border behavior so the active chat input uses `focusBorder` while focused. - -### 3.4 `AgentSessionsChatInputPart` - -**Location:** `src/vs/sessions/browser/parts/agentSessionsChatInputPart.ts` - -A standalone adapter around `ChatInputPart` that bridges `IAgentChatTargetConfig` to the existing `ISessionTypePickerDelegate` interface. It creates a `createTargetConfigDelegate()` bridge so the standard `ChatInputPart` can work with the new target config system without modifications. - -**Important:** `AgentSessionsChatWidget` does *not* use this adapter directly. Instead, it creates its own bridge delegate inline and passes it to `ChatWidget` via `wrappedViewOptions.sessionTypePickerDelegate`. The `AgentSessionsChatInputPart` is available for consumers that need a `ChatInputPart` with target config integration outside the context of a full `ChatWidget` (e.g., a detached input field). - -### 3.5 `AgentSessionsTargetPickerActionItem` - -**Location:** `src/vs/sessions/browser/widget/agentSessionsTargetPickerActionItem.ts` - -A dropdown picker action item for the input toolbar that reads available targets from `IAgentChatTargetConfig` (rather than `chatSessionsService`). Selection calls `targetConfig.setSelectedTarget()` with no session creation side effects. It renders the current target's icon and name, with a chevron to open the dropdown of allowed targets. The picker automatically re-renders when the selected target or allowed targets change. - ---- - -## 4. Chat Input Lifecycle: First Load vs New Session - -The chat input behaves differently depending on whether it's the very first load (before the extension activates) or a subsequent "New Session" after the extension is already active. - -### 4.1 First Load (Extension Not Yet Activated) - -When the agent sessions window opens for the first time: - -1. The `ChatWidget` renders with **no model** — `viewModel` is `undefined` -2. `ChatInputPart` has no `sessionResource`, so pickers query the `sessionTypePickerDelegate` for the effective session type -3. The extension hasn't activated yet, so: - - `chatSessionHasModels` context key is `false` (no option groups registered) - - `lockedToCodingAgent` is `false` (contribution not available yet) - - The `ChatSessionPrimaryPickerAction` menu item is **hidden** (its `when` clause requires both) -4. **Cached option groups** (from a previous run) are loaded from storage and seeded into the service, allowing pickers to render immediately with stale-but-useful data -5. **Pending session resource** — `_generatePendingSessionResource()` generates a lightweight URI (e.g., `copilotcli:/untitled-`) synchronously. No async work or extension activation needed. This resource allows picker commands and `notifySessionOptionsChange` events to flow through the existing pipeline. -6. When the extension activates: - - `onDidChangeAvailability` fires → `updateWidgetLockStateFromSessionType` sets `lockedToCodingAgent = true` - - `onDidChangeOptionGroups` fires with fresh data → `chatSessionHasModels = true` - - The `when` clause is now satisfied → toolbar re-renders with the picker action - - The welcome part re-renders pickers with live data from the extension -7. **Extension can now fire `notifySessionOptionsChange`** with the pending resource — the service stores values in `_pendingSessionOptions`, fires `onDidChangeSessionOptions`, and the welcome part and `ChatInputPart` match the resource and sync picker state. - -``` -State: No viewModel, _pendingSessionResource is set immediately (sync) -ChatInputPart: Uses delegate.getActiveSessionProvider() for session type -Pickers: Initially hidden, appear when extension activates -Option groups: Cached from storage → overwritten by extension -Session options: Stored in lightweight _pendingSessionOptions map (no ContributedChatSessionData) -``` - -### 4.2 New Session (Extension Already Active) - -When the user clicks "New Session" after completing a request: - -1. `resetSession()` is called -2. The old model is cleared via `setModel(undefined)` and the model ref is disposed -3. `_sessionCreated` is reset to `false` -4. `_pendingSessionResource` is cleared -5. Pending option selections from `ChatInputPart` are cleared via `takePendingOptionSelections()` -6. The welcome view becomes visible and pickers are re-rendered via `resetSelectedOptions()` -7. `_generatePendingSessionResource()` generates a fresh pending resource (synchronous) -8. The `ChatWidget` again has **no model** — same as first load from the input's perspective -9. BUT the extension is already active, so: - - `lockedToCodingAgent` is already `true` (contribution is available) - - `chatSessionHasModels` is already `true` (option groups are registered) - - Pickers render **immediately** with live data — no waiting for extension activation - - Option groups are fresh (not stale cached data) - - `getOrCreateChatSession` resolves quickly since the content provider is already registered - -``` -State: No viewModel, _pendingSessionResource set after init -ChatInputPart: Uses delegate.getActiveSessionProvider() for session type -Pickers: Render immediately (extension already active, context keys already set) -Option groups: Live data from extension (already registered) -``` - -### 4.3 Key Differences - -| Aspect | First Load | New Session | -|--------|-----------|-------------| -| Extension state | Not activated | Already active | -| `lockedToCodingAgent` | `false` → `true` (async) | Already `true` | -| `chatSessionHasModels` | `false` → `true` (async) | Already `true` | -| Input toolbar pickers | Hidden → appear on activation | Visible immediately | -| Welcome part pickers | Cached → replaced with live data | Live data from start | -| Session resource | Generated as pending, session data created eagerly | Old cleared, new pending generated | -| `_pendingSessionResource` | Set after `getOrCreateChatSession` completes | Cleared and re-initialized | -| `_pendingOptionSelections` | Empty | Cleared via `takePendingOptionSelections()` | -| Extension option changes | Received after pending init completes | Received immediately | - -### 4.4 The `locked` Flag and Session Reset - -Extensions can mark option items as `locked` (e.g., locking the folder picker after a request starts). This is a **session-specific** concept: - -- During an active session, the extension sends `notifySessionOptionsChange` with `{ ...option, locked: true }` -- The welcome part syncs these via `syncOptionsFromSession`, but **strips the `locked` flag** before storing in `_selectedOptions` -- This ensures that when the welcome view re-renders (e.g., after reset), pickers are always interactive -- Locking only affects the `ChatSessionPickerActionItem` widget's `currentOption.locked` check, which disables the dropdown - ---- - -## 5. Resourceless Chat Input - -### The Problem - -Traditional chat sessions require a session resource (URI) to exist before the user can interact. This means: -- Extensions must register and load before the UI is usable -- Creating a session involves an async round-trip to the extension -- The user sees a loading state instead of being productive - -### The Solution - -The Agent Sessions Chat Widget defers **chat model creation** to the **moment of first submit**, but eagerly initializes **session data** so extensions can interact with options before the user sends a message: - -```mermaid -stateDiagram-v2 - [*] --> PendingSession: Widget renders - PendingSession --> PendingSession: getOrCreateChatSession (session data only) - PendingSession --> PendingSession: Extension fires notifySessionOptionsChange - PendingSession --> PendingSession: User selects target - PendingSession --> PendingSession: User picks options - PendingSession --> PendingSession: User types message - PendingSession --> SessionCreated: User clicks Send - SessionCreated --> Active: Chat model created + model set - Active --> Active: Subsequent sends go through normally - Active --> PendingSession: User clears/resets -``` - -**Before chat model creation (pending session state):** -- A **pending session resource** is generated via `getResourceForNewChatSession()` and `chatSessionsService.getOrCreateChatSession()` is called eagerly. This creates session data (options store) and invokes `provideChatSessionContent` so the extension knows the resource. -- The extension can fire `notifySessionOptionsChange(pendingResource, updates)` at any time — the welcome part matches the pending resource and syncs option values. -- Target selection is tracked in `AgentSessionsChatTargetConfig` -- User option selections are cached in `_pendingOptionSelections` (ChatInputPart) and `_selectedOptions` (welcome part), AND forwarded to the extension via `notifySessionOptionsChange` using the pending resource. -- The chat input works normally — user can type, attach context, change mode - -**At chat model creation (triggered by either `submitHandler` or the patched `acceptInput`):** -1. `_createSessionForCurrentTarget()` reads the current target from the config -2. **Reuses the pending session resource** (the same URI used for session data) — no new resource is generated -3. For non-local targets, calls `loadSessionForResource(resource, location, CancellationToken.None)` which reuses the existing session data from `getOrCreateChatSession()`; for local targets, calls `startSession(location)` directly -4. Sets the model on the `ChatWidget` via `setModel()` (this clears the input editor, so the input text is captured and restored) -5. `_gatherAllOptionSelections()` collects options from welcome part + input toolbar -6. Options are attached to `contributedChatSession.initialSessionOptions` via `model.setContributedChatSession()` -7. The request proceeds through the normal `ChatWidget._acceptInput` flow - ---- - -## 6. Initial Session Options (`initialSessionOptions`) - -### The Problem - -When a session is created on first submit, the extension needs to know what options the user selected (model, repository, agent, etc.). But the traditional `provideHandleOptionsChange` mechanism is async and fire-and-forget — there's no guarantee the extension processes it before the request arrives. - -### The Solution - -Options travel **atomically with the first request** via `initialSessionOptions` on the `ChatSessionContext`: - -```mermaid -flowchart LR - A[Welcome Part
_selectedOptions] -->|merge| C[_gatherAllOptionSelections] - B[Input Toolbar
_pendingOptionSelections] -->|merge| C - C --> D[model.setContributedChatSession
initialSessionOptions] - D --> E[mainThreadChatAgents2
serialize to DTO] - E --> F[extHostChatAgents2
pass to extension] - F --> G[Extension handler
reads from context] -``` - -### Data Flow - -| Layer | Type | Field | -|-------|------|-------| -| Internal model | `IChatSessionContext` | `initialSessionOptions?: ReadonlyArray<{optionId: string, value: string \| { id: string; name: string }}>` | -| Protocol DTO | `IChatSessionContextDto` | `initialSessionOptions?: ReadonlyArray<{optionId: string, value: string}>` | -| Extension API | `ChatSessionContext` | `initialSessionOptions?: ReadonlyArray<{optionId: string, value: string}>` | - -> **Note:** The internal model allows `value` to be either a `string` or `{ id, name }` (matching `IChatSessionProviderOptionItem`'s structural type). During serialization to the protocol DTO in `mainThreadChatAgents2`, the value is converted to `string`. The extension always receives `string` values. - -### Extension Usage - -```typescript -// In the extension's request handler: -async handleRequest(request, context, stream, token) { - const { chatSessionContext } = context; - - // ⚠️ IMPORTANT: Apply initial options BEFORE any code that reads - // folder/model/agent state (e.g., lockRepoOption, hasUncommittedChanges). - // The initialSessionOptions override defaults set by provideChatSessionContent. - const initialOptions = chatSessionContext?.initialSessionOptions; - if (initialOptions) { - for (const { optionId, value } of initialOptions) { - // Apply options to internal state - if (optionId === 'model') { setModel(value); } - if (optionId === 'repository') { setRepository(value); } - } - } - - // Now downstream reads (trust checks, uncommitted changes, etc.) - // see the correct options. - // ... -} -``` - -### Priority Order - -When `_gatherAllOptionSelections()` merges options: - -1. **Welcome part selections** (lowest priority) — includes defaults for repository/folder pickers -2. **Input toolbar selections** (highest priority) — explicit user picks override welcome defaults - ---- - -## 7. Picker Placement - -Pickers can appear in two locations: -- **Welcome view** — above the input, managed by `AgentSessionsChatWelcomePart` -- **Input toolbar** — inside the chat input, managed by `ChatInputPart` - -To avoid duplication, the widget uses two mechanisms: - -### `hiddenPickerIds` - -Hides entire picker actions from the input toolbar: - -```typescript -hiddenPickerIds: new Set([ - OpenSessionTargetPickerAction.ID, // Target picker in welcome - OpenModePickerAction.ID, // Mode picker hidden - OpenModelPickerAction.ID, // Model picker hidden - ConfigureToolsAction.ID, // Tools config hidden -]) -``` - -### `excludeOptionGroup` - -Selectively excludes specific option groups from `ChatSessionPrimaryPickerAction` in the input toolbar while keeping others: - -```typescript -excludeOptionGroup: (group) => { - const idLower = group.id.toLowerCase(); - const nameLower = group.name.toLowerCase(); - // Repository/folder pickers are in the welcome view - return idLower === 'repositories' || idLower === 'folders' || - nameLower === 'repository' || nameLower === 'repositories' || - nameLower === 'folder' || nameLower === 'folders'; -} -``` - -This allows the input toolbar to still show model pickers from `ChatSessionPrimaryPickerAction` while the welcome view handles the repository picker. - -```mermaid -graph TB - subgraph "Welcome View (above input)" - T[Target Buttons
Local | Cloud] - R[Repository Picker
via excludeOptionGroup] - end - - subgraph "Input Toolbar (inside input)" - M[Model Picker
from ChatSessionPrimaryPickerAction] - S[Send Button] - end - - subgraph "Hidden from toolbar" - H1[Target Picker
hiddenPickerIds] - H2[Mode Picker
hiddenPickerIds] - end -``` - ---- - -## 8. File Structure - -``` -src/vs/sessions/browser/widget/ -├── AGENTS_CHAT_WIDGET.md # This document -├── agentSessionsChatWidget.ts # Main widget wrapper -├── agentSessionsChatTargetConfig.ts # Target configuration (observable) -├── agentSessionsTargetPickerActionItem.ts # Target picker for input toolbar -└── media/ - └── agentSessionsChatWidget.css # Widget-specific styles - -src/vs/sessions/browser/parts/ -├── agentSessionsChatInputPart.ts # Input part adapter -└── agentSessionsChatWelcomePart.ts # Welcome view (mascot + pickers) -``` - ---- - -## 9. Adding a New Agent Provider - -To add a new agent provider (e.g., "Codex"): - -1. **Add to `AgentSessionProviders` enum** in `agentSessions.ts` -2. **Update target config** in `chatViewPane.ts`: - ```typescript - allowedTargets: [Background, Cloud, Codex] - ``` -3. **Register session content provider** in the extension -4. **Handle `initialSessionOptions`** in the extension's request handler -5. **Register option groups** via `provideChatSessionProviderOptions` - -The welcome part and input toolbar automatically pick up new targets and option groups. - ---- - -## 10. Comparison with Old Architecture - -### Side-by-Side - -| Aspect | Old (`ChatFullWelcomePart` inside `ChatWidget`) | New (`AgentSessionsChatWidget` wrapper) | -|--------|---------------------------|--------------------------------| -| Session creation | Eager (on load) | Deferred (on first send) | -| Target selection | `ISessionTypePickerDelegate` callback | `IAgentChatTargetConfig` observable | -| Option delivery | `provideHandleOptionsChange` (async, fire-and-forget) | `initialSessionOptions` (atomic with request) | -| Welcome view | Inside `ChatWidget` via `showFullWelcome` flag | Separate `AgentSessionsChatWelcomePart` | -| Picker placement | Hardcoded in `ChatInputPart` | Configurable via `hiddenPickerIds` + `excludeOptionGroup` | -| Input reparenting | `ChatWidget` reaches into welcome part's DOM | `AgentSessionsChatWidget` manages its own DOM layout | -| Agent lock state | `lockToCodingAgent()` / `unlockFromCodingAgent()` on `ChatWidget` | Not needed — target config is external state | -| Extensibility | Requires modifying `ChatWidget` internals | Self-contained, composable components | - -### Benefits of the New Architecture - -**1. Clean Separation of Concerns** - -The old approach embeds agent session logic (target selection, welcome view, lock state, option caching) directly inside `ChatWidget`. This means every agent feature touches the same file that powers the standard chat experience. The new architecture keeps `ChatWidget` focused on its core responsibility — rendering a chat conversation — and pushes agent-specific behavior into dedicated components. - -**2. Reduced Risk of Regressions** - -In the old architecture, `ChatWidget.render()` has forked control flow gated on `showFullWelcome`, and `ChatInputPart` has ~15 call sites checking session type delegates. A change to how pickers render could break the standard chat. In the new architecture, `AgentSessionsChatWidget` composes with `ChatWidget` through stable, narrow interfaces (`submitHandler`, `hiddenPickerIds`, `excludeOptionGroup`), so changes to agent session behavior cannot break the core widget. - -**3. Testable in Isolation** - -`AgentSessionsChatTargetConfig` can be unit-tested independently — it's a pure observable state container with no DOM or service dependencies beyond `Disposable`. The old `ISessionTypePickerDelegate` was an ad-hoc callback interface defined inline, making it harder to mock and test. - -**4. Deferred Session Creation** - -The old architecture creates sessions eagerly, requiring an async round-trip to the extension before the UI is usable. The new architecture lets the user interact immediately (type, select targets, pick options) and only creates the session on first send. This eliminates the loading state and makes the initial experience feel instant. - -**5. Atomic Option Delivery** - -The old `provideHandleOptionsChange` mechanism sends option changes asynchronously — if the user changes a repository picker and immediately sends a message, there's a race condition where the extension might not have processed the option change yet. The new `initialSessionOptions` mechanism bundles all option selections with the first request, guaranteeing the extension sees the correct state. - -**6. Easier to Add New Agent Providers** - -Adding a new provider in the old architecture requires modifying `ChatWidget`, `ChatInputPart`, and `ChatFullWelcomePart`. In the new architecture, it's a matter of adding to the `AgentSessionProviders` enum and updating the `allowedTargets` config — the welcome part and input toolbar automatically discover new targets and option groups. - -**7. No Core Widget Modifications Required** - -The entire agent sessions feature works by wrapping `ChatWidget` with composition hooks that `ChatWidget` already exposes (`submitHandler`, `viewOptions`). This means the agent sessions team can iterate independently without coordinating changes to shared core widget code. diff --git a/src/vs/sessions/browser/workbench.ts b/src/vs/sessions/browser/workbench.ts deleted file mode 100644 index 42983348..00000000 --- a/src/vs/sessions/browser/workbench.ts +++ /dev/null @@ -1,1871 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import '../../workbench/browser/style.js'; -import './media/style.css'; -import { Disposable, DisposableStore, IDisposable, toDisposable } from '../../base/common/lifecycle.js'; -import { Emitter, Event, setGlobalLeakWarningThreshold } from '../../base/common/event.js'; -import { getActiveDocument, getActiveElement, getClientArea, getWindowId, getWindows, IDimension, isAncestorUsingFlowTo, isHTMLElement, size, Dimension, runWhenWindowIdle } from '../../base/browser/dom.js'; -import { DeferredPromise, RunOnceScheduler } from '../../base/common/async.js'; -import { isFullscreen, onDidChangeFullscreen, isChrome, isFirefox, isSafari } from '../../base/browser/browser.js'; -import { mark } from '../../base/common/performance.js'; -import { onUnexpectedError, setUnexpectedErrorHandler } from '../../base/common/errors.js'; -import { isWindows, isLinux, isWeb, isNative, isMacintosh } from '../../base/common/platform.js'; -import { Parts, Position, PanelAlignment, IWorkbenchLayoutService, SINGLE_WINDOW_PARTS, MULTI_WINDOW_PARTS, IPartVisibilityChangeEvent, positionToString } from '../../workbench/services/layout/browser/layoutService.js'; -import { ILayoutOffsetInfo } from '../../platform/layout/browser/layoutService.js'; -import { Part } from '../../workbench/browser/part.js'; -import { Direction, ISerializableView, ISerializedGrid, ISerializedLeafNode, ISerializedNode, IViewSize, Orientation, SerializableGrid } from '../../base/browser/ui/grid/grid.js'; -import { IEditorGroupsService } from '../../workbench/services/editor/common/editorGroupsService.js'; -import { IEditorService } from '../../workbench/services/editor/common/editorService.js'; -import { IPaneCompositePartService } from '../../workbench/services/panecomposite/browser/panecomposite.js'; -import { IViewDescriptorService, ViewContainerLocation } from '../../workbench/common/views.js'; -import { ILogService } from '../../platform/log/common/log.js'; -import { IInstantiationService, refineServiceDecorator, ServicesAccessor } from '../../platform/instantiation/common/instantiation.js'; -import { ITitleService } from '../../workbench/services/title/browser/titleService.js'; -import { mainWindow, CodeWindow } from '../../base/browser/window.js'; -import { coalesce } from '../../base/common/arrays.js'; -import { ServiceCollection } from '../../platform/instantiation/common/serviceCollection.js'; -import { InstantiationService } from '../../platform/instantiation/common/instantiationService.js'; -import { getSingletonServiceDescriptors } from '../../platform/instantiation/common/extensions.js'; -import { ILifecycleService, LifecyclePhase, WillShutdownEvent } from '../../workbench/services/lifecycle/common/lifecycle.js'; -import { IStorageService, WillSaveStateReason, StorageScope, StorageTarget } from '../../platform/storage/common/storage.js'; -import { IConfigurationChangeEvent, IConfigurationService } from '../../platform/configuration/common/configuration.js'; -import { IHostService } from '../../workbench/services/host/browser/host.js'; -import { IDialogService } from '../../platform/dialogs/common/dialogs.js'; -import { INotificationService } from '../../platform/notification/common/notification.js'; -import { NotificationService } from '../../workbench/services/notification/common/notificationService.js'; -import { IHoverService, WorkbenchHoverDelegate } from '../../platform/hover/browser/hover.js'; -import { setHoverDelegateFactory } from '../../base/browser/ui/hover/hoverDelegateFactory.js'; -import { setBaseLayerHoverDelegate } from '../../base/browser/ui/hover/hoverDelegate2.js'; -import { Registry } from '../../platform/registry/common/platform.js'; -import { IWorkbenchContributionsRegistry, Extensions as WorkbenchExtensions } from '../../workbench/common/contributions.js'; -import { IEditorFactoryRegistry, EditorExtensions } from '../../workbench/common/editor.js'; -import { setARIAContainer } from '../../base/browser/ui/aria/aria.js'; -import { FontMeasurements } from '../../editor/browser/config/fontMeasurements.js'; -import { createBareFontInfoFromRawSettings } from '../../editor/common/config/fontInfoFromSettings.js'; -import { toErrorMessage } from '../../base/common/errorMessage.js'; -import { WorkbenchContextKeysHandler } from '../../workbench/browser/contextkeys.js'; -import { PixelRatio } from '../../base/browser/pixelRatio.js'; -import { AccessibilityProgressSignalScheduler } from '../../platform/accessibilitySignal/browser/progressAccessibilitySignalScheduler.js'; -import { setProgressAccessibilitySignalScheduler } from '../../base/browser/ui/progressbar/progressAccessibilitySignal.js'; -import { AccessibleViewRegistry } from '../../platform/accessibility/browser/accessibleViewRegistry.js'; -import { NotificationAccessibleView } from '../../workbench/browser/parts/notifications/notificationAccessibleView.js'; -import { NotificationsCenter } from '../../workbench/browser/parts/notifications/notificationsCenter.js'; -import { NotificationsAlerts } from '../../workbench/browser/parts/notifications/notificationsAlerts.js'; -import { NotificationsStatus } from '../../workbench/browser/parts/notifications/notificationsStatus.js'; -import { registerNotificationCommands } from '../../workbench/browser/parts/notifications/notificationsCommands.js'; -import { NotificationsToasts } from '../../workbench/browser/parts/notifications/notificationsToasts.js'; -import { IMarkdownRendererService } from '../../platform/markdown/browser/markdownRenderer.js'; -import { EditorMarkdownCodeBlockRenderer } from '../../editor/browser/widget/markdownRenderer/browser/editorMarkdownCodeBlockRenderer.js'; -import { SyncDescriptor } from '../../platform/instantiation/common/descriptors.js'; -import { TitleService } from './parts/titlebarPart.js'; -import { IContextKeyService } from '../../platform/contextkey/common/contextkey.js'; -import { EditorMaximizedContext, IsPhoneLayoutContext, KeyboardVisibleContext } from '../common/contextkeys.js'; -import { - NotificationsPosition, - NotificationsSettings, - getNotificationsPosition -} from '../../workbench/common/notifications.js'; -import { SessionsLayoutPolicy } from './layoutPolicy.js'; -import { MobileNavigationStack } from './mobileNavigationStack.js'; -import { MobileTitlebarPart } from './parts/mobile/mobileTitlebarPart.js'; -import { autorun } from '../../base/common/observable.js'; -import { ISessionsManagementService } from '../services/sessions/common/sessionsManagement.js'; - -//#region Workbench Options - -export interface IWorkbenchOptions { - /** - * Extra classes to be added to the workbench container. - */ - extraClasses?: string[]; -} - -//#endregion - -//#region Layout Classes - -enum LayoutClasses { - SIDEBAR_HIDDEN = 'nosidebar', - MAIN_EDITOR_AREA_HIDDEN = 'nomaineditorarea', - PANEL_HIDDEN = 'nopanel', - AUXILIARYBAR_HIDDEN = 'noauxiliarybar', - CHATBAR_HIDDEN = 'nochatbar', - STATUSBAR_HIDDEN = 'nostatusbar', - SHELL_GRADIENT_BACKGROUND = 'shell-gradient-background', - FULLSCREEN = 'fullscreen', - MAXIMIZED = 'maximized', - PHONE_LAYOUT = 'phone-layout' -} - -//#endregion - -//#region Part Visibility State - -interface IPartVisibilityState { - sidebar: boolean; - auxiliaryBar: boolean; - editor: boolean; - panel: boolean; - chatBar: boolean; -} - -//#endregion - -export interface IAgentWorkbenchLayoutService extends IWorkbenchLayoutService { - isEditorMaximized(): boolean; - setEditorMaximized(maximized: boolean): void; - - readonly onDidChangeEditorMaximized: Event; -} - -export const IAgentWorkbenchLayoutService = refineServiceDecorator(IWorkbenchLayoutService); - -export class Workbench extends Disposable implements IAgentWorkbenchLayoutService { - - declare readonly _serviceBrand: undefined; - - //#region Lifecycle Events - - private readonly _onWillShutdown = this._register(new Emitter()); - readonly onWillShutdown = this._onWillShutdown.event; - - private readonly _onDidShutdown = this._register(new Emitter()); - readonly onDidShutdown = this._onDidShutdown.event; - - //#endregion - - //#region Events - - private readonly _onDidChangeZenMode = this._register(new Emitter()); - readonly onDidChangeZenMode = this._onDidChangeZenMode.event; - - private readonly _onDidChangeMainEditorCenteredLayout = this._register(new Emitter()); - readonly onDidChangeMainEditorCenteredLayout = this._onDidChangeMainEditorCenteredLayout.event; - - private readonly _onDidChangePanelAlignment = this._register(new Emitter()); - readonly onDidChangePanelAlignment = this._onDidChangePanelAlignment.event; - - private readonly _onDidChangeWindowMaximized = this._register(new Emitter<{ windowId: number; maximized: boolean }>()); - readonly onDidChangeWindowMaximized = this._onDidChangeWindowMaximized.event; - - private readonly _onDidChangePanelPosition = this._register(new Emitter()); - readonly onDidChangePanelPosition = this._onDidChangePanelPosition.event; - - private readonly _onDidChangePartVisibility = this._register(new Emitter()); - readonly onDidChangePartVisibility = this._onDidChangePartVisibility.event; - - private readonly _onDidChangeNotificationsVisibility = this._register(new Emitter()); - readonly onDidChangeNotificationsVisibility = this._onDidChangeNotificationsVisibility.event; - - private readonly _onDidChangeAuxiliaryBarMaximized = this._register(new Emitter()); - readonly onDidChangeAuxiliaryBarMaximized = this._onDidChangeAuxiliaryBarMaximized.event; - - private readonly _onDidChangeEditorMaximized = this._register(new Emitter()); - readonly onDidChangeEditorMaximized = this._onDidChangeEditorMaximized.event; - - private readonly _onDidLayoutMainContainer = this._register(new Emitter()); - readonly onDidLayoutMainContainer = this._onDidLayoutMainContainer.event; - - private readonly _onDidLayoutActiveContainer = this._register(new Emitter()); - readonly onDidLayoutActiveContainer = this._onDidLayoutActiveContainer.event; - - private readonly _onDidLayoutContainer = this._register(new Emitter<{ container: HTMLElement; dimension: IDimension }>()); - readonly onDidLayoutContainer = this._onDidLayoutContainer.event; - - private readonly _onDidAddContainer = this._register(new Emitter<{ container: HTMLElement; disposables: DisposableStore }>()); - readonly onDidAddContainer = this._onDidAddContainer.event; - - private readonly _onDidChangeActiveContainer = this._register(new Emitter()); - readonly onDidChangeActiveContainer = this._onDidChangeActiveContainer.event; - - //#endregion - - //#region Properties - - readonly mainContainer = document.createElement('div'); - - get activeContainer(): HTMLElement { - return this.getContainerFromDocument(getActiveDocument()); - } - - get containers(): Iterable { - const containers: HTMLElement[] = []; - for (const { window } of getWindows()) { - containers.push(this.getContainerFromDocument(window.document)); - } - return containers; - } - - private getContainerFromDocument(targetDocument: Document): HTMLElement { - if (targetDocument === this.mainContainer.ownerDocument) { - return this.mainContainer; - } else { - // eslint-disable-next-line no-restricted-syntax - return targetDocument.body.getElementsByClassName('monaco-workbench')[0] as HTMLElement; - } - } - - private _mainContainerDimension!: IDimension; - get mainContainerDimension(): IDimension { return this._mainContainerDimension; } - - get activeContainerDimension(): IDimension { - return this.getContainerDimension(this.activeContainer); - } - - private getContainerDimension(container: HTMLElement): IDimension { - if (container === this.mainContainer) { - return this.mainContainerDimension; - } else { - return getClientArea(container); - } - } - - get mainContainerOffset(): ILayoutOffsetInfo { - return this.computeContainerOffset(); - } - - get activeContainerOffset(): ILayoutOffsetInfo { - return this.computeContainerOffset(); - } - - private computeContainerOffset(): ILayoutOffsetInfo { - let top = 0; - let quickPickTop = 0; - - if (this.isVisible(Parts.TITLEBAR_PART, mainWindow)) { - top = this.getPart(Parts.TITLEBAR_PART).maximumHeight; - quickPickTop = top; - } else if (this.mobileTopBarElement) { - // On phone layout the MobileTitlebarPart replaces the titlebar - top = this.mobileTopBarElement.offsetHeight; - quickPickTop = top; - } - - return { top, quickPickTop }; - } - - //#endregion - - //#region State - - private readonly parts = new Map(); - private workbenchGrid!: SerializableGrid; - - private titleBarPartView!: ISerializableView; - private sideBarPartView!: ISerializableView; - private panelPartView!: ISerializableView; - private auxiliaryBarPartView!: ISerializableView; - private editorPartView!: ISerializableView; - - private chatBarPartView!: ISerializableView; - - private readonly partVisibility: IPartVisibilityState = { - sidebar: true, - auxiliaryBar: true, - editor: false, - panel: false, - chatBar: true - }; - - private mainWindowFullscreen = false; - private readonly maximized = new Set(); - private readonly layoutPolicy = this._register(new SessionsLayoutPolicy()); - private readonly mobileNavStack = this._register(new MobileNavigationStack()); - private mobileTopBarElement: HTMLElement | undefined; - private readonly mobileTopBarDisposables = this._register(new DisposableStore()); - - private _editorMaximized = false; - private _editorLastNonMaximizedVisibility: IPartVisibilityState | undefined; - - private readonly restoredPromise = new DeferredPromise(); - readonly whenRestored = this.restoredPromise.p; - private restored = false; - - readonly openedDefaultEditors = false; - - //#endregion - - //#region Services - - private editorGroupService!: IEditorGroupsService; - private editorService!: IEditorService; - private paneCompositeService!: IPaneCompositePartService; - private viewDescriptorService!: IViewDescriptorService; - private sessionsManagementService!: ISessionsManagementService; - private instantiationService!: IInstantiationService; - - //#endregion - - constructor( - protected readonly parent: HTMLElement, - private readonly options: IWorkbenchOptions | undefined, - private readonly serviceCollection: ServiceCollection, - private readonly logService: ILogService - ) { - super(); - - // Sessions-scoped mobile viewport tweaks. These are applied here - // (rather than in the shared workbench.html) so that the regular - // code-web workbench — which does not handle safe-area insets — is - // not affected on notched mobile devices. - // The viewport `` tag is injected by the shared workbench.html, - // so we cannot use dom.ts `h()` to create it. Look it up by tag name - // and filter by the `name` attribute to avoid a selector query. - // eslint-disable-next-line no-restricted-syntax - const metaElements = mainWindow.document.head.getElementsByTagName('meta'); - let viewportMeta: HTMLMetaElement | undefined; - for (let i = 0; i < metaElements.length; i++) { - if (metaElements[i].name === 'viewport') { - viewportMeta = metaElements[i]; - break; - } - } - if (viewportMeta && !viewportMeta.content.includes('viewport-fit=')) { - viewportMeta.content = `${viewportMeta.content}, viewport-fit=cover`; - } - - // Perf: measure workbench startup time - mark('code/willStartWorkbench'); - - this.registerErrorHandler(logService); - } - - //#region Error Handling - - private registerErrorHandler(logService: ILogService): void { - // Increase stack trace limit for better errors stacks - if (!isFirefox) { - Error.stackTraceLimit = 100; - } - - // Listen on unhandled rejection events - // Note: intentionally not registered as disposable to handle - // errors that can occur during shutdown phase. - mainWindow.addEventListener('unhandledrejection', (event) => { - // See https://developer.mozilla.org/en-US/docs/Web/API/PromiseRejectionEvent - onUnexpectedError(event.reason); - - // Prevent the printing of this event to the console - event.preventDefault(); - }); - - // Install handler for unexpected errors - setUnexpectedErrorHandler(error => this.handleUnexpectedError(error, logService)); - } - - private previousUnexpectedError: { message: string | undefined; time: number } = { message: undefined, time: 0 }; - private handleUnexpectedError(error: unknown, logService: ILogService): void { - const message = toErrorMessage(error, true); - if (!message) { - return; - } - - const now = Date.now(); - if (message === this.previousUnexpectedError.message && now - this.previousUnexpectedError.time <= 1000) { - return; // Return if error message identical to previous and shorter than 1 second - } - - this.previousUnexpectedError.time = now; - this.previousUnexpectedError.message = message; - - // Log it - logService.error(message); - } - - //#endregion - - //#region Startup - - startup(): IInstantiationService { - try { - // Configure emitter leak warning threshold - this._register(setGlobalLeakWarningThreshold(175)); - - // Services - const instantiationService = this.initServices(this.serviceCollection); - - instantiationService.invokeFunction(accessor => { - const lifecycleService = accessor.get(ILifecycleService); - const storageService = accessor.get(IStorageService); - const configurationService = accessor.get(IConfigurationService); - const hostService = accessor.get(IHostService); - const hoverService = accessor.get(IHoverService); - const dialogService = accessor.get(IDialogService); - const notificationService = accessor.get(INotificationService) as NotificationService; - const markdownRendererService = accessor.get(IMarkdownRendererService); - - // On web, the configuration service needs access to the - // instantiation service for dynamic configuration resolution. - if (isWeb && typeof (configurationService as IConfigurationService & { acquireInstantiationService?(i: IInstantiationService): void }).acquireInstantiationService === 'function') { - (configurationService as IConfigurationService & { acquireInstantiationService(i: IInstantiationService): void }).acquireInstantiationService(instantiationService); - } - - // Set code block renderer for markdown rendering - markdownRendererService.setDefaultCodeBlockRenderer(instantiationService.createInstance(EditorMarkdownCodeBlockRenderer)); - - // Default Hover Delegate must be registered before creating any workbench/layout components - setHoverDelegateFactory((placement, enableInstantHover) => instantiationService.createInstance(WorkbenchHoverDelegate, placement, { instantHover: enableInstantHover }, {})); - setBaseLayerHoverDelegate(hoverService); - - // Layout - this.initLayout(accessor); - - // Registries - this creates and registers all parts - Registry.as(WorkbenchExtensions.Workbench).start(accessor); - Registry.as(EditorExtensions.EditorFactory).start(accessor); - - // Context Keys - this._register(instantiationService.createInstance(WorkbenchContextKeysHandler)); - - // Editor Maximized Context Key - const editorMaximizedContext = EditorMaximizedContext.bindTo(accessor.get(IContextKeyService)); - this._register(this.onDidChangeEditorMaximized(() => { - editorMaximizedContext.set(this.isEditorMaximized()); - })); - - // Phone Layout Context Key - const contextKeyService = accessor.get(IContextKeyService); - const isPhoneLayoutCtx = IsPhoneLayoutContext.bindTo(contextKeyService); - this._register(autorun(reader => { - isPhoneLayoutCtx.set(this.layoutPolicy.viewportClass.read(reader) === 'phone'); - })); - - // Virtual keyboard detection via visualViewport API. - // Use `window.innerHeight` (layout viewport) as the baseline - // rather than a captured initial height. Layout viewport - // updates on orientation change and split-screen resizes, so - // comparing against it avoids stale baselines on landscape - // launches, Android split-screen, and iOS URL-bar collapse. - if (mainWindow.visualViewport) { - const keyboardVisibleCtx = KeyboardVisibleContext.bindTo(contextKeyService); - const KEYBOARD_HEIGHT_THRESHOLD_PX = 100; - - const onViewportResize = () => { - const vp = mainWindow.visualViewport; - if (!vp) { - return; - } - const heightDiff = mainWindow.innerHeight - vp.height; - keyboardVisibleCtx.set(heightDiff > KEYBOARD_HEIGHT_THRESHOLD_PX); - }; - - mainWindow.visualViewport.addEventListener('resize', onViewportResize); - this._register({ dispose: () => mainWindow.visualViewport?.removeEventListener('resize', onViewportResize) }); - } - - // Orientation changes produce a window `resize` event which - // is already handled by `registerLayoutListeners()`. No - // separate matchMedia listener is needed — the previous - // implementation caused a redundant second layout. - - // Register Listeners - this.registerListeners(lifecycleService, storageService, configurationService, hostService, dialogService); - - // Render Workbench - this.renderWorkbench(instantiationService, notificationService, storageService, configurationService); - - // Workbench Layout - this.createWorkbenchLayout(); - - // Create mobile navigation after grid exists (so DOM order is correct) - if (this.layoutPolicy.viewportClass.get() === 'phone') { - this.createMobileTitlebar(); - } - - // Workbench Management - this.createWorkbenchManagement(instantiationService); - - // Layout - this.layout(); - - // Restore - this.restore(lifecycleService); - }); - - return instantiationService; - } catch (error) { - onUnexpectedError(error); - - throw error; // rethrow because this is a critical issue we cannot handle properly here - } - } - - private initServices(serviceCollection: ServiceCollection): IInstantiationService { - // Layout Service - serviceCollection.set(IAgentWorkbenchLayoutService, this); - - // Title Service - agent sessions titlebar with dedicated part overrides - serviceCollection.set(ITitleService, new SyncDescriptor(TitleService, [])); - - // All Contributed Services - const contributedServices = getSingletonServiceDescriptors(); - for (const [id, descriptor] of contributedServices) { - serviceCollection.set(id, descriptor); - } - - const instantiationService = new InstantiationService(serviceCollection, true); - - // Wrap up - instantiationService.invokeFunction(accessor => { - const lifecycleService = accessor.get(ILifecycleService); - lifecycleService.phase = LifecyclePhase.Ready; - }); - - return instantiationService; - } - - private registerListeners(lifecycleService: ILifecycleService, storageService: IStorageService, configurationService: IConfigurationService, hostService: IHostService, dialogService: IDialogService): void { - // Configuration changes - this._register(configurationService.onDidChangeConfiguration(e => this.updateFontAliasing(e, configurationService))); - - // Font Info - if (isNative) { - this._register(storageService.onWillSaveState(e => { - if (e.reason === WillSaveStateReason.SHUTDOWN) { - this.storeFontInfo(storageService); - } - })); - } else { - this._register(lifecycleService.onWillShutdown(() => this.storeFontInfo(storageService))); - } - - // Lifecycle - this._register(lifecycleService.onWillShutdown(event => this._onWillShutdown.fire(event))); - this._register(lifecycleService.onDidShutdown(() => { - this._onDidShutdown.fire(); - this.dispose(); - })); - - // Flush storage on window focus loss - this._register(hostService.onDidChangeFocus(focus => { - if (!focus) { - storageService.flush(); - } - })); - - // Dialogs showing/hiding - this._register(dialogService.onWillShowDialog(() => this.mainContainer.classList.add('modal-dialog-visible'))); - this._register(dialogService.onDidShowDialog(() => this.mainContainer.classList.remove('modal-dialog-visible'))); - } - - //#region Font Aliasing and Caching - - private fontAliasing: 'default' | 'antialiased' | 'none' | 'auto' | undefined; - private updateFontAliasing(e: IConfigurationChangeEvent | undefined, configurationService: IConfigurationService) { - if (!isMacintosh) { - return; // macOS only - } - - if (e && !e.affectsConfiguration('workbench.fontAliasing')) { - return; - } - - const aliasing = configurationService.getValue<'default' | 'antialiased' | 'none' | 'auto'>('workbench.fontAliasing'); - if (this.fontAliasing === aliasing) { - return; - } - - this.fontAliasing = aliasing; - - // Remove all - const fontAliasingValues: (typeof aliasing)[] = ['antialiased', 'none', 'auto']; - this.mainContainer.classList.remove(...fontAliasingValues.map(value => `monaco-font-aliasing-${value}`)); - - // Add specific - if (fontAliasingValues.some(option => option === aliasing)) { - this.mainContainer.classList.add(`monaco-font-aliasing-${aliasing}`); - } - } - - private restoreFontInfo(storageService: IStorageService, configurationService: IConfigurationService): void { - const storedFontInfoRaw = storageService.get('editorFontInfo', StorageScope.APPLICATION); - if (storedFontInfoRaw) { - try { - const storedFontInfo = JSON.parse(storedFontInfoRaw); - if (Array.isArray(storedFontInfo)) { - FontMeasurements.restoreFontInfo(mainWindow, storedFontInfo); - } - } catch (err) { - /* ignore */ - } - } - - FontMeasurements.readFontInfo(mainWindow, createBareFontInfoFromRawSettings(configurationService.getValue('editor'), PixelRatio.getInstance(mainWindow).value)); - } - - private storeFontInfo(storageService: IStorageService): void { - const serializedFontInfo = FontMeasurements.serializeFontInfo(mainWindow); - if (serializedFontInfo) { - storageService.store('editorFontInfo', JSON.stringify(serializedFontInfo), StorageScope.APPLICATION, StorageTarget.MACHINE); - } - } - - //#endregion - - private renderWorkbench(instantiationService: IInstantiationService, notificationService: NotificationService, storageService: IStorageService, configurationService: IConfigurationService): void { - // ARIA & Signals - setARIAContainer(this.mainContainer); - setProgressAccessibilitySignalScheduler((msDelayTime: number, msLoopTime?: number) => instantiationService.createInstance(AccessibilityProgressSignalScheduler, msDelayTime, msLoopTime)); - - // Initialize viewport classification before building layout classes - const initialDimension = getClientArea(this.parent); - this.layoutPolicy.update(initialDimension.width, initialDimension.height); - - // Apply initial part visibility from layout policy (phone hides sidebar, etc.) - const visibilityDefaults = this.layoutPolicy.getPartVisibilityDefaults(); - this.partVisibility.sidebar = visibilityDefaults.sidebar; - this.partVisibility.auxiliaryBar = visibilityDefaults.auxiliaryBar; - this.partVisibility.panel = visibilityDefaults.panel; - this.partVisibility.chatBar = visibilityDefaults.chatBar; - this.partVisibility.editor = visibilityDefaults.editor; - - // State specific classes - const platformClass = isWindows ? 'windows' : isLinux ? 'linux' : 'mac'; - const workbenchClasses = coalesce([ - 'monaco-workbench', - 'agent-sessions-workbench', - LayoutClasses.SHELL_GRADIENT_BACKGROUND, - platformClass, - isWeb ? 'web' : undefined, - isChrome ? 'chromium' : isFirefox ? 'firefox' : isSafari ? 'safari' : undefined, - ...this.getLayoutClasses(), - ...(this.options?.extraClasses ? this.options.extraClasses : []) - ]); - - this.mainContainer.classList.add(...workbenchClasses); - - // Apply font aliasing - this.updateFontAliasing(undefined, configurationService); - - // Warm up font cache information before building up too many dom elements - this.restoreFontInfo(storageService, configurationService); - - // Create Parts (editor starts hidden and is shown when an editor opens) - for (const { id, role, classes } of [ - { id: Parts.TITLEBAR_PART, role: 'none', classes: ['titlebar'] }, - { id: Parts.SIDEBAR_PART, role: 'none', classes: ['sidebar', 'left'] }, - { id: Parts.AUXILIARYBAR_PART, role: 'none', classes: ['auxiliarybar', 'basepanel', 'right'] }, - { id: Parts.CHATBAR_PART, role: 'main', classes: ['chatbar', 'basepanel', 'right'] }, - { id: Parts.PANEL_PART, role: 'none', classes: ['panel', 'basepanel', positionToString(this.getPanelPosition())] }, - ]) { - const partContainer = this.createPartContainer(id, role, classes); - - mark(`code/willCreatePart/${id}`); - this.getPart(id).create(partContainer); - mark(`code/didCreatePart/${id}`); - } - - // Create Editor Part (hidden by default) - this.createEditorPart(); - - // Notification Handlers - this.createNotificationsHandlers(instantiationService, notificationService, configurationService); - - // Add Workbench to DOM - this.parent.appendChild(this.mainContainer); - } - - private createMobileTitlebar(): void { - this.mobileTopBarDisposables.clear(); - const mobileTitlebar = this.mobileTopBarDisposables.add(this.instantiationService.createInstance(MobileTitlebarPart, this.mainContainer)); - this.mobileTopBarElement = mobileTitlebar.element; - - // Hamburger: toggle sidebar drawer overlay - this.mobileTopBarDisposables.add(mobileTitlebar.onDidClickHamburger(() => { - this.toggleMobileSidebarDrawer(); - })); - - // New session: open new chat view - this.mobileTopBarDisposables.add(mobileTitlebar.onDidClickNewSession(() => { - this.sessionsManagementService.openNewSessionView(); - })); - } - - private toggleMobileSidebarDrawer(): void { - const isOpen = this.partVisibility.sidebar; - if (isOpen) { - this.closeMobileSidebarDrawer(); - } else { - this.openMobileSidebarDrawer(); - } - } - - private openMobileSidebarDrawer(): void { - // Push a history entry so the Android back button dismisses the drawer. - // Must come before setSideBarHidden(false) so layoutMobileSidebar() sees - // the drawer state. - if (!this.mobileNavStack.has('sidebar')) { - this.mobileNavStack.push('sidebar'); - } - - // Show sidebar in grid — the actual drawer dimensions are applied by - // layoutMobileSidebar() from within layout(), which uses the full - // viewport width below the mobile top bar on phone. The toggle button - // in the top bar remains visible and is used to close the drawer. - this.setSideBarHidden(false); - } - - private closeMobileSidebarDrawer(): void { - // Hide sidebar in grid - this.setSideBarHidden(true); - - // Sync the navigation stack with the browser history: if there is a - // pending 'sidebar' entry (UI-initiated close), rewind history without - // firing onDidPop. If we're being called from the back-button path - // (onDidPop already fired), this is a no-op. - if (this.mobileNavStack.has('sidebar')) { - this.mobileNavStack.popSilently('sidebar'); - } - } - - private createNotificationsHandlers( - instantiationService: IInstantiationService, - notificationService: NotificationService, - configurationService: IConfigurationService - ): void { - // Instantiate Notification components - const notificationsCenter = this._register(instantiationService.createInstance(NotificationsCenter, this.mainContainer, notificationService.model)); - const notificationsToasts = this._register(instantiationService.createInstance(NotificationsToasts, this.mainContainer, notificationService.model)); - this._register(instantiationService.createInstance(NotificationsAlerts, notificationService.model)); - const notificationsStatus = this._register(instantiationService.createInstance(NotificationsStatus, notificationService.model)); - - // Visibility - this._register(notificationsCenter.onDidChangeVisibility(() => { - notificationsStatus.update(notificationsCenter.isVisible, notificationsToasts.isVisible); - notificationsToasts.update(notificationsCenter.isVisible); - })); - - this._register(notificationsToasts.onDidChangeVisibility(() => { - notificationsStatus.update(notificationsCenter.isVisible, notificationsToasts.isVisible); - })); - - // Register Commands - registerNotificationCommands(notificationsCenter, notificationsToasts, notificationService.model); - - // Register notification accessible view - AccessibleViewRegistry.register(new NotificationAccessibleView()); - - // The shared notification controllers apply a top-right inline offset based on the - // default workbench custom titlebar height. The sessions workbench has its own - // fixed chrome, so re-apply the sessions-specific top-right offset after they run. - this.registerSessionsNotificationOffsets(configurationService, notificationsCenter, notificationsToasts); - - // Register with Layout - this.registerNotifications({ - onDidChangeNotificationsVisibility: Event.map( - Event.any(notificationsToasts.onDidChangeVisibility, notificationsCenter.onDidChangeVisibility), - () => notificationsToasts.isVisible || notificationsCenter.isVisible - ) - }); - } - - private registerSessionsNotificationOffsets( - configurationService: IConfigurationService, - notificationsCenter: NotificationsCenter, - notificationsToasts: NotificationsToasts - ): void { - const applySessionsNotificationOffsets = () => { - const position = getNotificationsPosition(configurationService); - const notificationsCenterContainer = this.getWorkbenchChildByClassName('notifications-center'); - const notificationsToastsContainer = this.getWorkbenchChildByClassName('notifications-toasts'); - - if (position === NotificationsPosition.TOP_RIGHT) { - notificationsCenterContainer?.style.setProperty('top', '40px'); - notificationsToastsContainer?.style.setProperty('top', '40px'); - } - }; - - this._register(this.onDidLayoutMainContainer(() => applySessionsNotificationOffsets())); - this._register(notificationsCenter.onDidChangeVisibility(() => applySessionsNotificationOffsets())); - this._register(notificationsToasts.onDidChangeVisibility(() => applySessionsNotificationOffsets())); - this._register(configurationService.onDidChangeConfiguration(e => { - if (e.affectsConfiguration(NotificationsSettings.NOTIFICATIONS_POSITION)) { - applySessionsNotificationOffsets(); - } - })); - } - - private getWorkbenchChildByClassName(className: string): HTMLElement | undefined { - for (const child of this.mainContainer.children) { - if (isHTMLElement(child) && child.classList.contains(className)) { - return child; - } - } - - return undefined; - } - - private createPartContainer(id: string, role: string, classes: string[]): HTMLElement { - const part = document.createElement('div'); - part.classList.add('part', ...classes); - part.id = id; - part.setAttribute('role', role); - return part; - } - - private createEditorPart(): void { - const editorPartContainer = document.createElement('div'); - editorPartContainer.classList.add('part', 'editor'); - editorPartContainer.id = Parts.EDITOR_PART; - editorPartContainer.setAttribute('role', 'main'); - - mark('code/willCreatePart/workbench.parts.editor'); - this.getPart(Parts.EDITOR_PART).create(editorPartContainer, { restorePreviousState: false }); - mark('code/didCreatePart/workbench.parts.editor'); - - this.mainContainer.appendChild(editorPartContainer); - } - - private restore(lifecycleService: ILifecycleService): void { - // Update perf marks - mark('code/didStartWorkbench'); - performance.measure('perf: workbench create & restore', 'code/didLoadWorkbenchMain', 'code/didStartWorkbench'); - - // Restore parts (open default view containers) - this.restoreParts(); - - // Set lifecycle phase to `Restored` - lifecycleService.phase = LifecyclePhase.Restored; - - // Mark as restored - this.setRestored(); - - // Set lifecycle phase to `Eventually` after a short delay and when idle (min 2.5sec, max 5sec) - const eventuallyPhaseScheduler = this._register(new RunOnceScheduler(() => { - this._register(runWhenWindowIdle(mainWindow, () => lifecycleService.phase = LifecyclePhase.Eventually, 2500)); - }, 2500)); - eventuallyPhaseScheduler.schedule(); - } - - private restoreParts(): void { - // Open default view containers for each visible part - const partsToRestore: { location: ViewContainerLocation; visible: boolean }[] = [ - { location: ViewContainerLocation.Sidebar, visible: this.partVisibility.sidebar }, - { location: ViewContainerLocation.Panel, visible: this.partVisibility.panel }, - { location: ViewContainerLocation.AuxiliaryBar, visible: this.partVisibility.auxiliaryBar }, - { location: ViewContainerLocation.ChatBar, visible: this.partVisibility.chatBar }, - ]; - - for (const { location, visible } of partsToRestore) { - if (visible) { - const defaultViewContainer = this.viewDescriptorService.getDefaultViewContainer(location); - if (defaultViewContainer) { - this.paneCompositeService.openPaneComposite(defaultViewContainer.id, location); - } - } - } - } - - //#endregion - - //#region Initialization - - initLayout(accessor: ServicesAccessor): void { - // Services - accessing these triggers their instantiation - // which creates and registers the parts - this.editorGroupService = accessor.get(IEditorGroupsService); - this.editorService = accessor.get(IEditorService); - this.paneCompositeService = accessor.get(IPaneCompositePartService); - this.viewDescriptorService = accessor.get(IViewDescriptorService); - this.sessionsManagementService = accessor.get(ISessionsManagementService); - this.instantiationService = accessor.get(IInstantiationService); - accessor.get(ITitleService); - - // Register layout listeners - this.registerLayoutListeners(); - - // Editor opens should only affect the main editor part when - // they actually target one of the main editor groups. Modal - // opens stay neutral. - this._register(this.editorService.onWillOpenEditor(e => { - const targetsMainEditorPart = this.editorGroupService.mainPart.groups.some(group => group.id === e.groupId); - if (!targetsMainEditorPart) { - return; - } - - if (!this.partVisibility.editor) { - this.setEditorHidden(false); - } - })); - - // Hide editor part when last editor closes - this._register(this.editorService.onDidCloseEditor(() => { - if (this.partVisibility.editor && this.areAllGroupsEmpty()) { - this.setEditorHidden(true); - } - })); - - // Initialize layout state (must be done before createWorkbenchLayout) - this._mainContainerDimension = getClientArea(this.parent, new Dimension(800, 600)); - this.layoutPolicy.update(this._mainContainerDimension.width, this._mainContainerDimension.height); - - // Update part visibility based on final viewport classification - const visDefaults = this.layoutPolicy.getPartVisibilityDefaults(); - this.partVisibility.sidebar = visDefaults.sidebar; - this.partVisibility.auxiliaryBar = visDefaults.auxiliaryBar; - this.partVisibility.panel = visDefaults.panel; - this.partVisibility.chatBar = visDefaults.chatBar; - this.partVisibility.editor = visDefaults.editor; - } - - private areAllGroupsEmpty(): boolean { - for (const group of this.editorGroupService.groups) { - if (!group.isEmpty) { - return false; - } - } - return true; - } - - private registerLayoutListeners(): void { - // Fullscreen changes - this._register(onDidChangeFullscreen(windowId => { - if (windowId === getWindowId(mainWindow)) { - this.mainWindowFullscreen = isFullscreen(mainWindow); - this.updateFullscreenClass(); - this.layout(); - } - })); - - // Window resize — needed for device emulation and mobile viewport changes - const onWindowResize = () => this.layout(); - mainWindow.addEventListener('resize', onWindowResize); - this._register({ dispose: () => mainWindow.removeEventListener('resize', onWindowResize) }); - } - - private updateFullscreenClass(): void { - if (this.mainWindowFullscreen) { - this.mainContainer.classList.add(LayoutClasses.FULLSCREEN); - } else { - this.mainContainer.classList.remove(LayoutClasses.FULLSCREEN); - } - } - - //#endregion - - //#region Workbench Layout Creation - - createWorkbenchLayout(): void { - const titleBar = this.getPart(Parts.TITLEBAR_PART); - const editorPart = this.getPart(Parts.EDITOR_PART); - const panelPart = this.getPart(Parts.PANEL_PART); - const auxiliaryBarPart = this.getPart(Parts.AUXILIARYBAR_PART); - const sideBar = this.getPart(Parts.SIDEBAR_PART); - const chatBarPart = this.getPart(Parts.CHATBAR_PART); - - // View references for parts in the grid - this.titleBarPartView = titleBar; - this.sideBarPartView = sideBar; - this.panelPartView = panelPart; - this.auxiliaryBarPartView = auxiliaryBarPart; - this.chatBarPartView = chatBarPart; - this.editorPartView = editorPart; - - const viewMap: { [key: string]: ISerializableView } = { - [Parts.TITLEBAR_PART]: this.titleBarPartView, - [Parts.PANEL_PART]: this.panelPartView, - [Parts.SIDEBAR_PART]: this.sideBarPartView, - [Parts.AUXILIARYBAR_PART]: this.auxiliaryBarPartView, - [Parts.CHATBAR_PART]: this.chatBarPartView, - [Parts.EDITOR_PART]: this.editorPartView - }; - - const fromJSON = ({ type }: { type: string }) => viewMap[type]; - const workbenchGrid = SerializableGrid.deserialize( - this.createGridDescriptor(), - { fromJSON }, - { proportionalLayout: false } - ); - - this.mainContainer.prepend(workbenchGrid.element); - this.mainContainer.setAttribute('role', 'application'); - this.workbenchGrid = workbenchGrid; - this.workbenchGrid.edgeSnapping = this.mainWindowFullscreen; - - // Listen for part visibility changes (for parts in grid) - for (const part of [titleBar, panelPart, sideBar, auxiliaryBarPart, chatBarPart, editorPart]) { - this._register(part.onDidVisibilityChange(visible => { - if (part === sideBar) { - this.setSideBarHidden(!visible); - } else if (part === panelPart) { - this.setPanelHidden(!visible); - } else if (part === auxiliaryBarPart) { - this.setAuxiliaryBarHidden(!visible); - } else if (part === chatBarPart) { - this.setChatBarHidden(!visible); - } else if (part === editorPart) { - this.setEditorHidden(!visible); - } - - this._onDidChangePartVisibility.fire({ partId: part.getId(), visible }); - this.handleContainerDidLayout(this.mainContainer, this._mainContainerDimension); - })); - } - - // Wire up mobile nav stack: back-button pops close the corresponding part - this._register(this.mobileNavStack.onDidPop(layer => { - switch (layer) { - case 'sidebar': - this.closeMobileSidebarDrawer(); - break; - case 'panel': - this.setPanelHidden(true); - break; - case 'auxbar': - this.setAuxiliaryBarHidden(true); - break; - case 'editor': - // Editor modal close is handled by the editor service - break; - } - })); - } - - createWorkbenchManagement(_instantiationService: IInstantiationService): void { - // No floating toolbars in this layout - } - - /** - * Creates the grid descriptor for the Agent Sessions layout. - * - * Structure (horizontal orientation): - * - Sidebar (left, spans full height from top to bottom) - * - Right section (vertical): - * - Titlebar (top of right section) - * - Top right (horizontal): Chat Bar | Editor | Auxiliary Bar - * - Panel (below chat, editor, and auxiliary bar) - */ - private createGridDescriptor(): ISerializedGrid { - const { width, height } = this._mainContainerDimension; - - return this.createDesktopGridDescriptor(width, height); - } - - /** - * Standard multi-part layout for all viewport classes. - * On phone, the titlebar is hidden via CSS and a MobileTitlebarPart - * is prepended before the grid. Sidebar/panel/auxbar are hidden - * in the grid via partVisibility defaults. - */ - private createDesktopGridDescriptor(width: number, height: number): ISerializedGrid { - - // Default sizes from layout policy - const sizes = this.layoutPolicy.getPartSizes(width, height); - // For hidden parts, still provide a reasonable cached size for when they're shown later - const sideBarSize = this.partVisibility.sidebar ? sizes.sideBarSize : Math.max(sizes.sideBarSize, 250); - const auxiliaryBarSize = this.partVisibility.auxiliaryBar ? sizes.auxiliaryBarSize : Math.max(sizes.auxiliaryBarSize, 300); - const panelSize = this.partVisibility.panel ? sizes.panelSize : Math.max(sizes.panelSize, 250); - const editorSize = 600; - const titleBarHeight = this.titleBarPartView?.minimumHeight ?? 30; - - // Calculate right section width — when sidebar is hidden it takes no space - const effectiveSideBarWidth = this.partVisibility.sidebar ? sideBarSize : 0; - const rightSectionWidth = Math.max(0, width - effectiveSideBarWidth); - const effectiveAuxBarWidth = this.partVisibility.auxiliaryBar ? auxiliaryBarSize : 0; - const effectiveEditorWidth = this.partVisibility.editor ? editorSize : 0; - const chatBarWidth = Math.max(0, rightSectionWidth - effectiveAuxBarWidth - effectiveEditorWidth); - - const contentHeight = Math.max(0, height - titleBarHeight); - const topRightHeight = Math.max(0, contentHeight - panelSize); - - const isPhone = this.layoutPolicy.viewportClass.get() === 'phone'; - - const titleBarNode: ISerializedLeafNode = { - type: 'leaf', - data: { type: Parts.TITLEBAR_PART }, - size: titleBarHeight, - visible: !isPhone - }; - - const sideBarNode: ISerializedLeafNode = { - type: 'leaf', - data: { type: Parts.SIDEBAR_PART }, - size: sideBarSize, - visible: this.partVisibility.sidebar - }; - - const auxiliaryBarNode: ISerializedLeafNode = { - type: 'leaf', - data: { type: Parts.AUXILIARYBAR_PART }, - size: auxiliaryBarSize, - visible: this.partVisibility.auxiliaryBar - }; - - const chatBarNode: ISerializedLeafNode = { - type: 'leaf', - data: { type: Parts.CHATBAR_PART }, - size: chatBarWidth, - visible: this.partVisibility.chatBar - }; - - const editorNode: ISerializedLeafNode = { - type: 'leaf', - data: { type: Parts.EDITOR_PART }, - size: editorSize, - visible: this.partVisibility.editor - }; - - const panelNode: ISerializedLeafNode = { - type: 'leaf', - data: { type: Parts.PANEL_PART }, - size: panelSize, - visible: this.partVisibility.panel - }; - - // Top right section: Chat Bar | Editor | Auxiliary Bar (horizontal) - const topRightSection: ISerializedNode = { - type: 'branch', - data: [chatBarNode, editorNode, auxiliaryBarNode], - size: topRightHeight - }; - - // Right section: Top Right | Panel (vertical) - const rightSection: ISerializedNode = { - type: 'branch', - data: [topRightSection, panelNode], - size: rightSectionWidth - }; - - // Content section: Sidebar | Right section (horizontal) - const contentSection: ISerializedNode = { - type: 'branch', - data: [sideBarNode, rightSection], - size: contentHeight - }; - - const result: ISerializedGrid = { - root: { - type: 'branch', - size: width, - data: [ - titleBarNode, - contentSection - ] - }, - orientation: Orientation.VERTICAL, - width, - height - }; - - return result; - } - - //#endregion - - //#region Layout Methods - - private _previousViewportClass: string | undefined; - - layout(): void { - this._mainContainerDimension = getClientArea( - this.mainWindowFullscreen ? mainWindow.document.body : this.parent - ); - - // Update viewport classification and toggle mobile CSS classes - const previousClass = this._previousViewportClass; - this.layoutPolicy.update(this._mainContainerDimension.width, this._mainContainerDimension.height); - const currentClass = this.layoutPolicy.viewportClass.get(); - this.mainContainer.classList.toggle(LayoutClasses.PHONE_LAYOUT, currentClass === 'phone'); - - // When viewport class changes at runtime (e.g., device emulation toggle), - // update part visibility and create/destroy mobile components - if (previousClass !== undefined && previousClass !== currentClass) { - if (currentClass === 'phone' && !this.mobileTopBarElement) { - this.createMobileTitlebar(); - // Hide titlebar in grid on phone (replaced by MobileTitlebarPart) - this.workbenchGrid.setViewVisible(this.titleBarPartView, false); - // On phone, only chat is visible — hide everything else first - const defaults = this.layoutPolicy.getPartVisibilityDefaults(); - if (this.partVisibility.sidebar !== defaults.sidebar) { - this.setSideBarHidden(!defaults.sidebar); - } - if (this.partVisibility.auxiliaryBar !== defaults.auxiliaryBar) { - this.setAuxiliaryBarHidden(!defaults.auxiliaryBar); - } - if (this.partVisibility.panel !== defaults.panel) { - this.setPanelHidden(!defaults.panel); - } - } else if (currentClass !== 'phone' && this.mobileTopBarElement) { - // Remove mobile components when leaving phone layout - this.mobileTopBarDisposables.clear(); - this.mobileTopBarElement = undefined; - // Restore titlebar in grid - this.workbenchGrid.setViewVisible(this.titleBarPartView, true); - // Restore desktop part visibility - const defaults = this.layoutPolicy.getPartVisibilityDefaults(); - if (this.partVisibility.sidebar !== defaults.sidebar) { - this.setSideBarHidden(!defaults.sidebar); - } - if (this.partVisibility.chatBar !== defaults.chatBar) { - this.setChatBarHidden(!defaults.chatBar); - } - if (this.partVisibility.auxiliaryBar !== defaults.auxiliaryBar) { - this.setAuxiliaryBarHidden(!defaults.auxiliaryBar); - } - if (this.partVisibility.panel !== defaults.panel) { - this.setPanelHidden(!defaults.panel); - } - } - - // Re-run updateStyles() on pane composite parts so that - // mobile Part subclasses can re-apply or clear card-chrome - // inline styles based on the new `.phone-layout` class. - for (const partId of [Parts.CHATBAR_PART, Parts.SIDEBAR_PART, Parts.AUXILIARYBAR_PART, Parts.PANEL_PART]) { - this.parts.get(partId)?.updateStyles(); - } - } - this._previousViewportClass = currentClass; - - this.logService.trace(`Workbench#layout, height: ${this._mainContainerDimension.height}, width: ${this._mainContainerDimension.width}`); - - size(this.mainContainer, this._mainContainerDimension.width, this._mainContainerDimension.height); - - // On phone, subtract the mobile top bar height from the grid - const mobileTopBarHeight = this.mobileTopBarElement?.offsetHeight ?? 0; - const gridHeight = this._mainContainerDimension.height - mobileTopBarHeight; - - // Layout the grid widget - this.workbenchGrid.layout(this._mainContainerDimension.width, gridHeight); - this.layoutMobileSidebar(); - - // Emit as event - this.handleContainerDidLayout(this.mainContainer, this._mainContainerDimension); - } - - private layoutMobileSidebar(): void { - const sidebarContainer = this.getContainer(mainWindow, Parts.SIDEBAR_PART); - const sidebarPart = this.getPart(Parts.SIDEBAR_PART); - if (!sidebarContainer) { - return; - } - - // On phone the sidebar renders as a full-viewport overlay drawer. - // Geometry is fully expressed in CSS — see - // `mobileChatShell.css` (split-view-view fills the grid) and - // `sidebarPart.css` (drawer animation, z-index). We avoid setting - // inline position/size styles here because writing them after the - // grid has already laid out and painted the sidebar causes a - // visible one-frame snap on toggle. - const isPhone = this.layoutPolicy.viewportClass.get() === 'phone'; - if (!isPhone || !this.partVisibility.sidebar) { - sidebarContainer.classList.remove('mobile-overlay-sidebar'); - return; - } - - sidebarContainer.classList.add('mobile-overlay-sidebar'); - - // Re-layout the sidebar Part with the drawer's content dimensions - // so its internal composite/list sizing matches the CSS-positioned - // drawer (grid area minus the mobile top bar). - const topBarHeight = this.mobileTopBarElement?.offsetHeight ?? 48; - const drawerWidth = this._mainContainerDimension.width; - const drawerHeight = Math.max(0, this._mainContainerDimension.height - topBarHeight); - sidebarPart.layout(drawerWidth, drawerHeight, topBarHeight, 0); - } - - private handleContainerDidLayout(container: HTMLElement, dimension: IDimension): void { - this._onDidLayoutContainer.fire({ container, dimension }); - if (container === this.mainContainer) { - this._onDidLayoutMainContainer.fire(dimension); - } - if (container === this.activeContainer) { - this._onDidLayoutActiveContainer.fire(dimension); - } - } - - getLayoutClasses(): string[] { - return coalesce([ - !this.partVisibility.sidebar ? LayoutClasses.SIDEBAR_HIDDEN : undefined, - !this.partVisibility.editor ? LayoutClasses.MAIN_EDITOR_AREA_HIDDEN : undefined, - !this.partVisibility.panel ? LayoutClasses.PANEL_HIDDEN : undefined, - !this.partVisibility.auxiliaryBar ? LayoutClasses.AUXILIARYBAR_HIDDEN : undefined, - !this.partVisibility.chatBar ? LayoutClasses.CHATBAR_HIDDEN : undefined, - LayoutClasses.STATUSBAR_HIDDEN, // agents window never has a status bar - this.mainWindowFullscreen ? LayoutClasses.FULLSCREEN : undefined, - this.layoutPolicy.viewportClass.get() === 'phone' ? LayoutClasses.PHONE_LAYOUT : undefined, - ]); - } - - //#endregion - - //#region Part Management - - registerPart(part: Part): IDisposable { - const id = part.getId(); - this.parts.set(id, part); - return toDisposable(() => this.parts.delete(id)); - } - - getPart(key: Parts): Part { - const part = this.parts.get(key); - if (!part) { - throw new Error(`Unknown part ${key}`); - } - return part; - } - - hasFocus(part: Parts): boolean { - const container = this.getContainer(mainWindow, part); - if (!container) { - return false; - } - - const activeElement = getActiveElement(); - if (!activeElement) { - return false; - } - - return isAncestorUsingFlowTo(activeElement, container); - } - - focusPart(part: MULTI_WINDOW_PARTS, targetWindow: Window): void; - focusPart(part: SINGLE_WINDOW_PARTS): void; - focusPart(part: Parts, targetWindow: Window = mainWindow): void { - switch (part) { - case Parts.EDITOR_PART: - this.editorGroupService.activeGroup.focus(); - break; - case Parts.PANEL_PART: - this.paneCompositeService.getActivePaneComposite(ViewContainerLocation.Panel)?.focus(); - break; - case Parts.SIDEBAR_PART: - this.paneCompositeService.getActivePaneComposite(ViewContainerLocation.Sidebar)?.focus(); - break; - case Parts.AUXILIARYBAR_PART: - this.paneCompositeService.getActivePaneComposite(ViewContainerLocation.AuxiliaryBar)?.focus(); - break; - case Parts.CHATBAR_PART: - this.paneCompositeService.getActivePaneComposite(ViewContainerLocation.ChatBar)?.focus(); - break; - default: { - const container = this.getContainer(targetWindow, part); - container?.focus(); - } - } - } - - focus(): void { - this.focusPart(Parts.CHATBAR_PART); - } - - //#endregion - - //#region Container Methods - - getContainer(targetWindow: Window): HTMLElement; - getContainer(targetWindow: Window, part: Parts): HTMLElement | undefined; - getContainer(targetWindow: Window, part?: Parts): HTMLElement | undefined { - if (typeof part === 'undefined') { - return this.getContainerFromDocument(targetWindow.document); - } - - if (targetWindow === mainWindow) { - return this.parts.get(part)?.getContainer(); - } - - // For auxiliary windows, only editor part is supported - if (part === Parts.EDITOR_PART) { - const container = this.getContainerFromDocument(targetWindow.document); - const partCandidate = this.editorGroupService.getPart(container); - if (partCandidate instanceof Part) { - return partCandidate.getContainer(); - } - } - - return undefined; - } - - whenContainerStylesLoaded(_window: CodeWindow): Promise | undefined { - return undefined; - } - - //#endregion - - //#region Part Visibility - - isActivityBarHidden(): boolean { - return true; // No activity bar in this layout - } - - isVisible(part: SINGLE_WINDOW_PARTS): boolean; - isVisible(part: MULTI_WINDOW_PARTS, targetWindow: Window): boolean; - isVisible(part: Parts, targetWindow?: Window): boolean { - switch (part) { - case Parts.TITLEBAR_PART: - // On phone layout the grid titlebar is hidden (replaced by MobileTitlebarPart) - return this.layoutPolicy.viewportClass.get() !== 'phone'; - case Parts.SIDEBAR_PART: - return this.partVisibility.sidebar; - case Parts.AUXILIARYBAR_PART: - return this.partVisibility.auxiliaryBar; - case Parts.EDITOR_PART: - return this.partVisibility.editor; - case Parts.PANEL_PART: - return this.partVisibility.panel; - case Parts.CHATBAR_PART: - return this.partVisibility.chatBar; - case Parts.ACTIVITYBAR_PART: - case Parts.STATUSBAR_PART: - case Parts.BANNER_PART: - default: - return false; - } - } - - setPartHidden(hidden: boolean, part: Parts): void { - switch (part) { - case Parts.SIDEBAR_PART: - this.setSideBarHidden(hidden); - break; - case Parts.AUXILIARYBAR_PART: - this.setAuxiliaryBarHidden(hidden); - break; - case Parts.EDITOR_PART: - this.setEditorHidden(hidden); - break; - case Parts.PANEL_PART: - this.setPanelHidden(hidden); - break; - case Parts.CHATBAR_PART: - this.setChatBarHidden(hidden); - break; - } - } - - private setSideBarHidden(hidden: boolean): void { - if (this.partVisibility.sidebar === !hidden) { - return; - } - - this.partVisibility.sidebar = !hidden; - this.mainContainer.classList.toggle(LayoutClasses.SIDEBAR_HIDDEN, hidden); - - // Propagate to grid - this.workbenchGrid.setViewVisible( - this.sideBarPartView, - !hidden, - ); - - // If sidebar becomes hidden, also hide the current active pane composite - if (hidden && this.paneCompositeService.getActivePaneComposite(ViewContainerLocation.Sidebar)) { - this.paneCompositeService.hideActivePaneComposite(ViewContainerLocation.Sidebar); - } - - // If sidebar becomes visible, show last active Viewlet or default viewlet - if (!hidden && !this.paneCompositeService.getActivePaneComposite(ViewContainerLocation.Sidebar)) { - const viewletToOpen = this.paneCompositeService.getLastActivePaneCompositeId(ViewContainerLocation.Sidebar) ?? - this.viewDescriptorService.getDefaultViewContainer(ViewContainerLocation.Sidebar)?.id; - if (viewletToOpen) { - this.paneCompositeService.openPaneComposite(viewletToOpen, ViewContainerLocation.Sidebar); - } - } - - this.layoutMobileSidebar(); - } - - private setAuxiliaryBarHidden(hidden: boolean): void { - if (this.partVisibility.auxiliaryBar === !hidden) { - return; - } - - this.partVisibility.auxiliaryBar = !hidden; - this.mainContainer.classList.toggle(LayoutClasses.AUXILIARYBAR_HIDDEN, hidden); - - // Propagate to grid - this.workbenchGrid.setViewVisible( - this.auxiliaryBarPartView, - !hidden, - ); - - // If auxiliary bar becomes hidden, also hide the current active pane composite - if (hidden && this.paneCompositeService.getActivePaneComposite(ViewContainerLocation.AuxiliaryBar)) { - this.paneCompositeService.hideActivePaneComposite(ViewContainerLocation.AuxiliaryBar); - } - - // If auxiliary bar becomes visible, show last active pane composite or default - if (!hidden && !this.paneCompositeService.getActivePaneComposite(ViewContainerLocation.AuxiliaryBar)) { - const paneCompositeToOpen = this.paneCompositeService.getLastActivePaneCompositeId(ViewContainerLocation.AuxiliaryBar) ?? - this.viewDescriptorService.getDefaultViewContainer(ViewContainerLocation.AuxiliaryBar)?.id; - if (paneCompositeToOpen) { - this.paneCompositeService.openPaneComposite(paneCompositeToOpen, ViewContainerLocation.AuxiliaryBar); - } - } - } - - private setEditorHidden(hidden: boolean): void { - if (this.partVisibility.editor === !hidden) { - return; - } - - // If hiding the editor while maximized - if (hidden && this._editorMaximized) { - this.setEditorMaximized(false); - } - - this.partVisibility.editor = !hidden; - this.mainContainer.classList.toggle(LayoutClasses.MAIN_EDITOR_AREA_HIDDEN, hidden); - - if (this.editorPartView) { - this.workbenchGrid.setViewVisible(this.editorPartView, !hidden); - } - } - - private setPanelHidden(hidden: boolean): void { - if (this.partVisibility.panel === !hidden) { - return; - } - - // If hiding and the panel is maximized, exit maximized state first - if (hidden && this.workbenchGrid.hasMaximizedView()) { - this.workbenchGrid.exitMaximizedView(); - } - - const panelHadFocus = !hidden || this.hasFocus(Parts.PANEL_PART); - - this.partVisibility.panel = !hidden; - this.mainContainer.classList.toggle(LayoutClasses.PANEL_HIDDEN, hidden); - - // Propagate to grid - this.workbenchGrid.setViewVisible( - this.panelPartView, - !hidden, - ); - - // If panel becomes hidden, also hide the current active pane composite - if (hidden && this.paneCompositeService.getActivePaneComposite(ViewContainerLocation.Panel)) { - this.paneCompositeService.hideActivePaneComposite(ViewContainerLocation.Panel); - - // Focus the chat bar when hiding the panel if it had focus - if (panelHadFocus) { - this.focusPart(Parts.CHATBAR_PART); - } - } - - // If panel becomes visible, show last active panel or default and focus it - if (!hidden) { - if (!this.paneCompositeService.getActivePaneComposite(ViewContainerLocation.Panel)) { - const panelToOpen = this.paneCompositeService.getLastActivePaneCompositeId(ViewContainerLocation.Panel) ?? - this.viewDescriptorService.getDefaultViewContainer(ViewContainerLocation.Panel)?.id; - if (panelToOpen) { - this.paneCompositeService.openPaneComposite(panelToOpen, ViewContainerLocation.Panel); - } - } - - this.focusPart(Parts.PANEL_PART); - } - } - - private setChatBarHidden(hidden: boolean): void { - if (this.partVisibility.chatBar === !hidden) { - return; - } - - this.partVisibility.chatBar = !hidden; - this.mainContainer.classList.toggle(LayoutClasses.CHATBAR_HIDDEN, hidden); - - // Propagate to grid - this.workbenchGrid.setViewVisible(this.chatBarPartView, !hidden); - - // If chat bar becomes hidden, also hide the current active pane composite - if (hidden && this.paneCompositeService.getActivePaneComposite(ViewContainerLocation.ChatBar)) { - this.paneCompositeService.hideActivePaneComposite(ViewContainerLocation.ChatBar); - } - - // If chat bar becomes visible, show last active pane composite or default - if (!hidden && !this.paneCompositeService.getActivePaneComposite(ViewContainerLocation.ChatBar)) { - const paneCompositeToOpen = this.paneCompositeService.getLastActivePaneCompositeId(ViewContainerLocation.ChatBar) ?? - this.viewDescriptorService.getDefaultViewContainer(ViewContainerLocation.ChatBar)?.id; - if (paneCompositeToOpen) { - this.paneCompositeService.openPaneComposite(paneCompositeToOpen, ViewContainerLocation.ChatBar); - } - } - } - - //#endregion - - //#region Position Methods (Fixed - Not Configurable) - - getSideBarPosition(): Position { - return Position.LEFT; // Always left in this layout - } - - getPanelPosition(): Position { - return Position.BOTTOM; // Always bottom in this layout - } - - setPanelPosition(_position: Position): void { - // No-op: Panel position is fixed in this layout - } - - getPanelAlignment(): PanelAlignment { - return 'justify'; // Full width panel - } - - setPanelAlignment(_alignment: PanelAlignment): void { - // No-op: Panel alignment is fixed in this layout - } - - //#endregion - - //#region Size Methods - - getSize(part: Parts): IViewSize { - const view = this.getPartView(part); - if (!view) { - return { width: 0, height: 0 }; - } - return this.workbenchGrid.getViewSize(view); - } - - setSize(part: Parts, size: IViewSize): void { - const view = this.getPartView(part); - if (view) { - this.workbenchGrid.resizeView(view, size); - } - } - - resizePart(part: Parts, sizeChangeWidth: number, sizeChangeHeight: number): void { - const view = this.getPartView(part); - if (!view) { - return; - } - - const currentSize = this.workbenchGrid.getViewSize(view); - this.workbenchGrid.resizeView(view, { - width: currentSize.width + sizeChangeWidth, - height: currentSize.height + sizeChangeHeight - }); - } - - private getPartView(part: Parts): ISerializableView | undefined { - switch (part) { - case Parts.TITLEBAR_PART: - return this.titleBarPartView; - case Parts.SIDEBAR_PART: - return this.sideBarPartView; - case Parts.AUXILIARYBAR_PART: - return this.auxiliaryBarPartView; - case Parts.EDITOR_PART: - return this.editorPartView; - case Parts.PANEL_PART: - return this.panelPartView; - case Parts.CHATBAR_PART: - return this.chatBarPartView; - default: - return undefined; - } - } - - getMaximumEditorDimensions(_container: HTMLElement): IDimension { - // Return the available space for editor (excluding other parts) - const sidebarWidth = this.partVisibility.sidebar ? this.workbenchGrid.getViewSize(this.sideBarPartView).width : 0; - const auxiliaryBarWidth = this.partVisibility.auxiliaryBar ? this.workbenchGrid.getViewSize(this.auxiliaryBarPartView).width : 0; - const panelHeight = this.partVisibility.panel ? this.workbenchGrid.getViewSize(this.panelPartView).height : 0; - const titleBarHeight = this.workbenchGrid.getViewSize(this.titleBarPartView).height; - - return new Dimension( - this._mainContainerDimension.width - sidebarWidth - auxiliaryBarWidth, - this._mainContainerDimension.height - titleBarHeight - panelHeight - ); - } - - //#endregion - - //#region Unsupported Features (No-ops) - - toggleMaximizedPanel(): void { - if (!this.workbenchGrid) { - return; - } - - if (this.isPanelMaximized()) { - this.workbenchGrid.exitMaximizedView(); - } else { - this.workbenchGrid.maximizeView(this.panelPartView, [this.titleBarPartView, this.sideBarPartView]); - } - } - - isPanelMaximized(): boolean { - if (!this.workbenchGrid) { - return false; - } - - return this.workbenchGrid.isViewMaximized(this.panelPartView); - } - - toggleMaximizedAuxiliaryBar(): void { - // No-op: Maximize not supported in this layout - } - - setAuxiliaryBarMaximized(_maximized: boolean): boolean { - return false; // Maximize not supported - } - - isAuxiliaryBarMaximized(): boolean { - return false; // Maximize not supported - } - - isEditorMaximized(): boolean { - return this._editorMaximized; - } - - setEditorMaximized(maximized: boolean): void { - if (maximized === this._editorMaximized) { - return; - } - - if (maximized) { - // Save current visibility state - this._editorLastNonMaximizedVisibility = { - sidebar: this.partVisibility.sidebar, - auxiliaryBar: this.partVisibility.auxiliaryBar, - editor: this.partVisibility.editor, - panel: this.partVisibility.panel, - chatBar: this.partVisibility.chatBar, - }; - - // Ensure editor is visible - if (!this.partVisibility.editor) { - this.setEditorHidden(false); - } - - // Hide all other content parts - if (this.partVisibility.sidebar) { - this.setSideBarHidden(true); - } - if (this.partVisibility.chatBar) { - this.setChatBarHidden(true); - } - - this._editorMaximized = true; - } else { - const state = this._editorLastNonMaximizedVisibility; - - // Restore previous visibility state - this.setSideBarHidden(!state?.sidebar); - this.setChatBarHidden(!state?.chatBar); - - this._editorMaximized = false; - } - - this._onDidChangeEditorMaximized.fire(); - } - - toggleZenMode(): void { - // No-op: Zen mode not supported in this layout - } - - toggleMenuBar(): void { - // No-op: Menu bar toggle not supported in this layout - } - - isMainEditorLayoutCentered(): boolean { - return false; // Centered layout not supported - } - - centerMainEditorLayout(_active: boolean): void { - // No-op: Centered layout not supported in this layout - } - - hasMainWindowBorder(): boolean { - return false; - } - - getMainWindowBorderRadius(): string | undefined { - return undefined; - } - - //#endregion - - //#region Window Maximized State - - isWindowMaximized(targetWindow: Window): boolean { - return this.maximized.has(getWindowId(targetWindow)); - } - - updateWindowMaximizedState(targetWindow: Window, maximized: boolean): void { - const windowId = getWindowId(targetWindow); - if (maximized) { - this.maximized.add(windowId); - if (targetWindow === mainWindow) { - this.mainContainer.classList.add(LayoutClasses.MAXIMIZED); - } - } else { - this.maximized.delete(windowId); - if (targetWindow === mainWindow) { - this.mainContainer.classList.remove(LayoutClasses.MAXIMIZED); - } - } - - this._onDidChangeWindowMaximized.fire({ windowId, maximized }); - } - - //#endregion - - //#region Neighbor Parts - - getVisibleNeighborPart(part: Parts, direction: Direction): Parts | undefined { - if (!this.workbenchGrid) { - return undefined; - } - - const view = this.getPartView(part); - if (!view) { - return undefined; - } - - const neighbor = this.workbenchGrid.getNeighborViews(view, direction, false); - if (neighbor.length === 0) { - return undefined; - } - - const neighborView = neighbor[0]; - - if (neighborView === this.titleBarPartView) { - return Parts.TITLEBAR_PART; - } - if (neighborView === this.sideBarPartView) { - return Parts.SIDEBAR_PART; - } - if (neighborView === this.auxiliaryBarPartView) { - return Parts.AUXILIARYBAR_PART; - } - if (neighborView === this.editorPartView) { - return Parts.EDITOR_PART; - } - if (neighborView === this.panelPartView) { - return Parts.PANEL_PART; - } - if (neighborView === this.chatBarPartView) { - return Parts.CHATBAR_PART; - } - - return undefined; - } - - //#endregion - - //#region Restore - - isRestored(): boolean { - return this.restored; - } - - setRestored(): void { - this.restored = true; - this.restoredPromise.complete(); - } - - //#endregion - - //#region Notifications Registration - - registerNotifications(delegate: { onDidChangeNotificationsVisibility: Event }): void { - this._register(delegate.onDidChangeNotificationsVisibility(visible => this._onDidChangeNotificationsVisibility.fire(visible))); - } - - //#endregion -} diff --git a/src/vs/sessions/common/agentHostSessionWorkspace.ts b/src/vs/sessions/common/agentHostSessionWorkspace.ts deleted file mode 100644 index 0ad74010..00000000 --- a/src/vs/sessions/common/agentHostSessionWorkspace.ts +++ /dev/null @@ -1,129 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { Codicon } from '../../base/common/codicons.js'; -import { match as matchGlob } from '../../base/common/glob.js'; -import { extUri, basename } from '../../base/common/resources.js'; -import { ThemeIcon } from '../../base/common/themables.js'; -import { URI } from '../../base/common/uri.js'; -import type { ISessionGitState } from '../../platform/agentHost/common/state/sessionState.js'; -import { IConfigurationService } from '../../platform/configuration/common/configuration.js'; -import { ISessionWorkspace } from '../services/sessions/common/session.js'; - -export interface IAgentHostSessionProjectSummary { - readonly uri: URI; - readonly displayName: string; -} - -export interface IAgentHostSessionWorkspaceOptions { - readonly providerLabel?: string; - readonly fallbackIcon: ThemeIcon; - readonly requiresWorkspaceTrust: boolean; - readonly description?: string; - /** - * Group label used by the workspace picker to bucket the produced - * workspace into a top-level tab (e.g. `"Local"`, `"Remote"`). - */ - readonly group?: string; - /** - * Configured `git.branchProtection` glob patterns. Used to compute - * `baseBranchProtected` on the resulting repository. - */ - readonly branchProtectionPatterns?: readonly string[]; -} - -/** - * Returns true when `branchName` matches any of the configured - * `git.branchProtection` glob patterns. - */ -export function matchesAnyBranchProtectionPattern(branchName: string, patterns: readonly string[] | undefined): boolean { - if (!patterns) { - return false; - } - for (const pattern of patterns) { - const trimmed = pattern.trim(); - if (trimmed && matchGlob(trimmed, branchName)) { - return true; - } - } - return false; -} - -/** - * Reads `git.branchProtection` from configuration and normalizes the result - * into an array of trimmed, non-empty pattern strings. - * - * The `git.branchProtection` setting is `resource`-scoped, so the value can - * differ between workspace folders. Pass the session's working directory (or - * project URI as a fallback) as `resource` so we read the setting in the - * scope of the folder VS Code actually has loaded rather than the host - * window's active workspace. - */ -export function readBranchProtectionPatterns(configurationService: IConfigurationService, resource?: URI): readonly string[] { - const raw = configurationService.getValue('git.branchProtection', { resource }) ?? []; - const list = Array.isArray(raw) ? raw : [raw]; - return list - .map(p => typeof p === 'string' ? p.trim() : '') - .filter(p => p !== ''); -} - -export function agentHostSessionWorkspaceKey(workspace: ISessionWorkspace | undefined): string | undefined { - const repository = workspace?.repositories[0]; - if (!workspace || !repository) { - return undefined; - } - return [ - workspace.label, - extUri.getComparisonKey(repository.uri), - repository.workingDirectory ? extUri.getComparisonKey(repository.workingDirectory) : '', - repository.branchName ?? '', - repository.baseBranchName ?? '', - String(repository.baseBranchProtected ?? ''), - String(repository.hasGitHubRemote ?? ''), - repository.upstreamBranchName ?? '', - String(repository.incomingChanges ?? ''), - String(repository.outgoingChanges ?? ''), - String(repository.uncommittedChanges ?? ''), - ].join('\n'); -} - -export function buildAgentHostSessionWorkspace(project: IAgentHostSessionProjectSummary | undefined, workingDirectory: URI | undefined, options: IAgentHostSessionWorkspaceOptions, gitState?: ISessionGitState): ISessionWorkspace | undefined { - const baseBranchName = gitState?.baseBranchName; - const baseBranchProtected = baseBranchName !== undefined - ? matchesAnyBranchProtectionPattern(baseBranchName, options.branchProtectionPatterns) - : undefined; - const hasGitHubRemote = gitState?.hasGitHubRemote; - const upstreamBranchName = gitState?.upstreamBranchName; - const incomingChanges = gitState?.incomingChanges; - const outgoingChanges = gitState?.outgoingChanges; - const uncommittedChanges = gitState?.uncommittedChanges; - const branchName = gitState?.branchName; - const gitFields = { branchName, baseBranchName, baseBranchProtected, hasGitHubRemote, upstreamBranchName, incomingChanges, outgoingChanges, uncommittedChanges }; - if (project) { - const repositoryWorkingDirectory = extUri.isEqual(workingDirectory, project.uri) ? undefined : workingDirectory; - return { - label: options.providerLabel ? `${project.displayName} [${options.providerLabel}]` : project.displayName, - description: options.description, - icon: Codicon.repo, - group: options.group, - repositories: [{ uri: project.uri, workingDirectory: repositoryWorkingDirectory, detail: undefined, ...gitFields }], - requiresWorkspaceTrust: options.requiresWorkspaceTrust, - }; - } - - if (!workingDirectory) { - return undefined; - } - - const folderName = basename(workingDirectory) || workingDirectory.path; - return { - label: options.providerLabel ? `${folderName} [${options.providerLabel}]` : folderName, - description: options.description, - icon: options.fallbackIcon, - group: options.group, - repositories: [{ uri: workingDirectory, workingDirectory: undefined, detail: undefined, ...gitFields }], - requiresWorkspaceTrust: options.requiresWorkspaceTrust, - }; -} diff --git a/src/vs/sessions/common/agentHostSessionsProvider.ts b/src/vs/sessions/common/agentHostSessionsProvider.ts deleted file mode 100644 index c1382b3b..00000000 --- a/src/vs/sessions/common/agentHostSessionsProvider.ts +++ /dev/null @@ -1,189 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { Event } from '../../base/common/event.js'; -import { IObservable } from '../../base/common/observable.js'; -import { equals } from '../../base/common/objects.js'; -import { RemoteAgentHostConnectionStatus } from '../../platform/agentHost/common/remoteAgentHostService.js'; -import { ResolveSessionConfigResult, SessionConfigValueItem } from '../../platform/agentHost/common/state/protocol/commands.js'; -import { RootConfigState } from '../../platform/agentHost/common/state/protocol/state.js'; -import { ISessionsProvider } from '../services/sessions/common/sessionsProvider.js'; - -/** - * Extended sessions provider for agent host providers (local and remote). - * Adds remote connection properties and dynamic session configuration. - */ -export interface IAgentHostSessionsProvider extends ISessionsProvider { - // -- Remote Connection (optional, used by remote agent host providers) -- - /** Connection status observable, present on remote providers. */ - readonly connectionStatus?: IObservable; - /** Remote address string, present on remote providers. */ - readonly remoteAddress?: string; - /** Output channel ID for remote provider logs. */ - outputChannelId?: string; - /** - * Establish (or re-establish) the connection for this host on demand. - * Tears down any existing connection first. Present on remote providers - * that manage their own transport (e.g. tunnel relay); providers that - * use the generic {@link IRemoteAgentHostService} reconnect flow may - * leave this undefined. - */ - connect?(): Promise; - /** - * Tear down the active connection for this host without forgetting the - * entry. A subsequent {@link connect} call should be able to re-establish - * it. Present on remote providers that manage their own transport. - */ - disconnect?(): Promise; - - // -- Dynamic Session Config -- - - /** Fires when dynamic configuration for a session changes. */ - readonly onDidChangeSessionConfig: Event; - /** Returns the last resolved dynamic configuration for a session. */ - getSessionConfig(sessionId: string): ResolveSessionConfigResult | undefined; - /** Sets one dynamic configuration property and re-resolves the schema. */ - setSessionConfigValue(sessionId: string, property: string, value: unknown): Promise; - /** - * Replaces the full set of running-session config values atomically. - * - * Dispatches a single `session/configChanged` action with replace - * semantics. Only user-editable properties (`sessionMutable: true` and - * not `readOnly`) are actually replaced from the caller-supplied values — - * for every other property the current value is carried through, so - * non-mutable / read-only properties (e.g. `isolation`, `branch`) can - * never be altered through this API even if included in the input. - * Unknown keys (no schema entry) are ignored. - * - * No-op for pre-creation (new) sessions — use {@link setSessionConfigValue} - * there since the schema is still being resolved. - */ - replaceSessionConfig(sessionId: string, values: Record): Promise; - /** Returns dynamic completions for a configuration property. */ - getSessionConfigCompletions(sessionId: string, property: string, query?: string): Promise; - /** Returns the resolved config that should be sent to createSession. */ - getCreateSessionConfig(sessionId: string): Record | undefined; - /** Clears dynamic configuration state for an abandoned new session. */ - clearSessionConfig(sessionId: string): void; - - // -- Root (agent host) Config -- - - /** Fires when the root (agent host) configuration schema or values change. */ - readonly onDidChangeRootConfig: Event; - /** Returns the last-known root (agent host) configuration, or `undefined` if the host has not published any. */ - getRootConfig(): RootConfigState | undefined; - /** - * Sets one root configuration property. - * - * Optimistically updates local state and dispatches a - * `root/configChanged` action (non-replace) to the agent host. - */ - setRootConfigValue(property: string, value: unknown): Promise; - /** - * Replaces the full set of root configuration values atomically. - * - * Dispatches a single `root/configChanged` action with replace semantics. - * Unknown keys (no schema entry) are ignored. - */ - replaceRootConfig(values: Record): Promise; -} - -export const LOCAL_AGENT_HOST_PROVIDER_ID = 'local-agent-host'; -export const REMOTE_AGENT_HOST_PROVIDER_PREFIX = 'agenthost-'; -export const REMOTE_AGENT_HOST_PROVIDER_RE = /^agenthost-/; -export const ANY_AGENT_HOST_PROVIDER_RE = /^(local-agent-host|agenthost-)/; - -/** - * Checks whether a provider is an agent host provider based on its - * reserved provider ID (`local-agent-host` or `agenthost-*` prefix). - */ -export function isAgentHostProvider(provider: ISessionsProvider): provider is IAgentHostSessionsProvider { - return provider.id === LOCAL_AGENT_HOST_PROVIDER_ID || provider.id.startsWith(REMOTE_AGENT_HOST_PROVIDER_PREFIX); -} - -/** - * Structural equality for resolved session configs. Returns true when both - * inputs have the same value-key set with deep-equal values and the same set - * of schema property keys with identical (by-identity) property objects. - * Schema property objects are compared by identity since they originate from - * the same protocol snapshot in the providers that use this helper. Values - * are deep-compared via {@link equals} so non-string entries (e.g. permission - * objects) compare correctly. - */ -export function resolvedConfigsEqual(a: ResolveSessionConfigResult, b: ResolveSessionConfigResult): boolean { - const aValueKeys = Object.keys(a.values); - const bValueKeys = Object.keys(b.values); - if (aValueKeys.length !== bValueKeys.length) { - return false; - } - for (const key of aValueKeys) { - if (!equals(a.values[key], b.values[key])) { - return false; - } - } - const aPropKeys = Object.keys(a.schema.properties); - const bPropKeys = Object.keys(b.schema.properties); - if (aPropKeys.length !== bPropKeys.length) { - return false; - } - for (const key of aPropKeys) { - if (a.schema.properties[key] !== b.schema.properties[key]) { - return false; - } - } - return true; -} - -/** Known auto-approve config values. */ -const AUTO_APPROVE_ENUM = ['default', 'autoApprove', 'autopilot']; - -type MutableConfigSchemaItem = - | { type: 'string'; title: string; sessionMutable: true; enum: string[] } - | { type: 'number'; title: string; sessionMutable: true } - | { type: 'boolean'; title: string; sessionMutable: true } - | { type: 'array'; title: string; sessionMutable: true } - | { type: 'object'; title: string; sessionMutable: true }; - -function buildMutableConfigSchemaItem(key: string, value: unknown): MutableConfigSchemaItem | undefined { - if (typeof value === 'string') { - return { - type: 'string', - title: key, - sessionMutable: true, - enum: key === 'autoApprove' ? AUTO_APPROVE_ENUM : [value], - }; - } - if (typeof value === 'number') { - return { type: 'number', title: key, sessionMutable: true }; - } - if (typeof value === 'boolean') { - return { type: 'boolean', title: key, sessionMutable: true }; - } - if (Array.isArray(value)) { - return { type: 'array', title: key, sessionMutable: true }; - } - if (value && typeof value === 'object') { - return { type: 'object', title: key, sessionMutable: true }; - } - return undefined; -} - -/** - * Builds a minimal session-mutable config schema from changed values. - * Used when a restored session receives a ConfigChanged action before - * the full schema has been hydrated. Properties whose value type isn't - * representable in the config schema (e.g. `null`, `undefined`) are - * omitted. - */ -export function buildMutableConfigSchema(config: Record): Record { - const properties: Record = {}; - for (const key of Object.keys(config)) { - const property = buildMutableConfigSchemaItem(key, config[key]); - if (property) { - properties[key] = property; - } - } - return properties; -} diff --git a/src/vs/sessions/common/categories.ts b/src/vs/sessions/common/categories.ts deleted file mode 100644 index 4645c05d..00000000 --- a/src/vs/sessions/common/categories.ts +++ /dev/null @@ -1,10 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { localize2 } from '../../nls.js'; - -export const SessionsCategories = Object.freeze({ - Sessions: localize2('agents', "Agents"), -}); diff --git a/src/vs/sessions/common/contextkeys.ts b/src/vs/sessions/common/contextkeys.ts deleted file mode 100644 index b1032e87..00000000 --- a/src/vs/sessions/common/contextkeys.ts +++ /dev/null @@ -1,59 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { localize } from '../../nls.js'; -import { RawContextKey } from '../../platform/contextkey/common/contextkey.js'; - -//#region < --- Active Session --- > - -export const IsNewChatSessionContext = new RawContextKey('isNewChatSession', true); -export const IsNewChatInSessionContext = new RawContextKey('isNewChatInSession', false, localize('isNewChatInSession', "Whether the user is composing a new chat within the active session")); -export const ActiveSessionProviderIdContext = new RawContextKey('activeSessionProviderId', '', localize('activeSessionProviderId', "The provider ID of the active session")); -export const ActiveSessionTypeContext = new RawContextKey('activeSessionType', '', localize('activeSessionType', "The session type of the active session")); -export const IsActiveSessionBackgroundProviderContext = new RawContextKey('isActiveSessionBackgroundProvider', false, localize('isActiveSessionBackgroundProvider', "Whether the active session uses the background agent provider")); -export const IsActiveSessionArchivedContext = new RawContextKey('isActiveSessionArchived', false, localize('isActiveSessionArchived', "Whether the active session is archived (marked as done)")); -export const ActiveSessionHasGitRepositoryContext = new RawContextKey('activeSessionHasGitRepository', false, localize('activeSessionHasGitRepository', "Whether the active session has an associated git repository")); -export const ChatSessionProviderIdContext = new RawContextKey('chatSessionProviderId', '', localize('chatSessionProviderId', "The provider ID of a session in context menu overlays")); - -//#endregion - -//#region < --- Chat Bar --- > - -export const ActiveChatBarContext = new RawContextKey('activeChatBar', '', localize('activeChatBar', "The identifier of the active chat bar panel")); -export const ChatBarFocusContext = new RawContextKey('chatBarFocus', false, localize('chatBarFocus', "Whether the chat bar has keyboard focus")); -export const ChatBarVisibleContext = new RawContextKey('chatBarVisible', false, localize('chatBarVisible', "Whether the chat bar is visible")); - -//#endregion - -//#region < --- Welcome --- > - -export const SessionsWelcomeVisibleContext = new RawContextKey('sessionsWelcomeVisible', false, localize('sessionsWelcomeVisible', "Whether the sessions welcome overlay is visible")); - -//#endregion - -//#region < --- Workspace Picker --- > - -export const SessionWorkspacePickerGroupContext = new RawContextKey('sessionWorkspacePickerGroup', '', localize('sessionWorkspacePickerGroup', "The currently active group tab in the session workspace picker")); - -//#endregion - -//#region < --- Aquarium --- > - -export const SessionsAquariumActiveContext = new RawContextKey('sessionsAquariumActive', false, localize('sessionsAquariumActive', "Whether the sessions aquarium overlay is active")); - -//#endregion - -//#region < --- Editor --- > - -export const EditorMaximizedContext = new RawContextKey('editorMaximized', false, localize('editorMaximized', "Whether the editor area is maximized")); - -//#endregion - -//#region < --- Mobile Layout --- > - -export const IsPhoneLayoutContext = new RawContextKey('sessionsIsPhoneLayout', false, localize('sessionsIsPhoneLayout', "Whether the current layout is the phone layout")); -export const KeyboardVisibleContext = new RawContextKey('sessionsKeyboardVisible', false, localize('sessionsKeyboardVisible', "Whether the virtual keyboard is visible")); - -//#endregion diff --git a/src/vs/sessions/common/sessionsTelemetry.ts b/src/vs/sessions/common/sessionsTelemetry.ts deleted file mode 100644 index 04926331..00000000 --- a/src/vs/sessions/common/sessionsTelemetry.ts +++ /dev/null @@ -1,279 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { ITelemetryService } from '../../platform/telemetry/common/telemetry.js'; - -// --- Titlebar button interactions --- - -export type SessionsInteractionButton = - | 'newSession' - | 'runPrimaryTask' - | 'addTask' - | 'generateNewTask' - | 'openTerminal' - | 'openInVSCode'; - -export type SessionsInteractionSource = 'menu' | 'actionWidget'; - -type SessionsInteractionEvent = { - button: string; - source?: string; -}; - -type SessionsInteractionClassification = { - owner: 'osortega'; - comment: 'Tracks user interactions with buttons in the Agents window'; - button: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The identifier of the button that was clicked' }; - source?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The UI surface that triggered the interaction (menu or actionWidget)' }; -}; - -/** - * Log a titlebar button interaction in the Agents window. - */ -export function logSessionsInteraction(telemetryService: ITelemetryService, button: SessionsInteractionButton, source?: SessionsInteractionSource): void { - telemetryService.publicLog2('vscodeAgents.interaction', source ? { button, source } : { button }); -} - -// --- Changes panel interactions --- - -type ChangesViewTogglePanelEvent = { - visible: boolean; -}; - -type ChangesViewTogglePanelClassification = { - owner: 'osortega'; - comment: 'Tracks when the user toggles the Changes panel open or closed.'; - visible: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the Changes panel is now visible.' }; -}; - -export function logChangesViewToggle(telemetryService: ITelemetryService, visible: boolean): void { - telemetryService.publicLog2('vscodeAgents.changesView/togglePanel', { visible }); -} - -type ChangesViewVersionModeChangeEvent = { - mode: string; -}; - -type ChangesViewVersionModeChangeClassification = { - owner: 'osortega'; - comment: 'Tracks when the user switches the version mode in the Changes panel (Branch Changes, All Changes, Last Turn).'; - mode: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The version mode selected by the user.' }; -}; - -export function logChangesViewVersionModeChange(telemetryService: ITelemetryService, mode: string): void { - telemetryService.publicLog2('vscodeAgents.changesView/versionModeChange', { mode }); -} - -type ChangesViewFileSelectEvent = { - changeType: string; -}; - -type ChangesViewFileSelectClassification = { - owner: 'osortega'; - comment: 'Tracks when the user selects a changed file in the Changes panel.'; - changeType: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The type of change (added, modified, deleted).' }; -}; - -export function logChangesViewFileSelect(telemetryService: ITelemetryService, changeType: string): void { - telemetryService.publicLog2('vscodeAgents.changesView/fileSelect', { changeType }); -} - -type ChangesViewViewModeChangeEvent = { - mode: string; -}; - -type ChangesViewViewModeChangeClassification = { - owner: 'osortega'; - comment: 'Tracks when the user switches between list and tree view modes in the Changes panel.'; - mode: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The view mode selected by the user (list or tree).' }; -}; - -export function logChangesViewViewModeChange(telemetryService: ITelemetryService, mode: string): void { - telemetryService.publicLog2('vscodeAgents.changesView/viewModeChange', { mode }); -} - -type ChangesViewReviewCommentAddedEvent = { - hasExistingFeedback: boolean; - hasSuggestion: boolean; - isFromPRReview: boolean; -}; - -type ChangesViewReviewCommentAddedClassification = { - owner: 'osortega'; - comment: 'Tracks when a user adds a review comment (feedback) to a file in the Changes panel.'; - hasExistingFeedback: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether there was already feedback on this file.' }; - hasSuggestion: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the feedback includes a code suggestion.' }; - isFromPRReview: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the feedback was converted from a PR review comment.' }; -}; - -export function logChangesViewReviewCommentAdded(telemetryService: ITelemetryService, data: { hasExistingFeedback: boolean; hasSuggestion: boolean; isFromPRReview: boolean }): void { - telemetryService.publicLog2('vscodeAgents.changesView/reviewCommentAdded', data); -} - -// --- Tunnel agent host connect --- - -export type TunnelConnectErrorCategory = - | 'relayConnectionFailed' - | 'auth' - | 'authExpired' - | 'network' - | 'other'; - -export type TunnelConnectFailureReason = - | 'hostOffline' - | 'maxAttemptsReached' - | 'auth' - | 'authExpired'; - -type TunnelConnectAttemptEvent = { - isReconnect: boolean; - attempt: number; - durationMs: number; - success: boolean; - errorCategory: string; -}; - -type TunnelConnectAttemptClassification = { - owner: 'osortega'; - comment: 'Tracks individual agent-host tunnel connect attempts for performance and reliability.'; - isReconnect: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether this attempt was part of a reconnect cycle (true) or an initial connect (false).' }; - attempt: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Attempt number within the current connect session (1-based).' }; - durationMs: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Duration of this individual attempt in milliseconds.' }; - success: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Whether this individual attempt succeeded.' }; - errorCategory: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Category of error when the attempt failed (relayConnectionFailed, auth, authExpired, network, other); empty on success.' }; -}; - -export function logTunnelConnectAttempt(telemetryService: ITelemetryService, data: { isReconnect: boolean; attempt: number; durationMs: number; success: boolean; errorCategory?: TunnelConnectErrorCategory }): void { - telemetryService.publicLog2('vscodeAgents.tunnelConnect/attempt', { - isReconnect: data.isReconnect, - attempt: data.attempt, - durationMs: data.durationMs, - success: data.success, - errorCategory: data.errorCategory ?? '', - }); -} - -type TunnelConnectResolvedEvent = { - isReconnect: boolean; - totalAttempts: number; - totalDurationMs: number; - success: boolean; - failureReason: string; -}; - -type TunnelConnectResolvedClassification = { - owner: 'osortega'; - comment: 'Tracks overall agent-host tunnel connect session outcomes for reliability.'; - isReconnect: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the resolved session was a reconnect cycle (true) or an initial connect (false).' }; - totalAttempts: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Total number of attempts made before resolution.' }; - totalDurationMs: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Total elapsed time from session start to resolution in milliseconds.' }; - success: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Whether the connect session ultimately succeeded.' }; - failureReason: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Reason the session terminated without connecting (hostOffline, maxAttemptsReached, auth, authExpired); empty on success.' }; -}; - -export function logTunnelConnectResolved(telemetryService: ITelemetryService, data: { isReconnect: boolean; totalAttempts: number; totalDurationMs: number; success: boolean; failureReason?: TunnelConnectFailureReason }): void { - telemetryService.publicLog2('vscodeAgents.tunnelConnect/resolved', { - isReconnect: data.isReconnect, - totalAttempts: data.totalAttempts, - totalDurationMs: data.totalDurationMs, - success: data.success, - failureReason: data.failureReason ?? '', - }); -} - -// --- Socket lifecycle telemetry --- - -export type SocketCloseTrigger = - | 'server' - | 'sendOnDeadSocket' - | 'visibility' - | 'offline' - | 'malformedFrames' - | 'disposed' - | 'error'; - -type SocketCloseEvent = { - closeCode: number; - wasClean: boolean; - lifetimeMs: number; - messagesSent: number; - messagesReceived: number; - messagesDropped: number; - trigger: string; -}; - -type SocketCloseClassification = { - owner: 'osortega'; - comment: 'Tracks WebSocket close events for agent host connections to measure connection reliability.'; - closeCode: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'WebSocket close code.' }; - wasClean: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Whether the close was clean.' }; - lifetimeMs: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'How long the socket was alive in milliseconds.' }; - messagesSent: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Total messages sent.' }; - messagesReceived: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Total messages received.' }; - messagesDropped: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Total messages dropped due to non-OPEN socket.' }; - trigger: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'What triggered the close (server, sendOnDeadSocket, visibility, offline, malformedFrames, disposed, error).' }; -}; - -export function logSocketClose(telemetryService: ITelemetryService, data: { closeCode: number; wasClean: boolean; lifetimeMs: number; messagesSent: number; messagesReceived: number; messagesDropped: number; trigger: SocketCloseTrigger }): void { - telemetryService.publicLog2('vscodeAgents.socket/close', data); -} - -// --- Send dropped telemetry --- - -type SendDroppedEvent = { - readyState: number; - timeSinceLastReceiveMs: number; - timeSinceLastSendMs: number; -}; - -type SendDroppedClassification = { - owner: 'osortega'; - comment: 'Tracks when a message is silently dropped due to a non-OPEN WebSocket, indicating a zombie socket.'; - readyState: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'WebSocket readyState at drop time (0=CONNECTING, 1=OPEN, 2=CLOSING, 3=CLOSED).' }; - timeSinceLastReceiveMs: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Milliseconds since last received message.' }; - timeSinceLastSendMs: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Milliseconds since last sent message.' }; -}; - -export function logSendDropped(telemetryService: ITelemetryService, data: { readyState: number; timeSinceLastReceiveMs: number; timeSinceLastSendMs: number }): void { - telemetryService.publicLog2('vscodeAgents.socket/sendDropped', data); -} - -// --- Visibility resumed telemetry --- - -type VisibilityResumedEvent = { - hiddenDurationMs: number; - socketAlive: boolean; - forceClosed: boolean; -}; - -type VisibilityResumedClassification = { - owner: 'osortega'; - comment: 'Tracks tab visibility resume events to measure zombie socket detection effectiveness.'; - hiddenDurationMs: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'How long the tab was hidden in milliseconds.' }; - socketAlive: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Whether the socket was alive after zombie detection check.' }; - forceClosed: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Whether the socket was force-closed on resume.' }; -}; - -export function logVisibilityResumed(telemetryService: ITelemetryService, data: { hiddenDurationMs: number; socketAlive: boolean; forceClosed: boolean }): void { - telemetryService.publicLog2('vscodeAgents.socket/visibilityResumed', data); -} - -// --- Terminal recovery telemetry --- - -type TerminalRecoveryEvent = { - recoveredCount: number; - totalCount: number; -}; - -type TerminalRecoveryClassification = { - owner: 'osortega'; - comment: 'Tracks terminal reconnection outcomes after agent host disconnect.'; - recoveredCount: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Number of terminals successfully reconnected.' }; - totalCount: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Total number of active terminals at reconnect time.' }; -}; - -export function logTerminalRecovery(telemetryService: ITelemetryService, data: { recoveredCount: number; totalCount: number }): void { - telemetryService.publicLog2('vscodeAgents.terminal/recovery', data); -} diff --git a/src/vs/sessions/common/theme.ts b/src/vs/sessions/common/theme.ts deleted file mode 100644 index 822061d2..00000000 --- a/src/vs/sessions/common/theme.ts +++ /dev/null @@ -1,163 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -// Agent-sessions color tokens. -// -// Registrations live here in the sessions layer. The workbench entry point -// (`workbench.common.main.ts`) imports this file as a side-effect so the -// tokens are present in the global color registry and JSON theme schema -// for both the main workbench and the sessions workbench. - -import { localize } from '../../nls.js'; -import { registerColor, transparent } from '../../platform/theme/common/colorUtils.js'; -import { contrastBorder, focusBorder } from '../../platform/theme/common/colorRegistry.js'; -import { editorWidgetBorder, editorBackground, toolbarHoverBackground } from '../../platform/theme/common/colors/editorColors.js'; -import { buttonBackground, buttonBorder, inputBackground, inputBorder, inputForeground, inputPlaceholderForeground } from '../../platform/theme/common/colors/inputColors.js'; -import { ACTIVITY_BAR_BADGE_BACKGROUND, ACTIVITY_BAR_BADGE_FOREGROUND, PANEL_BORDER, SIDE_BAR_BACKGROUND, SIDE_BAR_FOREGROUND } from '../../workbench/common/theme.js'; - -// ============================================================================ -// Shell background (used by the gradient system) -// ============================================================================ - -export const agentsBackground = registerColor( - 'agents.background', - { dark: editorBackground, light: SIDE_BAR_BACKGROUND, hcDark: editorBackground, hcLight: editorBackground }, - localize('agents.background', 'Background color of the agent sessions window shell and gradient base.') -); - -// ============================================================================ -// Panels (chat panel, auxiliary bar, terminal panel) -// ============================================================================ - -export const agentsPanelBackground = registerColor( - 'agentsPanel.background', - { dark: SIDE_BAR_BACKGROUND, light: editorBackground, hcDark: SIDE_BAR_BACKGROUND, hcLight: SIDE_BAR_BACKGROUND }, - localize('agentsPanel.background', 'Background color of the card panels (chat, files, terminal) in the agent sessions window.') -); - -export const agentsPanelForeground = registerColor( - 'agentsPanel.foreground', SIDE_BAR_FOREGROUND, - localize('agentsPanel.foreground', 'Foreground color of the card panels (chat, files, terminal) in the agent sessions window.') -); - -export const agentsPanelBorder = registerColor( - 'agentsPanel.border', - { dark: PANEL_BORDER, light: editorWidgetBorder, hcDark: contrastBorder, hcLight: contrastBorder }, - localize('agentsPanel.border', 'Border color of the card panels (chat, files, terminal) in the agent sessions window.') -); - -// ============================================================================ -// Gradient background tint -// ============================================================================ - -export const agentsGradientTintColor = registerColor( - 'agentsGradient.tintColor', buttonBackground, - localize('agentsGradient.tintColor', 'Tint color used in the background gradient of the agent sessions window shell.') -); - -// ============================================================================ -// Agent feedback input widget -// ============================================================================ - -export const agentFeedbackInputWidgetBorder = registerColor( - 'agentFeedbackInputWidget.border', - { dark: editorWidgetBorder, light: editorWidgetBorder, hcDark: contrastBorder, hcLight: contrastBorder }, - localize('agentFeedbackInputWidget.border', 'Border color of the agent feedback input widget shown in the editor.') -); - -// ============================================================================ -// Update button -// ============================================================================ - -export const agentsUpdateButtonDownloadingBackground = registerColor( - 'agentsUpdateButton.downloadingBackground', transparent(buttonBackground, 0.4), - localize('agentsUpdateButton.downloadingBackground', 'Background color of the update button to show download progress in the agent sessions window.') -); - -export const agentsUpdateButtonDownloadedBackground = registerColor( - 'agentsUpdateButton.downloadedBackground', transparent(buttonBackground, 0.7), - localize('agentsUpdateButton.downloadedBackground', 'Background color of the update button when download is complete in the agent sessions window.') -); - -// ============================================================================ -// Chat input -// ============================================================================ - -export const agentsChatInputBackground = registerColor( - 'agentsChatInput.background', inputBackground, - localize('agentsChatInput.background', 'Background color of the chat input field in the agent sessions window.') -); - -export const agentsChatInputForeground = registerColor( - 'agentsChatInput.foreground', inputForeground, - localize('agentsChatInput.foreground', 'Foreground color of the chat input field in the agent sessions window.') -); - -export const agentsChatInputBorder = registerColor( - 'agentsChatInput.border', inputBorder, - localize('agentsChatInput.border', 'Border color of the chat input field in the agent sessions window.') -); - -export const agentsChatInputFocusBorder = registerColor( - 'agentsChatInput.focusBorder', focusBorder, - localize('agentsChatInput.focusBorder', 'Border color of the chat input field when focused in the agent sessions window.') -); - -export const agentsChatInputPlaceholderForeground = registerColor( - 'agentsChatInput.placeholderForeground', inputPlaceholderForeground, - localize('agentsChatInput.placeholderForeground', 'Placeholder text color in the chat input field in the agent sessions window.') -); - -// ============================================================================ -// New session button -// ============================================================================ - -export const agentsNewSessionButtonBackground = registerColor( - 'agentsNewSessionButton.background', '#00000000', - localize('agentsNewSessionButton.background', 'Background color of the New Session button in the agent sessions sidebar.') -); - -export const agentsNewSessionButtonForeground = registerColor( - 'agentsNewSessionButton.foreground', SIDE_BAR_FOREGROUND, - localize('agentsNewSessionButton.foreground', 'Foreground color of the New Session button in the agent sessions sidebar.') -); - -export const agentsNewSessionButtonBorder = registerColor( - 'agentsNewSessionButton.border', buttonBorder, - localize('agentsNewSessionButton.border', 'Border color of the New Session button in the agent sessions sidebar.') -); - -export const agentsNewSessionButtonHoverBackground = registerColor( - 'agentsNewSessionButton.hoverBackground', toolbarHoverBackground, - localize('agentsNewSessionButton.hoverBackground', 'Background color of the New Session button when hovered in the agent sessions sidebar.') -); - -// ============================================================================ -// Badge -// ============================================================================ - -export const agentsBadgeBackground = registerColor( - 'agentsBadge.background', ACTIVITY_BAR_BADGE_BACKGROUND, - localize('agentsBadge.background', 'Background color of badges in the agent sessions window.') -); - -export const agentsBadgeForeground = registerColor( - 'agentsBadge.foreground', ACTIVITY_BAR_BADGE_FOREGROUND, - localize('agentsBadge.foreground', 'Foreground color of badges in the agent sessions window.') -); - -// ============================================================================ -// Unread session indicator -// ============================================================================ - -export const agentsUnreadBadgeBackground = registerColor( - 'agentsUnreadBadge.background', ACTIVITY_BAR_BADGE_BACKGROUND, - localize('agentsUnreadBadge.background', 'Background color of the unread sessions count badge on the sidebar toggle.') -); - -export const agentsUnreadBadgeForeground = registerColor( - 'agentsUnreadBadge.foreground', ACTIVITY_BAR_BADGE_FOREGROUND, - localize('agentsUnreadBadge.foreground', 'Foreground color of the unread sessions count badge on the sidebar toggle.') -); diff --git a/src/vs/sessions/common/welcome.ts b/src/vs/sessions/common/welcome.ts deleted file mode 100644 index a43463a2..00000000 --- a/src/vs/sessions/common/welcome.ts +++ /dev/null @@ -1,6 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -export const WELCOME_COMPLETE_KEY = 'workbench.agentsession.welcomeComplete'; diff --git a/src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts b/src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts deleted file mode 100644 index ba8cd08f..00000000 --- a/src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts +++ /dev/null @@ -1,949 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import '../../../browser/media/sidebarActionButton.css'; -import './media/accountWidget.css'; -import './media/accountTitleBarWidget.css'; -import '../../../../workbench/contrib/chat/browser/chatStatus/media/chatStatus.css'; -import Severity from '../../../../base/common/severity.js'; -import { Disposable, DisposableStore, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; -import { localize, localize2 } from '../../../../nls.js'; -import { Action2, MenuRegistry, registerAction2, IMenuService } from '../../../../platform/actions/common/actions.js'; -import { ContextKeyExpr, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; -import { IDefaultAccountService } from '../../../../platform/defaultAccount/common/defaultAccount.js'; -import { IInstantiationService, ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; -import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; -import { appendUpdateMenuItems as registerUpdateMenuItems } from '../../../../workbench/contrib/update/browser/update.js'; -import { Menus } from '../../../browser/menus.js'; -import { IActionViewItemService } from '../../../../platform/actions/browser/actionViewItemService.js'; -import { fillInActionBarActions } from '../../../../platform/actions/browser/menuEntryActionViewItem.js'; -import { $, addDisposableListener, append, disposableWindowInterval, EventType, getDomNodePagePosition } from '../../../../base/browser/dom.js'; -import { mainWindow } from '../../../../base/browser/window.js'; -import { BaseActionViewItem, IBaseActionViewItemOptions } from '../../../../base/browser/ui/actionbar/actionViewItems.js'; -import { IAction, Separator } from '../../../../base/common/actions.js'; -import { renderLabelWithIcons } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; -import { Codicon } from '../../../../base/common/codicons.js'; -import { IUpdateService, State, StateType } from '../../../../platform/update/common/update.js'; -import { IHoverService } from '../../../../platform/hover/browser/hover.js'; -import { IProductService } from '../../../../platform/product/common/productService.js'; -import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js'; -import { IHostService } from '../../../../workbench/services/host/browser/host.js'; -import { IOpenerService } from '../../../../platform/opener/common/opener.js'; -import { URI } from '../../../../base/common/uri.js'; -import { isWindows, isMacintosh } from '../../../../base/common/platform.js'; -import { UpdateHoverWidget } from './updateHoverWidget.js'; -import { ChatEntitlement, ChatEntitlementService, IChatEntitlementService } from '../../../../workbench/services/chat/common/chatEntitlementService.js'; -import { ChatStatusDashboard, IChatStatusDashboardOptions } from '../../../../workbench/contrib/chat/browser/chatStatus/chatStatusDashboard.js'; -import { HoverPosition } from '../../../../base/browser/ui/hover/hoverWidget.js'; -import { ThemeIcon } from '../../../../base/common/themables.js'; -import { getAccountProfileImageUrl, getAccountTitleBarBadgeKey, getAccountTitleBarState, resolveAccountInfo } from '../../../browser/accountTitleBarState.js'; -import { IsPhoneLayoutContext, SessionsWelcomeVisibleContext } from '../../../common/contextkeys.js'; -import { IsAuxiliaryWindowContext } from '../../../../workbench/common/contextkeys.js'; -import { IAuthenticationAccessService } from '../../../../workbench/services/authentication/browser/authenticationAccessService.js'; -import { IAuthenticationUsageService } from '../../../../workbench/services/authentication/browser/authenticationUsageService.js'; -import { IAuthenticationService } from '../../../../workbench/services/authentication/common/authentication.js'; -import { IChatDashboardService } from '../../../browser/chatDashboardService.js'; -import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; - -// --- Account Menu Items --- // -const AccountMenu = Menus.AccountMenu; -const SessionsTitleBarAccountWidgetAction = 'sessions.action.titleBarAccountWidget'; -const SessionsTitleBarUpdateWidgetAction = 'sessions.action.titleBarUpdateWidget'; -const SESSIONS_ACCOUNT_TITLEBAR_PANEL_WIDTH = 360; - -const PERSONALIZE_ACTION_IDS: readonly string[] = [ - 'workbench.action.openSettings', - 'workbench.action.openGlobalKeybindings', - 'workbench.action.selectTheme', -]; -const SIGN_OUT_ACTION_ID = 'workbench.action.agenticSignOut'; -const SIGN_IN_ACTION_ID = 'workbench.action.agenticSignIn'; - -function shouldHideSessionsTitleBarUpdateWidget(type: StateType): boolean { - return type === StateType.Uninitialized - || type === StateType.Idle - || type === StateType.Disabled - || type === StateType.CheckingForUpdates; -} - -function isPrimarySessionsTitleBarUpdateWidget(type: StateType): boolean { - return type === StateType.AvailableForDownload - || type === StateType.Downloaded - || type === StateType.Ready; -} - -function isBusySessionsTitleBarUpdateWidget(type: StateType): boolean { - return type === StateType.Downloading - || type === StateType.Overwriting - || type === StateType.Updating - || type === StateType.Restarting; -} - -function getSessionsTitleBarUpdateLabel(state: State): string { - switch (state.type) { - case StateType.AvailableForDownload: - return localize('sessionsTitleBarUpdateAvailable', "Update Available"); - case StateType.Downloaded: - return localize('sessionsTitleBarInstallUpdate', "Install Update"); - case StateType.Ready: - return localize('sessionsTitleBarRestartToUpdate', "Restart to Update"); - case StateType.Downloading: - case StateType.Overwriting: - return localize('sessionsTitleBarDownloading', "Downloading..."); - case StateType.Updating: - case StateType.Restarting: - return localize('sessionsTitleBarInstalling', "Installing..."); - default: - return localize('sessionsTitleBarUpdate', "Update"); - } -} - -function getSessionsTitleBarUpdateAriaLabel(state: State): string { - switch (state.type) { - case StateType.AvailableForDownload: - return localize('sessionsTitleBarUpdateAvailableAria', "Update available"); - case StateType.Downloaded: - return localize('sessionsTitleBarInstallUpdateAria', "Install downloaded update"); - case StateType.Ready: - return localize('sessionsTitleBarRestartToUpdateAria', "Restart to apply update"); - case StateType.Downloading: - case StateType.Overwriting: - return localize('sessionsTitleBarDownloadingAria', "Update download in progress"); - case StateType.Updating: - case StateType.Restarting: - return localize('sessionsTitleBarInstallingAria', "Update install in progress"); - default: - return localize('sessionsTitleBarUpdateAria', "Update"); - } -} - -async function runSessionsUpdateAction( - state: State, - updateService: IUpdateService, - openerService: IOpenerService, - productService: IProductService, - dialogService: IDialogService, - hostService: IHostService, -): Promise { - if (state.type === StateType.AvailableForDownload) { - const isInsiderOrExploration = productService.quality === 'insider' || productService.quality === 'exploration'; - const hasCrossAppCoordinator = (isWindows || isMacintosh) && isInsiderOrExploration; - if (!hasCrossAppCoordinator) { - const { confirmed } = await dialogService.confirm({ - message: localize('sessionsUpdateFromVSCode.title', "Update from VS Code"), - detail: localize('sessionsUpdateFromVSCode.detail', "This will close the Agents app and open VS Code so you can install the update.\n\nLaunch Agents again after the update is complete."), - primaryButton: localize('sessionsUpdateFromVSCode.open', "Close and Open VS Code"), - }); - - if (confirmed) { - await openerService.open(URI.from({ - scheme: productService.urlProtocol, - query: 'windowId=_blank', - }), { openExternal: true }); - await hostService.shutdown(); - } - - return; - } - - await updateService.downloadUpdate(true); - return; - } - - if (state.type === StateType.Ready) { - await updateService.quitAndInstall(); - return; - } - - if (state.type === StateType.Downloaded) { - await updateService.applyUpdate(); - } -} - -// Sign In (shown when signed out) -registerAction2(class extends Action2 { - constructor() { - super({ - id: 'workbench.action.agenticSignIn', - title: localize2('signIn', 'Sign In'), - menu: { - id: AccountMenu, - when: ContextKeyExpr.notEquals('defaultAccountStatus', 'available'), - group: '1_account', - order: 1, - } - }); - } - async run(accessor: ServicesAccessor): Promise { - const defaultAccountService = accessor.get(IDefaultAccountService); - await defaultAccountService.signIn(); - } -}); - -// Sign Out (shown when signed in) -registerAction2(class extends Action2 { - constructor() { - super({ - id: 'workbench.action.agenticSignOut', - title: localize2('signOut', 'Sign Out'), - menu: { - id: AccountMenu, - when: ContextKeyExpr.equals('defaultAccountStatus', 'available'), - group: '1_account', - order: 1, - } - }); - } - async run(accessor: ServicesAccessor): Promise { - const defaultAccountService = accessor.get(IDefaultAccountService); - const dialogService = accessor.get(IDialogService); - const authenticationService = accessor.get(IAuthenticationService); - const authenticationUsageService = accessor.get(IAuthenticationUsageService); - const authenticationAccessService = accessor.get(IAuthenticationAccessService); - const defaultAccount = await defaultAccountService.getDefaultAccount(); - if (!defaultAccount) { - return; - } - - const providerId = defaultAccount.authenticationProvider.id; - const accountLabel = defaultAccount.accountName; - const { confirmed } = await dialogService.confirm({ - type: Severity.Info, - message: localize('agenticSignOutMessage', "Sign out of the Agents app?"), - detail: localize('agenticSignOutDetail', "This will sign out '{0}' from the Agents app.", accountLabel), - primaryButton: localize({ key: 'agenticSignOutButton', comment: ['&& denotes a mnemonic'] }, "&&Sign Out") - }); - - if (!confirmed) { - return; - } - - const allSessions = await authenticationService.getSessions(providerId); - const sessions = allSessions.filter(session => session.account.label === accountLabel); - await Promise.all(sessions.map(session => authenticationService.removeSession(providerId, session.id))); - authenticationUsageService.removeAccountUsage(providerId, accountLabel); - authenticationAccessService.removeAllowedExtensions(providerId, accountLabel); - } -}); - -// Color Theme (hidden on phone — no theme picker UI on mobile) -MenuRegistry.appendMenuItem(AccountMenu, { - command: { - id: 'workbench.action.selectTheme', - title: localize('selectColorTheme', "Color Theme"), - }, - when: IsPhoneLayoutContext.negate(), - group: '2_settings', - order: 1, -}); - -// Settings (hidden on phone — no settings UI on mobile) -MenuRegistry.appendMenuItem(AccountMenu, { - command: { - id: 'workbench.action.openSettings', - title: localize('settings', "Settings"), - }, - when: IsPhoneLayoutContext.negate(), - group: '2_settings', - order: 2, -}); - -// Keyboard Shortcuts (hidden on phone — no keybindings UI on mobile) -MenuRegistry.appendMenuItem(AccountMenu, { - command: { - id: 'workbench.action.openGlobalKeybindings', - title: localize('sessionsAccountMenu.keyboardShortcuts', "Keyboard Shortcuts"), - }, - when: IsPhoneLayoutContext.negate(), - group: '2_settings', - order: 3, -}); - -// Update actions -registerUpdateMenuItems(AccountMenu, '3_updates'); - -class TitleBarAccountWidget extends BaseActionViewItem { - - private container: HTMLElement | undefined; - private avatarElement: HTMLImageElement | undefined; - private iconElement: HTMLElement | undefined; - private labelElement: HTMLElement | undefined; - private badgeElement: HTMLElement | undefined; - private accountName: string | undefined; - private accountProviderId: string | undefined; - private accountProviderLabel: string | undefined; - private isAccountLoading = true; - private accountRequestCounter = 0; - private avatarRequestCounter = 0; - private currentAvatarUrl: string | undefined; - private loadedAvatarUrl: string | undefined; - private lastState: ReturnType; - private isMenuVisible = false; - private lastBadgeKey: string | undefined; - private dismissedBadgeKey: string | undefined; - private readonly copilotDashboardStore = this._register(new MutableDisposable()); - private readonly clickPanelDisposable = this._register(new MutableDisposable()); - private readonly avatarLoadDisposable = this._register(new MutableDisposable()); - - constructor( - action: IAction, - options: IBaseActionViewItemOptions | undefined, - @IDefaultAccountService private readonly defaultAccountService: IDefaultAccountService, - @IAuthenticationService private readonly authenticationService: IAuthenticationService, - @IMenuService private readonly menuService: IMenuService, - @IContextKeyService private readonly contextKeyService: IContextKeyService, - @IHoverService private readonly hoverService: IHoverService, - @IInstantiationService private readonly instantiationService: IInstantiationService, - @IChatEntitlementService private readonly chatEntitlementService: ChatEntitlementService, - ) { - super(undefined, action, options); - this.lastState = getAccountTitleBarState({ - isAccountLoading: true, - entitlement: this.chatEntitlementService.entitlement, - sentiment: this.chatEntitlementService.sentiment, - quotas: this.chatEntitlementService.quotas, - }); - - this._register(this.defaultAccountService.onDidChangeDefaultAccount(() => this.refreshAccount())); - this._register(this.authenticationService.onDidChangeSessions(() => this.refreshAccount())); - this._register(this.chatEntitlementService.onDidChangeEntitlement(() => this.renderState())); - this._register(this.chatEntitlementService.onDidChangeSentiment(() => this.renderState())); - this._register(this.chatEntitlementService.onDidChangeQuotaExceeded(() => this.renderState())); - this._register(this.chatEntitlementService.onDidChangeQuotaRemaining(() => this.renderState())); - this.refreshAccount(); - } - - override setFocusable(_focusable: boolean): void { - // Don't let the ActionBar remove focusability - this widget must - // always be reachable via Tab even when a sibling item is hidden. - } - - override render(container: HTMLElement): void { - super.render(container); - - this.container = container; - container.classList.add('sessions-account-titlebar-widget'); - container.setAttribute('role', 'button'); - container.tabIndex = 0; - - this.avatarElement = append(container, $('img.sessions-account-titlebar-widget-avatar', { alt: localize('accountAvatarAltFallback', "Account profile image"), draggable: 'false' })) as HTMLImageElement; - this.avatarElement.decoding = 'async'; - this.avatarElement.referrerPolicy = 'no-referrer'; - this.iconElement = append(container, $('.sessions-account-titlebar-widget-icon')); - this.labelElement = append(container, $('span.sessions-account-titlebar-widget-label')); - this.badgeElement = append(container, $('span.sessions-account-titlebar-widget-badge')); - - this.renderState(); - } - - override onClick(): void { - if (!this.container) { - return; - } - - this.showCombinedPanel(); - } - - private async refreshAccount(): Promise { - const requestId = ++this.accountRequestCounter; - this.isAccountLoading = true; - this.renderState(); - - const info = await resolveAccountInfo(this.defaultAccountService, this.authenticationService); - if (requestId !== this.accountRequestCounter) { - return; - } - - this.accountName = info?.accountName; - this.accountProviderId = info?.accountProviderId; - this.accountProviderLabel = info?.accountProviderLabel; - this.isAccountLoading = false; - this.refreshAvatar(); - this.renderState(); - } - - private renderState(): void { - if (!this.container || !this.avatarElement || !this.iconElement || !this.labelElement || !this.badgeElement) { - return; - } - - // When we have a session but entitlement hasn't resolved yet, - // treat as Unresolved to avoid showing "Agents Signed Out". - const entitlement = this.accountName && this.chatEntitlementService.entitlement === ChatEntitlement.Unknown - ? ChatEntitlement.Unresolved - : this.chatEntitlementService.entitlement; - - const state = getAccountTitleBarState({ - isAccountLoading: this.isAccountLoading, - accountName: this.accountName, - accountProviderLabel: this.accountProviderLabel, - entitlement, - sentiment: this.chatEntitlementService.sentiment, - quotas: this.chatEntitlementService.quotas, - }); - this.lastState = state; - - this.container.classList.remove('kind-default', 'kind-accent', 'kind-warning', 'kind-prominent'); - this.container.classList.add(`kind-${state.kind}`); - this.container.classList.toggle('menu-visible', this.isMenuVisible); - this.container.setAttribute('aria-label', state.ariaLabel); - - const badgeKey = getAccountTitleBarBadgeKey(state); - if (badgeKey !== this.lastBadgeKey) { - this.lastBadgeKey = badgeKey; - this.dismissedBadgeKey = undefined; - } - - const shouldShowDotBadge = !!badgeKey && badgeKey !== this.dismissedBadgeKey; - const loadedAvatarUrl = !this.isAccountLoading ? this.loadedAvatarUrl : undefined; - const hasLoadedAvatar = !!loadedAvatarUrl; - const titleBarIcon = state.dotBadge ? Codicon.account : state.icon; - - this.avatarElement.classList.toggle('visible', hasLoadedAvatar); - this.avatarElement.alt = this.getAvatarAltText(hasLoadedAvatar); - if (hasLoadedAvatar) { - if (this.avatarElement.src !== loadedAvatarUrl) { - this.avatarElement.src = loadedAvatarUrl; - } - } else { - this.avatarElement.removeAttribute('src'); - } - - this.iconElement.className = `sessions-account-titlebar-widget-icon ${ThemeIcon.asClassName(titleBarIcon)}`; - this.iconElement.classList.toggle('hidden', hasLoadedAvatar); - this.labelElement.textContent = ''; - this.badgeElement.textContent = ''; - this.badgeElement.classList.toggle('dot-badge', shouldShowDotBadge); - this.badgeElement.classList.toggle('dot-badge-warning', shouldShowDotBadge && state.dotBadge === 'warning'); - this.badgeElement.classList.toggle('dot-badge-error', shouldShowDotBadge && state.dotBadge === 'error'); - this.badgeElement.style.display = shouldShowDotBadge ? '' : 'none'; - } - - private getAvatarAltText(hasLoadedAvatar: boolean): string { - if (hasLoadedAvatar && this.accountProviderId === 'github' && this.accountName) { - return localize('accountAvatarAlt', "GitHub profile image for {0}", this.accountName); - } - - return localize('accountAvatarAltFallback', "Account profile image"); - } - - private refreshAvatar(): void { - const avatarUrl = getAccountProfileImageUrl(this.accountProviderId, this.accountName); - if (avatarUrl === this.currentAvatarUrl) { - return; - } - - this.currentAvatarUrl = avatarUrl; - this.loadedAvatarUrl = undefined; - this.avatarLoadDisposable.clear(); - const requestId = ++this.avatarRequestCounter; - - if (!avatarUrl) { - this.renderState(); - return; - } - - const image = new Image(); - image.referrerPolicy = 'no-referrer'; - const clearHandlers = () => { - image.onload = null; - image.onerror = null; - }; - image.onload = () => { - if (requestId !== this.avatarRequestCounter) { - return; - } - - this.loadedAvatarUrl = avatarUrl; - this.renderState(); - clearHandlers(); - }; - image.onerror = () => { - if (requestId !== this.avatarRequestCounter) { - return; - } - - this.loadedAvatarUrl = undefined; - this.renderState(); - clearHandlers(); - }; - this.avatarLoadDisposable.value = toDisposable(() => { - clearHandlers(); - image.src = ''; - }); - image.src = avatarUrl; - this.renderState(); - } - - private getHoverTarget(): { targetElements: HTMLElement[]; x: number } { - const { left, width } = getDomNodePagePosition(this.container!); - return { - targetElements: [this.container!], - x: left + width - SESSIONS_ACCOUNT_TITLEBAR_PANEL_WIDTH, - }; - } - - private showCombinedPanel(): void { - if (!this.container) { - return; - } - - if (this.isMenuVisible) { - this.hoverService.hideHover(true); - this.clickPanelDisposable.clear(); - return; - } - - this.hoverService.hideHover(true); - this.clickPanelDisposable.clear(); - - const panelStore = new DisposableStore(); - this.clickPanelDisposable.value = panelStore; - - const badgeKey = getAccountTitleBarBadgeKey(this.lastState); - if (badgeKey) { - this.dismissedBadgeKey = badgeKey; - } - - this.isMenuVisible = true; - this.container.classList.add('menu-visible'); - this.renderState(); - - panelStore.add({ - dispose: () => { - this.isMenuVisible = false; - this.container?.classList.remove('menu-visible'); - this.renderState(); - this.container?.focus(); - } - }); - - const panelContent = this.createCombinedPanelContent(panelStore); - const hoverWidget = this.hoverService.showInstantHover({ - content: panelContent, - target: this.getHoverTarget(), - additionalClasses: ['sessions-account-titlebar-panel-hover'], - position: { hoverPosition: HoverPosition.BELOW }, - persistence: { sticky: true, hideOnHover: false }, - appearance: { showPointer: false, skipFadeInAnimation: true, maxHeightRatio: 0.8 }, - }, true); - - if (hoverWidget) { - panelStore.add(hoverWidget); - } - - panelStore.add(disposableWindowInterval(mainWindow, () => { - if (!panelContent.isConnected || hoverWidget?.isDisposed) { - this.clickPanelDisposable.clear(); - } - }, 500)); - } - - private createCombinedPanelContent(panelStore: DisposableStore): HTMLElement { - const panel = $('div.sessions-account-titlebar-panel'); - - // Build the menu actions once and partition them. - const menu = this.menuService.createMenu(AccountMenu, this.contextKeyService); - const rawActions: IAction[] = []; - fillInActionBarActions(menu.getActions(), rawActions); - menu.dispose(); - const partitioned = this.partitionMenuActions(rawActions); - - // Header: account label + sign-out icon. - const headerSection = append(panel, $('.sessions-account-titlebar-panel-header')); - const loadedAvatarUrl = !this.isAccountLoading ? this.loadedAvatarUrl : undefined; - if (loadedAvatarUrl) { - const avatar = append(headerSection, $('img.sessions-account-titlebar-panel-avatar', { - alt: this.getAvatarAltText(true), - draggable: 'false', - src: loadedAvatarUrl, - })) as HTMLImageElement; - avatar.decoding = 'async'; - avatar.referrerPolicy = 'no-referrer'; - } - const title = append(headerSection, $('div.sessions-account-titlebar-panel-title')); - title.textContent = this.getPanelHeaderLabel(); - if (partitioned.signOut) { - const headerActionsContainer = append(headerSection, $('.sessions-account-titlebar-panel-header-actions')); - this.createPanelButton(headerActionsContainer, partitioned.signOut, panelStore, { - classNames: ['sessions-account-titlebar-panel-header-action'], - icon: this.getHeaderActionIcon(partitioned.signOut), - }); - } - - // Personalize section. - if (partitioned.personalize.length > 0) { - const personalizeId = 'sessions-account-personalize-title'; - const personalizeSection = append(panel, $('section.sessions-account-titlebar-panel-section', { 'aria-labelledby': personalizeId })); - const personalizeHeading = append(personalizeSection, $('div.sessions-account-titlebar-panel-section-title', { id: personalizeId })); - personalizeHeading.textContent = localize('sessionsAccountMenu.personalize', "Personalize"); - const personalizeActionsContainer = append(personalizeSection, $('.sessions-account-titlebar-panel-actions')); - for (const action of partitioned.personalize) { - this.createPanelButton(personalizeActionsContainer, action, panelStore, { - classNames: ['sessions-account-titlebar-panel-action', 'with-icon'], - icon: this.getPersonalizeActionIcon(action), - includeLabel: true, - }); - } - } - - // Other panel actions (sign-in, etc.) — only render if there's at least one non-separator action. - if (partitioned.other.some(a => !(a instanceof Separator))) { - const actionsSection = append(panel, $('.sessions-account-titlebar-panel-actions')); - let lastWasSeparator = true; - for (const action of partitioned.other) { - if (action instanceof Separator) { - if (!lastWasSeparator) { - append(actionsSection, $('.sessions-account-titlebar-panel-separator')); - lastWasSeparator = true; - } - continue; - } - lastWasSeparator = false; - this.createPanelButton(actionsSection, action, panelStore, { - classNames: ['sessions-account-titlebar-panel-action'], - includeLabel: true, - checked: !!action.checked, - }); - } - } - - // Subscription / Copilot dashboard. - const contentSection = append(panel, $('.sessions-account-titlebar-panel-content')); - if (this.shouldShowCopilotDashboardHover()) { - const subscriptionId = 'sessions-account-subscription-title'; - const subscriptionSection = append(contentSection, $('section.sessions-account-titlebar-panel-section.subscription', { 'aria-labelledby': subscriptionId })); - const subscriptionHeader = append(subscriptionSection, $('.sessions-account-titlebar-panel-section-header')); - const subscriptionHeading = append(subscriptionHeader, $('div.sessions-account-titlebar-panel-section-title', { id: subscriptionId })); - subscriptionHeading.textContent = localize('sessionsAccountMenu.subscription', "Subscription"); - // Render the dashboard's title header (plan name + manage / CTA actions) - // directly into our section header row via the dashboard's public API. - const dashboard = this.createCopilotHoverContent({ titleHeaderContainer: subscriptionHeader }); - append(subscriptionSection, dashboard); - } else if (!this.isAccountLoading) { - const summary = append(contentSection, $('.sessions-account-titlebar-panel-summary')); - summary.textContent = this.lastState.ariaLabel; - } - - return panel; - } - - private partitionMenuActions(rawActions: IAction[]): { signOut: IAction | undefined; personalize: IAction[]; other: IAction[] } { - let signOut: IAction | undefined; - const personalizeMap = new Map(); - const other: IAction[] = []; - - const pushSeparator = () => { - // Collapse runs and skip leading separators so groups whose only - // items get filtered (e.g. update.*) don't leave orphans behind. - if (other.length === 0 || other[other.length - 1] instanceof Separator) { - return; - } - other.push(new Separator()); - }; - - for (const action of rawActions) { - if (action instanceof Separator) { - pushSeparator(); - continue; - } - if (action.id === SIGN_OUT_ACTION_ID) { - signOut = action; - continue; - } - if (PERSONALIZE_ACTION_IDS.includes(action.id)) { - personalizeMap.set(action.id, action); - continue; - } - if (action.id.startsWith('update.')) { - continue; - } - if (this.isAccountLoading && action.id === SIGN_IN_ACTION_ID) { - continue; - } - other.push(action); - } - - // Trim trailing separator left after filtering. - if (other.length > 0 && other[other.length - 1] instanceof Separator) { - other.pop(); - } - - // Preserve canonical personalize order. - const personalize = PERSONALIZE_ACTION_IDS - .map(id => personalizeMap.get(id)) - .filter((a): a is IAction => !!a); - - return { signOut, personalize, other }; - } - - private createPanelButton( - parent: HTMLElement, - action: IAction, - panelStore: DisposableStore, - options: { classNames: readonly string[]; icon?: ThemeIcon; includeLabel?: boolean; checked?: boolean }, - ): HTMLButtonElement { - const button = append(parent, $('button', { type: 'button' })) as HTMLButtonElement; - button.classList.add(...options.classNames); - button.disabled = !action.enabled; - button.setAttribute('aria-label', action.tooltip || action.label); - if (options.checked) { - button.classList.add('checked'); - } - - if (options.icon && options.includeLabel) { - const iconElement = append(button, $('span.sessions-account-titlebar-panel-action-icon')); - iconElement.classList.add(...ThemeIcon.asClassNameArray(options.icon)); - const labelElement = append(button, $('span.sessions-account-titlebar-panel-action-label')); - append(labelElement, ...renderLabelWithIcons(action.label)); - } else if (options.icon) { - button.title = action.tooltip || action.label; - button.classList.add(...ThemeIcon.asClassNameArray(options.icon)); - } else { - append(button, ...renderLabelWithIcons(action.label)); - } - - panelStore.add(addDisposableListener(button, EventType.CLICK, async event => { - event.preventDefault(); - event.stopPropagation(); - this.hoverService.hideHover(true); - this.clickPanelDisposable.clear(); - await Promise.resolve(action.run()); - })); - - return button; - } - - private getPanelHeaderLabel(): string { - if (this.accountName) { - return this.accountName; - } - - if (this.isAccountLoading) { - return localize('loadingAccountHeader', "Loading Account..."); - } - - return localize('accountMenuHeaderFallback', "Account"); - } - - private getHeaderActionIcon(action: IAction): ThemeIcon { - switch (action.id) { - case 'workbench.action.selectTheme': - return Codicon.symbolColor; - case 'workbench.action.openSettings': - return Codicon.settingsGear; - case SIGN_OUT_ACTION_ID: - return Codicon.signOut; - default: - return Codicon.circleLargeFilled; - } - } - - private getPersonalizeActionIcon(action: IAction): ThemeIcon { - switch (action.id) { - case 'workbench.action.openSettings': - return Codicon.settingsGear; - case 'workbench.action.openGlobalKeybindings': - return Codicon.keyboard; - case 'workbench.action.selectTheme': - return Codicon.symbolColor; - default: - return Codicon.circleLargeFilled; - } - } - - private shouldShowCopilotDashboardHover(): boolean { - return !this.chatEntitlementService.sentiment.hidden && !!this.accountName; - } - - private createCopilotHoverContent(extraOptions?: Partial): HTMLElement { - const store = new DisposableStore(); - this.copilotDashboardStore.value = store; - const dashboardElement = ChatStatusDashboard.instantiateInContents(this.instantiationService, store, { - disableInlineSuggestionsSettings: true, - disableModelSelection: true, - disableProviderOptions: true, - disableCompletionsSnooze: true, - disableQuickSettingsCollapsible: true, - disableContributedSectionsCollapsible: true, - ...extraOptions, - }); - - store.add(disposableWindowInterval(mainWindow, () => { - if (!dashboardElement.isConnected) { - store.dispose(); - } - }, 2000)); - - return dashboardElement; - } -} - -class TitleBarUpdateWidget extends BaseActionViewItem { - - private container: HTMLElement | undefined; - private labelElement: HTMLElement | undefined; - private readonly updateHoverWidget: UpdateHoverWidget; - private readonly hoverAttachment = this._register(new MutableDisposable()); - - constructor( - action: IAction, - options: IBaseActionViewItemOptions | undefined, - @IUpdateService private readonly updateService: IUpdateService, - @IHoverService private readonly hoverService: IHoverService, - @IProductService private readonly productService: IProductService, - @IOpenerService private readonly openerService: IOpenerService, - @IDialogService private readonly dialogService: IDialogService, - @IHostService private readonly hostService: IHostService, - ) { - super(undefined, action, options); - this.updateHoverWidget = new UpdateHoverWidget(this.updateService, this.productService, this.hoverService); - this._register(this.updateService.onStateChange(() => this.renderState())); - } - - override render(container: HTMLElement): void { - super.render(container); - - this.container = container; - container.classList.add('sessions-update-titlebar-widget'); - container.setAttribute('role', 'button'); - - this.labelElement = append(container, $('span.sessions-update-titlebar-widget-label')); - this.hoverAttachment.value = this.updateHoverWidget.attachTo(container); - - this.renderState(); - } - - override onClick(): void { - const state = this.updateService.state; - if (shouldHideSessionsTitleBarUpdateWidget(state.type) || isBusySessionsTitleBarUpdateWidget(state.type)) { - return; - } - - void runSessionsUpdateAction( - state, - this.updateService, - this.openerService, - this.productService, - this.dialogService, - this.hostService, - ); - } - - private renderState(): void { - if (!this.container || !this.labelElement) { - return; - } - - const state = this.updateService.state; - const hidden = shouldHideSessionsTitleBarUpdateWidget(state.type); - const busy = isBusySessionsTitleBarUpdateWidget(state.type); - const primary = isPrimarySessionsTitleBarUpdateWidget(state.type); - - this.container.classList.toggle('hidden', hidden); - this.container.classList.toggle('disabled', busy); - this.container.classList.toggle('primary-state', primary); - this.container.classList.toggle('busy-state', busy); - - if (hidden) { - this.container.removeAttribute('aria-label'); - this.labelElement.textContent = ''; - return; - } - - this.container.setAttribute('aria-label', getSessionsTitleBarUpdateAriaLabel(state)); - this.labelElement.textContent = getSessionsTitleBarUpdateLabel(state); - } -} - -// --- Register custom view item --- // - -// Actions registered at module level so Menus.TitleBarRightLayout is non-empty when the -// toolbar is first constructed. The run() is a no-op — rendering is handled by the custom -// view items registered in AccountWidgetContribution. -registerAction2(class extends Action2 { - constructor() { - super({ - id: SessionsTitleBarUpdateWidgetAction, - title: localize2('agentsUpdateTitleBar', "Agents Update"), - menu: { - id: Menus.TitleBarRightLayout, - group: 'navigation', - order: 99, - when: ContextKeyExpr.and(IsAuxiliaryWindowContext.toNegated(), SessionsWelcomeVisibleContext.toNegated()), - } - }); - } - - run(): void { } -}); - -registerAction2(class extends Action2 { - constructor() { - super({ - id: SessionsTitleBarAccountWidgetAction, - title: localize2('agentsAccountStatusTitleBar', "Agents Account and Status"), - menu: { - id: Menus.TitleBarRightLayout, - group: 'navigation', - order: 100, - when: IsAuxiliaryWindowContext.toNegated(), - } - }); - } - - run(): void { } -}); - -class AccountWidgetContribution extends Disposable implements IWorkbenchContribution { - - static readonly ID = 'workbench.contrib.sessionsWidget'; - - constructor( - @IActionViewItemService actionViewItemService: IActionViewItemService, - @IInstantiationService instantiationService: IInstantiationService, - ) { - super(); - - this._register(actionViewItemService.register(Menus.TitleBarRightLayout, SessionsTitleBarUpdateWidgetAction, (action, options) => { - return instantiationService.createInstance(TitleBarUpdateWidget, action, options); - }, undefined)); - - this._register(actionViewItemService.register(Menus.TitleBarRightLayout, SessionsTitleBarAccountWidgetAction, (action, options) => { - return instantiationService.createInstance(TitleBarAccountWidget, action, options); - }, undefined)); - } -} - -registerWorkbenchContribution2(AccountWidgetContribution.ID, AccountWidgetContribution, WorkbenchPhase.BlockRestore); - -// --- Chat Dashboard Service (real implementation for mobile account sheet) --- // - -class ChatDashboardServiceImpl implements IChatDashboardService { - readonly _serviceBrand: undefined; - - constructor( - @IInstantiationService private readonly instantiationService: IInstantiationService, - ) { } - - createDashboardElement(store: DisposableStore): HTMLElement | undefined { - const dashboardElement = ChatStatusDashboard.instantiateInContents(this.instantiationService, store, { - disableInlineSuggestionsSettings: true, - disableModelSelection: true, - disableProviderOptions: true, - disableCompletionsSnooze: true, - }); - - store.add(disposableWindowInterval(mainWindow, () => { - if (!dashboardElement.isConnected) { - store.dispose(); - } - }, 2000)); - - return dashboardElement; - } -} - -registerSingleton(IChatDashboardService, ChatDashboardServiceImpl, InstantiationType.Delayed); diff --git a/src/vs/sessions/contrib/accountMenu/browser/media/accountTitleBarWidget.css b/src/vs/sessions/contrib/accountMenu/browser/media/accountTitleBarWidget.css deleted file mode 100644 index a288ee7a..00000000 --- a/src/vs/sessions/contrib/accountMenu/browser/media/accountTitleBarWidget.css +++ /dev/null @@ -1,584 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -.agent-sessions-workbench .sessions-account-titlebar-widget { - display: flex; - align-items: center; - justify-content: center; - gap: 0; - position: relative; - height: 22px; - width: 22px; - min-width: 22px; - background: transparent; - border-radius: var(--vscode-cornerRadius-medium); - color: inherit; - cursor: pointer; - -webkit-app-region: no-drag; - overflow: hidden; -} - -.agent-sessions-workbench .sessions-account-titlebar-widget:hover, -.agent-sessions-workbench .action-item:focus-within .sessions-account-titlebar-widget { - background: var(--vscode-toolbar-hoverBackground); -} - -.agent-sessions-workbench .sessions-account-titlebar-widget:active, -.agent-sessions-workbench .sessions-account-titlebar-widget.menu-visible, -.agent-sessions-workbench .action-item:focus-within .sessions-account-titlebar-widget.menu-visible { - background: var(--vscode-toolbar-activeBackground); -} - -.agent-sessions-workbench .action-item:focus-within .sessions-account-titlebar-widget { - outline: 1px solid var(--vscode-focusBorder); - outline-offset: -1px; -} - -.agent-sessions-workbench .sessions-account-titlebar-widget-icon { - flex: 0 0 auto; - font-size: 16px; - color: inherit; -} - -.agent-sessions-workbench .sessions-account-titlebar-widget-icon.hidden { - display: none; -} - -.agent-sessions-workbench .sessions-account-titlebar-widget-avatar { - display: none; - flex: 0 0 auto; - width: 18px; - height: 18px; - border: 1px solid var(--vscode-commandCenter-border, transparent); - border-radius: 50%; - box-sizing: border-box; - object-fit: cover; -} - -.agent-sessions-workbench .sessions-account-titlebar-widget-avatar.visible { - display: block; -} - -.monaco-workbench .part.titlebar.inactive .sessions-account-titlebar-widget-avatar { - border-color: var(--vscode-commandCenter-inactiveBorder, var(--vscode-commandCenter-border, transparent)); -} - -.agent-sessions-workbench .sessions-account-titlebar-widget-label { - display: none; -} - -/* Update widget in titlebar */ - -.monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-right > .titlebar-right-layout-container .monaco-action-bar .actions-container { - gap: 5px; -} - -.monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-right > .titlebar-right-layout-container .monaco-action-bar .action-item.sessions-update-titlebar-widget { - display: flex; - align-items: center; - height: 22px; - min-width: 0; - padding: 0 8px; - border-radius: var(--vscode-cornerRadius-medium); - background: transparent; - color: var(--vscode-foreground); - cursor: pointer; - -webkit-app-region: no-drag; - overflow: hidden; -} - -.monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-right > .titlebar-right-layout-container .monaco-action-bar .action-item.sessions-update-titlebar-widget.hidden { - display: none; -} - -.monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-right > .titlebar-right-layout-container .monaco-action-bar .action-item.sessions-update-titlebar-widget:hover, -.monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-right > .titlebar-right-layout-container .monaco-action-bar .action-item.sessions-update-titlebar-widget:focus-within { - background: var(--vscode-toolbar-hoverBackground); -} - -.monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-right > .titlebar-right-layout-container .monaco-action-bar .action-item.sessions-update-titlebar-widget:active, -.monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-right > .titlebar-right-layout-container .monaco-action-bar .action-item.sessions-update-titlebar-widget.primary-state { - background: var(--vscode-button-background); - color: var(--vscode-button-foreground); -} - -.monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-right > .titlebar-right-layout-container .monaco-action-bar .action-item.sessions-update-titlebar-widget.primary-state:hover, -.monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-right > .titlebar-right-layout-container .monaco-action-bar .action-item.sessions-update-titlebar-widget.primary-state:focus-within, -.monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-right > .titlebar-right-layout-container .monaco-action-bar .action-item.sessions-update-titlebar-widget.primary-state:active { - background: var(--vscode-button-hoverBackground); - color: var(--vscode-button-foreground); -} - -.monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-right > .titlebar-right-layout-container .monaco-action-bar .action-item.sessions-update-titlebar-widget.busy-state { - cursor: default; - color: var(--vscode-descriptionForeground); -} - -.monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-right > .titlebar-right-layout-container .monaco-action-bar .action-item.sessions-update-titlebar-widget .sessions-update-titlebar-widget-label { - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - font-size: 12px; - font-weight: 500; - line-height: 16px; - color: inherit; -} - -/* Badge on account icon */ - -.agent-sessions-workbench .sessions-account-titlebar-widget-badge { - flex: 0 0 auto; - display: inline-flex; - align-items: center; - justify-content: center; - min-width: 18px; - height: 16px; - padding: 0 5px; - border-radius: 8px; - background: var(--vscode-badge-background); - color: var(--vscode-badge-foreground); - font-size: 10px; - font-weight: 700; - font-variant-numeric: tabular-nums; - line-height: 1; -} - -.agent-sessions-workbench .sessions-account-titlebar-widget-badge.dot-badge { - position: absolute; - top: 3px; - right: 3px; - min-width: 0; - width: 6px; - height: 6px; - padding: 0; - border-radius: 50%; - border: 1px solid var(--vscode-titleBar-activeBackground); - background: var(--vscode-editorWarning-foreground); - color: transparent; - pointer-events: none; -} - -.agent-sessions-workbench .sessions-account-titlebar-widget-badge.dot-badge-warning { - background: var(--vscode-editorWarning-foreground) !important; -} - -.agent-sessions-workbench .sessions-account-titlebar-widget-badge.dot-badge-error { - background: var(--vscode-editorError-foreground) !important; -} - -/* Kind-based styling */ - -.agent-sessions-workbench .sessions-account-titlebar-widget.kind-warning .sessions-account-titlebar-widget-icon, -.agent-sessions-workbench .sessions-account-titlebar-widget.kind-warning .sessions-account-titlebar-widget-label { - color: var(--vscode-statusBarItem-warningForeground); -} - -.agent-sessions-workbench .sessions-account-titlebar-widget.kind-prominent .sessions-account-titlebar-widget-badge:not(.dot-badge), -.agent-sessions-workbench .sessions-account-titlebar-widget.kind-warning .sessions-account-titlebar-widget-badge:not(.dot-badge) { - background: var(--vscode-activityBarBadge-background); - color: var(--vscode-activityBarBadge-foreground); -} - -.agent-sessions-workbench .sessions-account-titlebar-widget.kind-accent .sessions-account-titlebar-widget-badge:not(.dot-badge) { - background: var(--vscode-button-background); - color: var(--vscode-button-foreground); -} - -/* Panel (hover popup) */ - -.agent-sessions-workbench .sessions-account-titlebar-panel { - display: flex; - flex-direction: column; - width: 360px; - max-width: 360px; - color: var(--vscode-foreground); -} - -.agent-sessions-workbench .sessions-account-titlebar-panel-header { - display: flex; - align-items: center; - gap: 6px; - min-height: 28px; - padding: 4px 8px; - border-bottom: 1px solid var(--vscode-menu-separatorBackground, var(--vscode-disabledForeground)); -} - -.agent-sessions-workbench .sessions-account-titlebar-panel-avatar { - flex: 0 0 auto; - width: 20px; - height: 20px; - border-radius: 50%; - object-fit: cover; - border: 1px solid var(--vscode-commandCenter-border, var(--vscode-contrastBorder, transparent)); -} - -.agent-sessions-workbench .sessions-account-titlebar-panel-title { - flex: 1 1 auto; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - font-size: 12px; - font-weight: 500; - line-height: 18px; -} - -.agent-sessions-workbench .sessions-account-titlebar-panel-header-actions { - display: flex; - align-items: center; - justify-content: flex-end; - gap: 2px; - margin-left: auto; -} - -.agent-sessions-workbench .sessions-account-titlebar-panel-header-action { - display: inline-flex; - align-items: center; - justify-content: center; - width: 22px; - height: 22px; - padding: 0; - border: none; - border-radius: var(--vscode-cornerRadius-medium); - background: transparent; - color: var(--vscode-icon-foreground, var(--vscode-descriptionForeground)); - cursor: default; - font-size: 14px !important; -} - -.agent-sessions-workbench .sessions-account-titlebar-panel-header-action:hover { - background: var(--vscode-toolbar-hoverBackground); - color: var(--vscode-foreground); -} - -.agent-sessions-workbench .sessions-account-titlebar-panel-header-action:active { - background: var(--vscode-toolbar-activeBackground); - color: var(--vscode-foreground); -} - -.agent-sessions-workbench .sessions-account-titlebar-panel-header-action:focus-visible { - outline: 1px solid var(--vscode-focusBorder); - outline-offset: -1px; - background: var(--vscode-list-focusBackground); - color: var(--vscode-list-focusForeground); -} - -.agent-sessions-workbench .sessions-account-titlebar-panel-header-action:disabled { - opacity: 0.5; - cursor: default; -} - -.monaco-hover.workbench-hover.sessions-account-titlebar-panel-hover, -.workbench-hover-container.locked .monaco-hover.workbench-hover.sessions-account-titlebar-panel-hover, -.workbench-hover-container:focus-within.locked .monaco-hover.workbench-hover.sessions-account-titlebar-panel-hover { - outline: none; -} - -.agent-sessions-workbench .sessions-account-titlebar-panel-content { - display: flex; - flex-direction: column; - min-height: 0; - border-top: 1px solid var(--vscode-menu-separatorBackground, var(--vscode-disabledForeground)); -} - -.agent-sessions-workbench .sessions-account-titlebar-panel-summary { - padding: 12px 16px; - font-size: 12px; - line-height: 1.4; - color: var(--vscode-descriptionForeground); -} - -.agent-sessions-workbench .sessions-account-titlebar-panel-actions { - display: flex; - flex-direction: column; - gap: 0; - padding: 2px 0 6px 0; -} - -.agent-sessions-workbench .sessions-account-titlebar-panel-section { - display: flex; - flex-direction: column; - padding: 6px 0 0; -} - -/* The embedded chat status dashboard ships dividers/HRs and header borders - intended for its standalone tooltip presentation. The section headings - provide the only visual grouping we want here. */ -.agent-sessions-workbench .sessions-account-titlebar-panel-section .chat-status-bar-entry-tooltip hr { - display: none; -} - -.agent-sessions-workbench .sessions-account-titlebar-panel-section .chat-status-bar-entry-tooltip > div.header, -.agent-sessions-workbench .sessions-account-titlebar-panel-section .chat-status-bar-entry-tooltip .contribution > div.header { - border-bottom: none; - padding-bottom: 0; -} - -/* Flatten contributed sub-sections (e.g. "Codebase Semantic Index") into - the parent Subscription section. The dashboard renders these non-collapsibly - (via `disableContributedSectionsCollapsible`); these rules just align the - header typography with the panel's quota-title style above it. */ -.agent-sessions-workbench .sessions-account-titlebar-panel-section .chat-status-bar-entry-tooltip .collapsible-header.non-collapsible { - margin-top: 4px; - padding: 0 28px 0 0; - border-top: none; - background: none; - font-size: 12px; - line-height: 18px; - font-weight: 400; - color: var(--vscode-descriptionForeground); -} - -.agent-sessions-workbench .sessions-account-titlebar-panel-section .chat-status-bar-entry-tooltip .collapsible-header.non-collapsible .collapsible-label { - color: var(--vscode-foreground); -} - -.agent-sessions-workbench .sessions-account-titlebar-panel-section .chat-status-bar-entry-tooltip .collapsible-header.non-collapsible .collapsible-status { - margin-left: auto; - font-size: 11px; - line-height: 15px; -} - -.agent-sessions-workbench .sessions-account-titlebar-panel-section .chat-status-bar-entry-tooltip .collapsible-content > .collapsible-inner { - gap: 6px; - margin-top: 0; - margin-bottom: 0; -} - -.agent-sessions-workbench .sessions-account-titlebar-panel-section .chat-status-bar-entry-tooltip .section-description, -.agent-sessions-workbench .sessions-account-titlebar-panel-section .chat-status-bar-entry-tooltip .section-detail { - font-size: 11px; - line-height: 15px; -} - -.agent-sessions-workbench .sessions-account-titlebar-panel-section .chat-status-bar-entry-tooltip .section-detail { - padding-bottom: 4px; -} - -.agent-sessions-workbench .sessions-account-titlebar-panel-section-title { - padding: 4px 8px 2px; - font-size: 12px; - font-weight: 500; - line-height: 18px; - color: var(--vscode-foreground); -} - -/* Subscription section: place "Subscription" label and the dashboard's - plan-name + manage-action header on a single right-aligned row. - The dashboard's `div.header` is reparented into this row at runtime, - so it's no longer matched by chatStatus.css rules. */ -.agent-sessions-workbench .sessions-account-titlebar-panel-section-header { - display: flex; - align-items: center; - gap: 8px; - padding: 4px 10px 2px 8px; -} - -.agent-sessions-workbench .sessions-account-titlebar-panel-section-header .sessions-account-titlebar-panel-section-title { - flex: 1 1 auto; - min-width: 0; - padding: 0; -} - -.agent-sessions-workbench .sessions-account-titlebar-panel-section-header > div.header { - flex: 0 0 auto; - display: flex; - align-items: center; - gap: 6px; - font-size: 12px; - line-height: 18px; - color: var(--vscode-descriptionForeground); -} - -.agent-sessions-workbench .sessions-account-titlebar-panel-section-header > div.header .header-label { - flex: 0 0 auto; - color: var(--vscode-descriptionForeground); - font-weight: 400; - font-size: 11px; -} - -.agent-sessions-workbench .sessions-account-titlebar-panel-section-header > div.header .monaco-action-bar { - flex: 0 0 auto; -} - -.agent-sessions-workbench .sessions-account-titlebar-panel-action.with-icon { - display: flex; - align-items: center; - gap: 6px; - height: 24px; -} - -.agent-sessions-workbench .sessions-account-titlebar-panel-action-icon { - display: inline-flex; - align-items: center; - justify-content: center; - width: 16px; - height: 16px; - flex: 0 0 auto; - color: var(--vscode-icon-foreground, var(--vscode-descriptionForeground)); - /* `!important` to override `.codicon`'s own `font-size` rule. */ - font-size: 14px !important; -} - -.agent-sessions-workbench .sessions-account-titlebar-panel-action.with-icon:hover .sessions-account-titlebar-panel-action-icon, -.agent-sessions-workbench .sessions-account-titlebar-panel-action.with-icon:focus-visible .sessions-account-titlebar-panel-action-icon { - color: inherit; -} - -.agent-sessions-workbench .sessions-account-titlebar-panel-action-label { - flex: 1 1 auto; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.agent-sessions-workbench .sessions-account-titlebar-panel-separator { - height: 1px; - margin: 5px 0; - background: var(--vscode-menu-separatorBackground, var(--vscode-disabledForeground)); -} - -.agent-sessions-workbench .sessions-account-titlebar-panel-action { - display: flex; - align-items: center; - width: auto; - box-sizing: border-box; - height: 24px; - margin: 0 4px; - padding: 0 6px; - border: none; - border-radius: var(--vscode-cornerRadius-medium); - background: transparent; - color: inherit; - font-size: 12px; - line-height: 1; - text-align: left; - cursor: default; -} - -.agent-sessions-workbench .sessions-account-titlebar-panel-action:hover { - background: var(--vscode-list-hoverBackground); -} - -.agent-sessions-workbench .sessions-account-titlebar-panel-action.checked, -.agent-sessions-workbench .sessions-account-titlebar-panel-action:active { - background: var(--vscode-list-activeSelectionBackground); - color: var(--vscode-list-activeSelectionForeground); -} - -.agent-sessions-workbench .sessions-account-titlebar-panel-action:focus-visible { - outline: 1px solid var(--vscode-focusBorder); - outline-offset: -1px; - background: var(--vscode-list-focusBackground); - color: var(--vscode-list-focusForeground); -} - -.agent-sessions-workbench .sessions-account-titlebar-panel-action:disabled { - opacity: 0.6; - cursor: default; -} - -.agent-sessions-workbench .sessions-account-titlebar-panel-action.primary-action { - margin: 4px 8px; - padding: 0 12px; - justify-content: center; - border-radius: var(--vscode-cornerRadius-medium); - background: var(--vscode-button-background); - color: var(--vscode-button-foreground); - font-weight: 500; - cursor: pointer; -} - -.agent-sessions-workbench .sessions-account-titlebar-panel-action.primary-action:hover { - background: var(--vscode-button-hoverBackground); - color: var(--vscode-button-foreground); -} - -.agent-sessions-workbench .sessions-account-titlebar-panel-action.primary-action.checked, -.agent-sessions-workbench .sessions-account-titlebar-panel-action.primary-action:active { - background: var(--vscode-button-secondaryHoverBackground, var(--vscode-button-hoverBackground)); - color: var(--vscode-button-foreground); -} - -.agent-sessions-workbench .sessions-account-titlebar-panel-action.primary-action:focus-visible { - background: var(--vscode-button-background); - color: var(--vscode-button-foreground); -} - -.agent-sessions-workbench .sessions-account-titlebar-panel-action.primary-action:disabled { - opacity: 0.5; - background: var(--vscode-button-background); - color: var(--vscode-button-foreground); - cursor: default; -} - -/* Signed-out CTA inside the embedded chat status dashboard - (e.g. "Sign in to use AI Features"). The dashboard ships its own - default-styled