diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/exitPlanModeHandler.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/exitPlanModeHandler.ts index 8912081eaa28f..e971147b78204 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/exitPlanModeHandler.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/exitPlanModeHandler.ts @@ -17,10 +17,10 @@ import { IToolsService } from '../../../tools/common/toolsService'; type ExitPlanModeActionType = Parameters>[0]['actions'][number]; const actionDescriptions: Record = { - 'autopilot': { label: 'Autopilot', description: l10n.t('Auto-approve all tool calls and continue until the task is done') }, - 'interactive': { label: 'Approve and Implement', description: l10n.t('Let the agent continue in interactive mode, asking for input and approval for each action.') }, - 'exit_only': { label: 'Approve', description: l10n.t('Approve plan, but do not execute the plan. I will execute the plan myself.') }, - 'autopilot_fleet': { label: 'Autopilot Fleet', description: l10n.t('Auto-approve all tool calls, including fleet management actions, and continue until the task is done.') }, + 'autopilot': { label: l10n.t("Implement with Autopilot"), description: l10n.t('Auto-approve all tool calls and continue until the task is done.') }, + 'autopilot_fleet': { label: l10n.t("Implement with Autopilot Fleet"), description: l10n.t('Auto-approve all tool calls, including fleet management actions, and continue until the task is done.') }, + 'interactive': { label: l10n.t("Implement Plan"), description: l10n.t('Implement the plan, asking for input and approval for each action.') }, + 'exit_only': { label: l10n.t("Approve Plan Only"), description: l10n.t('Approve the plan without executing it. I will implement it myself.') }, }; /** diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/exitPlanModeHandler.spec.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/exitPlanModeHandler.spec.ts index 279bee80706f4..cbd9f5fe6e8b4 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/exitPlanModeHandler.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/exitPlanModeHandler.spec.ts @@ -197,43 +197,43 @@ describe('handleExitPlanMode', () => { }); it('returns approved with selected action mapped from label', async () => { - toolService.setResult({ rejected: false, action: 'Autopilot' }); + toolService.setResult({ rejected: false, action: 'Implement with Autopilot' }); const event = makeEvent(); const result = await handleExitPlanMode(event, session as unknown as Session, 'interactive', FAKE_TOKEN, workspaceService, logService, toolService, CANCEL_TOKEN); expect(result).toEqual({ approved: true, selectedAction: 'autopilot', autoApproveEdits: undefined }); }); - it('maps "Approve and exit" label to exit_only', async () => { - toolService.setResult({ rejected: false, action: 'Approve' }); + it('maps "Approve Plan Only" label to exit_only', async () => { + toolService.setResult({ rejected: false, action: 'Approve Plan Only' }); const event = makeEvent(); const result = await handleExitPlanMode(event, session as unknown as Session, 'interactive', FAKE_TOKEN, workspaceService, logService, toolService, CANCEL_TOKEN); expect(result.selectedAction).toBe('exit_only'); }); it('sets autoApproveEdits when permissionLevel is autoApprove', async () => { - toolService.setResult({ rejected: false, action: 'Interactive' }); + toolService.setResult({ rejected: false, action: 'Implement Plan' }); const event = makeEvent(); const result = await handleExitPlanMode(event, session as unknown as Session, 'autoApprove', FAKE_TOKEN, workspaceService, logService, toolService, CANCEL_TOKEN); expect(result.autoApproveEdits).toBe(true); }); it('does not set autoApproveEdits when permissionLevel is interactive', async () => { - toolService.setResult({ rejected: false, action: 'Interactive' }); + toolService.setResult({ rejected: false, action: 'Implement Plan' }); const event = makeEvent(); const result = await handleExitPlanMode(event, session as unknown as Session, 'interactive', FAKE_TOKEN, workspaceService, logService, toolService, CANCEL_TOKEN); expect(result.autoApproveEdits).toBeUndefined(); }); it('passes actions with labels and recommended flag to tool', async () => { - toolService.setResult({ rejected: false, action: 'Interactive' }); + toolService.setResult({ rejected: false, action: 'Implement Plan' }); const event = makeEvent({ actions: ['autopilot', 'exit_only'], recommendedAction: 'exit_only' }); await handleExitPlanMode(event, session as unknown as Session, 'interactive', FAKE_TOKEN, workspaceService, logService, toolService, CANCEL_TOKEN); const call = toolService.invokeToolCalls[0]; expect(call.name).toBe('vscode_reviewPlan'); const input = call.input as any; expect(input.actions).toHaveLength(2); - expect(input.actions[0]).toEqual(expect.objectContaining({ label: 'Autopilot', default: false })); - expect(input.actions[1]).toEqual(expect.objectContaining({ label: 'Approve', default: true })); + expect(input.actions[0]).toEqual(expect.objectContaining({ label: 'Implement with Autopilot', default: false })); + expect(input.actions[1]).toEqual(expect.objectContaining({ label: 'Approve Plan Only', default: true })); }); it('includes plan path in tool input when plan path exists', async () => { diff --git a/src/vs/platform/actionWidget/browser/actionList.ts b/src/vs/platform/actionWidget/browser/actionList.ts index e04bf3ebe14aa..2f6a6a6256076 100644 --- a/src/vs/platform/actionWidget/browser/actionList.ts +++ b/src/vs/platform/actionWidget/browser/actionList.ts @@ -1366,9 +1366,10 @@ export class ActionListWidget extends Disposable { } for (let ci = 0; ci < group.actions.length; ci++) { const child = group.actions[ci]; - const icon = (child as IAction & { icon?: ThemeIcon }).icon + const extendedChild = child as IAction & { icon?: ThemeIcon; hoverContent?: string; onRemove?: () => void }; + const icon = extendedChild.icon ?? ThemeIcon.fromId(child.checked ? Codicon.check.id : Codicon.blank.id); - const hoverContent = (child as IAction & { hoverContent?: string }).hoverContent; + const hoverContent = extendedChild.hoverContent; submenuItems.push({ item: child, kind: ActionListItemKind.Action, @@ -1377,6 +1378,7 @@ export class ActionListWidget extends Disposable { group: { title: '', icon }, hideIcon: false, hover: hoverContent ? { content: hoverContent } : {}, + onRemove: extendedChild.onRemove, }); } if (gi < groupsWithActions.length - 1) { @@ -1386,6 +1388,7 @@ export class ActionListWidget extends Disposable { // Also include non-SubmenuAction items directly for (const action of element.submenuActions!) { if (!(action instanceof SubmenuAction)) { + const extendedAction = action as IAction & { onRemove?: () => void }; submenuItems.push({ item: action, kind: ActionListItemKind.Action, @@ -1394,6 +1397,7 @@ export class ActionListWidget extends Disposable { group: { title: '' }, hideIcon: false, hover: {}, + onRemove: extendedAction.onRemove, }); } } diff --git a/src/vs/platform/agentHost/browser/nullSshRemoteAgentHostService.ts b/src/vs/platform/agentHost/browser/nullSshRemoteAgentHostService.ts index 8edaea2d95500..70119aebe8637 100644 --- a/src/vs/platform/agentHost/browser/nullSshRemoteAgentHostService.ts +++ b/src/vs/platform/agentHost/browser/nullSshRemoteAgentHostService.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ 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'; /** @@ -26,6 +27,14 @@ export class NullSSHRemoteAgentHostService implements ISSHRemoteAgentHostService 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.'); } diff --git a/src/vs/platform/agentHost/common/agentHostFileSystemProvider.ts b/src/vs/platform/agentHost/common/agentHostFileSystemProvider.ts index 62756371249b2..345db957cad39 100644 --- a/src/vs/platform/agentHost/common/agentHostFileSystemProvider.ts +++ b/src/vs/platform/agentHost/common/agentHostFileSystemProvider.ts @@ -91,7 +91,7 @@ export abstract class AHPFileSystemProvider extends Disposable implements IFileS return { type: FileType.Directory, mtime: 0, ctime: 0, size: 0, permissions: FilePermission.Readonly }; } const decoded = this._decodeUri(resource); - if (decoded.scheme === 'session-db') { + if (decoded.scheme === 'session-db' || decoded.scheme === 'git-blob') { return { type: FileType.File, mtime: 0, ctime: 0, size: 0, permissions: FilePermission.Readonly }; } diff --git a/src/vs/platform/agentHost/common/sshRemoteAgentHost.ts b/src/vs/platform/agentHost/common/sshRemoteAgentHost.ts index 2a5f39d0775f5..1d2f192867bcc 100644 --- a/src/vs/platform/agentHost/common/sshRemoteAgentHost.ts +++ b/src/vs/platform/agentHost/common/sshRemoteAgentHost.ts @@ -5,6 +5,7 @@ import { Event } from '../../../base/common/event.js'; import { IDisposable } from '../../../base/common/lifecycle.js'; +import { URI } from '../../../base/common/uri.js'; import { createDecorator } from '../../instantiation/common/instantiation.js'; export const ISSHRemoteAgentHostService = createDecorator('sshRemoteAgentHostService'); @@ -107,6 +108,20 @@ export interface ISSHRemoteAgentHostService { /** List SSH config host aliases (excluding wildcards). */ listSSHConfigHosts(): Promise; + /** + * Ensure `~/.ssh/config` exists (creating it with the right permissions if + * missing) and return its URI. The parent `~/.ssh` directory is created + * with mode 0700 and the config file with mode 0600 on POSIX systems. + */ + ensureUserSSHConfig(): Promise; + + /** + * List the known SSH configuration file URIs in priority order — typically the + * per-user `~/.ssh/config` (always returned, even if it does not yet exist) and + * the system-wide `/etc/ssh/ssh_config` (only when present on disk). + */ + listSSHConfigFiles(): Promise; + /** Resolve full SSH config for a host via `ssh -G`. */ resolveSSHConfig(host: string): Promise; @@ -202,6 +217,15 @@ export interface ISSHRemoteAgentHostMainService { /** List SSH config host aliases (excluding wildcards). */ listSSHConfigHosts(): Promise; + /** + * Ensure `~/.ssh/config` exists (creating it with the right permissions if + * missing) and return its URI. + */ + ensureUserSSHConfig(): Promise; + + /** List the known SSH configuration file URIs (user config always included). */ + listSSHConfigFiles(): Promise; + /** Resolve full SSH config for a host via `ssh -G`. */ resolveSSHConfig(host: string): Promise; diff --git a/src/vs/platform/agentHost/electron-browser/sshRemoteAgentHostServiceImpl.ts b/src/vs/platform/agentHost/electron-browser/sshRemoteAgentHostServiceImpl.ts index 3b66d6418b155..3f0c5987429c0 100644 --- a/src/vs/platform/agentHost/electron-browser/sshRemoteAgentHostServiceImpl.ts +++ b/src/vs/platform/agentHost/electron-browser/sshRemoteAgentHostServiceImpl.ts @@ -5,6 +5,7 @@ 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'; @@ -91,6 +92,14 @@ export class SSHRemoteAgentHostService extends Disposable implements ISSHRemoteA 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); } diff --git a/src/vs/platform/agentHost/node/agentHostGitService.ts b/src/vs/platform/agentHost/node/agentHostGitService.ts index 865ce104d7816..cf3dcbbf497de 100644 --- a/src/vs/platform/agentHost/node/agentHostGitService.ts +++ b/src/vs/platform/agentHost/node/agentHostGitService.ts @@ -5,8 +5,13 @@ 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 type { ISessionGitState } from '../common/state/sessionState.js'; +import { FileEditKind, type ISessionFileDiff, type ISessionGitState } from '../common/state/sessionState.js'; +import { buildGitBlobUri } from './gitDiffContent.js'; export const IAgentHostGitService = createDecorator('agentHostGitService'); @@ -27,6 +32,56 @@ export interface IAgentHostGitService { * 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 { @@ -52,6 +107,11 @@ export function getBranchCompletions(branches: readonly string[], options?: { re 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'; } @@ -114,6 +174,119 @@ export class AgentHostGitService implements IAgentHostGitService { await this._runGit(repositoryRoot, ['worktree', 'remove', '--force', worktree.fsPath], { timeout: 30_000, throwOnError: true }); } + 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); } @@ -171,9 +344,13 @@ export class AgentHostGitService implements IAgentHostGitService { return stripUndefined(result); } - private _runGit(workingDirectory: URI, args: readonly string[], options?: { readonly timeout?: number; readonly throwOnError?: boolean }): Promise { + 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) => { - cp.execFile('git', [...args], { cwd: workingDirectory.fsPath, timeout: options?.timeout ?? 5000 }, (error, stdout, stderr) => { + 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)); @@ -188,6 +365,134 @@ export class AgentHostGitService implements IAgentHostGitService { } } +/** + * 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: diff --git a/src/vs/platform/agentHost/node/agentHostMain.ts b/src/vs/platform/agentHost/node/agentHostMain.ts index 5fb0af528b3fc..e40bbb5119450 100644 --- a/src/vs/platform/agentHost/node/agentHostMain.ts +++ b/src/vs/platform/agentHost/node/agentHostMain.ts @@ -89,21 +89,23 @@ function startAgentHost(): void { // Create the real service implementation that lives in this process let agentService: AgentService; try { - const gitService = new AgentHostGitService(); - agentService = new AgentService(logService, fileService, sessionDataService, productService, gitService); - const pluginManager = new AgentPluginManager(URI.file(environmentService.userDataPath), fileService, logService); + // 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); + const instantiationService = new InstantiationService(diServices); + const gitService = instantiationService.createInstance(AgentHostGitService); + diServices.set(IAgentHostGitService, gitService); + agentService = new AgentService(logService, fileService, sessionDataService, productService, gitService); + 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); - const instantiationService = new InstantiationService(diServices); - diServices.set(IAgentHostGitService, gitService); agentService.registerProvider(instantiationService.createInstance(CopilotAgent)); } catch (err) { logService.error('Failed to create AgentService', err); diff --git a/src/vs/platform/agentHost/node/agentHostServerMain.ts b/src/vs/platform/agentHost/node/agentHostServerMain.ts index cb5b930d7424c..6bf63cdb060ae 100644 --- a/src/vs/platform/agentHost/node/agentHostServerMain.ts +++ b/src/vs/platform/agentHost/node/agentHostServerMain.ts @@ -162,26 +162,31 @@ async function main(): Promise { // Session data service const sessionDataService = new SessionDataService(URI.file(environmentService.userDataPath), fileService, logService); + // 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 gitService = new AgentHostGitService(); const agentService = new AgentService(logService, fileService, sessionDataService, productService, gitService); disposables.add(agentService); // Register agents if (!options.quiet) { // Production agents (require DI) - const diServices = new ServiceCollection(); const pluginManager = new AgentPluginManager(URI.file(environmentService.userDataPath), fileService, logService); - diServices.set(IProductService, productService); - diServices.set(INativeEnvironmentService, environmentService); - diServices.set(ILogService, logService); - diServices.set(IFileService, fileService); - diServices.set(ISessionDataService, sessionDataService); diServices.set(IAgentPluginManager, pluginManager); diServices.set(IDiffComputeService, disposables.add(new NodeWorkerDiffComputeService(logService))); diServices.set(IAgentHostTerminalManager, agentService.terminalManager); diServices.set(IAgentHostGitService, gitService); - const instantiationService = new InstantiationService(diServices); const copilotAgent = disposables.add(instantiationService.createInstance(CopilotAgent)); agentService.registerProvider(copilotAgent); log('CopilotAgent registered'); diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 553f65cf3a228..2ea1dad7f0dfb 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -26,6 +26,7 @@ import { AgentConfigurationService, IAgentConfigurationService } from './agentCo 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'; @@ -105,6 +106,7 @@ export class AgentService extends Disposable implements IAgentService { const services = new ServiceCollection( [ILogService, this._logService], [IAgentConfigurationService, configurationService], + [IAgentHostGitService, this._gitService], ); const instantiationService = this._register(new InstantiationService(services, /*strict*/ true)); @@ -648,6 +650,15 @@ export class AgentService extends Disposable implements IAgentService { 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 { @@ -1031,6 +1042,25 @@ export class AgentService extends Disposable implements IAgentService { } } + 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 diff --git a/src/vs/platform/agentHost/node/agentSideEffects.ts b/src/vs/platform/agentHost/node/agentSideEffects.ts index ea66d5083d475..738dcdc074a3b 100644 --- a/src/vs/platform/agentHost/node/agentSideEffects.ts +++ b/src/vs/platform/agentHost/node/agentSideEffects.ts @@ -14,7 +14,7 @@ import { ILogService } from '../../log/common/log.js'; import { IInstantiationService } from '../../instantiation/common/instantiation.js'; import { IAgent, IAgentAttachment, IAgentProgressEvent, type IAgentToolCompleteEvent, type IAgentToolReadyEvent } from '../common/agentService.js'; import { IDiffComputeService } from '../common/diffComputeService.js'; -import { ISessionDataService } from '../common/sessionDataService.js'; +import { ISessionDatabase, ISessionDataService } from '../common/sessionDataService.js'; import type { AgentInfo } from '../common/state/protocol/state.js'; import { ActionType, SessionAction } from '../common/state/sessionActions.js'; import { @@ -29,10 +29,12 @@ import { type SessionCustomization, type SessionState, type ToolResultContent, + type ISessionFileDiff, type URI as ProtocolURI, } from '../common/state/sessionState.js'; import { AgentEventMapper } from './agentEventMapper.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'; @@ -113,6 +115,7 @@ export class AgentSideEffects extends Disposable { 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)); @@ -931,20 +934,29 @@ export class AgentSideEffects extends Disposable { return; } try { - // 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 }; + // 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); } - const diffs = await computeSessionDiffs(session, ref.object, this._diffComputeService, incremental); this._stateManager.dispatchServerAction({ type: ActionType.SessionDiffsChanged, session, - diffs, + diffs: [...diffs], }); // Persist diffs to the session database so they survive restarts ref.object.setMetadata('diffs', JSON.stringify(diffs)).catch(err => { @@ -957,6 +969,35 @@ export class AgentSideEffects extends Disposable { } } + /** + * 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/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index 4e69d0bb97c79..14b2712860216 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -31,7 +31,7 @@ import type { ResolveSessionConfigResult, SessionConfigCompletionsResult } from import { ProtectedResourceMetadata, type ConfigSchema, type ModelSelection, type ToolDefinition } from '../../common/state/protocol/state.js'; import { AHP_AUTH_REQUIRED, ProtocolError } from '../../common/state/sessionProtocol.js'; import { CustomizationStatus, CustomizationRef, SessionInputResponseKind, type PendingMessage, type SessionInputAnswer, type ToolCallResult, type PolicyState } from '../../common/state/sessionState.js'; -import { IAgentHostGitService } from '../agentHostGitService.js'; +import { IAgentHostGitService, META_DIFF_BASE_BRANCH } from '../agentHostGitService.js'; import { IAgentHostTerminalManager } from '../agentHostTerminalManager.js'; import { CopilotAgentSession, SessionWrapperFactory, type IActiveClientSnapshot } from './copilotAgentSession.js'; import { ICopilotSessionContext, projectFromCopilotContext } from './copilotGitProject.js'; @@ -1024,7 +1024,7 @@ export class CopilotAgent extends Disposable implements IAgent { this._pendingFirstTurnAnnouncements.set(sessionId, buildWorktreeAnnouncementText(branchName)); const sessionUri = AgentSession.uri(this.id, sessionId); try { - await this._writeWorktreeBranchMetadata(sessionUri, branchName); + await this._writeWorktreeBranchMetadata(sessionUri, branchName, baseBranch); } catch (error) { this._logService.warn(`[Copilot:${sessionId}] Failed to persist worktree branch metadata: ${error instanceof Error ? error.message : String(error)}`); } @@ -1054,10 +1054,14 @@ export class CopilotAgent extends Disposable implements IAgent { private static readonly _META_PROJECT_DISPLAY_NAME = 'copilot.project.displayName'; private static readonly _META_WORKTREE_BRANCH = 'copilot.worktree.branchName'; - private async _writeWorktreeBranchMetadata(session: URI, branchName: string): Promise { + private async _writeWorktreeBranchMetadata(session: URI, branchName: string, baseBranch: string | undefined): Promise { const dbRef = this._sessionDataService.openDatabase(session); try { - await dbRef.object.setMetadata(CopilotAgent._META_WORKTREE_BRANCH, branchName); + const work: Promise[] = [dbRef.object.setMetadata(CopilotAgent._META_WORKTREE_BRANCH, branchName)]; + if (baseBranch) { + work.push(dbRef.object.setMetadata(META_DIFF_BASE_BRANCH, baseBranch)); + } + await Promise.all(work); } finally { dbRef.dispose(); } diff --git a/src/vs/platform/agentHost/node/gitDiffContent.ts b/src/vs/platform/agentHost/node/gitDiffContent.ts new file mode 100644 index 0000000000000..255e99d07687a --- /dev/null +++ b/src/vs/platform/agentHost/node/gitDiffContent.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. + *--------------------------------------------------------------------------------------------*/ + +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/sshRemoteAgentHostService.ts b/src/vs/platform/agentHost/node/sshRemoteAgentHostService.ts index 24199db6ba463..86199e0ce6360 100644 --- a/src/vs/platform/agentHost/node/sshRemoteAgentHostService.ts +++ b/src/vs/platform/agentHost/node/sshRemoteAgentHostService.ts @@ -11,6 +11,7 @@ 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'; @@ -695,6 +696,47 @@ export class SSHRemoteAgentHostMainService extends Disposable implements ISSHRem } } + 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) => { diff --git a/src/vs/platform/agentHost/test/common/agentHostFileSystemProvider.test.ts b/src/vs/platform/agentHost/test/common/agentHostFileSystemProvider.test.ts index 43c47cb242320..7ce1c5ef6d9ef 100644 --- a/src/vs/platform/agentHost/test/common/agentHostFileSystemProvider.test.ts +++ b/src/vs/platform/agentHost/test/common/agentHostFileSystemProvider.test.ts @@ -4,10 +4,13 @@ *--------------------------------------------------------------------------------------------*/ 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 { agentHostRemotePath, agentHostUri } from '../../common/agentHostFileSystemProvider.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', () => { @@ -176,3 +179,111 @@ suite('AGENT_HOST_LABEL_FORMATTER', () => { 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/sessionTestHelpers.ts b/src/vs/platform/agentHost/test/common/sessionTestHelpers.ts index 435a5a203fe5a..bb792457ddcee 100644 --- a/src/vs/platform/agentHost/test/common/sessionTestHelpers.ts +++ b/src/vs/platform/agentHost/test/common/sessionTestHelpers.ts @@ -172,6 +172,8 @@ export function createNoopGitService(): import('../../node/agentHostGitService.j addWorktree: async () => { }, removeWorktree: async () => { }, getSessionGitState: async () => undefined, + computeSessionFileDiffs: async () => undefined, + showBlob: async () => undefined, }; } diff --git a/src/vs/platform/agentHost/test/electron-browser/sshRelayTransport.test.ts b/src/vs/platform/agentHost/test/electron-browser/sshRelayTransport.test.ts index ca074d5c58965..f66049b2a93c0 100644 --- a/src/vs/platform/agentHost/test/electron-browser/sshRelayTransport.test.ts +++ b/src/vs/platform/agentHost/test/electron-browser/sshRelayTransport.test.ts @@ -6,6 +6,7 @@ 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'; @@ -35,6 +36,8 @@ class MockSSHMainService { } 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'); } diff --git a/src/vs/platform/agentHost/test/electron-browser/sshRemoteAgentHostService.test.ts b/src/vs/platform/agentHost/test/electron-browser/sshRemoteAgentHostService.test.ts index 6f993547140bb..c44c04a60fe6a 100644 --- a/src/vs/platform/agentHost/test/electron-browser/sshRemoteAgentHostService.test.ts +++ b/src/vs/platform/agentHost/test/electron-browser/sshRemoteAgentHostService.test.ts @@ -7,6 +7,7 @@ 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'; @@ -80,6 +81,8 @@ class MockSSHMainService { } 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 }; } diff --git a/src/vs/platform/agentHost/test/node/agentHostGitService.integrationTest.ts b/src/vs/platform/agentHost/test/node/agentHostGitService.integrationTest.ts index a19bddeee7e15..8a5fa4071907e 100644 --- a/src/vs/platform/agentHost/test/node/agentHostGitService.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/agentHostGitService.integrationTest.ts @@ -17,13 +17,27 @@ 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)', () => { - ensureNoDisposablesAreLeakedInTestSuite(); + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); // Skip the on-disk git tests when `git` is not on PATH (e.g. minimal CI). const hasGit = (() => { @@ -35,7 +49,7 @@ suite('AgentHostGitService - getSessionGitState (real git)', () => { setup(() => { tmpRoot = undefined; - svc = new AgentHostGitService(); + svc = createGitService(disposables); }); teardown(() => { @@ -125,3 +139,133 @@ suite('AgentHostGitService - getSessionGitState (real git)', () => { } }); }); + +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'); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/agentHostGitService.test.ts b/src/vs/platform/agentHost/test/node/agentHostGitService.test.ts index 25a0c66bdfe28..957c43f080056 100644 --- a/src/vs/platform/agentHost/test/node/agentHostGitService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostGitService.test.ts @@ -5,7 +5,9 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { getBranchCompletions, parseDefaultBranchRef, parseGitStatusV2, parseHasGitHubRemote } from '../../node/agentHostGitService.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(); @@ -116,5 +118,79 @@ suite('AgentHostGitService', () => { 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/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index e0f531fed291c..8cb64838d7335 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -291,6 +291,8 @@ suite('AgentService (node dispatcher)', () => { addWorktree: async () => { }, removeWorktree: async () => { }, 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'); @@ -328,6 +330,8 @@ suite('AgentService (node dispatcher)', () => { addWorktree: async () => { }, removeWorktree: async () => { }, 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'); diff --git a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts index 2485516dc9943..77f9db0f38858 100644 --- a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts +++ b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts @@ -23,6 +23,7 @@ import { ActionType, ActionEnvelope, SessionAction } from '../../common/state/se import { AttachmentType, buildSubagentSessionUri, PendingMessageKind, ResponsePartKind, SessionStatus, 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'; @@ -35,14 +36,15 @@ import { MockAgent } from './mockAgent.js'; /** * Constructs an {@link AgentSideEffects} with a minimal local instantiation * scope that satisfies its {@link IAgentConfigurationService} / - * {@link ILogService} dependencies. + * {@link ILogService} / {@link IAgentHostGitService} dependencies. */ -function createTestSideEffects(disposables: DisposableStore, stateManager: AgentHostStateManager, options: IAgentSideEffectsOptions): AgentSideEffects { +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)); } @@ -1907,4 +1909,135 @@ suite('AgentSideEffects', () => { ]); }); }); + + // ---- 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({ session: URI.parse(sessionUri.toString()), type: 'idle' }); + + // 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({ session: URI.parse(sessionUri.toString()), type: 'idle' }); + + 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/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index e7b6b177a2e9a..3185e0b45e76c 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -56,6 +56,8 @@ class TestAgentHostGitService implements IAgentHostGitService { } async removeWorktree(): Promise { } async getSessionGitState(): Promise { return undefined; } + async computeSessionFileDiffs(): Promise { return undefined; } + async showBlob(): Promise { return undefined; } } class TestAgentHostTerminalManager implements IAgentHostTerminalManager { diff --git a/src/vs/platform/agentHost/test/node/copilotGitProject.test.ts b/src/vs/platform/agentHost/test/node/copilotGitProject.test.ts index 89b219bd1a72a..4f5812531daa3 100644 --- a/src/vs/platform/agentHost/test/node/copilotGitProject.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotGitProject.test.ts @@ -25,6 +25,8 @@ class TestAgentHostGitService implements IAgentHostGitService { async addWorktree(): Promise { } async removeWorktree(): Promise { } async getSessionGitState(): Promise { return undefined; } + async computeSessionFileDiffs(): Promise { return undefined; } + async showBlob(): Promise { return undefined; } } suite('Copilot Git Project', () => { diff --git a/src/vs/platform/agentHost/test/node/gitDiffContent.test.ts b/src/vs/platform/agentHost/test/node/gitDiffContent.test.ts new file mode 100644 index 0000000000000..c401175854641 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/gitDiffContent.test.ts @@ -0,0 +1,35 @@ +/*--------------------------------------------------------------------------------------------- + * 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/mockAgent.ts b/src/vs/platform/agentHost/test/node/mockAgent.ts index 649ce829d788b..3652cf73e2621 100644 --- a/src/vs/platform/agentHost/test/node/mockAgent.ts +++ b/src/vs/platform/agentHost/test/node/mockAgent.ts @@ -569,6 +569,29 @@ export class ScriptedMockAgent implements IAgent { } 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 () => { + this._onDidSessionProgress.fire({ type: 'tool_start', session, toolCallId: 'tc-term-edit-1', toolName: 'bash', displayName: 'Run Command', invocationMessage: 'Edit file via shell' }); + const fs = await import('fs/promises'); + await fs.writeFile(filePath, 'edited-from-terminal\n'); + this._fireSequence(session, [ + { type: 'tool_complete', session, toolCallId: 'tc-term-edit-1', result: { pastTenseMessage: 'Edited file', content: [{ type: ToolResultContentType.Text, text: 'ok' }], success: true } }, + { type: 'idle', session }, + ]); + })().catch(err => { + // Surface failures deterministically — an unhandled rejection + // would make the test suite flaky. + this._fireSequence(session, [ + { type: 'delta', session, messageId: 'msg-err', content: 'terminal-edit failed: ' + (err instanceof Error ? err.message : String(err)) }, + { type: 'idle', session }, + ]); + }); + break; + } this._fireSequence(session, [ { type: 'delta', session, messageId: 'msg-1', content: 'Unknown prompt: ' + prompt }, { type: 'idle', session }, diff --git a/src/vs/platform/agentHost/test/node/protocol/sessionDiffs.integrationTest.ts b/src/vs/platform/agentHost/test/node/protocol/sessionDiffs.integrationTest.ts new file mode 100644 index 0000000000000..236fbb3b4f235 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/protocol/sessionDiffs.integrationTest.ts @@ -0,0 +1,104 @@ +/*--------------------------------------------------------------------------------------------- + * 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 new file mode 100644 index 0000000000000..0916117d98fe7 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/protocol/sessionDiffsRealSdk.integrationTest.ts @@ -0,0 +1,188 @@ +/*--------------------------------------------------------------------------------------------- + * 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/sessions/contrib/accountMenu/browser/account.contribution.ts b/src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts index 293ec68110f07..a6b6aaee53bf9 100644 --- a/src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts +++ b/src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts @@ -757,16 +757,9 @@ class TitleBarUpdateWidget extends BaseActionViewItem { // --- Register custom view item --- // -// These actions are registered at module level (not lazily inside AccountWidgetContribution) -// so that Menus.TitleBarRightLayout is non-empty when the MenuWorkbenchToolBar is first -// constructed. If they were registered lazily (WorkbenchPhase.AfterRestored), the toolbar's -// IntersectionObserver would have already observed an empty, display:none container and -// stopped listening for menu changes — causing the widgets to never appear. -// -// The actual widget rendering and interaction is handled by TitleBarUpdateWidget / -// TitleBarAccountWidget, which are custom view items registered via IActionViewItemService -// in AccountWidgetContribution. Those widgets attach their own DOM click handlers, so -// run() here is intentionally a no-op. +// 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({ @@ -811,16 +804,14 @@ class AccountWidgetContribution extends Disposable implements IWorkbenchContribu ) { super(); - // Titlebar update widget (to the right of separator, left of account badge) this._register(actionViewItemService.register(Menus.TitleBarRightLayout, SessionsTitleBarUpdateWidgetAction, (action, options) => { return instantiationService.createInstance(TitleBarUpdateWidget, action, options); }, undefined)); - // Titlebar account widget (rightmost in titlebar) this._register(actionViewItemService.register(Menus.TitleBarRightLayout, SessionsTitleBarAccountWidgetAction, (action, options) => { return instantiationService.createInstance(TitleBarAccountWidget, action, options); }, undefined)); } } -registerWorkbenchContribution2(AccountWidgetContribution.ID, AccountWidgetContribution, WorkbenchPhase.AfterRestored); +registerWorkbenchContribution2(AccountWidgetContribution.ID, AccountWidgetContribution, WorkbenchPhase.BlockRestore); diff --git a/src/vs/sessions/contrib/agentHost/browser/agentHostDiffs.ts b/src/vs/sessions/contrib/agentHost/browser/agentHostDiffs.ts index 904730c6f82f7..57e1427d9cb75 100644 --- a/src/vs/sessions/contrib/agentHost/browser/agentHostDiffs.ts +++ b/src/vs/sessions/contrib/agentHost/browser/agentHostDiffs.ts @@ -7,8 +7,8 @@ import { isDefined } from '../../../../base/common/types.js'; import { URI } from '../../../../base/common/uri.js'; import { SessionStatus as ProtocolSessionStatus } from '../../../../platform/agentHost/common/state/protocol/state.js'; import { ISessionFileDiff } from '../../../../platform/agentHost/common/state/sessionState.js'; -import { IChatSessionFileChange } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; -import { SessionStatus } from '../../../services/sessions/common/session.js'; +import { IChatSessionFileChange2, isIChatSessionFileChange2 } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; +import { ISessionFileChange, SessionStatus } from '../../../services/sessions/common/session.js'; /** * Maps the protocol-layer session status bitset to the UI-layer @@ -34,14 +34,21 @@ export function mapProtocolStatus(protocol: ProtocolSessionStatus): SessionStatu * @param mapUri Optional URI mapper applied after parsing. The remote agent * host provider uses this to rewrite `file:` URIs into agent-host URIs. */ -export function diffsToChanges(diffs: readonly ISessionFileDiff[], mapUri?: (uri: URI) => URI): IChatSessionFileChange[] { +export function diffsToChanges(diffs: readonly ISessionFileDiff[], mapUri?: (uri: URI) => URI): IChatSessionFileChange2[] { return diffs.map(d => { - const uri = d.after?.uri || d.before?.uri; - if (!uri) { + const rawUri = d.after?.uri ?? d.before?.uri; + if (!rawUri) { return undefined; } - const modifiedUri = mapUri ? mapUri(URI.parse(uri)) : URI.parse(uri); + const uri = mapUri ? mapUri(URI.parse(rawUri)) : URI.parse(rawUri); + + // For deletions (no `after`), `modifiedUri` is `undefined` so the + // renderer treats the entry as a deletion and doesn't try to open the + // (now-missing) file as the "modified" side of the diff editor. + const modifiedUri = d.after + ? (mapUri ? mapUri(URI.parse(d.after.uri)) : URI.parse(d.after.uri)) + : undefined; // Use the before-content reference URI so the diff editor can // fetch the snapshot of the file *before* the session's edits. @@ -52,11 +59,12 @@ export function diffsToChanges(diffs: readonly ISessionFileDiff[], mapUri?: (uri } return { + uri, modifiedUri, originalUri, insertions: d.diff?.added ?? 0, deletions: d.diff?.removed ?? 0, - }; + } satisfies IChatSessionFileChange2; }).filter(isDefined); } @@ -64,20 +72,21 @@ export function diffsToChanges(diffs: readonly ISessionFileDiff[], mapUri?: (uri * Returns `true` when the current file changes already * match the incoming diffs, avoiding unnecessary observable updates. */ -export function diffsEqual(current: readonly IChatSessionFileChange[], diffs: readonly ISessionFileDiff[], mapUri?: (uri: URI) => URI): boolean { +export function diffsEqual(current: readonly ISessionFileChange[], diffs: readonly ISessionFileDiff[], mapUri?: (uri: URI) => URI): boolean { if (current.length !== diffs.length) { return false; } for (let i = 0; i < current.length; i++) { const c = current[i]; const d = diffs[i]; - const uri = d.after?.uri || d.before?.uri; - if (!uri) { + const rawUri = d.after?.uri ?? d.before?.uri; + if (!rawUri) { continue; } - const parsed = URI.parse(uri); + const parsed = URI.parse(rawUri); const diffUri = mapUri ? mapUri(parsed) : parsed; - if (c.modifiedUri.toString() !== diffUri.toString() || c.insertions !== (d.diff?.added ?? 0) || c.deletions !== (d.diff?.removed ?? 0)) { + const cUri = isIChatSessionFileChange2(c) ? c.uri : c.modifiedUri; + if (cUri.toString() !== diffUri.toString() || c.insertions !== (d.diff?.added ?? 0) || c.deletions !== (d.diff?.removed ?? 0)) { return false; } diff --git a/src/vs/sessions/contrib/agentHost/browser/baseAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/agentHost/browser/baseAgentHostSessionsProvider.ts index 6c09eeabe5523..a1d936a6a71ff 100644 --- a/src/vs/sessions/contrib/agentHost/browser/baseAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/agentHost/browser/baseAgentHostSessionsProvider.ts @@ -22,7 +22,7 @@ import { ActionType, isSessionAction } from '../../../../platform/agentHost/comm import { readSessionGitState, StateComponents, type ISessionGitState } from '../../../../platform/agentHost/common/state/sessionState.js'; import { ChatViewPaneTarget, IChatWidgetService } from '../../../../workbench/contrib/chat/browser/chat.js'; import { IChatSendRequestOptions, IChatService } from '../../../../workbench/contrib/chat/common/chatService/chatService.js'; -import { IChatSessionFileChange, IChatSessionsService } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; +import { IChatSessionFileChange, IChatSessionFileChange2, IChatSessionsService } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; import { ChatAgentLocation, ChatModeKind } from '../../../../workbench/contrib/chat/common/constants.js'; import { ILanguageModelsService } from '../../../../workbench/contrib/chat/common/languageModels.js'; import { diffsEqual, diffsToChanges, mapProtocolStatus } from './agentHostDiffs.js'; @@ -70,7 +70,7 @@ export class AgentHostSessionAdapter implements ISession { readonly title: ISettableObservable; readonly updatedAt: ISettableObservable; readonly status: ISettableObservable; - readonly changes = observableValue('changes', []); + readonly changes = observableValue('changes', []); readonly modelId: ISettableObservable; modelSelection: ModelSelection | undefined; readonly mode = observableValue<{ readonly id: string; readonly kind: string } | undefined>('mode', undefined); @@ -523,7 +523,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement const status = observableValue(this, SessionStatus.Untitled); const title = observableValue(this, ''); const updatedAt = observableValue(this, new Date()); - const changes = observableValue(this, []); + const changes = observableValue(this, []); const modelId = observableValue(this, undefined); const mode = observableValue<{ readonly id: string; readonly kind: string } | undefined>(this, undefined); const isArchived = observableValue(this, false); diff --git a/src/vs/sessions/contrib/chat/browser/agentHost/agentHostSessionConfigPicker.ts b/src/vs/sessions/contrib/chat/browser/agentHost/agentHostSessionConfigPicker.ts index 7917a88c4ac7d..eac82e5d99c5c 100644 --- a/src/vs/sessions/contrib/chat/browser/agentHost/agentHostSessionConfigPicker.ts +++ b/src/vs/sessions/contrib/chat/browser/agentHost/agentHostSessionConfigPicker.ts @@ -5,6 +5,7 @@ import '../media/agentHostSessionConfigPicker.css'; import * as dom from '../../../../../base/browser/dom.js'; +import { Gesture, EventType as TouchEventType } from '../../../../../base/browser/touch.js'; import { renderIcon } from '../../../../../base/browser/ui/iconLabel/iconLabels.js'; import { ActionListItemKind, IActionListDelegate, IActionListItem } from '../../../../../platform/actionWidget/browser/actionList.js'; import { IActionWidgetService } from '../../../../../platform/actionWidget/browser/actionWidget.js'; @@ -109,10 +110,13 @@ function renderPickerTrigger(slot: HTMLElement, disabled: boolean, disposables: trigger.role = 'button'; trigger.tabIndex = 0; trigger.setAttribute('aria-haspopup', 'listbox'); - disposables.add(dom.addDisposableListener(trigger, dom.EventType.CLICK, e => { - dom.EventHelper.stop(e, true); - onOpen(); - })); + disposables.add(Gesture.addTarget(trigger)); + for (const eventType of [dom.EventType.CLICK, TouchEventType.Tap]) { + disposables.add(dom.addDisposableListener(trigger, eventType, e => { + dom.EventHelper.stop(e, true); + onOpen(); + })); + } disposables.add(dom.addDisposableListener(trigger, dom.EventType.KEY_DOWN, e => { if (e.key === 'Enter' || e.key === ' ') { dom.EventHelper.stop(e, true); diff --git a/src/vs/sessions/contrib/chat/browser/media/agentHostSessionConfigPicker.css b/src/vs/sessions/contrib/chat/browser/media/agentHostSessionConfigPicker.css index 359c012db4cb1..2210d92132cdc 100644 --- a/src/vs/sessions/contrib/chat/browser/media/agentHostSessionConfigPicker.css +++ b/src/vs/sessions/contrib/chat/browser/media/agentHostSessionConfigPicker.css @@ -12,3 +12,7 @@ .sessions-chat-agent-host-config:empty { display: none; } + +.sessions-chat-agent-host-config a.action-label { + touch-action: manipulation; +} diff --git a/src/vs/sessions/contrib/chat/browser/scopedWorkspacePicker.ts b/src/vs/sessions/contrib/chat/browser/scopedWorkspacePicker.ts index 3899e133ab0c0..01d2e573d8b7b 100644 --- a/src/vs/sessions/contrib/chat/browser/scopedWorkspacePicker.ts +++ b/src/vs/sessions/contrib/chat/browser/scopedWorkspacePicker.ts @@ -9,15 +9,12 @@ import { IActionWidgetService } from '../../../../platform/actionWidget/browser/ import { ActionListItemKind, IActionListItem } from '../../../../platform/actionWidget/browser/actionList.js'; import { IMenuService } from '../../../../platform/actions/common/actions.js'; import { IRemoteAgentHostService } from '../../../../platform/agentHost/common/remoteAgentHostService.js'; -import { IClipboardService } from '../../../../platform/clipboard/common/clipboardService.js'; import { ICommandService } from '../../../../platform/commands/common/commands.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; -import { IQuickInputService } from '../../../../platform/quickinput/common/quickInput.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { IStorageService } from '../../../../platform/storage/common/storage.js'; import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js'; -import { IOutputService } from '../../../../workbench/services/output/common/output.js'; -import { IPreferencesService } from '../../../../workbench/services/preferences/common/preferences.js'; import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js'; import { IAgentHostFilterService } from '../../remoteAgentHost/common/agentHostFilter.js'; import { IWorkspacePickerItem, IWorkspaceSelection, WorkspacePicker } from './sessionWorkspacePicker.js'; @@ -41,15 +38,12 @@ export class ScopedWorkspacePicker extends WorkspacePicker { @IUriIdentityService uriIdentityService: IUriIdentityService, @ISessionsProvidersService sessionsProvidersService: ISessionsProvidersService, @IRemoteAgentHostService remoteAgentHostService: IRemoteAgentHostService, - @IQuickInputService quickInputService: IQuickInputService, - @IClipboardService clipboardService: IClipboardService, - @IPreferencesService preferencesService: IPreferencesService, - @IOutputService outputService: IOutputService, @IConfigurationService configurationService: IConfigurationService, @ICommandService commandService: ICommandService, @IWorkspacesService workspacesService: IWorkspacesService, @IMenuService menuService: IMenuService, @IContextKeyService contextKeyService: IContextKeyService, + @IInstantiationService instantiationService: IInstantiationService, @IAgentHostFilterService private readonly _agentHostFilterService: IAgentHostFilterService, ) { super( @@ -58,15 +52,12 @@ export class ScopedWorkspacePicker extends WorkspacePicker { uriIdentityService, sessionsProvidersService, remoteAgentHostService, - quickInputService, - clipboardService, - preferencesService, - outputService, configurationService, commandService, workspacesService, menuService, contextKeyService, + instantiationService, ); // When the scoped host changes, if the current selection no longer diff --git a/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts b/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts index c649172a7ba14..259c1acba6b8e 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts @@ -19,13 +19,9 @@ import { ActionListItemKind, IActionListDelegate, IActionListItem } from '../../ import { IMenuService, MenuItemAction } from '../../../../platform/actions/common/actions.js'; import { IRemoteAgentHostService, RemoteAgentHostConnectionStatus } from '../../../../platform/agentHost/common/remoteAgentHostService.js'; import { TUNNEL_ADDRESS_PREFIX } from '../../../../platform/agentHost/common/tunnelAgentHost.js'; -import { IClipboardService } from '../../../../platform/clipboard/common/clipboardService.js'; import { ICommandService } from '../../../../platform/commands/common/commands.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; -import { IPreferencesService } from '../../../../workbench/services/preferences/common/preferences.js'; -import { IOutputService } from '../../../../workbench/services/output/common/output.js'; -import { IQuickInputService, IQuickPickItem } from '../../../../platform/quickinput/common/quickInput.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js'; import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; @@ -33,6 +29,8 @@ import { ThemeIcon } from '../../../../base/common/themables.js'; import { ISessionWorkspace, ISessionWorkspaceBrowseAction } from '../../../services/sessions/common/session.js'; import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js'; import { IAgentHostSessionsProvider, isAgentHostProvider } from '../../../common/agentHostSessionsProvider.js'; +import { getStatusHover, getStatusLabel, showRemoteHostOptions } from '../../remoteAgentHost/browser/remoteHostOptions.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { COPILOT_PROVIDER_ID } from '../../copilotChatSessions/browser/copilotChatSessionsProvider.js'; import { IWorkspacesService, isRecentFolder } from '../../../../platform/workspaces/common/workspaces.js'; import { Menus } from '../../../browser/menus.js'; @@ -126,15 +124,12 @@ export class WorkspacePicker extends Disposable { @IUriIdentityService private readonly uriIdentityService: IUriIdentityService, @ISessionsProvidersService protected readonly sessionsProvidersService: ISessionsProvidersService, @IRemoteAgentHostService private readonly remoteAgentHostService: IRemoteAgentHostService, - @IQuickInputService private readonly quickInputService: IQuickInputService, - @IClipboardService private readonly clipboardService: IClipboardService, - @IPreferencesService private readonly preferencesService: IPreferencesService, - @IOutputService private readonly outputService: IOutputService, @IConfigurationService _configurationService: IConfigurationService, @ICommandService private readonly commandService: ICommandService, @IWorkspacesService private readonly workspacesService: IWorkspacesService, @IMenuService private readonly menuService: IMenuService, @IContextKeyService private readonly contextKeyService: IContextKeyService, + @IInstantiationService private readonly instantiationService: IInstantiationService, ) { super(); @@ -476,16 +471,22 @@ export class WorkspacePicker extends Disposable { const action = toAction({ id: `workspacePicker.remote.${provider.id}`, label: provider.label, - tooltip: this._getStatusLabel(status), + tooltip: getStatusLabel(status), enabled: true, run: () => { this.actionWidgetService.hide(); this._showRemoteHostOptionsDelayed(provider); }, }); - const extended = action as IAction & { icon?: ThemeIcon; hoverContent?: string }; + const extended = action as IAction & { icon?: ThemeIcon; hoverContent?: string; onRemove?: () => void }; extended.icon = isTunnel ? Codicon.cloud : Codicon.remote; - extended.hoverContent = this._getStatusHover(status, provider.remoteAddress); + extended.hoverContent = getStatusHover(status, provider.remoteAddress); + if (!isTunnel && provider.remoteAddress) { + const address = provider.remoteAddress; + extended.onRemove = async () => { + await this.remoteAgentHostService.removeRemoteAgentHost(address); + }; + } remoteProviderActions.push(action); } @@ -527,95 +528,15 @@ export class WorkspacePicker extends Disposable { return items; } - private _getStatusLabel(status: RemoteAgentHostConnectionStatus): string { - switch (status) { - case RemoteAgentHostConnectionStatus.Connected: - return localize('workspacePicker.statusOnline', "Online"); - case RemoteAgentHostConnectionStatus.Connecting: - return localize('workspacePicker.statusConnecting', "Connecting"); - case RemoteAgentHostConnectionStatus.Disconnected: - return localize('workspacePicker.statusOffline', "Offline"); - } - } - - private _getStatusHover(status: RemoteAgentHostConnectionStatus, address?: string): string { - switch (status) { - case RemoteAgentHostConnectionStatus.Connected: - return address - ? localize('workspacePicker.hoverConnectedAddr', "Remote agent host is connected and ready.\n\nAddress: {0}", address) - : localize('workspacePicker.hoverConnected', "Remote agent host is connected and ready."); - case RemoteAgentHostConnectionStatus.Connecting: - return address - ? localize('workspacePicker.hoverConnectingAddr', "Attempting to connect to remote agent host...\n\nAddress: {0}", address) - : localize('workspacePicker.hoverConnecting', "Attempting to connect to remote agent host..."); - case RemoteAgentHostConnectionStatus.Disconnected: - return address - ? localize('workspacePicker.hoverDisconnectedAddr', "Remote agent host is disconnected.\n\nAddress: {0}", address) - : localize('workspacePicker.hoverDisconnected', "Remote agent host is disconnected."); - } - } - - /** - * Show the remote host options quickpick after a short delay. - * This ensures the action widget has fully hidden before the quickpick opens, - * preventing focus conflicts that cause the quickpick to flash and disappear. - */ private _showRemoteHostOptionsDelayed(provider: IAgentHostSessionsProvider): void { - const timeout = setTimeout(() => this._showRemoteHostOptions(provider), 1); + // Defer one tick so the action widget fully tears down (focus/DOM cleanup) + // before the QuickPick opens and claims focus. + const timeout = setTimeout(() => { + this.instantiationService.invokeFunction(accessor => showRemoteHostOptions(accessor, provider)); + }, 1); this._renderDisposables.add({ dispose: () => clearTimeout(timeout) }); } - private async _showRemoteHostOptions(provider: IAgentHostSessionsProvider): Promise { - const address = provider.remoteAddress; - if (!address) { - return; - } - - const status = provider.connectionStatus?.get(); - const isConnected = status === RemoteAgentHostConnectionStatus.Connected; - - const items: IQuickPickItem[] = []; - if (!isConnected) { - items.push({ label: '$(debug-restart) ' + localize('workspacePicker.reconnect', "Reconnect"), id: 'reconnect' }); - } - items.push( - { label: '$(trash) ' + localize('workspacePicker.removeRemote', "Remove Remote"), id: 'remove' }, - { label: '$(copy) ' + localize('workspacePicker.copyAddress', "Copy Address"), id: 'copy' }, - { label: '$(settings-gear) ' + localize('workspacePicker.openSettings', "Open Settings"), id: 'settings' }, - ); - if (provider.outputChannelId) { - items.push({ label: '$(output) ' + localize('workspacePicker.showOutput', "Show Output"), id: 'output' }); - } - - const picked = await this.quickInputService.pick(items, { - placeHolder: localize('workspacePicker.remoteOptionsTitle', "Options for {0}", provider.label), - }); - if (!picked) { - return; - } - - const action = (picked as IQuickPickItem & { id: string }).id; - switch (action) { - case 'reconnect': - this.remoteAgentHostService.reconnect(address); - break; - case 'remove': - await this.remoteAgentHostService.removeRemoteAgentHost(address); - break; - case 'copy': - await this.clipboardService.writeText(address); - break; - case 'settings': - await this.preferencesService.openSettings({ query: 'chat.remoteAgentHosts' }); - break; - case 'output': - if (provider.outputChannelId) { - this.outputService.showChannel(provider.outputChannelId, true); - } - break; - } - } - private _updateTriggerLabel(): void { if (!this._triggerElement) { return; diff --git a/src/vs/sessions/contrib/remoteAgentHost/browser/manageRemoteAgentHosts.ts b/src/vs/sessions/contrib/remoteAgentHost/browser/manageRemoteAgentHosts.ts new file mode 100644 index 0000000000000..879089b1bbc43 --- /dev/null +++ b/src/vs/sessions/contrib/remoteAgentHost/browser/manageRemoteAgentHosts.ts @@ -0,0 +1,178 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { DisposableStore } from '../../../../base/common/lifecycle.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; +import { autorun } from '../../../../base/common/observable.js'; +import { localize, localize2 } from '../../../../nls.js'; +import { Action2, IMenuService, MenuItemAction, registerAction2 } from '../../../../platform/actions/common/actions.js'; +import { ICommandService } from '../../../../platform/commands/common/commands.js'; +import { ContextKeyExpr, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; +import { ServicesAccessor, IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { IRemoteAgentHostService, RemoteAgentHostsEnabledSettingId } from '../../../../platform/agentHost/common/remoteAgentHostService.js'; +import { TUNNEL_ADDRESS_PREFIX } from '../../../../platform/agentHost/common/tunnelAgentHost.js'; +import { IQuickInputButton, IQuickInputService, IQuickPickItem, IQuickPickSeparator } from '../../../../platform/quickinput/common/quickInput.js'; +import { Menus } from '../../../browser/menus.js'; +import { SessionsCategories } from '../../../common/categories.js'; +import { IAgentHostSessionsProvider, isAgentHostProvider } from '../../../common/agentHostSessionsProvider.js'; +import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js'; +import { getStatusLabel, showRemoteHostOptions } from './remoteHostOptions.js'; +import { RemoteAgentHostCommandIds } from './remoteAgentHostActions.js'; + +interface IRemoteHostQuickPickItem extends IQuickPickItem { + readonly kind: 'remote'; + readonly provider: IAgentHostSessionsProvider; +} + +interface IMenuActionQuickPickItem extends IQuickPickItem { + readonly kind: 'menu-action'; + readonly action: MenuItemAction; +} + +type ManageHostsPickItem = IRemoteHostQuickPickItem | IMenuActionQuickPickItem; + +registerAction2(class extends Action2 { + constructor() { + super({ + id: RemoteAgentHostCommandIds.manageRemoteAgentHosts, + title: localize2('manageRemoteAgentHosts', "Manage Remote Agent Hosts..."), + category: SessionsCategories.Sessions, + f1: true, + precondition: ContextKeyExpr.equals(`config.${RemoteAgentHostsEnabledSettingId}`, true), + }); + } + + override async run(accessor: ServicesAccessor): Promise { + const quickInputService = accessor.get(IQuickInputService); + const sessionsProvidersService = accessor.get(ISessionsProvidersService); + const remoteAgentHostService = accessor.get(IRemoteAgentHostService); + const menuService = accessor.get(IMenuService); + const contextKeyService = accessor.get(IContextKeyService); + const commandService = accessor.get(ICommandService); + const instantiationService = accessor.get(IInstantiationService); + + const removeButton: IQuickInputButton = { + iconClass: ThemeIcon.asClassName(Codicon.close), + tooltip: localize('manageHosts.removeTooltip', "Remove"), + }; + + const buildItems = (): (ManageHostsPickItem | IQuickPickSeparator)[] => { + const remoteProviders: IAgentHostSessionsProvider[] = sessionsProvidersService.getProviders() + .filter(isAgentHostProvider) + .filter((p: IAgentHostSessionsProvider) => !!p.remoteAddress); + + const remoteItems: IRemoteHostQuickPickItem[] = remoteProviders.map((p: IAgentHostSessionsProvider) => { + const isTunnel = p.remoteAddress?.startsWith(TUNNEL_ADDRESS_PREFIX); + const status = p.connectionStatus?.get(); + const item: IRemoteHostQuickPickItem = { + kind: 'remote', + provider: p, + label: `$(${isTunnel ? 'cloud' : 'remote'}) ${p.label}`, + description: status !== undefined ? getStatusLabel(status) : undefined, + detail: p.remoteAddress, + }; + if (!isTunnel) { + (item as IRemoteHostQuickPickItem & { buttons?: IQuickInputButton[] }).buttons = [removeButton]; + } + return item; + }); + + const menuActionItems: IMenuActionQuickPickItem[] = []; + const menuActions = menuService.getMenuActions(Menus.SessionWorkspaceManage, contextKeyService, { renderShortTitle: true }); + for (const [, actions] of menuActions) { + for (const action of actions) { + if (action instanceof MenuItemAction) { + const icon = ThemeIcon.isThemeIcon(action.item.icon) ? action.item.icon : undefined; + menuActionItems.push({ + kind: 'menu-action', + action, + label: icon ? `$(${icon.id}) ${action.label}` : action.label, + description: action.tooltip || undefined, + }); + } + } + } + + const items: (ManageHostsPickItem | IQuickPickSeparator)[] = []; + if (remoteItems.length > 0) { + items.push({ type: 'separator', label: localize('manageHosts.remoteHostsHeader', "Remote Agent Hosts") }); + items.push(...remoteItems); + } + if (menuActionItems.length > 0) { + items.push({ type: 'separator', label: localize('manageHosts.actionsHeader', "Add or Manage") }); + items.push(...menuActionItems); + } + return items; + }; + + const showManagePicker = () => { + const store = new DisposableStore(); + const picker = quickInputService.createQuickPick({ useSeparators: true }); + store.add(picker); + picker.title = localize('manageHosts.title', "Manage Remote Agent Hosts"); + picker.placeholder = localize('manageHosts.placeholder', "Select a remote to manage or pick an action"); + picker.matchOnDescription = true; + picker.matchOnDetail = true; + + let lastFilter = ''; + const refresh = () => { + lastFilter = picker.value; + picker.items = buildItems(); + picker.value = lastFilter; + }; + refresh(); + + // Refresh when providers/connection status change + store.add(sessionsProvidersService.onDidChangeProviders(() => refresh())); + const observerStore = store.add(new DisposableStore()); + const subscribeToProviders = () => { + observerStore.clear(); + for (const p of sessionsProvidersService.getProviders()) { + if (isAgentHostProvider(p) && p.connectionStatus) { + observerStore.add(autorun(reader => { + p.connectionStatus!.read(reader); + refresh(); + })); + } + } + }; + subscribeToProviders(); + store.add(sessionsProvidersService.onDidChangeProviders(() => subscribeToProviders())); + + store.add(picker.onDidTriggerItemButton(async e => { + if (e.item.kind === 'remote' && e.button === removeButton) { + const address = e.item.provider.remoteAddress; + if (address) { + await remoteAgentHostService.removeRemoteAgentHost(address); + // onDidChangeProviders will refresh + } + } + })); + + store.add(picker.onDidAccept(() => { + const selected = picker.selectedItems[0]; + picker.hide(); + if (!selected) { + return; + } + if (selected.kind === 'remote') { + void instantiationService.invokeFunction(a => showRemoteHostOptions(a, selected.provider, { showBackButton: true })).then(result => { + if (result === 'back') { + showManagePicker(); + } + }); + } else if (selected.kind === 'menu-action') { + commandService.executeCommand(selected.action.id, () => showManagePicker()); + } + })); + + store.add(picker.onDidHide(() => store.dispose())); + picker.show(); + }; + + showManagePicker(); + } +}); diff --git a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts index 6cbf7a107ff17..df3909f39a94c 100644 --- a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts +++ b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts @@ -692,4 +692,5 @@ Registry.as(ConfigurationExtensions.Configuration).regis // Side-effect registrations for the remote agent host feature import './remoteAgentHostActions.js'; +import './manageRemoteAgentHosts.js'; import '../../chat/browser/agentHost/agentHostModelPicker.js'; diff --git a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostActions.ts b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostActions.ts index 64881379642de..571429dc8c7cf 100644 --- a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostActions.ts +++ b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostActions.ts @@ -6,6 +6,16 @@ import { localize, localize2 } from '../../../../nls.js'; import { Action2, registerAction2 } from '../../../../platform/actions/common/actions.js'; import { Codicon } from '../../../../base/common/codicons.js'; +import { DisposableStore } from '../../../../base/common/lifecycle.js'; +import { URI } from '../../../../base/common/uri.js'; +import { ICommandService } from '../../../../platform/commands/common/commands.js'; +import { IFileService } from '../../../../platform/files/common/files.js'; +import { ITextEditorOptions } from '../../../../platform/editor/common/editor.js'; +import { ICodeEditor, isCodeEditor } from '../../../../editor/browser/editorBrowser.js'; +import { EndOfLinePreference } from '../../../../editor/common/model.js'; +import { Range } from '../../../../editor/common/core/range.js'; +import { SnippetController2 } from '../../../../editor/contrib/snippet/browser/snippetController2.js'; +import { IEditorService } from '../../../../workbench/services/editor/common/editorService.js'; import { IRemoteAgentHostService, parseRemoteAgentHostInput, RemoteAgentHostEntryType, RemoteAgentHostInputValidationError, RemoteAgentHostsEnabledSettingId } from '../../../../platform/agentHost/common/remoteAgentHostService.js'; import { ISSHRemoteAgentHostService, SSHAuthMethod, type ISSHAgentHostConfig, type ISSHAgentHostConnection, type ISSHResolvedConfig } from '../../../../platform/agentHost/common/sshRemoteAgentHost.js'; import { ITunnelAgentHostService, TUNNEL_ADDRESS_PREFIX, type ITunnelInfo } from '../../../../platform/agentHost/common/tunnelAgentHost.js'; @@ -23,10 +33,20 @@ import { ISessionsManagementService } from '../../../services/sessions/common/se import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js'; import { IAgentHostSessionsProvider, isAgentHostProvider } from '../../../common/agentHostSessionsProvider.js'; +/** Action / command IDs registered by this file. */ +export const RemoteAgentHostCommandIds = { + addRemoteAgentHost: 'sessions.remoteAgentHost.add', + connectViaSSH: 'workbench.action.sessions.connectViaSSH', + addNewSSHHost: 'workbench.action.sessions.addNewSSHHost', + configureSSHHosts: 'workbench.action.sessions.configureSSHHosts', + connectViaTunnel: 'workbench.action.sessions.connectViaTunnel', + manageRemoteAgentHosts: 'workbench.action.sessions.manageRemoteAgentHosts', +} as const; + registerAction2(class extends Action2 { constructor() { super({ - id: 'sessions.remoteAgentHost.add', + id: RemoteAgentHostCommandIds.addRemoteAgentHost, title: localize2('addRemoteAgentHost', "Add Remote Agent Host..."), category: SessionsCategories.Sessions, f1: true, @@ -101,113 +121,301 @@ interface ISSHAuthMethodPickItem extends IQuickPickItem { readonly method: SSHAuthMethod; } -interface ISSHHostPickItem extends IQuickPickItem { - readonly hostAlias?: string; +/** + * Parse a free-form SSH connection string of the form `[user@]host[:port]`. + * Returns `undefined` for empty or invalid input. + */ +export function parseSSHHostInput(value: string): { host: string; username?: string; port?: number } | undefined { + const trimmed = value.trim(); + if (!trimmed) { + return undefined; + } + const atIdx = trimmed.indexOf('@'); + if (atIdx === 0 || atIdx === trimmed.length - 1) { + return undefined; + } + let username: string | undefined; + let hostPart: string; + if (atIdx !== -1) { + username = trimmed.substring(0, atIdx); + hostPart = trimmed.substring(atIdx + 1); + } else { + hostPart = trimmed; + } + if (!hostPart) { + return undefined; + } + let host: string; + let port: number | undefined; + const colonIdx = hostPart.lastIndexOf(':'); + if (colonIdx !== -1) { + host = hostPart.substring(0, colonIdx); + const portStr = hostPart.substring(colonIdx + 1); + if (!host) { + return undefined; + } + if (portStr) { + const portNum = Number(portStr); + if (!Number.isInteger(portNum) || portNum <= 0 || portNum > 65535) { + return undefined; + } + port = portNum; + } + } else { + host = hostPart; + } + if (!host) { + return undefined; + } + return { host, username, port }; +} + +function validateSSHHostInput(value: string): string | undefined { + const v = value.trim(); + if (!v) { + return localize('sshHostEmpty', "Enter an SSH host."); + } + const atIdx = v.indexOf('@'); + if (atIdx === 0) { + return localize('sshUsernameMissingInHost', "Enter a username before '@'."); + } + if (atIdx === v.length - 1) { + return localize('sshHostMissingAfterAt', "Enter a host name after '@'."); + } + const hostPart = atIdx !== -1 ? v.substring(atIdx + 1) : v; + if (!hostPart) { + return localize('sshHostMissingAfterAt', "Enter a host name after '@'."); + } + const colonIdx = hostPart.lastIndexOf(':'); + if (colonIdx !== -1) { + const hostName = hostPart.substring(0, colonIdx); + const portStr = hostPart.substring(colonIdx + 1); + if (!hostName) { + return localize('sshHostMissingAfterAt', "Enter a host name after '@'."); + } + if (portStr) { + const portNum = Number(portStr); + if (!Number.isInteger(portNum) || portNum <= 0 || portNum > 65535) { + return localize('sshHostInvalidPort', "Enter a valid port number."); + } + } + } + return undefined; +} + +interface ISSHAliasPickItem extends IQuickPickItem { + readonly kind: 'alias'; + readonly hostAlias: string; +} + +interface ISSHNewHostPickItem extends IQuickPickItem { + kind: 'new-host'; + hostInput: string; } +interface ISSHFooterPickItem extends IQuickPickItem { + readonly kind: 'add-config' | 'configure'; +} + +type SSHHostPickerItem = ISSHAliasPickItem | ISSHNewHostPickItem | ISSHFooterPickItem; + async function promptToConnectViaSSH( accessor: ServicesAccessor, -): Promise { + options: { showBackButton?: boolean } = {}, +): Promise<'back' | void> { const sshService = accessor.get(ISSHRemoteAgentHostService); const quickInputService = accessor.get(IQuickInputService); const notificationService = accessor.get(INotificationService); const instantiationService = accessor.get(IInstantiationService); - - let host: string; - let username: string | undefined; - let port: number | undefined; - let resolvedConfig: ISSHResolvedConfig | undefined; - let suggestedName: string | undefined; - let defaultAuthMethod: SSHAuthMethod | undefined; - let defaultKeyPath: string | undefined; + const commandService = accessor.get(ICommandService); const configHosts = await sshService.listSSHConfigHosts().catch(() => [] as string[]); - if (configHosts.length > 0) { - const hostPicks: ISSHHostPickItem[] = configHosts.map(h => ({ - label: h, - hostAlias: h, - })); - hostPicks.push({ - label: localize('sshEnterManually', "Enter Manually..."), - description: localize('sshEnterManuallyDesc', "Type in host, username, and port"), - }); - const picked = await quickInputService.pick(hostPicks, { - title: localize('sshHostTitle', "Connect via SSH"), - placeHolder: localize('sshPickHostPlaceholder', "Select an SSH host or enter manually"), - }); - if (!picked) { - return; + const aliasItems: ISSHAliasPickItem[] = configHosts.map(h => ({ + kind: 'alias', + hostAlias: h, + label: h, + })); + const addHostItem: ISSHFooterPickItem = { + kind: 'add-config', + label: '$(plus) ' + localize('sshAddNewHost', "Add New SSH Host..."), + alwaysShow: true, + }; + const configureHostsItem: ISSHFooterPickItem = { + kind: 'configure', + label: localize('sshConfigureHosts', "Configure SSH Hosts..."), + alwaysShow: true, + }; + const newHostItem: ISSHNewHostPickItem = { + kind: 'new-host', + hostInput: '', + label: '', + alwaysShow: true, + }; + + const result = await new Promise<'back' | SSHHostPickerItem | undefined>((resolve) => { + const store = new DisposableStore(); + const picker = store.add(quickInputService.createQuickPick()); + picker.title = localize('sshHostTitle', "Connect via SSH"); + picker.placeholder = localize('sshHostPickerPlaceholder', "Select configured SSH host or enter user@host"); + picker.ignoreFocusOut = true; + picker.matchOnDescription = true; + if (options.showBackButton) { + picker.buttons = [quickInputService.backButton]; } - if (picked.hostAlias) { - try { - resolvedConfig = await sshService.resolveSSHConfig(picked.hostAlias); - } catch (err) { - notificationService.error(localize('sshResolveConfigFailed', "Failed to resolve SSH config for {0}: {1}", picked.hostAlias, String(err))); - return; + let newHostVisible = false; + const updateItems = () => { + const items: SSHHostPickerItem[] = [...aliasItems]; + if (newHostVisible) { + items.push(newHostItem); } - - host = resolvedConfig.hostname; - username = resolvedConfig.user; - port = resolvedConfig.port !== 22 ? resolvedConfig.port : undefined; - suggestedName = picked.hostAlias; - - // Determine auth method from resolved config. - // Always prefer Agent auth (the SSH agent may already have the key - // loaded). Record a non-default IdentityFile as a fallback path for - // the manual picker only. - if (resolvedConfig.identityFile.length > 0) { - const firstKey = resolvedConfig.identityFile[0]; - const defaultKeys = ['~/.ssh/id_rsa', '~/.ssh/id_ecdsa', '~/.ssh/id_ed25519', '~/.ssh/id_dsa', '~/.ssh/id_xmss']; - if (!defaultKeys.includes(firstKey)) { - defaultKeyPath = firstKey; + items.push(addHostItem); + items.push(configureHostsItem); + picker.items = items; + }; + updateItems(); + + store.add(picker.onDidChangeValue(value => { + const parsed = parseSSHHostInput(value); + if (parsed) { + newHostItem.hostInput = value.trim(); + newHostItem.label = `\u27a4 ${value.trim()}`; + if (!newHostVisible) { + newHostVisible = true; + updateItems(); + } else { + // Force item refresh so the label updates + picker.items = picker.items; } + } else if (newHostVisible) { + newHostVisible = false; + updateItems(); } - // Default to SSH agent - if (!defaultAuthMethod) { - defaultAuthMethod = SSHAuthMethod.Agent; - } + })); - // Config host has enough info — connect directly, skip all prompts - if (username) { - const config: ISSHAgentHostConfig = { - host, - port, - username, - authMethod: defaultAuthMethod, - privateKeyPath: defaultKeyPath, - agentForward: resolvedConfig.forwardAgent || undefined, - name: suggestedName, - sshConfigHost: picked.hostAlias, - }; - const connection = await instantiationService.invokeFunction(accessor => - connectWithProgress(accessor, config, suggestedName!) - ); - if (connection) { - await instantiationService.invokeFunction(accessor => promptForRemoteFolder(accessor, connection)); - } - return; + store.add(picker.onDidTriggerButton(button => { + if (button === quickInputService.backButton) { + resolve('back'); + picker.hide(); } - } else { - const manualResult = await promptForManualHost(quickInputService); - if (!manualResult) { - return; - } - host = manualResult.host; - username = manualResult.username; - port = manualResult.port; + })); + store.add(picker.onDidAccept(() => { + const selected = picker.selectedItems[0]; + resolve(selected); + picker.hide(); + })); + store.add(picker.onDidHide(() => { + resolve(undefined); + store.dispose(); + })); + picker.show(); + }); + + if (result === 'back') { + return 'back'; + } + + if (!result) { + return; + } + + if (result.kind === 'add-config' || result.kind === 'configure') { + const cmdId = result.kind === 'add-config' + ? RemoteAgentHostCommandIds.addNewSSHHost + : RemoteAgentHostCommandIds.configureSSHHosts; + // Pass back callback so sub-picker can navigate back to this SSH picker + const onBackToSSH = () => instantiationService.invokeFunction(a => promptToConnectViaSSH(a, options)); + await commandService.executeCommand(cmdId, onBackToSSH); + return; + } + + if (result.kind === 'alias') { + await instantiationService.invokeFunction(accessor => + connectToConfiguredSSHHost(accessor, result.hostAlias) + ); + return; + } + + // kind === 'new-host' + const newHost = result as ISSHNewHostPickItem; + const parsed = parseSSHHostInput(newHost.hostInput); + if (!parsed) { + notificationService.error(validateSSHHostInput(newHost.hostInput) ?? localize('sshHostInvalid', "Invalid SSH host.")); + return; + } + await instantiationService.invokeFunction(accessor => + promptForCredentialsAndConnect(accessor, parsed.host, parsed.username, parsed.port) + ); +} + +async function connectToConfiguredSSHHost( + accessor: ServicesAccessor, + hostAlias: string, +): Promise { + const sshService = accessor.get(ISSHRemoteAgentHostService); + const notificationService = accessor.get(INotificationService); + const instantiationService = accessor.get(IInstantiationService); + + let resolvedConfig: ISSHResolvedConfig; + try { + resolvedConfig = await sshService.resolveSSHConfig(hostAlias); + } catch (err) { + notificationService.error(localize('sshResolveConfigFailed', "Failed to resolve SSH config for {0}: {1}", hostAlias, String(err))); + return; + } + + const host = resolvedConfig.hostname; + const username = resolvedConfig.user; + const port = resolvedConfig.port !== 22 ? resolvedConfig.port : undefined; + const suggestedName = hostAlias; + + let defaultKeyPath: string | undefined; + if (resolvedConfig.identityFile.length > 0) { + const firstKey = resolvedConfig.identityFile[0]; + const defaultKeys = ['~/.ssh/id_rsa', '~/.ssh/id_ecdsa', '~/.ssh/id_ed25519', '~/.ssh/id_dsa', '~/.ssh/id_xmss']; + if (!defaultKeys.includes(firstKey)) { + defaultKeyPath = firstKey; } - } else { - const manualResult = await promptForManualHost(quickInputService); - if (!manualResult) { - return; + } + + if (username) { + const config: ISSHAgentHostConfig = { + host, + port, + username, + authMethod: SSHAuthMethod.Agent, + privateKeyPath: defaultKeyPath, + agentForward: resolvedConfig.forwardAgent || undefined, + name: suggestedName, + sshConfigHost: hostAlias, + }; + const connection = await instantiationService.invokeFunction(accessor => + connectWithProgress(accessor, config, suggestedName) + ); + if (connection) { + await instantiationService.invokeFunction(accessor => promptForRemoteFolder(accessor, connection)); } - host = manualResult.host; - username = manualResult.username; - port = manualResult.port; + return; } + // Fallback: alias resolved without a user — fall through to manual flow + await instantiationService.invokeFunction(accessor => + promptForCredentialsAndConnect(accessor, host, undefined, port, suggestedName, defaultKeyPath) + ); +} + +async function promptForCredentialsAndConnect( + accessor: ServicesAccessor, + host: string, + username: string | undefined, + port: number | undefined, + suggestedName?: string, + defaultKeyPath?: string, +): Promise { + const quickInputService = accessor.get(IQuickInputService); + const instantiationService = accessor.get(IInstantiationService); + if (!username) { const usernameInput = await quickInputService.input({ title: localize('sshUsernameTitle', "SSH Username"), @@ -240,19 +448,14 @@ async function promptToConnectViaSSH( }, ]; - let authMethod: SSHAuthMethod; - if (defaultAuthMethod) { - authMethod = defaultAuthMethod; - } else { - const authPicked = await quickInputService.pick(authPicks, { - title: localize('sshAuthTitle', "Authentication Method"), - placeHolder: localize('sshAuthPlaceholder', "Choose how to authenticate with {0}", host), - }); - if (!authPicked) { - return; - } - authMethod = authPicked.method; + const authPicked = await quickInputService.pick(authPicks, { + title: localize('sshAuthTitle', "Authentication Method"), + placeHolder: localize('sshAuthPlaceholder', "Choose how to authenticate with {0}", host), + }); + if (!authPicked) { + return; } + const authMethod = authPicked.method; let privateKeyPath: string | undefined; let password: string | undefined; @@ -390,100 +593,198 @@ async function promptForRemoteFolder( view?.selectWorkspace({ providerId: provider.id, workspace }); } -async function promptForManualHost( - quickInputService: IQuickInputService, -): Promise<{ host: string; username: string | undefined; port: number | undefined } | undefined> { - const validateSshHostInput = (value: string): string | undefined => { - const v = value.trim(); - if (!v) { - return localize('sshHostEmpty', "Enter an SSH host."); - } - const atIdx = v.indexOf('@'); - if (atIdx === 0) { - return localize('sshUsernameMissingInHost', "Enter a username before '@'."); - } - if (atIdx === v.length - 1) { - return localize('sshHostMissingAfterAt', "Enter a host name after '@'."); - } - const hostPart = atIdx !== -1 ? v.substring(atIdx + 1) : v; - if (!hostPart) { - return localize('sshHostMissingAfterAt', "Enter a host name after '@'."); - } - const colonIdx = hostPart.lastIndexOf(':'); - if (colonIdx !== -1) { - const hostName = hostPart.substring(0, colonIdx); - const portStr = hostPart.substring(colonIdx + 1); - if (!hostName) { - return localize('sshHostMissingAfterAt', "Enter a host name after '@'."); - } - if (portStr) { - const portNum = Number(portStr); - if (!Number.isInteger(portNum) || portNum <= 0 || portNum > 65535) { - return localize('sshHostInvalidPort', "Enter a valid port number."); - } - } +registerAction2(class extends Action2 { + constructor() { + super({ + id: RemoteAgentHostCommandIds.connectViaSSH, + title: localize2('connectViaSSH', "Connect to Remote Agent Host via SSH"), + shortTitle: localize2('connectViaSSHShort', "SSH..."), + category: SessionsCategories.Sessions, + f1: true, + icon: Codicon.remote, + precondition: ContextKeyExpr.equals(`config.${RemoteAgentHostsEnabledSettingId}`, true), + menu: { + id: Menus.SessionWorkspaceManage, + order: 20, + }, + }); + } + + override async run(accessor: ServicesAccessor, onBack?: () => void): Promise { + const result = await promptToConnectViaSSH(accessor, { showBackButton: !!onBack }); + if (result === 'back') { + onBack?.(); } - return undefined; - }; + } +}); - const hostInput = await quickInputService.input({ - title: localize('sshManualHostTitle', "Connect via SSH"), - prompt: localize('sshHostPrompt', "Enter the SSH host (e.g. user@hostname or user@hostname:port)."), - placeHolder: 'user@myserver.example.com', - ignoreFocusLost: true, - validateInput: async value => validateSshHostInput(value), - }); - if (!hostInput) { - return undefined; +registerAction2(class extends Action2 { + constructor() { + super({ + id: RemoteAgentHostCommandIds.addNewSSHHost, + title: localize2('addNewSSHHost', "Add New SSH Host..."), + category: SessionsCategories.Sessions, + f1: true, + precondition: ContextKeyExpr.equals(`config.${RemoteAgentHostsEnabledSettingId}`, true), + }); } - const trimmed = hostInput.trim(); - let username: string | undefined; - let host: string; - let port: number | undefined; - const atIndex = trimmed.indexOf('@'); + override async run(accessor: ServicesAccessor): Promise { + const sshService = accessor.get(ISSHRemoteAgentHostService); + const editorService = accessor.get(IEditorService); + const fileService = accessor.get(IFileService); + const notificationService = accessor.get(INotificationService); - let hostPart: string; - if (atIndex !== -1) { - username = trimmed.substring(0, atIndex); - hostPart = trimmed.substring(atIndex + 1); - } else { - hostPart = trimmed; - } + let configUri; + try { + configUri = await sshService.ensureUserSSHConfig(); + } catch (err) { + notificationService.error(localize('sshConfigCreateFailed', "Failed to create SSH config file: {0}", String(err))); + return; + } - const colonIndex = hostPart.lastIndexOf(':'); - if (colonIndex !== -1) { - host = hostPart.substring(0, colonIndex); - const portStr = hostPart.substring(colonIndex + 1); - if (portStr) { - port = Number(portStr); + const editorPane = await editorService.openEditor({ resource: configUri, options: { pinned: true } satisfies ITextEditorOptions }); + if (!editorPane) { + return; + } + const control = editorPane.getControl(); + if (!isCodeEditor(control) || !control.hasModel()) { + return; + } + const editor = control as ICodeEditor; + const model = editor.getModel(); + if (!model) { + return; } - } else { - host = hostPart; - } - return { host, username, port }; -} + // Append a snippet at end of document. Read file content for length; + // fall back to model length to avoid races. + let appendNewline = false; + try { + const stat = await fileService.stat(configUri); + if (stat.size > 0) { + const content = model.getValueInRange(model.getFullModelRange(), EndOfLinePreference.LF); + appendNewline = content.length > 0 && !content.endsWith('\n'); + } + } catch { + // ignore + } + const lastLine = model.getLineCount(); + const lastCol = model.getLineMaxColumn(lastLine); + editor.setSelection(new Range(lastLine, lastCol, lastLine, lastCol)); + + const snippet = (appendNewline ? '\n' : '') + 'Host ${1:alias}\n HostName ${2:hostname}\n User ${3:user}\n'; + SnippetController2.get(editor)?.insert(snippet); + editor.focus(); + } +}); registerAction2(class extends Action2 { constructor() { super({ - id: 'workbench.action.sessions.connectViaSSH', - title: localize2('connectViaSSH', "Connect to Remote Agent Host via SSH"), - shortTitle: localize2('connectViaSSHShort', "SSH..."), + id: RemoteAgentHostCommandIds.configureSSHHosts, + title: localize2('configureSSHHosts', "Configure SSH Hosts..."), category: SessionsCategories.Sessions, f1: true, - icon: Codicon.remote, precondition: ContextKeyExpr.equals(`config.${RemoteAgentHostsEnabledSettingId}`, true), - menu: { - id: Menus.SessionWorkspaceManage, - order: 20, - }, }); } - override async run(accessor: ServicesAccessor): Promise { - await promptToConnectViaSSH(accessor); + override async run(accessor: ServicesAccessor, onBack?: () => void): Promise { + const sshService = accessor.get(ISSHRemoteAgentHostService); + const editorService = accessor.get(IEditorService); + const quickInputService = accessor.get(IQuickInputService); + const notificationService = accessor.get(INotificationService); + + let configFiles: URI[]; + try { + configFiles = await sshService.listSSHConfigFiles(); + } catch (err) { + notificationService.error(localize('sshConfigListFailed', "Failed to list SSH config files: {0}", String(err))); + return; + } + + // Always offer the user-config fallback so we have something openable. + if (configFiles.length === 0) { + try { + const uri = await sshService.ensureUserSSHConfig(); + await editorService.openEditor({ resource: uri, options: { pinned: true } satisfies ITextEditorOptions }); + } catch (err) { + notificationService.error(localize('sshConfigOpenFailed', "Failed to open SSH config file: {0}", String(err))); + } + return; + } + + interface ISSHConfigFilePickItem extends IQuickPickItem { + readonly uri: URI; + readonly isUserConfig: boolean; + } + const userConfigUri = configFiles[0]; + const items: ISSHConfigFilePickItem[] = configFiles.map((uri, index) => ({ + label: uri.fsPath, + uri, + isUserConfig: index === 0, + })); + + // If there's only one file, skip the picker and open it directly. + // If onBack is provided we still need to show the picker to offer navigation. + if (items.length === 1 && !onBack) { + const picked = items[0]; + try { + const uri = picked.isUserConfig + ? await sshService.ensureUserSSHConfig().catch(() => userConfigUri) + : picked.uri; + await editorService.openEditor({ resource: uri, options: { pinned: true } satisfies ITextEditorOptions }); + } catch (err) { + notificationService.error(localize('sshConfigOpenFailed', "Failed to open SSH config file: {0}", String(err))); + } + return; + } + + const picked = await new Promise<'back' | ISSHConfigFilePickItem | undefined>(resolve => { + const store = new DisposableStore(); + const picker = store.add(quickInputService.createQuickPick()); + picker.title = localize('sshConfigPickTitle', "Select SSH configuration file to edit"); + picker.placeholder = localize('sshConfigPickPlaceholder', "Select an SSH configuration file"); + picker.items = items; + if (onBack) { + picker.buttons = [quickInputService.backButton]; + } + store.add(picker.onDidTriggerButton(button => { + if (button === quickInputService.backButton) { + resolve('back'); + picker.hide(); + } + })); + store.add(picker.onDidAccept(() => { + resolve(picker.selectedItems[0]); + picker.hide(); + })); + store.add(picker.onDidHide(() => { + resolve(undefined); + store.dispose(); + })); + picker.show(); + }); + + if (picked === 'back') { + onBack?.(); + return; + } + if (!picked) { + return; + } + + try { + // If the user picked the user config, ensure it exists (creating it on demand) + // before opening so we don't try to open a file that's not there yet. + const uri = picked.isUserConfig + ? await sshService.ensureUserSSHConfig().catch(() => userConfigUri) + : picked.uri; + await editorService.openEditor({ resource: uri, options: { pinned: true } satisfies ITextEditorOptions }); + } catch (err) { + notificationService.error(localize('sshConfigOpenFailed', "Failed to open SSH config file: {0}", String(err))); + } } }); @@ -499,7 +800,8 @@ interface IAuthProviderPickItem extends IQuickPickItem { async function promptToConnectViaTunnel( accessor: ServicesAccessor, -): Promise { + options: { showBackButton?: boolean } = {}, +): Promise<'back' | void> { const tunnelService = accessor.get(ITunnelAgentHostService); const quickInputService = accessor.get(IQuickInputService); const notificationService = accessor.get(INotificationService); @@ -547,23 +849,27 @@ async function promptToConnectViaTunnel( } // Step 2: Show tunnel picker immediately in busy state while enumerating - const tunnelPicker = quickInputService.createQuickPick(); + const store = new DisposableStore(); + const tunnelPicker = store.add(quickInputService.createQuickPick()); tunnelPicker.title = localize('tunnelPickTitle', "Connect via Dev Tunnel"); tunnelPicker.placeholder = localize('tunnelPickPlaceholder', "Select a dev tunnel to connect to"); tunnelPicker.busy = true; + if (options.showBackButton) { + tunnelPicker.buttons = [quickInputService.backButton]; + } tunnelPicker.show(); let tunnels: ITunnelInfo[]; try { tunnels = await tunnelService.listTunnels(); } catch (err) { - tunnelPicker.dispose(); + store.dispose(); notificationService.error(localize('tunnelListFailed', "Failed to list dev tunnels: {0}", err instanceof Error ? err.message : String(err))); return; } if (tunnels.length === 0) { - tunnelPicker.dispose(); + store.dispose(); notificationService.info(localize('tunnelNoneFound', "No dev tunnels with agent host support were found. Start a tunnel with 'code tunnel' on another machine.")); return; } @@ -576,16 +882,26 @@ async function promptToConnectViaTunnel( tunnelPicker.busy = false; // Step 3: Wait for user selection - const picked = await new Promise(resolve => { - tunnelPicker.onDidAccept(() => { + const picked = await new Promise<'back' | ITunnelPickItem | undefined>(resolve => { + store.add(tunnelPicker.onDidTriggerButton(button => { + if (button === quickInputService.backButton) { + resolve('back'); + tunnelPicker.hide(); + } + })); + store.add(tunnelPicker.onDidAccept(() => { resolve(tunnelPicker.selectedItems[0]); - tunnelPicker.dispose(); - }); - tunnelPicker.onDidHide(() => { + tunnelPicker.hide(); + })); + store.add(tunnelPicker.onDidHide(() => { resolve(undefined); - tunnelPicker.dispose(); - }); + store.dispose(); + })); }); + + if (picked === 'back') { + return 'back'; + } if (!picked) { return; } @@ -653,7 +969,7 @@ async function promptForTunnelFolder( registerAction2(class extends Action2 { constructor() { super({ - id: 'workbench.action.sessions.connectViaTunnel', + id: RemoteAgentHostCommandIds.connectViaTunnel, title: localize2('connectViaTunnel', "Connect to Remote Agent Host via Dev Tunnel"), shortTitle: localize2('connectViaTunnelShort', "Tunnels..."), category: SessionsCategories.Sessions, @@ -667,7 +983,10 @@ registerAction2(class extends Action2 { }); } - override async run(accessor: ServicesAccessor): Promise { - await promptToConnectViaTunnel(accessor); + override async run(accessor: ServicesAccessor, onBack?: () => void): Promise { + const result = await promptToConnectViaTunnel(accessor, { showBackButton: !!onBack }); + if (result === 'back') { + onBack?.(); + } } }); diff --git a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteHostOptions.ts b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteHostOptions.ts new file mode 100644 index 0000000000000..b4a18cb9d532f --- /dev/null +++ b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteHostOptions.ts @@ -0,0 +1,143 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { DisposableStore } from '../../../../base/common/lifecycle.js'; +import { IRemoteAgentHostService, RemoteAgentHostConnectionStatus } from '../../../../platform/agentHost/common/remoteAgentHostService.js'; +import { IClipboardService } from '../../../../platform/clipboard/common/clipboardService.js'; +import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; +import { IQuickInputService, IQuickPickItem } from '../../../../platform/quickinput/common/quickInput.js'; +import { IOutputService } from '../../../../workbench/services/output/common/output.js'; +import { IPreferencesService } from '../../../../workbench/services/preferences/common/preferences.js'; +import { IAgentHostSessionsProvider } from '../../../common/agentHostSessionsProvider.js'; + +export function getStatusLabel(status: RemoteAgentHostConnectionStatus): string { + switch (status) { + case RemoteAgentHostConnectionStatus.Connected: + return localize('workspacePicker.statusOnline', "Online"); + case RemoteAgentHostConnectionStatus.Connecting: + return localize('workspacePicker.statusConnecting', "Connecting"); + case RemoteAgentHostConnectionStatus.Disconnected: + return localize('workspacePicker.statusOffline', "Offline"); + } +} + +export function getStatusHover(status: RemoteAgentHostConnectionStatus, address?: string): string { + switch (status) { + case RemoteAgentHostConnectionStatus.Connected: + return address + ? localize('workspacePicker.hoverConnectedAddr', "Remote agent host is connected and ready.\n\nAddress: {0}", address) + : localize('workspacePicker.hoverConnected', "Remote agent host is connected and ready."); + case RemoteAgentHostConnectionStatus.Connecting: + return address + ? localize('workspacePicker.hoverConnectingAddr', "Attempting to connect to remote agent host...\n\nAddress: {0}", address) + : localize('workspacePicker.hoverConnecting', "Attempting to connect to remote agent host..."); + case RemoteAgentHostConnectionStatus.Disconnected: + return address + ? localize('workspacePicker.hoverDisconnectedAddr', "Remote agent host is disconnected.\n\nAddress: {0}", address) + : localize('workspacePicker.hoverDisconnected', "Remote agent host is disconnected."); + } +} + +export interface IShowRemoteHostOptionsOptions { + /** When true, show a Back button in the picker title bar. The promise resolves to `'back'` if pressed. */ + readonly showBackButton?: boolean; +} + +/** + * Show the per-remote management options quickpick (Reconnect / Remove / + * Copy Address / Open Settings / Show Output) for the given provider. + * + * Used by both the Workspace Picker's Manage submenu and the F1 + * "Manage Remote Agent Hosts..." command, so both surfaces drive the + * same actions. Callers that don't have a {@link ServicesAccessor} should + * use `instantiationService.invokeFunction(accessor => showRemoteHostOptions(accessor, provider))`. + * + * Returns `'back'` if the user clicked the back button (only possible when + * `options.showBackButton` is true), otherwise `undefined`. + */ +export async function showRemoteHostOptions(accessor: ServicesAccessor, provider: IAgentHostSessionsProvider, options: IShowRemoteHostOptionsOptions = {}): Promise<'back' | undefined> { + const address = provider.remoteAddress; + if (!address) { + return undefined; + } + + const quickInputService = accessor.get(IQuickInputService); + const remoteAgentHostService = accessor.get(IRemoteAgentHostService); + const clipboardService = accessor.get(IClipboardService); + const preferencesService = accessor.get(IPreferencesService); + const outputService = accessor.get(IOutputService); + + const status = provider.connectionStatus?.get(); + const isConnected = status === RemoteAgentHostConnectionStatus.Connected; + + type RemoteOptionPickItem = IQuickPickItem & { id: string }; + const items: RemoteOptionPickItem[] = []; + if (!isConnected) { + items.push({ label: '$(debug-restart) ' + localize('workspacePicker.reconnect', "Reconnect"), id: 'reconnect' }); + } + items.push( + { label: '$(trash) ' + localize('workspacePicker.removeRemote', "Remove Remote"), id: 'remove' }, + { label: '$(copy) ' + localize('workspacePicker.copyAddress', "Copy Address"), id: 'copy' }, + { label: '$(settings-gear) ' + localize('workspacePicker.openSettings', "Open Settings"), id: 'settings' }, + ); + if (provider.outputChannelId) { + items.push({ label: '$(output) ' + localize('workspacePicker.showOutput', "Show Output"), id: 'output' }); + } + + const result = await new Promise<'back' | RemoteOptionPickItem | undefined>((resolve) => { + const store = new DisposableStore(); + const picker = store.add(quickInputService.createQuickPick()); + picker.placeholder = localize('workspacePicker.remoteOptionsTitle', "Options for {0}", provider.label); + picker.items = items; + if (options.showBackButton) { + picker.buttons = [quickInputService.backButton]; + } + store.add(picker.onDidTriggerButton(button => { + if (button === quickInputService.backButton) { + resolve('back'); + picker.hide(); + } + })); + store.add(picker.onDidAccept(() => { + resolve(picker.selectedItems[0]); + picker.hide(); + })); + store.add(picker.onDidHide(() => { + resolve(undefined); + store.dispose(); + })); + picker.show(); + }); + + if (result === 'back') { + return 'back'; + } + if (!result) { + return undefined; + } + + switch (result.id) { + case 'reconnect': + remoteAgentHostService.reconnect(address); + break; + case 'remove': + await remoteAgentHostService.removeRemoteAgentHost(address); + break; + case 'copy': + await clipboardService.writeText(address); + break; + case 'settings': + await preferencesService.openSettings({ query: 'chat.remoteAgentHosts' }); + break; + case 'output': + if (provider.outputChannelId) { + outputService.showChannel(provider.outputChannelId, true); + } + break; + } + return undefined; +} + diff --git a/src/vs/sessions/contrib/welcome/browser/media/sessionsWalkthrough.css b/src/vs/sessions/contrib/welcome/browser/media/sessionsWalkthrough.css index e281e2982a176..eaf9acafd7ae1 100644 --- a/src/vs/sessions/contrib/welcome/browser/media/sessionsWalkthrough.css +++ b/src/vs/sessions/contrib/welcome/browser/media/sessionsWalkthrough.css @@ -363,6 +363,43 @@ border-radius: 2px !important; } +/* ---- Welcome screen (first launch + signed in) ---- */ + +.sessions-walkthrough-welcome-actions { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 12px; + margin-top: 16px; +} + +.sessions-walkthrough-get-started-btn { + padding: 8px 24px; + border-radius: 6px; + border: none; + background: var(--vscode-button-background); + color: var(--vscode-button-foreground); + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: background 100ms; +} + +.sessions-walkthrough-get-started-btn:hover { + background: var(--vscode-button-hoverBackground); +} + +.sessions-walkthrough-get-started-btn:focus-visible { + outline: 2px solid var(--vscode-focusBorder); + outline-offset: 2px; +} + +.sessions-walkthrough-tagline { + font-style: italic; + opacity: 0.85; + margin-top: 4px; +} + /* Reduced motion */ .monaco-reduce-motion .sessions-walkthrough-overlay, diff --git a/src/vs/sessions/contrib/welcome/browser/sessionsWalkthrough.ts b/src/vs/sessions/contrib/welcome/browser/sessionsWalkthrough.ts index a551af470e4d9..1ca47e0a3b776 100644 --- a/src/vs/sessions/contrib/welcome/browser/sessionsWalkthrough.ts +++ b/src/vs/sessions/contrib/welcome/browser/sessionsWalkthrough.ts @@ -49,12 +49,22 @@ export class SessionsWalkthroughOverlay extends Disposable { private currentFocusableElements: readonly HTMLElement[] = []; private _resolveOutcome!: (outcome: WalkthroughOutcome) => void; private _outcomeResolved = false; + private _isShowingWelcome = false; + + /** + * Whether the overlay is currently displaying the signed-in welcome + * greeting (as opposed to the sign-in provider buttons). When `true`, + * external callers should **not** auto-dismiss the overlay — the user + * is expected to click "Get Started" to proceed. + */ + get isShowingWelcome(): boolean { return this._isShowingWelcome; } /** Resolves when the user completes or dismisses the walkthrough. */ readonly outcome: Promise = new Promise(resolve => { this._resolveOutcome = resolve; }); constructor( container: HTMLElement, + private readonly _isFirstLaunch: boolean, @IChatEntitlementService private readonly chatEntitlementService: ChatEntitlementService, @IAuthenticationService private readonly authenticationService: IAuthenticationService, @ICommandService private readonly commandService: ICommandService, @@ -102,6 +112,12 @@ export class SessionsWalkthroughOverlay extends Disposable { this.disclaimerElement = disclaimer.element; this.disclaimerLinks = disclaimer.links; + // Set synchronously so the autorun in the contribution doesn't + // auto-dismiss before the async _renderSignIn completes. + if (this._isFirstLaunch && this._isAlreadySetUp()) { + this._isShowingWelcome = true; + } + this._renderSignIn(); } @@ -115,26 +131,42 @@ export class SessionsWalkthroughOverlay extends Disposable { this.footerContainer.textContent = ''; this.disclaimerElement.classList.toggle('hidden', this.disclaimerLinks.length === 0); + const productName = this.productService.nameLong; + // Horizontal layout: icon left, text + buttons right const layout = append(this.contentContainer, $('.sessions-walkthrough-hero')); append(layout, $('div.sessions-walkthrough-logo')); const right = append(layout, $('.sessions-walkthrough-hero-text')); - const titleEl = append(right, $('h2', undefined, localize('walkthrough.step1.title', "Welcome to Agents"))); - const subtitleEl = append(right, $('p', undefined, localize('walkthrough.step1.subtitle', "Sign in to continue with agent-powered development."))); - // If already signed in, finish immediately so the app can render. - if (this._isAlreadySetUp()) { - this.complete(); + // First time + signed in → welcome greeting with "Get Started" + if (this._isFirstLaunch && this._isAlreadySetUp()) { + this._renderWelcome(stepDisposables, right, productName); return; } + // First time + not signed in → welcome content with sign-in buttons + // Returning + not signed in → plain sign-in screen + const titleEl = this._isFirstLaunch + ? append(right, $('h2', undefined, localize('walkthrough.welcome.title', "Welcome to {0}", productName))) + : append(right, $('h2', undefined, localize('walkthrough.signin.title', "Sign In"))); + const subtitleEl = append(right, $('p', undefined, this._isFirstLaunch + ? localize('walkthrough.welcome.subtitle', "Your AI-powered coding agent that builds, tests, and iterates for you.") + : localize('walkthrough.signin.subtitle', "Sign in to continue."))); + if (this._isFirstLaunch) { + append(right, $('p.sessions-walkthrough-tagline', undefined, localize('walkthrough.welcome.tagline', "Happy Agentic Coding!"))); + } + + this._renderSignInButtons(stepDisposables, right, titleEl, subtitleEl); + } + + private _renderSignInButtons(stepDisposables: DisposableStore, right: HTMLElement, titleEl: HTMLElement, subtitleEl: HTMLElement): void { const signInActions = append(right, $('.sessions-walkthrough-sign-in-actions')); const providerRow = append(signInActions, $('.sessions-walkthrough-providers-row')); const githubBtn = append(providerRow, $('button.sessions-walkthrough-provider-btn.sessions-walkthrough-provider-primary.provider-github')) as HTMLButtonElement; - append(githubBtn, $('span.sessions-walkthrough-provider-label', undefined, localize('walkthrough.signin.github', "Continue with GitHub"))); + append(githubBtn, $('span.sessions-walkthrough-provider-label', undefined, localize('walkthrough.signin.github', "Sign in with GitHub"))); // Desktop-only provider buttons let providerButtons: HTMLButtonElement[]; @@ -202,6 +234,34 @@ export class SessionsWalkthroughOverlay extends Disposable { } } + // ------------------------------------------------------------------ + // Welcome (first launch + signed in) + + private _renderWelcome(stepDisposables: DisposableStore, right: HTMLElement, productName: string): void { + this._isShowingWelcome = true; + this.disclaimerElement.classList.add('hidden'); + + append(right, $('h2', undefined, localize('walkthrough.welcome.title', "Welcome to {0}", productName))); + append(right, $('p', undefined, localize('walkthrough.welcome.subtitle', "Your AI-powered coding agent that builds, tests, and iterates for you."))); + append(right, $('p.sessions-walkthrough-tagline', undefined, localize('walkthrough.welcome.tagline', "Happy Agentic Coding!"))); + + const actions = append(right, $('.sessions-walkthrough-welcome-actions')); + const getStartedBtn = append(actions, $('button.sessions-walkthrough-get-started-btn')) as HTMLButtonElement; + getStartedBtn.textContent = localize('walkthrough.welcome.getStarted', "Get Started"); + stepDisposables.add(addDisposableListener(getStartedBtn, EventType.CLICK, () => { + this._isShowingWelcome = false; + this.complete(); + })); + + this.currentFocusableElements = [getStartedBtn]; + + disposableTimeout(() => { + if (this.overlay.isConnected) { + getStartedBtn.focus(); + } + }, 0, stepDisposables); + } + private _isAlreadySetUp(): boolean { const { sentiment, entitlement } = this.chatEntitlementService; return !!( diff --git a/src/vs/sessions/contrib/welcome/browser/welcome.contribution.ts b/src/vs/sessions/contrib/welcome/browser/welcome.contribution.ts index 3ad2a355b1910..7d18eb1d5f123 100644 --- a/src/vs/sessions/contrib/welcome/browser/welcome.contribution.ts +++ b/src/vs/sessions/contrib/welcome/browser/welcome.contribution.ts @@ -75,13 +75,14 @@ export function resetSessionsWelcome( const walkthrough = store.add(instantiationService.createInstance( SessionsWalkthroughOverlay, layoutService.mainContainer, + true, )); store.add(autorun(reader => { chatEntitlementService.sentimentObs.read(reader); chatEntitlementService.entitlementObs.read(reader); - if (!needsChatSetup(chatEntitlementService)) { + if (!walkthrough.isShowingWelcome && !needsChatSetup(chatEntitlementService)) { storageService.store(WELCOME_COMPLETE_KEY, true, StorageScope.APPLICATION, StorageTarget.MACHINE); walkthrough.complete(); store.dispose(); @@ -141,7 +142,7 @@ export class SessionsWelcomeContribution extends Disposable implements IWorkbenc } const isFirstLaunch = !this.storageService.getBoolean(WELCOME_COMPLETE_KEY, StorageScope.APPLICATION, false); if (isFirstLaunch) { - this.showWalkthrough(); + this.showWalkthrough(true); } else { this.showWalkthroughIfNeeded(); } @@ -163,7 +164,7 @@ export class SessionsWelcomeContribution extends Disposable implements IWorkbenc } catch { // Provider not available yet — show walkthrough } - this.showWalkthrough(); + this.showWalkthrough(false); } /** @@ -188,13 +189,13 @@ export class SessionsWelcomeContribution extends Disposable implements IWorkbenc } this.logService.info('[sessions welcome] GitHub session removed on web, re-showing walkthrough'); this.storageService.remove(WELCOME_COMPLETE_KEY, StorageScope.APPLICATION); - this.showWalkthrough(); + this.showWalkthrough(false); })); } private showWalkthroughIfNeeded(): void { if (this._needsChatSetup()) { - this.showWalkthrough(); + this.showWalkthrough(false); } else { this.watchEntitlementState(); } @@ -220,7 +221,7 @@ export class SessionsWelcomeContribution extends Disposable implements IWorkbenc const includeUnknown = !this.storageService.getBoolean(WELCOME_COMPLETE_KEY, StorageScope.APPLICATION, false); const needsSetup = this._needsChatSetup(includeUnknown); if (setupComplete && needsSetup) { - this.showWalkthrough(); + this.showWalkthrough(false); } setupComplete = !needsSetup; }); @@ -230,7 +231,7 @@ export class SessionsWelcomeContribution extends Disposable implements IWorkbenc return needsChatSetup(this.chatEntitlementService, includeUnknown); } - private showWalkthrough(): void { + private showWalkthrough(isFirstLaunch: boolean): void { if (this.overlayRef.value) { return; } @@ -247,6 +248,7 @@ export class SessionsWelcomeContribution extends Disposable implements IWorkbenc const walkthrough = this.overlayRef.value.add(this.instantiationService.createInstance( SessionsWalkthroughOverlay, this.layoutService.mainContainer, + isFirstLaunch, )); // When chat setup completes (observables flip), persist completion and @@ -255,7 +257,7 @@ export class SessionsWelcomeContribution extends Disposable implements IWorkbenc this.chatEntitlementService.sentimentObs.read(reader); this.chatEntitlementService.entitlementObs.read(reader); - if (!welcomeCompletionStored && !this._needsChatSetup()) { + if (!welcomeCompletionStored && !walkthrough.isShowingWelcome && !this._needsChatSetup()) { welcomeCompletionStored = true; this.storageService.store(WELCOME_COMPLETE_KEY, true, StorageScope.APPLICATION, StorageTarget.MACHINE); walkthrough.complete(); diff --git a/src/vs/sessions/contrib/welcome/test/browser/welcome.contribution.test.ts b/src/vs/sessions/contrib/welcome/test/browser/welcome.contribution.test.ts index 48c9cedf71036..822fc8bd23953 100644 --- a/src/vs/sessions/contrib/welcome/test/browser/welcome.contribution.test.ts +++ b/src/vs/sessions/contrib/welcome/test/browser/welcome.contribution.test.ts @@ -279,6 +279,50 @@ suite('SessionsWelcomeContribution', () => { assert.strictEqual(isOverlayVisible(), false, 'should dismiss once setup completes'); }); + (isWeb ? test.skip : test)('first-launch + already signed in shows welcome screen; Get Started completes it', async () => { + // Already set up: installed, not disabled, has entitlement + mockEntitlementService.entitlementObs.set(ChatEntitlement.Free, undefined); + mockEntitlementService.sentimentObs.set({ completed: true, installed: true } as IChatSentiment, undefined); + + instantiationService.stub(IDefaultAccountService, { + getDefaultAccount: () => Promise.resolve(undefined) + } as unknown as IDefaultAccountService); + instantiationService.stub(ILogService, new NullLogService()); + instantiationService.stub(ICommandService, { + executeCommand: () => Promise.resolve(false) + } as unknown as ICommandService); + instantiationService.stub(IExtensionService, { + stopExtensionHosts: () => Promise.resolve(false), + startExtensionHosts: () => Promise.resolve() + } as unknown as IExtensionService); + + const container = document.createElement('div'); + document.body.appendChild(container); + + try { + const overlay = disposables.add(instantiationService.createInstance(SessionsWalkthroughOverlay, container, true)); + + assert.strictEqual(overlay.isShowingWelcome, true, 'should be in welcome mode'); + assert.ok(container.querySelector('.sessions-walkthrough-get-started-btn'), 'should show Get Started button'); + assert.strictEqual(container.querySelector('.sessions-walkthrough-provider-btn'), null, 'should not show sign-in buttons'); + + let outcomeResolved = false; + overlay.outcome.then(() => { outcomeResolved = true; }); + + const getStartedBtn = container.querySelector('.sessions-walkthrough-get-started-btn'); + assert.ok(getStartedBtn); + getStartedBtn.click(); + await flushMicrotasks(); + + assert.strictEqual(overlay.isShowingWelcome, false, 'isShowingWelcome should be cleared after Get Started'); + assert.strictEqual(outcomeResolved, true, 'outcome should resolve after Get Started click'); + + overlay.dispose(); + } finally { + container.remove(); + } + }); + test('walkthrough cannot be dismissed by Escape or backdrop click', () => { mockEntitlementService.entitlementObs.set(ChatEntitlement.Unknown, undefined); mockEntitlementService.sentimentObs.set({ installed: false } as IChatSentiment, undefined); @@ -299,7 +343,7 @@ suite('SessionsWelcomeContribution', () => { document.body.appendChild(container); try { - const overlay = disposables.add(instantiationService.createInstance(SessionsWalkthroughOverlay, container)); + const overlay = disposables.add(instantiationService.createInstance(SessionsWalkthroughOverlay, container, true)); const overlayElement = container.querySelector('.sessions-walkthrough-overlay'); assert.ok(overlayElement); @@ -349,7 +393,7 @@ suite('SessionsWelcomeContribution', () => { } } as unknown as ICommandService); - const overlay = disposables.add(instantiationService.createInstance(SessionsWalkthroughOverlay, container)); + const overlay = disposables.add(instantiationService.createInstance(SessionsWalkthroughOverlay, container, true)); const githubButton = container.querySelector('.sessions-walkthrough-provider-btn.provider-github'); const googleButton = container.querySelector('.sessions-walkthrough-provider-btn.provider-google'); const appleButton = container.querySelector('.sessions-walkthrough-provider-btn.provider-apple'); @@ -406,7 +450,7 @@ suite('SessionsWelcomeContribution', () => { document.body.appendChild(container); try { - const overlay = disposables.add(instantiationService.createInstance(SessionsWalkthroughOverlay, container)); + const overlay = disposables.add(instantiationService.createInstance(SessionsWalkthroughOverlay, container, true)); const enterpriseButton = container.querySelector('.sessions-walkthrough-provider-btn.provider-enterprise'); assert.ok(enterpriseButton); @@ -455,7 +499,7 @@ suite('SessionsWelcomeContribution', () => { document.body.appendChild(container); try { - const overlay = disposables.add(instantiationService.createInstance(SessionsWalkthroughOverlay, container)); + const overlay = disposables.add(instantiationService.createInstance(SessionsWalkthroughOverlay, container, true)); const disclaimer = container.querySelector('.sessions-walkthrough-disclaimer'); assert.ok(disclaimer); assert.strictEqual(disclaimer.classList.contains('hidden'), false); @@ -499,7 +543,7 @@ suite('SessionsWelcomeContribution', () => { document.body.appendChild(container); try { - const overlay = disposables.add(instantiationService.createInstance(SessionsWalkthroughOverlay, container)); + const overlay = disposables.add(instantiationService.createInstance(SessionsWalkthroughOverlay, container, true)); const disclaimer = container.querySelector('.sessions-walkthrough-disclaimer'); assert.ok(disclaimer); assert.strictEqual(disclaimer.classList.contains('hidden'), false); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatPlanReviewPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatPlanReviewPart.ts index 0353f8c113d36..e5fc37ae323d2 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatPlanReviewPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatPlanReviewPart.ts @@ -322,24 +322,28 @@ export class ChatPlanReviewPart extends Disposable implements IChatContentPart { } this._buttonStore.add(approveButton.onDidClick(() => this.submitApproval(primary))); + // Reject button (grey secondary) immediately after the approve button + // so the primary Approve / Reject pair stays grouped together — + // omitted in the collapsed title bar (parity with + // chatToolConfirmationCarouselPart which only surfaces the primary + // action when collapsed). + if (includeReject) { + const rejectButton = new Button(container, { ...defaultButtonStyles, secondary: true }); + rejectButton.label = localize('chat.planReview.reject', 'Reject'); + this._buttonStore.add(rejectButton); + this._buttonStore.add(rejectButton.onDidClick(() => this.submitRejection())); + } + // Provide Feedback button (grey secondary) — shown only when feedback - // is enabled and we are not in collapsed mode. + // is enabled and we are not in collapsed mode. Right-aligned via CSS + // so the primary Approve / Reject pair stays grouped on the left. if (this.review.canProvideFeedback && includeReject) { const feedbackButton = new Button(container, { ...defaultButtonStyles, secondary: true }); + feedbackButton.element.classList.add('chat-plan-review-feedback-button'); feedbackButton.label = localize('chat.planReview.provideFeedback', 'Provide Feedback'); this._buttonStore.add(feedbackButton); this._buttonStore.add(feedbackButton.onDidClick(() => this.enterFeedbackMode())); } - - // Reject button (grey secondary) after the approve button — omitted in - // the collapsed title bar (parity with chatToolConfirmationCarouselPart - // which only surfaces the primary action when collapsed). - if (includeReject) { - const rejectButton = new Button(container, { ...defaultButtonStyles, secondary: true }); - rejectButton.label = localize('chat.planReview.reject', 'Reject'); - this._buttonStore.add(rejectButton); - this._buttonStore.add(rejectButton.onDidClick(() => this.submitRejection())); - } } private toggleCollapsed(): void { diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatPlanReview.css b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatPlanReview.css index 96b443aec7789..a36c11991690a 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatPlanReview.css +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatPlanReview.css @@ -273,6 +273,10 @@ align-items: center; } +.interactive-session .chat-plan-review-container > .chat-confirmation-widget2.chat-plan-review > .chat-confirmation-widget-buttons.chat-plan-review-footer .chat-buttons .monaco-button.chat-plan-review-feedback-button { + margin-left: auto; +} + /* ---------- Hidden helper ---------- */ .interactive-session .chat-plan-review-container .monaco-button.chat-plan-review-hidden { display: none; diff --git a/src/vs/workbench/contrib/webviewPanel/browser/webviewEditor.ts b/src/vs/workbench/contrib/webviewPanel/browser/webviewEditor.ts index 7e954cca0c556..dd36822808a26 100644 --- a/src/vs/workbench/contrib/webviewPanel/browser/webviewEditor.ts +++ b/src/vs/workbench/contrib/webviewPanel/browser/webviewEditor.ts @@ -107,6 +107,8 @@ export class WebviewEditor extends EditorPane { if (this.webview && this._visible) { this.setWebviewAnchorElement(this.webview); } + + this.setEditorVisible(dimension.width > 0 && dimension.height > 0); } public override focus(): void { @@ -123,6 +125,10 @@ export class WebviewEditor extends EditorPane { } protected override setEditorVisible(visible: boolean): void { + if (visible === this._visible) { + return; + } + this._visible = visible; if (this.input instanceof WebviewInput && this.webview) { if (visible) {