diff --git a/.vscode/notebooks/api.github-issues b/.vscode/notebooks/api.github-issues index d466fa1b04b5d..aca29690dc229 100644 --- a/.vscode/notebooks/api.github-issues +++ b/.vscode/notebooks/api.github-issues @@ -7,7 +7,7 @@ { "kind": 2, "language": "github-issues", - "value": "$REPO=repo:microsoft/vscode\n$MILESTONE=milestone:\"October 2025\"" + "value": "$REPO=repo:microsoft/vscode\n$MILESTONE=milestone:\"January 2026\"" }, { "kind": 1, diff --git a/eslint.config.js b/eslint.config.js index 37fb7fe63bf5b..b245f9466ac42 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -899,6 +899,7 @@ export default tseslint.config( ], 'verbs': [ 'accept', + 'archive', 'change', 'close', 'collapse', diff --git a/extensions/json-language-features/client/src/jsonClient.ts b/extensions/json-language-features/client/src/jsonClient.ts index 6d832e6c1592e..95d0a131b7c18 100644 --- a/extensions/json-language-features/client/src/jsonClient.ts +++ b/extensions/json-language-features/client/src/jsonClient.ts @@ -7,9 +7,9 @@ export type JSONLanguageStatus = { schemas: string[] }; import { workspace, window, languages, commands, LogOutputChannel, ExtensionContext, extensions, Uri, ColorInformation, - Diagnostic, StatusBarAlignment, TextEditor, TextDocument, FormattingOptions, CancellationToken, FoldingRange, + Diagnostic, StatusBarAlignment, TextDocument, FormattingOptions, CancellationToken, FoldingRange, ProviderResult, TextEdit, Range, Position, Disposable, CompletionItem, CompletionList, CompletionContext, Hover, MarkdownString, FoldingContext, DocumentSymbol, SymbolInformation, l10n, - RelativePattern + RelativePattern, CodeAction, CodeActionKind, CodeActionContext } from 'vscode'; import { LanguageClientOptions, RequestType, NotificationType, FormattingOptions as LSPFormattingOptions, DocumentDiagnosticReportKind, @@ -20,8 +20,9 @@ import { import { hash } from './utils/hash'; -import { createDocumentSymbolsLimitItem, createLanguageStatusItem, createLimitStatusItem } from './languageStatus'; +import { createDocumentSymbolsLimitItem, createLanguageStatusItem, createLimitStatusItem, createSchemaLoadIssueItem, createSchemaLoadStatusItem } from './languageStatus'; import { getLanguageParticipants, LanguageParticipants } from './languageParticipants'; +import { matchesUrlPattern } from './utils/urlMatch'; namespace VSCodeContentRequest { export const type: RequestType = new RequestType('vscode/content'); @@ -42,6 +43,7 @@ namespace LanguageStatusRequest { namespace ValidateContentRequest { export const type: RequestType<{ schemaUri: string; content: string }, LSPDiagnostic[], any> = new RequestType('json/validateContent'); } + interface SortOptions extends LSPFormattingOptions { } @@ -110,6 +112,7 @@ export namespace SettingIds { export const enableKeepLines = 'json.format.keepLines'; export const enableValidation = 'json.validate.enable'; export const enableSchemaDownload = 'json.schemaDownload.enable'; + export const trustedDomains = 'json.schemaDownload.trustedDomains'; export const maxItemsComputed = 'json.maxItemsComputed'; export const editorFoldingMaximumRegions = 'editor.foldingMaximumRegions'; export const editorColorDecoratorsLimit = 'editor.colorDecoratorsLimit'; @@ -119,6 +122,17 @@ export namespace SettingIds { export const colorDecoratorsLimit = 'colorDecoratorsLimit'; } +export namespace CommandIds { + export const workbenchActionOpenSettings = 'workbench.action.openSettings'; + export const workbenchTrustManage = 'workbench.trust.manage'; + export const retryResolveSchemaCommandId = '_json.retryResolveSchema'; + export const configureTrustedDomainsCommandId = '_json.configureTrustedDomains'; + export const showAssociatedSchemaList = '_json.showAssociatedSchemaList'; + export const clearCacheCommandId = 'json.clearCache'; + export const validateCommandId = 'json.validate'; + export const sortCommandId = 'json.sort'; +} + export interface TelemetryReporter { sendTelemetryEvent(eventName: string, properties?: { [key: string]: string; @@ -143,6 +157,16 @@ export interface SchemaRequestService { clearCache?(): Promise; } +export enum SchemaRequestServiceErrors { + UntrustedWorkspaceError = 1, + UntrustedSchemaError = 2, + OpenTextDocumentAccessError = 3, + HTTPDisabledError = 4, + HTTPError = 5, + VSCodeAccessError = 6, + UntitledAccessError = 7, +} + export const languageServerDescription = l10n.t('JSON Language Server'); let resultLimit = 5000; @@ -191,6 +215,8 @@ async function startClientWithParticipants(_context: ExtensionContext, languageP const toDispose: Disposable[] = []; let rangeFormatting: Disposable | undefined = undefined; + let settingsCache: Settings | undefined = undefined; + let schemaAssociationsCache: Promise | undefined = undefined; const documentSelector = languageParticipants.documentSelector; @@ -200,14 +226,18 @@ async function startClientWithParticipants(_context: ExtensionContext, languageP toDispose.push(schemaResolutionErrorStatusBarItem); const fileSchemaErrors = new Map(); - let schemaDownloadEnabled = true; + let schemaDownloadEnabled = !!workspace.getConfiguration().get(SettingIds.enableSchemaDownload); + let trustedDomains = workspace.getConfiguration().get>(SettingIds.trustedDomains, {}); let isClientReady = false; const documentSymbolsLimitStatusbarItem = createLimitStatusItem((limit: number) => createDocumentSymbolsLimitItem(documentSelector, SettingIds.maxItemsComputed, limit)); toDispose.push(documentSymbolsLimitStatusbarItem); - toDispose.push(commands.registerCommand('json.clearCache', async () => { + const schemaLoadStatusItem = createSchemaLoadStatusItem((diagnostic: Diagnostic) => createSchemaLoadIssueItem(documentSelector, schemaDownloadEnabled, diagnostic)); + toDispose.push(schemaLoadStatusItem); + + toDispose.push(commands.registerCommand(CommandIds.clearCacheCommandId, async () => { if (isClientReady && runtime.schemaRequests.clearCache) { const cachedSchemas = await runtime.schemaRequests.clearCache(); await client.sendNotification(SchemaContentChangeNotification.type, cachedSchemas); @@ -215,12 +245,12 @@ async function startClientWithParticipants(_context: ExtensionContext, languageP window.showInformationMessage(l10n.t('JSON schema cache cleared.')); })); - toDispose.push(commands.registerCommand('json.validate', async (schemaUri: Uri, content: string) => { + toDispose.push(commands.registerCommand(CommandIds.validateCommandId, async (schemaUri: Uri, content: string) => { const diagnostics: LSPDiagnostic[] = await client.sendRequest(ValidateContentRequest.type, { schemaUri: schemaUri.toString(), content }); return diagnostics.map(client.protocol2CodeConverter.asDiagnostic); })); - toDispose.push(commands.registerCommand('json.sort', async () => { + toDispose.push(commands.registerCommand(CommandIds.sortCommandId, async () => { if (isClientReady) { const textEditor = window.activeTextEditor; @@ -239,17 +269,10 @@ async function startClientWithParticipants(_context: ExtensionContext, languageP } })); - function filterSchemaErrorDiagnostics(uri: Uri, diagnostics: Diagnostic[]): Diagnostic[] { - const schemaErrorIndex = diagnostics.findIndex(isSchemaResolveError); - if (schemaErrorIndex !== -1) { - const schemaResolveDiagnostic = diagnostics[schemaErrorIndex]; - fileSchemaErrors.set(uri.toString(), schemaResolveDiagnostic.message); - if (!schemaDownloadEnabled) { - diagnostics = diagnostics.filter(d => !isSchemaResolveError(d)); - } - if (window.activeTextEditor && window.activeTextEditor.document.uri.toString() === uri.toString()) { - schemaResolutionErrorStatusBarItem.show(); - } + function handleSchemaErrorDiagnostics(uri: Uri, diagnostics: Diagnostic[]): Diagnostic[] { + schemaLoadStatusItem.update(uri, diagnostics); + if (!schemaDownloadEnabled) { + return diagnostics.filter(d => !isSchemaResolveError(d)); } return diagnostics; } @@ -270,18 +293,18 @@ async function startClientWithParticipants(_context: ExtensionContext, languageP }, middleware: { workspace: { - didChangeConfiguration: () => client.sendNotification(DidChangeConfigurationNotification.type, { settings: getSettings() }) + didChangeConfiguration: () => client.sendNotification(DidChangeConfigurationNotification.type, { settings: getSettings(true) }) }, provideDiagnostics: async (uriOrDoc, previousResolutId, token, next) => { const diagnostics = await next(uriOrDoc, previousResolutId, token); if (diagnostics && diagnostics.kind === DocumentDiagnosticReportKind.Full) { const uri = uriOrDoc instanceof Uri ? uriOrDoc : uriOrDoc.uri; - diagnostics.items = filterSchemaErrorDiagnostics(uri, diagnostics.items); + diagnostics.items = handleSchemaErrorDiagnostics(uri, diagnostics.items); } return diagnostics; }, handleDiagnostics: (uri: Uri, diagnostics: Diagnostic[], next: HandleDiagnosticsSignature) => { - diagnostics = filterSchemaErrorDiagnostics(uri, diagnostics); + diagnostics = handleSchemaErrorDiagnostics(uri, diagnostics); next(uri, diagnostics); }, // testing the replace / insert mode @@ -373,7 +396,7 @@ async function startClientWithParticipants(_context: ExtensionContext, languageP const uri = Uri.parse(uriPath); const uriString = uri.toString(true); if (uri.scheme === 'untitled') { - throw new ResponseError(3, l10n.t('Unable to load {0}', uriString)); + throw new ResponseError(SchemaRequestServiceErrors.UntitledAccessError, l10n.t('Unable to load {0}', uriString)); } if (uri.scheme === 'vscode') { try { @@ -382,7 +405,7 @@ async function startClientWithParticipants(_context: ExtensionContext, languageP const content = await workspace.fs.readFile(uri); return new TextDecoder().decode(content); } catch (e) { - throw new ResponseError(5, e.toString(), e); + throw new ResponseError(SchemaRequestServiceErrors.VSCodeAccessError, e.toString(), e); } } else if (uri.scheme !== 'http' && uri.scheme !== 'https') { try { @@ -390,9 +413,15 @@ async function startClientWithParticipants(_context: ExtensionContext, languageP schemaDocuments[uriString] = true; return document.getText(); } catch (e) { - throw new ResponseError(2, e.toString(), e); + throw new ResponseError(SchemaRequestServiceErrors.OpenTextDocumentAccessError, e.toString(), e); + } + } else if (schemaDownloadEnabled) { + if (!workspace.isTrusted) { + throw new ResponseError(SchemaRequestServiceErrors.UntrustedWorkspaceError, l10n.t('Downloading schemas is disabled in untrusted workspaces')); + } + if (!await isTrusted(uri)) { + throw new ResponseError(SchemaRequestServiceErrors.UntrustedSchemaError, l10n.t('Location {0} is untrusted', uriString)); } - } else if (schemaDownloadEnabled && workspace.isTrusted) { if (runtime.telemetry && uri.authority === 'schema.management.azure.com') { /* __GDPR__ "json.schema" : { @@ -406,13 +435,10 @@ async function startClientWithParticipants(_context: ExtensionContext, languageP try { return await runtime.schemaRequests.getContent(uriString); } catch (e) { - throw new ResponseError(4, e.toString()); + throw new ResponseError(SchemaRequestServiceErrors.HTTPError, e.toString(), e); } } else { - if (!workspace.isTrusted) { - throw new ResponseError(1, l10n.t('Downloading schemas is disabled in untrusted workspaces')); - } - throw new ResponseError(1, l10n.t('Downloading schemas is disabled through setting \'{0}\'', SettingIds.enableSchemaDownload)); + throw new ResponseError(SchemaRequestServiceErrors.HTTPDisabledError, l10n.t('Downloading schemas is disabled through setting \'{0}\'', SettingIds.enableSchemaDownload)); } }); @@ -427,19 +453,6 @@ async function startClientWithParticipants(_context: ExtensionContext, languageP } return false; }; - const handleActiveEditorChange = (activeEditor?: TextEditor) => { - if (!activeEditor) { - return; - } - - const activeDocUri = activeEditor.document.uri.toString(); - - if (activeDocUri && fileSchemaErrors.has(activeDocUri)) { - schemaResolutionErrorStatusBarItem.show(); - } else { - schemaResolutionErrorStatusBarItem.hide(); - } - }; const handleContentClosed = (uriString: string) => { if (handleContentChange(uriString)) { delete schemaDocuments[uriString]; @@ -484,59 +497,81 @@ async function startClientWithParticipants(_context: ExtensionContext, languageP toDispose.push(workspace.onDidChangeTextDocument(e => handleContentChange(e.document.uri.toString()))); toDispose.push(workspace.onDidCloseTextDocument(d => handleContentClosed(d.uri.toString()))); - toDispose.push(window.onDidChangeActiveTextEditor(handleActiveEditorChange)); + toDispose.push(commands.registerCommand(CommandIds.retryResolveSchemaCommandId, triggerValidation)); - const handleRetryResolveSchemaCommand = () => { - if (window.activeTextEditor) { - schemaResolutionErrorStatusBarItem.text = '$(watch)'; - const activeDocUri = window.activeTextEditor.document.uri.toString(); - client.sendRequest(ForceValidateRequest.type, activeDocUri).then((diagnostics) => { - const schemaErrorIndex = diagnostics.findIndex(isSchemaResolveError); - if (schemaErrorIndex !== -1) { - // Show schema resolution errors in status bar only; ref: #51032 - const schemaResolveDiagnostic = diagnostics[schemaErrorIndex]; - fileSchemaErrors.set(activeDocUri, schemaResolveDiagnostic.message); - } else { - schemaResolutionErrorStatusBarItem.hide(); + toDispose.push(commands.registerCommand(CommandIds.configureTrustedDomainsCommandId, configureTrustedDomains)); + + toDispose.push(languages.registerCodeActionsProvider(documentSelector, { + provideCodeActions(_document: TextDocument, _range: Range, context: CodeActionContext): CodeAction[] { + const codeActions: CodeAction[] = []; + + for (const diagnostic of context.diagnostics) { + if (typeof diagnostic.code !== 'number') { + continue; } - schemaResolutionErrorStatusBarItem.text = '$(alert)'; - }); - } - }; + switch (diagnostic.code) { + case ErrorCodes.UntrustedSchemaError: { + const title = l10n.t('Configure Trusted Domains...'); + const action = new CodeAction(title, CodeActionKind.QuickFix); + const schemaUri = diagnostic.relatedInformation?.[0]?.location.uri; + if (schemaUri) { + action.command = { command: CommandIds.configureTrustedDomainsCommandId, arguments: [schemaUri.toString()], title }; + } else { + action.command = { command: CommandIds.workbenchActionOpenSettings, arguments: [SettingIds.trustedDomains], title }; + } + action.diagnostics = [diagnostic]; + action.isPreferred = true; + codeActions.push(action); + } + break; + case ErrorCodes.HTTPDisabledError: { + const title = l10n.t('Enable Schema Downloading...'); + const action = new CodeAction(title, CodeActionKind.QuickFix); + action.command = { command: CommandIds.workbenchActionOpenSettings, arguments: [SettingIds.enableSchemaDownload], title }; + action.diagnostics = [diagnostic]; + action.isPreferred = true; + codeActions.push(action); + } + break; + } + } - toDispose.push(commands.registerCommand('_json.retryResolveSchema', handleRetryResolveSchemaCommand)); + return codeActions; + } + }, { + providedCodeActionKinds: [CodeActionKind.QuickFix] + })); - client.sendNotification(SchemaAssociationNotification.type, await getSchemaAssociations()); + client.sendNotification(SchemaAssociationNotification.type, await getSchemaAssociations(false)); toDispose.push(extensions.onDidChange(async _ => { - client.sendNotification(SchemaAssociationNotification.type, await getSchemaAssociations()); + client.sendNotification(SchemaAssociationNotification.type, await getSchemaAssociations(true)); })); - const associationWatcher = workspace.createFileSystemWatcher(new RelativePattern( - Uri.parse(`vscode://schemas-associations/`), - '**/schemas-associations.json') - ); + const associationWatcher = workspace.createFileSystemWatcher(new RelativePattern(Uri.parse(`vscode://schemas-associations/`), '**/schemas-associations.json')); toDispose.push(associationWatcher); toDispose.push(associationWatcher.onDidChange(async _e => { - client.sendNotification(SchemaAssociationNotification.type, await getSchemaAssociations()); + client.sendNotification(SchemaAssociationNotification.type, await getSchemaAssociations(true)); })); // manually register / deregister format provider based on the `json.format.enable` setting avoiding issues with late registration. See #71652. updateFormatterRegistration(); toDispose.push({ dispose: () => rangeFormatting && rangeFormatting.dispose() }); - updateSchemaDownloadSetting(); - toDispose.push(workspace.onDidChangeConfiguration(e => { if (e.affectsConfiguration(SettingIds.enableFormatter)) { updateFormatterRegistration(); } else if (e.affectsConfiguration(SettingIds.enableSchemaDownload)) { - updateSchemaDownloadSetting(); + schemaDownloadEnabled = !!workspace.getConfiguration().get(SettingIds.enableSchemaDownload); + triggerValidation(); } else if (e.affectsConfiguration(SettingIds.editorFoldingMaximumRegions) || e.affectsConfiguration(SettingIds.editorColorDecoratorsLimit)) { - client.sendNotification(DidChangeConfigurationNotification.type, { settings: getSettings() }); + client.sendNotification(DidChangeConfigurationNotification.type, { settings: getSettings(true) }); + } else if (e.affectsConfiguration(SettingIds.trustedDomains)) { + trustedDomains = workspace.getConfiguration().get>(SettingIds.trustedDomains, {}); + triggerValidation(); } })); - toDispose.push(workspace.onDidGrantWorkspaceTrust(updateSchemaDownloadSetting)); + toDispose.push(workspace.onDidGrantWorkspaceTrust(() => triggerValidation())); toDispose.push(createLanguageStatusItem(documentSelector, (uri: string) => client.sendRequest(LanguageStatusRequest.type, uri))); @@ -572,20 +607,13 @@ async function startClientWithParticipants(_context: ExtensionContext, languageP } } - function updateSchemaDownloadSetting() { - if (!workspace.isTrusted) { - schemaResolutionErrorStatusBarItem.tooltip = l10n.t('Unable to download schemas in untrusted workspaces.'); - schemaResolutionErrorStatusBarItem.command = 'workbench.trust.manage'; - return; - } - schemaDownloadEnabled = workspace.getConfiguration().get(SettingIds.enableSchemaDownload) !== false; - if (schemaDownloadEnabled) { - schemaResolutionErrorStatusBarItem.tooltip = l10n.t('Unable to resolve schema. Click to retry.'); - schemaResolutionErrorStatusBarItem.command = '_json.retryResolveSchema'; - handleRetryResolveSchemaCommand(); - } else { - schemaResolutionErrorStatusBarItem.tooltip = l10n.t('Downloading schemas is disabled. Click to configure.'); - schemaResolutionErrorStatusBarItem.command = { command: 'workbench.action.openSettings', arguments: [SettingIds.enableSchemaDownload], title: '' }; + async function triggerValidation() { + const activeTextEditor = window.activeTextEditor; + if (activeTextEditor && languageParticipants.hasLanguage(activeTextEditor.document.languageId)) { + schemaResolutionErrorStatusBarItem.text = '$(watch)'; + schemaResolutionErrorStatusBarItem.tooltip = l10n.t('Validating...'); + const activeDocUri = activeTextEditor.document.uri.toString(); + await client.sendRequest(ForceValidateRequest.type, activeDocUri); } } @@ -612,6 +640,113 @@ async function startClientWithParticipants(_context: ExtensionContext, languageP }); } + function getSettings(forceRefresh: boolean): Settings { + if (!settingsCache || forceRefresh) { + settingsCache = computeSettings(); + } + return settingsCache; + } + + async function getSchemaAssociations(forceRefresh: boolean): Promise { + if (!schemaAssociationsCache || forceRefresh) { + schemaAssociationsCache = computeSchemaAssociations(); + runtime.logOutputChannel.info(`Computed schema associations: ${(await schemaAssociationsCache).map(a => `${a.uri} -> [${a.fileMatch.join(', ')}]`).join('\n')}`); + + } + return schemaAssociationsCache; + } + + async function isTrusted(uri: Uri): Promise { + if (uri.scheme !== 'http' && uri.scheme !== 'https') { + return true; + } + const uriString = uri.toString(true); + + // Check against trustedDomains setting + if (matchesUrlPattern(uri, trustedDomains)) { + return true; + } + + const knownAssociations = await getSchemaAssociations(false); + for (const association of knownAssociations) { + if (association.uri === uriString) { + return true; + } + } + const settingsCache = getSettings(false); + if (settingsCache.json && settingsCache.json.schemas) { + for (const schemaSetting of settingsCache.json.schemas) { + const schemaUri = schemaSetting.url; + if (schemaUri === uriString) { + return true; + } + } + } + return false; + } + + async function configureTrustedDomains(schemaUri: string): Promise { + interface QuickPickItemWithAction { + label: string; + description?: string; + execute: () => Promise; + } + + const items: QuickPickItemWithAction[] = []; + + try { + const uri = Uri.parse(schemaUri); + const domain = `${uri.scheme}://${uri.authority}`; + + // Add "Trust domain" option + items.push({ + label: l10n.t('Trust Domain: {0}', domain), + description: l10n.t('Allow all schemas from this domain'), + execute: async () => { + const config = workspace.getConfiguration(); + const currentDomains = config.get>(SettingIds.trustedDomains, {}); + currentDomains[domain] = true; + await config.update(SettingIds.trustedDomains, currentDomains, true); + await commands.executeCommand(CommandIds.workbenchActionOpenSettings, SettingIds.trustedDomains); + } + }); + + // Add "Trust URI" option + items.push({ + label: l10n.t('Trust URI: {0}', schemaUri), + description: l10n.t('Allow only this specific schema'), + execute: async () => { + const config = workspace.getConfiguration(); + const currentDomains = config.get>(SettingIds.trustedDomains, {}); + currentDomains[schemaUri] = true; + await config.update(SettingIds.trustedDomains, currentDomains, true); + await commands.executeCommand(CommandIds.workbenchActionOpenSettings, SettingIds.trustedDomains); + } + }); + } catch (e) { + runtime.logOutputChannel.error(`Failed to parse schema URI: ${schemaUri}`); + } + + + // Always add "Configure setting" option + items.push({ + label: l10n.t('Configure Setting'), + description: l10n.t('Open settings editor'), + execute: async () => { + await commands.executeCommand(CommandIds.workbenchActionOpenSettings, SettingIds.trustedDomains); + } + }); + + const selected = await window.showQuickPick(items, { + placeHolder: l10n.t('Select how to configure trusted schema domains') + }); + + if (selected) { + await selected.execute(); + } + } + + return { dispose: async () => { await client.stop(); @@ -621,9 +756,9 @@ async function startClientWithParticipants(_context: ExtensionContext, languageP }; } -async function getSchemaAssociations(): Promise { - return getSchemaExtensionAssociations() - .concat(await getDynamicSchemaAssociations()); +async function computeSchemaAssociations(): Promise { + const extensionAssociations = getSchemaExtensionAssociations(); + return extensionAssociations.concat(await getDynamicSchemaAssociations()); } function getSchemaExtensionAssociations(): ISchemaAssociation[] { @@ -680,7 +815,9 @@ async function getDynamicSchemaAssociations(): Promise { return result; } -function getSettings(): Settings { + + +function computeSettings(): Settings { const configuration = workspace.getConfiguration(); const httpSettings = workspace.getConfiguration('http'); @@ -781,8 +918,14 @@ function updateMarkdownString(h: MarkdownString): MarkdownString { return n; } -function isSchemaResolveError(d: Diagnostic) { - return d.code === /* SchemaResolveError */ 0x300; +export namespace ErrorCodes { + export const SchemaResolveError = 0x10000; + export const UntrustedSchemaError = SchemaResolveError + SchemaRequestServiceErrors.UntrustedSchemaError; + export const HTTPDisabledError = SchemaResolveError + SchemaRequestServiceErrors.HTTPDisabledError; +} + +export function isSchemaResolveError(d: Diagnostic) { + return typeof d.code === 'number' && d.code >= ErrorCodes.SchemaResolveError; } diff --git a/extensions/json-language-features/client/src/languageStatus.ts b/extensions/json-language-features/client/src/languageStatus.ts index 1064a0b59561d..a608b4be7ca33 100644 --- a/extensions/json-language-features/client/src/languageStatus.ts +++ b/extensions/json-language-features/client/src/languageStatus.ts @@ -6,9 +6,9 @@ import { window, languages, Uri, Disposable, commands, QuickPickItem, extensions, workspace, Extension, WorkspaceFolder, QuickPickItemKind, - ThemeIcon, TextDocument, LanguageStatusSeverity, l10n, DocumentSelector + ThemeIcon, TextDocument, LanguageStatusSeverity, l10n, DocumentSelector, Diagnostic } from 'vscode'; -import { JSONLanguageStatus, JSONSchemaSettings } from './jsonClient'; +import { CommandIds, ErrorCodes, isSchemaResolveError, JSONLanguageStatus, JSONSchemaSettings, SettingIds } from './jsonClient'; type ShowSchemasInput = { schemas: string[]; @@ -168,7 +168,7 @@ export function createLanguageStatusItem(documentSelector: DocumentSelector, sta statusItem.name = l10n.t('JSON Validation Status'); statusItem.severity = LanguageStatusSeverity.Information; - const showSchemasCommand = commands.registerCommand('_json.showAssociatedSchemaList', showSchemaList); + const showSchemasCommand = commands.registerCommand(CommandIds.showAssociatedSchemaList, showSchemaList); const activeEditorListener = window.onDidChangeActiveTextEditor(() => { updateLanguageStatus(); @@ -195,7 +195,7 @@ export function createLanguageStatusItem(documentSelector: DocumentSelector, sta statusItem.detail = l10n.t('multiple JSON schemas configured'); } statusItem.command = { - command: '_json.showAssociatedSchemaList', + command: CommandIds.showAssociatedSchemaList, title: l10n.t('Show Schemas'), arguments: [{ schemas, uri: document.uri.toString() } satisfies ShowSchemasInput] }; @@ -279,3 +279,86 @@ export function createDocumentSymbolsLimitItem(documentSelector: DocumentSelecto } +export function createSchemaLoadStatusItem(newItem: (fileSchemaError: Diagnostic) => Disposable) { + let statusItem: Disposable | undefined; + const fileSchemaErrors: Map = new Map(); + + const toDispose: Disposable[] = []; + toDispose.push(window.onDidChangeActiveTextEditor(textEditor => { + statusItem?.dispose(); + statusItem = undefined; + const doc = textEditor?.document; + if (doc) { + const fileSchemaError = fileSchemaErrors.get(doc.uri.toString()); + if (fileSchemaError !== undefined) { + statusItem = newItem(fileSchemaError); + } + } + })); + toDispose.push(workspace.onDidCloseTextDocument(document => { + fileSchemaErrors.delete(document.uri.toString()); + })); + + function update(uri: Uri, diagnostics: Diagnostic[]) { + const fileSchemaError = diagnostics.find(isSchemaResolveError); + const uriString = uri.toString(); + + if (fileSchemaError === undefined) { + fileSchemaErrors.delete(uriString); + if (statusItem && uriString === window.activeTextEditor?.document.uri.toString()) { + statusItem.dispose(); + statusItem = undefined; + } + } else { + const current = fileSchemaErrors.get(uriString); + if (current?.message === fileSchemaError.message) { + return; + } + fileSchemaErrors.set(uriString, fileSchemaError); + if (uriString === window.activeTextEditor?.document.uri.toString()) { + statusItem?.dispose(); + statusItem = newItem(fileSchemaError); + } + } + } + return { + update, + dispose() { + statusItem?.dispose(); + toDispose.forEach(d => d.dispose()); + toDispose.length = 0; + statusItem = undefined; + fileSchemaErrors.clear(); + } + }; +} + + + +export function createSchemaLoadIssueItem(documentSelector: DocumentSelector, schemaDownloadEnabled: boolean | undefined, diagnostic: Diagnostic): Disposable { + const statusItem = languages.createLanguageStatusItem('json.documentSymbolsStatus', documentSelector); + statusItem.name = l10n.t('JSON Outline Status'); + statusItem.severity = LanguageStatusSeverity.Error; + statusItem.text = 'Schema download issue'; + if (!workspace.isTrusted) { + statusItem.detail = l10n.t('Workspace untrusted'); + statusItem.command = { command: CommandIds.workbenchTrustManage, title: 'Configure Trust' }; + } else if (!schemaDownloadEnabled) { + statusItem.detail = l10n.t('Download disabled'); + statusItem.command = { command: CommandIds.workbenchActionOpenSettings, arguments: [SettingIds.enableSchemaDownload], title: 'Configure' }; + } else if (typeof diagnostic.code === 'number' && diagnostic.code === ErrorCodes.UntrustedSchemaError) { + statusItem.detail = l10n.t('Location untrusted'); + const schemaUri = diagnostic.relatedInformation?.[0]?.location.uri; + if (schemaUri) { + statusItem.command = { command: CommandIds.configureTrustedDomainsCommandId, arguments: [schemaUri.toString()], title: 'Configure Trusted Domains' }; + } else { + statusItem.command = { command: CommandIds.workbenchActionOpenSettings, arguments: [SettingIds.trustedDomains], title: 'Configure Trusted Domains' }; + } + } else { + statusItem.detail = l10n.t('Unable to resolve schema'); + statusItem.command = { command: CommandIds.retryResolveSchemaCommandId, title: 'Retry' }; + } + return Disposable.from(statusItem); +} + + diff --git a/extensions/json-language-features/client/src/utils/urlMatch.ts b/extensions/json-language-features/client/src/utils/urlMatch.ts new file mode 100644 index 0000000000000..a870c2d072626 --- /dev/null +++ b/extensions/json-language-features/client/src/utils/urlMatch.ts @@ -0,0 +1,107 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Uri } from 'vscode'; + +/** + * Check whether a URL matches the list of trusted domains or URIs. + * + * trustedDomains is an object where: + * - Keys are full domains (https://www.microsoft.com) or full URIs (https://www.test.com/schemas/mySchema.json) + * - Keys can include wildcards (https://*.microsoft.com) or glob patterns + * - Values are booleans indicating if the domain/URI is trusted (true) or blocked (false) + * + * @param url The URL to check + * @param trustedDomains Object mapping domain patterns to boolean trust values + */ +export function matchesUrlPattern(url: Uri, trustedDomains: Record): boolean { + // Check localhost + if (isLocalhostAuthority(url.authority)) { + return true; + } + + for (const [pattern, isTrusted] of Object.entries(trustedDomains)) { + if (typeof pattern !== 'string' || pattern.trim() === '') { + continue; + } + + // Wildcard matches everything + if (pattern === '*') { + return isTrusted; + } + + try { + const patternUri = Uri.parse(pattern); + + // Scheme must match + if (url.scheme !== patternUri.scheme) { + continue; + } + + // Check authority (host:port) + if (!matchesAuthority(url.authority, patternUri.authority)) { + continue; + } + + // Check path + if (!matchesPath(url.path, patternUri.path)) { + continue; + } + + return isTrusted; + } catch { + // Invalid pattern, skip + continue; + } + } + + return false; +} + +function matchesAuthority(urlAuthority: string, patternAuthority: string): boolean { + urlAuthority = urlAuthority.toLowerCase(); + patternAuthority = patternAuthority.toLowerCase(); + + if (patternAuthority === urlAuthority) { + return true; + } + // Handle wildcard subdomains (e.g., *.github.com) + if (patternAuthority.startsWith('*.')) { + const patternDomain = patternAuthority.substring(2); + // Exact match or subdomain match + return urlAuthority === patternDomain || urlAuthority.endsWith('.' + patternDomain); + } + + return false; +} + +function matchesPath(urlPath: string, patternPath: string): boolean { + // Empty pattern path or just "/" matches any path + if (!patternPath || patternPath === '/') { + return true; + } + + // Exact match + if (urlPath === patternPath) { + return true; + } + + // If pattern ends with '/', it matches any path starting with it + if (patternPath.endsWith('/')) { + return urlPath.startsWith(patternPath); + } + + // Otherwise, pattern must be a prefix + return urlPath.startsWith(patternPath + '/') || urlPath === patternPath; +} + + +const rLocalhost = /^(.+\.)?localhost(:\d+)?$/i; +const r127 = /^127\.0\.0\.1(:\d+)?$/; +const rIPv6Localhost = /^\[::1\](:\d+)?$/; + +function isLocalhostAuthority(authority: string): boolean { + return rLocalhost.test(authority) || r127.test(authority) || rIPv6Localhost.test(authority); +} diff --git a/extensions/json-language-features/package.json b/extensions/json-language-features/package.json index 50da0468e48b9..429e051159e81 100644 --- a/extensions/json-language-features/package.json +++ b/extensions/json-language-features/package.json @@ -126,6 +126,22 @@ "tags": [ "usesOnlineServices" ] + }, + "json.schemaDownload.trustedDomains": { + "type": "object", + "default": { + "https://schemastore.azurewebsites.net/": true, + "https://raw.githubusercontent.com/": true, + "https://www.schemastore.org/": true, + "https://json-schema.org/": true + }, + "additionalProperties": { + "type": "boolean" + }, + "description": "%json.schemaDownload.trustedDomains.desc%", + "tags": [ + "usesOnlineServices" + ] } } }, diff --git a/extensions/json-language-features/package.nls.json b/extensions/json-language-features/package.nls.json index abc07c993dc80..9052d3781c9ce 100644 --- a/extensions/json-language-features/package.nls.json +++ b/extensions/json-language-features/package.nls.json @@ -19,6 +19,6 @@ "json.enableSchemaDownload.desc": "When enabled, JSON schemas can be fetched from http and https locations.", "json.command.clearCache": "Clear Schema Cache", "json.command.sort": "Sort Document", - "json.workspaceTrust": "The extension requires workspace trust to load schemas from http and https." - + "json.workspaceTrust": "The extension requires workspace trust to load schemas from http and https.", + "json.schemaDownload.trustedDomains.desc": "List of trusted domains for downloading JSON schemas over http(s). Use '*' to trust all domains. '*' can also be used as a wildcard in domain names." } diff --git a/extensions/json-language-features/server/package-lock.json b/extensions/json-language-features/server/package-lock.json index fc31206a0cdab..4761136e1bf2a 100644 --- a/extensions/json-language-features/server/package-lock.json +++ b/extensions/json-language-features/server/package-lock.json @@ -12,7 +12,7 @@ "@vscode/l10n": "^0.0.18", "jsonc-parser": "^3.3.1", "request-light": "^0.8.0", - "vscode-json-languageservice": "^5.6.4", + "vscode-json-languageservice": "^5.7.1", "vscode-languageserver": "^10.0.0-next.15", "vscode-uri": "^3.1.0" }, @@ -67,9 +67,9 @@ "license": "MIT" }, "node_modules/vscode-json-languageservice": { - "version": "5.6.4", - "resolved": "https://registry.npmjs.org/vscode-json-languageservice/-/vscode-json-languageservice-5.6.4.tgz", - "integrity": "sha512-i0MhkFmnQAbYr+PiE6Th067qa3rwvvAErCEUo0ql+ghFXHvxbwG3kLbwMaIUrrbCLUDEeULiLgROJjtuyYoIsA==", + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/vscode-json-languageservice/-/vscode-json-languageservice-5.7.1.tgz", + "integrity": "sha512-sMK2F8p7St0lJCr/4IfbQRoEUDUZRR7Ud0IiSl8I/JtN+m9Gv+FJlNkSAYns2R7Ebm/PKxqUuWYOfBej/rAdBQ==", "license": "MIT", "dependencies": { "@vscode/l10n": "^0.0.18", diff --git a/extensions/json-language-features/server/package.json b/extensions/json-language-features/server/package.json index 00fff97cbe702..6534e6f0eca86 100644 --- a/extensions/json-language-features/server/package.json +++ b/extensions/json-language-features/server/package.json @@ -15,7 +15,7 @@ "@vscode/l10n": "^0.0.18", "jsonc-parser": "^3.3.1", "request-light": "^0.8.0", - "vscode-json-languageservice": "^5.6.4", + "vscode-json-languageservice": "^5.7.1", "vscode-languageserver": "^10.0.0-next.15", "vscode-uri": "^3.1.0" }, diff --git a/extensions/json-language-features/server/src/jsonServer.ts b/extensions/json-language-features/server/src/jsonServer.ts index cbe1e7d02b48a..811cbcd2e9195 100644 --- a/extensions/json-language-features/server/src/jsonServer.ts +++ b/extensions/json-language-features/server/src/jsonServer.ts @@ -5,7 +5,7 @@ import { Connection, - TextDocuments, InitializeParams, InitializeResult, NotificationType, RequestType, + TextDocuments, InitializeParams, InitializeResult, NotificationType, RequestType, ResponseError, DocumentRangeFormattingRequest, Disposable, ServerCapabilities, TextDocumentSyncKind, TextEdit, DocumentFormattingRequest, TextDocumentIdentifier, FormattingOptions, Diagnostic, CodeAction, CodeActionKind } from 'vscode-languageserver'; @@ -36,6 +36,10 @@ namespace ForceValidateRequest { export const type: RequestType = new RequestType('json/validate'); } +namespace ForceValidateAllRequest { + export const type: RequestType = new RequestType('json/validateAll'); +} + namespace LanguageStatusRequest { export const type: RequestType = new RequestType('json/languageStatus'); } @@ -102,8 +106,8 @@ export function startServer(connection: Connection, runtime: RuntimeEnvironment) } return connection.sendRequest(VSCodeContentRequest.type, uri).then(responseText => { return responseText; - }, error => { - return Promise.reject(error.message); + }, (error: ResponseError) => { + return Promise.reject(error); }); }; } @@ -298,6 +302,10 @@ export function startServer(connection: Connection, runtime: RuntimeEnvironment) }); // Retry schema validation on all open documents + connection.onRequest(ForceValidateAllRequest.type, async () => { + diagnosticsSupport?.requestRefresh(); + }); + connection.onRequest(ForceValidateRequest.type, async uri => { const document = documents.get(uri); if (document) { @@ -387,11 +395,11 @@ export function startServer(connection: Connection, runtime: RuntimeEnvironment) connection.onDidChangeWatchedFiles((change) => { // Monitored files have changed in VSCode let hasChanges = false; - change.changes.forEach(c => { + for (const c of change.changes) { if (languageService.resetSchema(c.uri)) { hasChanges = true; } - }); + } if (hasChanges) { diagnosticsSupport?.requestRefresh(); } diff --git a/src/vs/platform/extensions/common/extensionsApiProposals.ts b/src/vs/platform/extensions/common/extensionsApiProposals.ts index 2a0fdff9f2430..069ec076c4256 100644 --- a/src/vs/platform/extensions/common/extensionsApiProposals.ts +++ b/src/vs/platform/extensions/common/extensionsApiProposals.ts @@ -69,7 +69,7 @@ const _allApiProposals = { }, chatSessionsProvider: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts', - version: 3 + version: 4 }, chatStatusItem: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.chatStatusItem.d.ts', diff --git a/src/vs/workbench/api/browser/mainThreadChatSessions.ts b/src/vs/workbench/api/browser/mainThreadChatSessions.ts index 6a18a39b05ff6..38de78caf4a25 100644 --- a/src/vs/workbench/api/browser/mainThreadChatSessions.ts +++ b/src/vs/workbench/api/browser/mainThreadChatSessions.ts @@ -382,7 +382,6 @@ export class MainThreadChatSessions extends Disposable implements MainThreadChat )); } - $onDidChangeChatSessionItems(handle: number): void { this._itemProvidersRegistrations.get(handle)?.onDidChangeItems.fire(); } @@ -491,6 +490,7 @@ export class MainThreadChatSessions extends Disposable implements MainThreadChat resource: uri, iconPath: session.iconPath, tooltip: session.tooltip ? this._reviveTooltip(session.tooltip) : undefined, + archived: session.archived, } satisfies IChatSessionItem; })); } catch (error) { diff --git a/src/vs/workbench/api/common/extHost.api.impl.ts b/src/vs/workbench/api/common/extHost.api.impl.ts index a77a0079ee02e..02eeb78c9373d 100644 --- a/src/vs/workbench/api/common/extHost.api.impl.ts +++ b/src/vs/workbench/api/common/extHost.api.impl.ts @@ -1530,6 +1530,10 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I checkProposedApiEnabled(extension, 'chatSessionsProvider'); return extHostChatSessions.registerChatSessionItemProvider(extension, chatSessionType, provider); }, + createChatSessionItemController: (chatSessionType: string, refreshHandler: () => Thenable) => { + checkProposedApiEnabled(extension, 'chatSessionsProvider'); + return extHostChatSessions.createChatSessionItemController(extension, chatSessionType, refreshHandler); + }, registerChatSessionContentProvider(scheme: string, provider: vscode.ChatSessionContentProvider, chatParticipant: vscode.ChatParticipant, capabilities?: vscode.ChatSessionCapabilities) { checkProposedApiEnabled(extension, 'chatSessionsProvider'); return extHostChatSessions.registerChatSessionContentProvider(extension, scheme, chatParticipant, provider, capabilities); diff --git a/src/vs/workbench/api/common/extHostChatSessions.ts b/src/vs/workbench/api/common/extHostChatSessions.ts index bc7366256c194..c4d34921e4521 100644 --- a/src/vs/workbench/api/common/extHostChatSessions.ts +++ b/src/vs/workbench/api/common/extHostChatSessions.ts @@ -2,12 +2,14 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +/* eslint-disable local/code-no-native-private */ import type * as vscode from 'vscode'; import { coalesce } from '../../../base/common/arrays.js'; import { CancellationToken, CancellationTokenSource } from '../../../base/common/cancellation.js'; import { CancellationError } from '../../../base/common/errors.js'; -import { Disposable, DisposableStore } from '../../../base/common/lifecycle.js'; +import { Emitter } from '../../../base/common/event.js'; +import { Disposable, DisposableStore, toDisposable } from '../../../base/common/lifecycle.js'; import { ResourceMap } from '../../../base/common/map.js'; import { MarshalledId } from '../../../base/common/marshallingIds.js'; import { URI, UriComponents } from '../../../base/common/uri.js'; @@ -29,6 +31,177 @@ import { basename } from '../../../base/common/resources.js'; import { Diagnostic } from './extHostTypeConverters.js'; import { SymbolKind, SymbolKinds } from '../../../editor/common/languages.js'; +type ChatSessionTiming = vscode.ChatSessionItem['timing']; + +// #region Chat Session Item Controller + +class ChatSessionItemImpl implements vscode.ChatSessionItem { + #label: string; + #iconPath?: vscode.IconPath; + #description?: string | vscode.MarkdownString; + #badge?: string | vscode.MarkdownString; + #status?: vscode.ChatSessionStatus; + #archived?: boolean; + #tooltip?: string | vscode.MarkdownString; + #timing?: ChatSessionTiming; + #changes?: readonly vscode.ChatSessionChangedFile[] | { files: number; insertions: number; deletions: number }; + #onChanged: () => void; + + readonly resource: vscode.Uri; + + constructor(resource: vscode.Uri, label: string, onChanged: () => void) { + this.resource = resource; + this.#label = label; + this.#onChanged = onChanged; + } + + get label(): string { + return this.#label; + } + + set label(value: string) { + if (this.#label !== value) { + this.#label = value; + this.#onChanged(); + } + } + + get iconPath(): vscode.IconPath | undefined { + return this.#iconPath; + } + + set iconPath(value: vscode.IconPath | undefined) { + if (this.#iconPath !== value) { + this.#iconPath = value; + this.#onChanged(); + } + } + + get description(): string | vscode.MarkdownString | undefined { + return this.#description; + } + + set description(value: string | vscode.MarkdownString | undefined) { + if (this.#description !== value) { + this.#description = value; + this.#onChanged(); + } + } + + get badge(): string | vscode.MarkdownString | undefined { + return this.#badge; + } + + set badge(value: string | vscode.MarkdownString | undefined) { + if (this.#badge !== value) { + this.#badge = value; + this.#onChanged(); + } + } + + get status(): vscode.ChatSessionStatus | undefined { + return this.#status; + } + + set status(value: vscode.ChatSessionStatus | undefined) { + if (this.#status !== value) { + this.#status = value; + this.#onChanged(); + } + } + + get archived(): boolean | undefined { + return this.#archived; + } + + set archived(value: boolean | undefined) { + if (this.#archived !== value) { + this.#archived = value; + this.#onChanged(); + } + } + + get tooltip(): string | vscode.MarkdownString | undefined { + return this.#tooltip; + } + + set tooltip(value: string | vscode.MarkdownString | undefined) { + if (this.#tooltip !== value) { + this.#tooltip = value; + this.#onChanged(); + } + } + + get timing(): ChatSessionTiming | undefined { + return this.#timing; + } + + set timing(value: ChatSessionTiming | undefined) { + if (this.#timing !== value) { + this.#timing = value; + this.#onChanged(); + } + } + + get changes(): readonly vscode.ChatSessionChangedFile[] | { files: number; insertions: number; deletions: number } | undefined { + return this.#changes; + } + + set changes(value: readonly vscode.ChatSessionChangedFile[] | { files: number; insertions: number; deletions: number } | undefined) { + if (this.#changes !== value) { + this.#changes = value; + this.#onChanged(); + } + } +} + +class ChatSessionItemCollectionImpl implements vscode.ChatSessionItemCollection { + readonly #items = new ResourceMap(); + #onItemsChanged: () => void; + + constructor(onItemsChanged: () => void) { + this.#onItemsChanged = onItemsChanged; + } + + get size(): number { + return this.#items.size; + } + + replace(items: readonly vscode.ChatSessionItem[]): void { + this.#items.clear(); + for (const item of items) { + this.#items.set(item.resource, item); + } + this.#onItemsChanged(); + } + + forEach(callback: (item: vscode.ChatSessionItem, collection: vscode.ChatSessionItemCollection) => unknown, thisArg?: any): void { + for (const [_, item] of this.#items) { + callback.call(thisArg, item, this); + } + } + + add(item: vscode.ChatSessionItem): void { + this.#items.set(item.resource, item); + this.#onItemsChanged(); + } + + delete(resource: vscode.Uri): void { + this.#items.delete(resource); + this.#onItemsChanged(); + } + + get(resource: vscode.Uri): vscode.ChatSessionItem | undefined { + return this.#items.get(resource); + } + + [Symbol.iterator](): Iterator { + return this.#items.entries(); + } +} + +// #endregion + class ExtHostChatSession { private _stream: ChatAgentResponseStream; @@ -62,13 +235,20 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio readonly extension: IExtensionDescription; readonly disposable: DisposableStore; }>(); + private readonly _chatSessionItemControllers = new Map(); + private _nextChatSessionItemProviderHandle = 0; private readonly _chatSessionContentProviders = new Map(); - private _nextChatSessionItemProviderHandle = 0; + private _nextChatSessionItemControllerHandle = 0; private _nextChatSessionContentProviderHandle = 0; /** @@ -140,6 +320,52 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio }; } + + createChatSessionItemController(extension: IExtensionDescription, id: string, refreshHandler: () => Thenable): vscode.ChatSessionItemController { + const controllerHandle = this._nextChatSessionItemControllerHandle++; + const disposables = new DisposableStore(); + + // TODO: Currently not hooked up + const onDidArchiveChatSessionItem = disposables.add(new Emitter()); + + const collection = new ChatSessionItemCollectionImpl(() => { + this._proxy.$onDidChangeChatSessionItems(controllerHandle); + }); + + let isDisposed = false; + + const controller: vscode.ChatSessionItemController = { + id, + refreshHandler, + items: collection, + onDidArchiveChatSessionItem: onDidArchiveChatSessionItem.event, + createChatSessionItem: (resource: vscode.Uri, label: string) => { + if (isDisposed) { + throw new Error('ChatSessionItemController has been disposed'); + } + + return new ChatSessionItemImpl(resource, label, () => { + // TODO: Optimize to only update the specific item + this._proxy.$onDidChangeChatSessionItems(controllerHandle); + }); + }, + dispose: () => { + isDisposed = true; + disposables.dispose(); + }, + }; + + this._chatSessionItemControllers.set(controllerHandle, { controller, extension, disposable: disposables, sessionType: id }); + this._proxy.$registerChatSessionItemProvider(controllerHandle, id); + + disposables.add(toDisposable(() => { + this._chatSessionItemControllers.delete(controllerHandle); + this._proxy.$unregisterChatSessionItemProvider(controllerHandle); + })); + + return controller; + } + registerChatSessionContentProvider(extension: IExtensionDescription, chatSessionScheme: string, chatParticipant: vscode.ChatParticipant, provider: vscode.ChatSessionContentProvider, capabilities?: vscode.ChatSessionCapabilities): vscode.Disposable { const handle = this._nextChatSessionContentProviderHandle++; const disposables = new DisposableStore(); @@ -184,17 +410,25 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio } } - private convertChatSessionItem(sessionType: string, sessionContent: vscode.ChatSessionItem): IChatSessionItem { + private convertChatSessionItem(sessionContent: vscode.ChatSessionItem): IChatSessionItem { + // Support both new (created, lastRequestStarted, lastRequestEnded) and old (startTime, endTime) timing properties + const timing = sessionContent.timing; + const created = timing?.created ?? timing?.startTime ?? 0; + const lastRequestStarted = timing?.lastRequestStarted ?? timing?.startTime; + const lastRequestEnded = timing?.lastRequestEnded ?? timing?.endTime; + return { resource: sessionContent.resource, label: sessionContent.label, description: sessionContent.description ? typeConvert.MarkdownString.from(sessionContent.description) : undefined, badge: sessionContent.badge ? typeConvert.MarkdownString.from(sessionContent.badge) : undefined, status: this.convertChatSessionStatus(sessionContent.status), + archived: sessionContent.archived, tooltip: typeConvert.MarkdownString.fromStrict(sessionContent.tooltip), timing: { - startTime: sessionContent.timing?.startTime ?? 0, - endTime: sessionContent.timing?.endTime + created, + lastRequestStarted, + lastRequestEnded, }, changes: sessionContent.changes instanceof Array ? sessionContent.changes : @@ -207,21 +441,35 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio } async $provideChatSessionItems(handle: number, token: vscode.CancellationToken): Promise { - const entry = this._chatSessionItemProviders.get(handle); - if (!entry) { - this._logService.error(`No provider registered for handle ${handle}`); - return []; - } + let items: vscode.ChatSessionItem[]; + + const controller = this._chatSessionItemControllers.get(handle); + if (controller) { + // Call the refresh handler to populate items + await controller.controller.refreshHandler(); + if (token.isCancellationRequested) { + return []; + } - const sessions = await entry.provider.provideChatSessionItems(token); - if (!sessions) { - return []; + items = Array.from(controller.controller.items, x => x[1]); + } else { + + const itemProvider = this._chatSessionItemProviders.get(handle); + if (!itemProvider) { + this._logService.error(`No provider registered for handle ${handle}`); + return []; + } + + items = await itemProvider.provider.provideChatSessionItems(token) ?? []; + if (token.isCancellationRequested) { + return []; + } } const response: IChatSessionItem[] = []; - for (const sessionContent of sessions) { + for (const sessionContent of items) { this._sessionItems.set(sessionContent.resource, sessionContent); - response.push(this.convertChatSessionItem(entry.sessionType, sessionContent)); + response.push(this.convertChatSessionItem(sessionContent)); } return response; } diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts index dc52a099eb25f..810a13614cfc2 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts @@ -156,6 +156,7 @@ export class ChatSubmitAction extends SubmitAction { const precondition = ContextKeyExpr.and( ChatContextKeys.inputHasText, whenNotInProgress, + ChatContextKeys.chatSessionOptionsValid, ); super({ @@ -494,7 +495,8 @@ export class ChatEditingSessionSubmitAction extends SubmitAction { const menuCondition = ChatContextKeys.chatModeKind.notEqualsTo(ChatModeKind.Ask); const precondition = ContextKeyExpr.and( ChatContextKeys.inputHasText, - whenNotInProgress + whenNotInProgress, + ChatContextKeys.chatSessionOptionsValid ); super({ diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsModel.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsModel.ts index b579321fec1d4..73776e50163f9 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsModel.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsModel.ts @@ -359,19 +359,24 @@ export class AgentSessionsModel extends Disposable implements IAgentSessionsMode ? { files: changes.files, insertions: changes.insertions, deletions: changes.deletions } : changes; - // Times: it is important to always provide a start and end time to track + // Times: it is important to always provide timing information to track // unread/read state for example. // If somehow the provider does not provide any, fallback to last known - let startTime = session.timing.startTime; - let endTime = session.timing.endTime; - if (!startTime || !endTime) { + let created = session.timing.created; + let lastRequestStarted = session.timing.lastRequestStarted; + let lastRequestEnded = session.timing.lastRequestEnded; + if (!created || !lastRequestEnded) { const existing = this._sessions.get(session.resource); - if (!startTime && existing?.timing.startTime) { - startTime = existing.timing.startTime; + if (!created && existing?.timing.created) { + created = existing.timing.created; } - if (!endTime && existing?.timing.endTime) { - endTime = existing.timing.endTime; + if (!lastRequestEnded && existing?.timing.lastRequestEnded) { + lastRequestEnded = existing.timing.lastRequestEnded; + } + + if (!lastRequestStarted && existing?.timing.lastRequestStarted) { + lastRequestStarted = existing.timing.lastRequestStarted; } } @@ -386,7 +391,13 @@ export class AgentSessionsModel extends Disposable implements IAgentSessionsMode tooltip: session.tooltip, status, archived: session.archived, - timing: { startTime, endTime, inProgressTime, finishedOrFailedTime }, + timing: { + created, + lastRequestStarted, + lastRequestEnded, + inProgressTime, + finishedOrFailedTime + }, changes: normalizedChanges, })); } @@ -454,7 +465,7 @@ export class AgentSessionsModel extends Disposable implements IAgentSessionsMode private isRead(session: IInternalAgentSessionData): boolean { const readDate = this.sessionStates.get(session.resource)?.read; - return (readDate ?? AgentSessionsModel.READ_STATE_INITIAL_DATE) >= (session.timing.endTime ?? session.timing.startTime); + return (readDate ?? AgentSessionsModel.READ_STATE_INITIAL_DATE) >= (session.timing.lastRequestEnded ?? session.timing.lastRequestStarted ?? session.timing.created); } private setRead(session: IInternalAgentSessionData, read: boolean): void { @@ -473,7 +484,7 @@ export class AgentSessionsModel extends Disposable implements IAgentSessionsMode //#region Sessions Cache -interface ISerializedAgentSession extends Omit { +interface ISerializedAgentSession { readonly providerType: string; readonly providerLabel: string; @@ -492,7 +503,11 @@ interface ISerializedAgentSession extends Omit ({ + return cached.map((session): IInternalAgentSessionData => ({ providerType: session.providerType, providerLabel: session.providerLabel, @@ -569,8 +585,10 @@ class AgentSessionsCache { archived: session.archived, timing: { - startTime: session.timing.startTime, - endTime: session.timing.endTime, + // Support loading both new and old cache formats + created: session.timing.created ?? session.timing.startTime ?? 0, + lastRequestStarted: session.timing.lastRequestStarted ?? session.timing.startTime, + lastRequestEnded: session.timing.lastRequestEnded ?? session.timing.endTime, }, changes: Array.isArray(session.changes) ? session.changes.map((change: IChatSessionFileChange) => ({ diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsPicker.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsPicker.ts index cd91ba6fbdb70..ba5bfac455de8 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsPicker.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsPicker.ts @@ -44,7 +44,7 @@ export const deleteButton: IQuickInputButton = { export function getSessionDescription(session: IAgentSession): string { const descriptionText = typeof session.description === 'string' ? session.description : session.description ? renderAsPlaintext(session.description) : undefined; - const timeAgo = fromNow(session.timing.endTime || session.timing.startTime); + const timeAgo = fromNow(session.timing.lastRequestEnded ?? session.timing.lastRequestStarted ?? session.timing.created); const descriptionParts = [descriptionText, session.providerLabel, timeAgo].filter(part => !!part); return descriptionParts.join(' • '); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts index f3d3e6e29cdcd..17c8d9f3a5aae 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts @@ -323,7 +323,7 @@ export class AgentSessionRenderer implements ICompressibleTreeRenderer= startOfToday) { todaySessions.push(session); } else if (sessionTime >= startOfYesterday) { @@ -826,7 +827,9 @@ export class AgentSessionsSorter implements ITreeSorter { } //Sort by end or start time (most recent first) - return (sessionB.timing.endTime || sessionB.timing.startTime) - (sessionA.timing.endTime || sessionA.timing.startTime); + const timeA = sessionA.timing.lastRequestEnded ?? sessionA.timing.lastRequestStarted ?? sessionA.timing.created; + const timeB = sessionB.timing.lastRequestEnded ?? sessionB.timing.lastRequestStarted ?? sessionB.timing.created; + return timeB - timeA; } } diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts index 9669d88db0071..b76274ea66596 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -330,6 +330,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge private withinEditSessionKey: IContextKey; private filePartOfEditSessionKey: IContextKey; private chatSessionHasOptions: IContextKey; + private chatSessionOptionsValid: IContextKey; private modelWidget: ModelPickerActionItem | undefined; private modeWidget: ModePickerActionItem | undefined; private chatSessionPickerWidgets: Map = new Map(); @@ -518,6 +519,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this.withinEditSessionKey = ChatContextKeys.withinEditSessionDiff.bindTo(contextKeyService); this.filePartOfEditSessionKey = ChatContextKeys.filePartOfEditSession.bindTo(contextKeyService); this.chatSessionHasOptions = ChatContextKeys.chatSessionHasModels.bindTo(contextKeyService); + this.chatSessionOptionsValid = ChatContextKeys.chatSessionOptionsValid.bindTo(contextKeyService); const chatToolCount = ChatContextKeys.chatToolCount.bindTo(contextKeyService); @@ -1362,6 +1364,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge const sessionResource = this._widget?.viewModel?.model.sessionResource; const hideAll = () => { this.chatSessionHasOptions.set(false); + this.chatSessionOptionsValid.set(true); // No options means nothing to validate this.hideAllSessionPickerWidgets(); }; @@ -1381,8 +1384,6 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge return hideAll(); } - this.chatSessionHasOptions.set(true); - // First update all context keys with current values (before evaluating visibility) for (const optionGroup of optionGroups) { const currentOption = this.chatSessionsService.getSessionOption(ctx.chatSessionResource, optionGroup.id); @@ -1405,6 +1406,28 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge } } + // Only show the picker if there are visible option groups + if (visibleGroupIds.size === 0) { + return hideAll(); + } + + // Validate that all selected options exist in their respective option group items + let allOptionsValid = true; + for (const optionGroup of optionGroups) { + const currentOption = this.chatSessionsService.getSessionOption(ctx.chatSessionResource, optionGroup.id); + if (currentOption) { + const currentOptionId = typeof currentOption === 'string' ? currentOption : currentOption.id; + const isValidOption = optionGroup.items.some(item => item.id === currentOptionId); + if (!isValidOption) { + this.logService.trace(`[ChatInputPart] Selected option '${currentOptionId}' is not valid for group '${optionGroup.id}'`); + allOptionsValid = false; + } + } + } + this.chatSessionOptionsValid.set(allOptionsValid); + + this.chatSessionHasOptions.set(true); + const currentWidgetGroupIds = new Set(this.chatSessionPickerWidgets.keys()); const needsRecreation = diff --git a/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts b/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts index 62efc9196578d..5f7e826e76bbb 100644 --- a/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts +++ b/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts @@ -55,6 +55,7 @@ export namespace ChatContextKeys { export const chatEditingCanRedo = new RawContextKey('chatEditingCanRedo', false, { type: 'boolean', description: localize('chatEditingCanRedo', "True when it is possible to redo an interaction in the editing panel.") }); export const languageModelsAreUserSelectable = new RawContextKey('chatModelsAreUserSelectable', false, { type: 'boolean', description: localize('chatModelsAreUserSelectable', "True when the chat model can be selected manually by the user.") }); export const chatSessionHasModels = new RawContextKey('chatSessionHasModels', false, { type: 'boolean', description: localize('chatSessionHasModels', "True when the chat is in a contributed chat session that has available 'models' to display.") }); + export const chatSessionOptionsValid = new RawContextKey('chatSessionOptionsValid', true, { type: 'boolean', description: localize('chatSessionOptionsValid', "True when all selected session options exist in their respective option group items.") }); export const extensionInvalid = new RawContextKey('chatExtensionInvalid', false, { type: 'boolean', description: localize('chatExtensionInvalid', "True when the installed chat extension is invalid and needs to be updated.") }); export const inputCursorAtTop = new RawContextKey('chatCursorAtTop', false); export const inputHasAgent = new RawContextKey('chatInputHasAgent', false); diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts index 7a757d8eb1ea4..6986780910b17 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts @@ -941,8 +941,24 @@ export interface IChatSessionStats { } export interface IChatSessionTiming { - startTime: number; - endTime?: number; + /** + * Timestamp when the session was created in milliseconds elapsed since January 1, 1970 00:00:00 UTC. + */ + created: number; + + /** + * Timestamp when the most recent request started in milliseconds elapsed since January 1, 1970 00:00:00 UTC. + * + * Should be undefined if no requests have been made yet. + */ + lastRequestStarted: number | undefined; + + /** + * Timestamp when the most recent request completed in milliseconds elapsed since January 1, 1970 00:00:00 UTC. + * + * Should be undefined if the most recent request is still in progress or if no requests have been made yet. + */ + lastRequestEnded: number | undefined; } export const enum ResponseModelState { diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts index d517d0ce5034d..e5d90f3d715b8 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts @@ -375,7 +375,11 @@ export class ChatService extends Disposable implements IChatService { ...entry, sessionResource, // TODO@roblourens- missing for old data- normalize inside the store - timing: entry.timing ?? { startTime: entry.lastMessageDate }, + timing: entry.timing ?? { + created: entry.lastMessageDate, + lastRequestStarted: undefined, + lastRequestEnded: entry.lastMessageDate, + }, isActive: this._sessionModels.has(sessionResource), // TODO@roblourens- missing for old data- normalize inside the store lastResponseState: entry.lastResponseState ?? ResponseModelState.Complete, @@ -391,7 +395,11 @@ export class ChatService extends Disposable implements IChatService { ...metadata, sessionResource, // TODO@roblourens- missing for old data- normalize inside the store - timing: metadata.timing ?? { startTime: metadata.lastMessageDate }, + timing: metadata.timing ?? { + created: metadata.lastMessageDate, + lastRequestStarted: undefined, + lastRequestEnded: metadata.lastMessageDate, + }, isActive: this._sessionModels.has(sessionResource), // TODO@roblourens- missing for old data- normalize inside the store lastResponseState: metadata.lastResponseState ?? ResponseModelState.Complete, diff --git a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts index 76a9b34869810..94126a5ffcf9f 100644 --- a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts +++ b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts @@ -14,7 +14,7 @@ import { createDecorator } from '../../../../platform/instantiation/common/insta import { IChatAgentAttachmentCapabilities, IChatAgentRequest } from './participants/chatAgents.js'; import { IChatEditingSession } from './editing/chatEditingService.js'; import { IChatModel, IChatRequestVariableData, ISerializableChatModelInputState } from './model/chatModel.js'; -import { IChatProgress, IChatService } from './chatService/chatService.js'; +import { IChatProgress, IChatService, IChatSessionTiming } from './chatService/chatService.js'; export const enum ChatSessionStatus { Failed = 0, @@ -73,6 +73,7 @@ export interface IChatSessionsExtensionPoint { readonly commands?: IChatSessionCommandContribution[]; readonly canDelegate?: boolean; } + export interface IChatSessionItem { resource: URI; label: string; @@ -81,10 +82,7 @@ export interface IChatSessionItem { description?: string | IMarkdownString; status?: ChatSessionStatus; tooltip?: string | IMarkdownString; - timing: { - startTime: number; - endTime?: number; - }; + timing: IChatSessionTiming; changes?: { files: number; insertions: number; diff --git a/src/vs/workbench/contrib/chat/common/model/chatModel.ts b/src/vs/workbench/contrib/chat/common/model/chatModel.ts index 7627b5f85fbcd..2a5cc4df78e6e 100644 --- a/src/vs/workbench/contrib/chat/common/model/chatModel.ts +++ b/src/vs/workbench/contrib/chat/common/model/chatModel.ts @@ -1687,10 +1687,14 @@ export class ChatModel extends Disposable implements IChatModel { } get timing(): IChatSessionTiming { - const lastResponse = this._requests.at(-1)?.response; + const lastRequest = this._requests.at(-1); + const lastResponse = lastRequest?.response; + const lastRequestStarted = lastRequest?.timestamp; + const lastRequestEnded = lastResponse?.completedAt ?? lastResponse?.timestamp; return { - startTime: this._timestamp, - endTime: lastResponse?.completedAt ?? lastResponse?.timestamp + created: this._timestamp, + lastRequestStarted, + lastRequestEnded, }; } diff --git a/src/vs/workbench/contrib/chat/common/model/chatSessionStore.ts b/src/vs/workbench/contrib/chat/common/model/chatSessionStore.ts index 63ac4c99c214b..1465a8d5c5465 100644 --- a/src/vs/workbench/contrib/chat/common/model/chatSessionStore.ts +++ b/src/vs/workbench/contrib/chat/common/model/chatSessionStore.ts @@ -665,12 +665,13 @@ async function getSessionMetadata(session: ChatModel | ISerializableChatData): P session.lastMessageDate : session.requests.at(-1)?.timestamp ?? session.creationDate; - const timing = session instanceof ChatModel ? + const timing: IChatSessionTiming = session instanceof ChatModel ? session.timing : // session is only ISerializableChatData in the old pre-fs storage data migration scenario { - startTime: session.creationDate, - endTime: lastMessageDate + created: session.creationDate, + lastRequestStarted: session.requests.at(-1)?.timestamp, + lastRequestEnded: lastMessageDate, }; return { diff --git a/src/vs/workbench/contrib/chat/common/model/chatViewModel.ts b/src/vs/workbench/contrib/chat/common/model/chatViewModel.ts index 4fd1a06dea977..c31571559bae9 100644 --- a/src/vs/workbench/contrib/chat/common/model/chatViewModel.ts +++ b/src/vs/workbench/contrib/chat/common/model/chatViewModel.ts @@ -8,7 +8,6 @@ import { Emitter, Event } from '../../../../../base/common/event.js'; import { hash } from '../../../../../base/common/hash.js'; import { IMarkdownString } from '../../../../../base/common/htmlContent.js'; import { Disposable, dispose } from '../../../../../base/common/lifecycle.js'; -import * as marked from '../../../../../base/common/marked/marked.js'; import { IObservable } from '../../../../../base/common/observable.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; import { URI } from '../../../../../base/common/uri.js'; @@ -17,7 +16,6 @@ import { IChatRequestVariableEntry } from '../attachments/chatVariableEntries.js import { ChatAgentVoteDirection, ChatAgentVoteDownReason, IChatCodeCitation, IChatContentReference, IChatFollowup, IChatMcpServersStarting, IChatProgressMessage, IChatResponseErrorDetails, IChatTask, IChatUsedContext } from '../chatService/chatService.js'; import { getFullyQualifiedId, IChatAgentCommand, IChatAgentData, IChatAgentNameService, IChatAgentResult } from '../participants/chatAgents.js'; import { IParsedChatRequest } from '../requestParser/chatParserTypes.js'; -import { annotateVulnerabilitiesInText } from '../widget/annotations.js'; import { CodeBlockModelCollection } from '../widget/codeBlockModelCollection.js'; import { IChatModel, IChatProgressRenderableResponseContent, IChatRequestDisablement, IChatRequestModel, IChatResponseModel, IChatTextEditGroup, IResponse } from './chatModel.js'; import { ChatStreamStatsTracker, IChatStreamStats } from './chatStreamStats.js'; @@ -270,7 +268,6 @@ export class ChatViewModel extends Disposable implements IChatViewModel { _model.getRequests().forEach((request, i) => { const requestModel = this.instantiationService.createInstance(ChatRequestViewModel, request); this._items.push(requestModel); - this.updateCodeBlockTextModels(requestModel); if (request.response) { this.onAddResponse(request.response); @@ -282,7 +279,6 @@ export class ChatViewModel extends Disposable implements IChatViewModel { if (e.kind === 'addRequest') { const requestModel = this.instantiationService.createInstance(ChatRequestViewModel, e.request); this._items.push(requestModel); - this.updateCodeBlockTextModels(requestModel); if (e.request.response) { this.onAddResponse(e.request.response); @@ -317,13 +313,9 @@ export class ChatViewModel extends Disposable implements IChatViewModel { private onAddResponse(responseModel: IChatResponseModel) { const response = this.instantiationService.createInstance(ChatResponseViewModel, responseModel, this); this._register(response.onDidChange(() => { - if (response.isComplete) { - this.updateCodeBlockTextModels(response); - } return this._onDidChange.fire(null); })); this._items.push(response); - this.updateCodeBlockTextModels(response); } getItems(): (IChatRequestViewModel | IChatResponseViewModel)[] { @@ -348,24 +340,6 @@ export class ChatViewModel extends Disposable implements IChatViewModel { super.dispose(); dispose(this._items.filter((item): item is ChatResponseViewModel => item instanceof ChatResponseViewModel)); } - - updateCodeBlockTextModels(model: IChatRequestViewModel | IChatResponseViewModel) { - let content: string; - if (isRequestVM(model)) { - content = model.messageText; - } else { - content = annotateVulnerabilitiesInText(model.response.value).map(x => x.content.value).join(''); - } - - let codeBlockIndex = 0; - marked.walkTokens(marked.lexer(content), token => { - if (token.type === 'code') { - const lang = token.lang || ''; - const text = token.text; - this.codeBlockModelCollection.update(this._model.sessionResource, model, codeBlockIndex++, { text, languageId: lang, isComplete: true }); - } - }); - } } const variablesHash = new WeakMap(); diff --git a/src/vs/workbench/contrib/chat/common/widget/annotations.ts b/src/vs/workbench/contrib/chat/common/widget/annotations.ts index 600decb9f3697..a1dbed9eb8968 100644 --- a/src/vs/workbench/contrib/chat/common/widget/annotations.ts +++ b/src/vs/workbench/contrib/chat/common/widget/annotations.ts @@ -9,7 +9,7 @@ import { URI } from '../../../../../base/common/uri.js'; import { IRange } from '../../../../../editor/common/core/range.js'; import { isLocation } from '../../../../../editor/common/languages.js'; import { IChatProgressRenderableResponseContent, IChatProgressResponseContent, appendMarkdownString, canMergeMarkdownStrings } from '../model/chatModel.js'; -import { IChatAgentVulnerabilityDetails, IChatMarkdownContent } from '../chatService/chatService.js'; +import { IChatAgentVulnerabilityDetails } from '../chatService/chatService.js'; export const contentRefUrl = 'http://_vscodecontentref_'; // must be lowercase for URI @@ -79,31 +79,6 @@ export interface IMarkdownVulnerability { readonly description: string; readonly range: IRange; } - -export function annotateVulnerabilitiesInText(response: ReadonlyArray): readonly IChatMarkdownContent[] { - const result: IChatMarkdownContent[] = []; - for (const item of response) { - const previousItem = result[result.length - 1]; - if (item.kind === 'markdownContent') { - if (previousItem?.kind === 'markdownContent') { - result[result.length - 1] = { content: new MarkdownString(previousItem.content.value + item.content.value, { isTrusted: previousItem.content.isTrusted }), kind: 'markdownContent' }; - } else { - result.push(item); - } - } else if (item.kind === 'markdownVuln') { - const vulnText = encodeURIComponent(JSON.stringify(item.vulnerabilities)); - const markdownText = `${item.content.value}`; - if (previousItem?.kind === 'markdownContent') { - result[result.length - 1] = { content: new MarkdownString(previousItem.content.value + markdownText, { isTrusted: previousItem.content.isTrusted }), kind: 'markdownContent' }; - } else { - result.push({ content: new MarkdownString(markdownText), kind: 'markdownContent' }); - } - } - } - - return result; -} - export function extractCodeblockUrisFromText(text: string): { uri: URI; isEdit?: boolean; textWithoutResult: string } | undefined { const match = /(.*?)<\/vscode_codeblock_uri>/ms.exec(text); if (match) { diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionViewModel.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionViewModel.test.ts index 114f666d13545..bacf032abd9b5 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionViewModel.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionViewModel.test.ts @@ -176,8 +176,8 @@ suite('Agent Sessions', () => { test('should handle session with all properties', async () => { return runWithFakedTimers({}, async () => { - const startTime = Date.now(); - const endTime = startTime + 1000; + const created = Date.now(); + const lastRequestEnded = created + 1000; const provider: IChatSessionItemProvider = { chatSessionType: 'test-type', @@ -190,8 +190,8 @@ suite('Agent Sessions', () => { status: ChatSessionStatus.Completed, tooltip: 'Session tooltip', iconPath: ThemeIcon.fromId('check'), - timing: { startTime, endTime }, - changes: { files: 1, insertions: 10, deletions: 5, details: [] } + timing: { created, lastRequestStarted: created, lastRequestEnded }, + changes: { files: 1, insertions: 10, deletions: 5 } } ] }; @@ -210,8 +210,8 @@ suite('Agent Sessions', () => { assert.strictEqual(session.description.value, '**Bold** description'); } assert.strictEqual(session.status, ChatSessionStatus.Completed); - assert.strictEqual(session.timing.startTime, startTime); - assert.strictEqual(session.timing.endTime, endTime); + assert.strictEqual(session.timing.created, created); + assert.strictEqual(session.timing.lastRequestEnded, lastRequestEnded); assert.deepStrictEqual(session.changes, { files: 1, insertions: 10, deletions: 5 }); }); }); @@ -1521,9 +1521,10 @@ suite('Agent Sessions', () => { test('should consider sessions before initial date as read by default', async () => { return runWithFakedTimers({}, async () => { // Session with timing before the READ_STATE_INITIAL_DATE (December 8, 2025) - const oldSessionTiming = { - startTime: Date.UTC(2025, 10 /* November */, 1), - endTime: Date.UTC(2025, 10 /* November */, 2), + const oldSessionTiming: IChatSessionItem['timing'] = { + created: Date.UTC(2025, 10 /* November */, 1), + lastRequestStarted: Date.UTC(2025, 10 /* November */, 1), + lastRequestEnded: Date.UTC(2025, 10 /* November */, 2), }; const provider: IChatSessionItemProvider = { @@ -1552,9 +1553,10 @@ suite('Agent Sessions', () => { test('should consider sessions after initial date as unread by default', async () => { return runWithFakedTimers({}, async () => { // Session with timing after the READ_STATE_INITIAL_DATE (December 8, 2025) - const newSessionTiming = { - startTime: Date.UTC(2025, 11 /* December */, 10), - endTime: Date.UTC(2025, 11 /* December */, 11), + const newSessionTiming: IChatSessionItem['timing'] = { + created: Date.UTC(2025, 11 /* December */, 10), + lastRequestStarted: Date.UTC(2025, 11 /* December */, 10), + lastRequestEnded: Date.UTC(2025, 11 /* December */, 11), }; const provider: IChatSessionItemProvider = { @@ -1583,9 +1585,10 @@ suite('Agent Sessions', () => { test('should use endTime for read state comparison when available', async () => { return runWithFakedTimers({}, async () => { // Session with startTime before initial date but endTime after - const sessionTiming = { - startTime: Date.UTC(2025, 10 /* November */, 1), - endTime: Date.UTC(2025, 11 /* December */, 10), + const sessionTiming: IChatSessionItem['timing'] = { + created: Date.UTC(2025, 10 /* November */, 1), + lastRequestStarted: Date.UTC(2025, 10 /* November */, 1), + lastRequestEnded: Date.UTC(2025, 11 /* December */, 10), }; const provider: IChatSessionItemProvider = { @@ -1606,7 +1609,7 @@ suite('Agent Sessions', () => { await viewModel.resolve(undefined); const session = viewModel.sessions[0]; - // Should use endTime (December 10) which is after the initial date + // Should use lastRequestEnded (December 10) which is after the initial date assert.strictEqual(session.isRead(), false); }); }); @@ -1614,8 +1617,10 @@ suite('Agent Sessions', () => { test('should use startTime for read state comparison when endTime is not available', async () => { return runWithFakedTimers({}, async () => { // Session with only startTime before initial date - const sessionTiming = { - startTime: Date.UTC(2025, 10 /* November */, 1), + const sessionTiming: IChatSessionItem['timing'] = { + created: Date.UTC(2025, 10 /* November */, 1), + lastRequestStarted: Date.UTC(2025, 10 /* November */, 1), + lastRequestEnded: undefined, }; const provider: IChatSessionItemProvider = { @@ -2054,8 +2059,15 @@ function makeSimpleSessionItem(id: string, overrides?: Partial }; } -function makeNewSessionTiming(): IChatSessionItem['timing'] { +function makeNewSessionTiming(options?: { + created?: number; + lastRequestStarted?: number | undefined; + lastRequestEnded?: number | undefined; +}): IChatSessionItem['timing'] { + const now = Date.now(); return { - startTime: Date.now(), + created: options?.created ?? now, + lastRequestStarted: options?.lastRequestStarted, + lastRequestEnded: options?.lastRequestEnded, }; } diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionsDataSource.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionsDataSource.test.ts index f29f8f83327e5..d551277757baf 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionsDataSource.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionsDataSource.test.ts @@ -36,8 +36,9 @@ suite('AgentSessionsDataSource', () => { label: `Session ${overrides.id ?? 'default'}`, icon: Codicon.terminal, timing: { - startTime: overrides.startTime ?? now, - endTime: overrides.endTime ?? now, + created: overrides.startTime ?? now, + lastRequestEnded: undefined, + lastRequestStarted: undefined, }, isArchived: () => overrides.isArchived ?? false, setArchived: () => { }, @@ -73,8 +74,8 @@ suite('AgentSessionsDataSource', () => { return { compare: (a, b) => { // Sort by end time, most recent first - const aTime = a.timing.endTime || a.timing.startTime; - const bTime = b.timing.endTime || b.timing.startTime; + const aTime = a.timing.lastRequestEnded ?? a.timing.lastRequestStarted ?? a.timing.created; + const bTime = b.timing.lastRequestEnded ?? b.timing.lastRequestStarted ?? b.timing.created; return bTime - aTime; } }; diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/localAgentSessionsProvider.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/localAgentSessionsProvider.test.ts index 04e96b80adc35..ac5db0d49804d 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/localAgentSessionsProvider.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/localAgentSessionsProvider.test.ts @@ -18,11 +18,24 @@ import { LocalAgentsSessionsProvider } from '../../../browser/agentSessions/loca import { ModifiedFileEntryState } from '../../../common/editing/chatEditingService.js'; import { IChatModel, IChatRequestModel, IChatResponseModel } from '../../../common/model/chatModel.js'; import { IChatDetail, IChatService, IChatSessionStartOptions, ResponseModelState } from '../../../common/chatService/chatService.js'; -import { ChatSessionStatus, IChatSessionsService, localChatSessionType } from '../../../common/chatSessionsService.js'; +import { ChatSessionStatus, IChatSessionItem, IChatSessionsService, localChatSessionType } from '../../../common/chatSessionsService.js'; import { LocalChatSessionUri } from '../../../common/model/chatUri.js'; import { ChatAgentLocation } from '../../../common/constants.js'; import { MockChatSessionsService } from '../../common/mockChatSessionsService.js'; +function createTestTiming(options?: { + created?: number; + lastRequestStarted?: number | undefined; + lastRequestEnded?: number | undefined; +}): IChatSessionItem['timing'] { + const now = Date.now(); + return { + created: options?.created ?? now, + lastRequestStarted: options?.lastRequestStarted, + lastRequestEnded: options?.lastRequestEnded, + }; +} + class MockChatService implements IChatService { private readonly _chatModels: ISettableObservable> = observableValue('chatModels', []); readonly chatModels = this._chatModels; @@ -318,7 +331,7 @@ suite('LocalAgentsSessionsProvider', () => { title: 'Test Session', lastMessageDate: Date.now(), isActive: true, - timing: { startTime: 0, endTime: 1 }, + timing: createTestTiming(), lastResponseState: ResponseModelState.Complete }]); @@ -342,7 +355,7 @@ suite('LocalAgentsSessionsProvider', () => { lastMessageDate: Date.now() - 10000, isActive: false, lastResponseState: ResponseModelState.Complete, - timing: { startTime: 0, endTime: 1 } + timing: createTestTiming() }]); const sessions = await provider.provideChatSessionItems(CancellationToken.None); @@ -368,7 +381,7 @@ suite('LocalAgentsSessionsProvider', () => { lastMessageDate: Date.now(), isActive: true, lastResponseState: ResponseModelState.Complete, - timing: { startTime: 0, endTime: 1 } + timing: createTestTiming() }]); mockChatService.setHistorySessionItems([{ sessionResource, @@ -376,7 +389,7 @@ suite('LocalAgentsSessionsProvider', () => { lastMessageDate: Date.now() - 10000, isActive: false, lastResponseState: ResponseModelState.Complete, - timing: { startTime: 0, endTime: 1 } + timing: createTestTiming() }]); const sessions = await provider.provideChatSessionItems(CancellationToken.None); @@ -404,7 +417,7 @@ suite('LocalAgentsSessionsProvider', () => { lastMessageDate: Date.now(), isActive: true, lastResponseState: ResponseModelState.Complete, - timing: { startTime: 0, endTime: 1 } + timing: createTestTiming() }]); const sessions = await provider.provideChatSessionItems(CancellationToken.None); @@ -434,7 +447,7 @@ suite('LocalAgentsSessionsProvider', () => { lastMessageDate: Date.now(), isActive: true, lastResponseState: ResponseModelState.Complete, - timing: { startTime: 0, endTime: 1 }, + timing: createTestTiming(), }]); const sessions = await provider.provideChatSessionItems(CancellationToken.None); @@ -463,7 +476,7 @@ suite('LocalAgentsSessionsProvider', () => { lastMessageDate: Date.now(), isActive: true, lastResponseState: ResponseModelState.Complete, - timing: { startTime: 0, endTime: 1 }, + timing: createTestTiming(), }]); const sessions = await provider.provideChatSessionItems(CancellationToken.None); @@ -492,7 +505,7 @@ suite('LocalAgentsSessionsProvider', () => { lastMessageDate: Date.now(), isActive: true, lastResponseState: ResponseModelState.Complete, - timing: { startTime: 0, endTime: 1 }, + timing: createTestTiming(), }]); const sessions = await provider.provideChatSessionItems(CancellationToken.None); @@ -536,7 +549,7 @@ suite('LocalAgentsSessionsProvider', () => { lastMessageDate: Date.now(), isActive: true, lastResponseState: ResponseModelState.Complete, - timing: { startTime: 0, endTime: 1 }, + timing: createTestTiming(), stats: { added: 30, removed: 8, @@ -581,7 +594,7 @@ suite('LocalAgentsSessionsProvider', () => { lastMessageDate: Date.now(), isActive: true, lastResponseState: ResponseModelState.Complete, - timing: { startTime: 0, endTime: 1 } + timing: createTestTiming() }]); const sessions = await provider.provideChatSessionItems(CancellationToken.None); @@ -592,7 +605,7 @@ suite('LocalAgentsSessionsProvider', () => { }); suite('Session Timing', () => { - test('should use model timestamp for startTime when model exists', async () => { + test('should use model timestamp for created when model exists', async () => { return runWithFakedTimers({}, async () => { const provider = createProvider(); @@ -611,16 +624,16 @@ suite('LocalAgentsSessionsProvider', () => { lastMessageDate: Date.now(), isActive: true, lastResponseState: ResponseModelState.Complete, - timing: { startTime: modelTimestamp } + timing: createTestTiming({ created: modelTimestamp }) }]); const sessions = await provider.provideChatSessionItems(CancellationToken.None); assert.strictEqual(sessions.length, 1); - assert.strictEqual(sessions[0].timing.startTime, modelTimestamp); + assert.strictEqual(sessions[0].timing.created, modelTimestamp); }); }); - test('should use lastMessageDate for startTime when model does not exist', async () => { + test('should use lastMessageDate for created when model does not exist', async () => { return runWithFakedTimers({}, async () => { const provider = createProvider(); @@ -634,16 +647,16 @@ suite('LocalAgentsSessionsProvider', () => { lastMessageDate, isActive: false, lastResponseState: ResponseModelState.Complete, - timing: { startTime: lastMessageDate } + timing: createTestTiming({ created: lastMessageDate }) }]); const sessions = await provider.provideChatSessionItems(CancellationToken.None); assert.strictEqual(sessions.length, 1); - assert.strictEqual(sessions[0].timing.startTime, lastMessageDate); + assert.strictEqual(sessions[0].timing.created, lastMessageDate); }); }); - test('should set endTime from last response completedAt', async () => { + test('should set lastRequestEnded from last response completedAt', async () => { return runWithFakedTimers({}, async () => { const provider = createProvider(); @@ -663,12 +676,12 @@ suite('LocalAgentsSessionsProvider', () => { lastMessageDate: Date.now(), isActive: true, lastResponseState: ResponseModelState.Complete, - timing: { startTime: 0, endTime: completedAt } + timing: createTestTiming({ lastRequestEnded: completedAt }) }]); const sessions = await provider.provideChatSessionItems(CancellationToken.None); assert.strictEqual(sessions.length, 1); - assert.strictEqual(sessions[0].timing.endTime, completedAt); + assert.strictEqual(sessions[0].timing.lastRequestEnded, completedAt); }); }); }); @@ -691,7 +704,7 @@ suite('LocalAgentsSessionsProvider', () => { lastMessageDate: Date.now(), isActive: true, lastResponseState: ResponseModelState.Complete, - timing: { startTime: 0, endTime: 1 } + timing: createTestTiming() }]); const sessions = await provider.provideChatSessionItems(CancellationToken.None); diff --git a/src/vs/workbench/contrib/chat/test/common/model/mockChatModel.ts b/src/vs/workbench/contrib/chat/test/common/model/mockChatModel.ts index 34f407b43a934..d9f5d6113d30f 100644 --- a/src/vs/workbench/contrib/chat/test/common/model/mockChatModel.ts +++ b/src/vs/workbench/contrib/chat/test/common/model/mockChatModel.ts @@ -10,13 +10,14 @@ import { URI } from '../../../../../../base/common/uri.js'; import { IChatEditingSession } from '../../../common/editing/chatEditingService.js'; import { IChatChangeEvent, IChatModel, IChatRequestModel, IChatRequestNeedsInputInfo, IExportableChatData, IInputModel, ISerializableChatData } from '../../../common/model/chatModel.js'; import { ChatAgentLocation } from '../../../common/constants.js'; +import { IChatSessionTiming } from '../../../common/chatService/chatService.js'; export class MockChatModel extends Disposable implements IChatModel { readonly onDidDispose = this._register(new Emitter()).event; readonly onDidChange = this._register(new Emitter()).event; sessionId = ''; readonly timestamp = 0; - readonly timing = { startTime: 0 }; + readonly timing: IChatSessionTiming = { created: Date.now(), lastRequestStarted: undefined, lastRequestEnded: undefined }; readonly initialLocation = ChatAgentLocation.Chat; readonly title = ''; readonly hasCustomTitle = false; diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/monitoring/outputMonitor.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/monitoring/outputMonitor.ts index eb1d8c9a3b848..2ac03d97b9476 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/monitoring/outputMonitor.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/monitoring/outputMonitor.ts @@ -61,6 +61,13 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { private _pollingResult: IPollingResult & { pollDurationMs: number } | undefined; get pollingResult(): IPollingResult & { pollDurationMs: number } | undefined { return this._pollingResult; } + /** + * Flag to track if user has inputted since idle was detected. + * This is used to skip showing prompts if the user already provided input. + */ + private _userInputtedSinceIdleDetected = false; + private _userInputListener: IDisposable | undefined; + private readonly _outputMonitorTelemetryCounters: IOutputMonitorTelemetryCounters = { inputToolManualAcceptCount: 0, inputToolManualRejectCount: 0, @@ -159,6 +166,9 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { pollDurationMs: Date.now() - pollStartTime, resources }; + // Clean up idle input listener if still active + this._userInputListener?.dispose(); + this._userInputListener = undefined; const promptPart = this._promptPart; this._promptPart = undefined; if (promptPart) { @@ -180,9 +190,28 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { return { shouldContinuePollling: false, output }; } + // Check if user already inputted since idle was detected (before we even got here) + if (this._userInputtedSinceIdleDetected) { + this._cleanupIdleInputListener(); + return { shouldContinuePollling: true }; + } + const confirmationPrompt = await this._determineUserInputOptions(this._execution, token); + // Check again after the async LLM call - user may have inputted while we were analyzing + if (this._userInputtedSinceIdleDetected) { + this._cleanupIdleInputListener(); + return { shouldContinuePollling: true }; + } + if (confirmationPrompt?.detectedRequestForFreeFormInput) { + // Check again right before showing prompt + if (this._userInputtedSinceIdleDetected) { + this._cleanupIdleInputListener(); + return { shouldContinuePollling: true }; + } + // Clean up the input listener now - the prompt will set up its own + this._cleanupIdleInputListener(); this._outputMonitorTelemetryCounters.inputToolFreeFormInputShownCount++; const receivedTerminalInput = await this._requestFreeFormTerminalInput(token, this._execution, confirmationPrompt); if (receivedTerminalInput) { @@ -200,8 +229,16 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { const suggestedOptionResult = await this._selectAndHandleOption(confirmationPrompt, token); if (suggestedOptionResult?.sentToTerminal) { // Continue polling as we sent the input + this._cleanupIdleInputListener(); + return { shouldContinuePollling: true }; + } + // Check again after LLM call - user may have inputted while we were selecting option + if (this._userInputtedSinceIdleDetected) { + this._cleanupIdleInputListener(); return { shouldContinuePollling: true }; } + // Clean up the input listener now - the prompt will set up its own + this._cleanupIdleInputListener(); const confirmed = await this._confirmRunInTerminal(token, suggestedOptionResult?.suggestedOption ?? confirmationPrompt.options[0], this._execution, confirmationPrompt); if (confirmed) { // Continue polling as we sent the input @@ -213,6 +250,9 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { } } + // Clean up input listener before custom poll/error assessment + this._cleanupIdleInputListener(); + // Let custom poller override if provided const custom = await this._pollFn?.(this._execution, token, this._taskService); const resources = custom?.resources; @@ -310,12 +350,14 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { if (detectsNonInteractiveHelpPattern(currentOutput)) { this._state = OutputMonitorState.Idle; + this._setupIdleInputListener(); return this._state; } const promptResult = detectsInputRequiredPattern(currentOutput); if (promptResult) { this._state = OutputMonitorState.Idle; + this._setupIdleInputListener(); return this._state; } @@ -331,6 +373,7 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { this._logService.trace(`OutputMonitor: waitForIdle check: waited=${waited}ms, recentlyIdle=${recentlyIdle}, isActive=${isActive}`); if (recentlyIdle && isActive !== true) { this._state = OutputMonitorState.Idle; + this._setupIdleInputListener(); return this._state; } } @@ -345,6 +388,32 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { return OutputMonitorState.Timeout; } + /** + * Sets up a listener for user input that triggers immediately when idle is detected. + * This ensures we catch any input that happens between idle detection and prompt creation. + */ + private _setupIdleInputListener(): void { + // Clean up any existing listener + this._userInputListener?.dispose(); + this._userInputtedSinceIdleDetected = false; + + // Set up new listener + this._userInputListener = this._execution.instance.onDidInputData((data) => { + if (data === '\r' || data === '\n' || data === '\r\n') { + this._userInputtedSinceIdleDetected = true; + } + }); + } + + /** + * Cleans up the idle input listener and resets the flag. + */ + private _cleanupIdleInputListener(): void { + this._userInputtedSinceIdleDetected = false; + this._userInputListener?.dispose(); + this._userInputListener = undefined; + } + private async _promptForMorePolling(command: string, token: CancellationToken, context: IToolInvocationContext | undefined): Promise<{ promise: Promise; part?: ChatElicitationRequestPart }> { if (token.isCancellationRequested || this._state === OutputMonitorState.Cancelled) { return { promise: Promise.resolve(false) }; @@ -404,7 +473,7 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { } const promptText = - `Analyze the following terminal output. If it contains a prompt requesting user input (such as a confirmation, selection, or yes/no question) and that prompt has NOT already been answered, extract the prompt text. The prompt may ask to choose from a set. If so, extract the possible options as a JSON object with keys 'prompt', 'options' (an array of strings or an object with option to description mappings), and 'freeFormInput': false. If no options are provided, and free form input is requested, for example: Password:, return the word freeFormInput. For example, if the options are "[Y] Yes [A] Yes to All [N] No [L] No to All [C] Cancel", the option to description mappings would be {"Y": "Yes", "A": "Yes to All", "N": "No", "L": "No to All", "C": "Cancel"}. If there is no such prompt, return null. If the option is ambiguous, return null. + `Analyze the following terminal output. If it contains a prompt requesting user input (such as a confirmation, selection, or yes/no question) that appears at the VERY END of the output and has NOT already been answered (i.e., there is no user response or subsequent output after the prompt), extract the prompt text. IMPORTANT: Only detect prompts that are at the end of the output with no content following them - if there is any output after the prompt, the prompt has already been answered and you should return null. The prompt may ask to choose from a set. If so, extract the possible options as a JSON object with keys 'prompt', 'options' (an array of strings or an object with option to description mappings), and 'freeFormInput': false. If no options are provided, and free form input is requested, for example: Password:, return the word freeFormInput. For example, if the options are "[Y] Yes [A] Yes to All [N] No [L] No to All [C] Cancel", the option to description mappings would be {"Y": "Yes", "A": "Yes to All", "N": "No", "L": "No to All", "C": "Cancel"}. If there is no such prompt, return null. If the option is ambiguous, return null. Examples: 1. Output: "Do you want to overwrite? (y/n)" Response: {"prompt": "Do you want to overwrite?", "options": ["y", "n"], "freeFormInput": false} @@ -434,6 +503,10 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { Response: {"prompt": "Password:", "freeFormInput": true, "options": []} 10. Output: "press ctrl-c to detach, ctrl-d to kill" Response: null + 11. Output: "Continue (y/n)? y" + Response: null (the prompt was already answered with 'y') + 12. Output: "Do you want to proceed? (yes/no)\nyes\nProceeding with operation..." + Response: null (the prompt was already answered and there is subsequent output) Alternatively, the prompt may request free form input, for example: 1. Output: "Enter your username:" diff --git a/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts b/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts index ac6ade0f4137b..c1cbdf9c7157f 100644 --- a/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts +++ b/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -// version: 3 +// version: 4 declare module 'vscode' { /** @@ -26,6 +26,25 @@ declare module 'vscode' { InProgress = 2 } + export namespace chat { + /** + * Registers a new {@link ChatSessionItemProvider chat session item provider}. + * + * To use this, also make sure to also add `chatSessions` contribution in the `package.json`. + * + * @param chatSessionType The type of chat session the provider is for. + * @param provider The provider to register. + * + * @returns A disposable that unregisters the provider when disposed. + */ + export function registerChatSessionItemProvider(chatSessionType: string, provider: ChatSessionItemProvider): Disposable; + + /** + * Creates a new {@link ChatSessionItemController chat session item controller} with the given unique identifier. + */ + export function createChatSessionItemController(id: string, refreshHandler: () => Thenable): ChatSessionItemController; + } + /** * Provides a list of information about chat sessions. */ @@ -52,6 +71,86 @@ declare module 'vscode' { // #endregion } + /** + * Provides a list of information about chat sessions. + */ + export interface ChatSessionItemController { + readonly id: string; + + /** + * Unregisters the controller, disposing of its associated chat session items. + */ + dispose(): void; + + /** + * Managed collection of chat session items + */ + readonly items: ChatSessionItemCollection; + + /** + * Creates a new managed chat session item that be added to the collection. + */ + createChatSessionItem(resource: Uri, label: string): ChatSessionItem; + + /** + * Handler called to refresh the collection of chat session items. + * + * This is also called on first load to get the initial set of items. + */ + refreshHandler: () => Thenable; + + /** + * Fired when an item is archived by the editor + * + * TODO: expose archive state on the item too? + */ + readonly onDidArchiveChatSessionItem: Event; + } + + /** + * A collection of chat session items. It provides operations for managing and iterating over the items. + */ + export interface ChatSessionItemCollection extends Iterable { + /** + * Gets the number of items in the collection. + */ + readonly size: number; + + /** + * Replaces the items stored by the collection. + * @param items Items to store. + */ + replace(items: readonly ChatSessionItem[]): void; + + /** + * Iterate over each entry in this collection. + * + * @param callback Function to execute for each entry. + * @param thisArg The `this` context used when invoking the handler function. + */ + forEach(callback: (item: ChatSessionItem, collection: ChatSessionItemCollection) => unknown, thisArg?: any): void; + + /** + * Adds the chat session item to the collection. If an item with the same resource URI already + * exists, it'll be replaced. + * @param item Item to add. + */ + add(item: ChatSessionItem): void; + + /** + * Removes a single chat session item from the collection. + * @param resource Item resource to delete. + */ + delete(resource: Uri): void; + + /** + * Efficiently gets a chat session item by resource, if it exists, in the collection. + * @param resource Item resource to get. + * @returns The found item or undefined if it does not exist. + */ + get(resource: Uri): ChatSessionItem | undefined; + } + export interface ChatSessionItem { /** * The resource associated with the chat session. @@ -91,15 +190,42 @@ declare module 'vscode' { tooltip?: string | MarkdownString; /** - * The times at which session started and ended + * Whether the chat session has been archived. + */ + archived?: boolean; + + /** + * Timing information for the chat session */ timing?: { + /** + * Timestamp when the session was created in milliseconds elapsed since January 1, 1970 00:00:00 UTC. + */ + created: number; + + /** + * Timestamp when the most recent request started in milliseconds elapsed since January 1, 1970 00:00:00 UTC. + * + * Should be undefined if no requests have been made yet. + */ + lastRequestStarted?: number; + + /** + * Timestamp when the most recent request completed in milliseconds elapsed since January 1, 1970 00:00:00 UTC. + * + * Should be undefined if the most recent request is still in progress or if no requests have been made yet. + */ + lastRequestEnded?: number; + /** * Session start timestamp in milliseconds elapsed since January 1, 1970 00:00:00 UTC. + * @deprecated Use `created` and `lastRequestStarted` instead. */ - startTime: number; + startTime?: number; + /** * Session end timestamp in milliseconds elapsed since January 1, 1970 00:00:00 UTC. + * @deprecated Use `lastRequestEnded` instead. */ endTime?: number; }; @@ -268,18 +394,6 @@ declare module 'vscode' { } export namespace chat { - /** - * Registers a new {@link ChatSessionItemProvider chat session item provider}. - * - * To use this, also make sure to also add `chatSessions` contribution in the `package.json`. - * - * @param chatSessionType The type of chat session the provider is for. - * @param provider The provider to register. - * - * @returns A disposable that unregisters the provider when disposed. - */ - export function registerChatSessionItemProvider(chatSessionType: string, provider: ChatSessionItemProvider): Disposable; - /** * Registers a new {@link ChatSessionContentProvider chat session content provider}. * diff --git a/test/automation/src/application.ts b/test/automation/src/application.ts index 81acded3853bf..848640a49839e 100644 --- a/test/automation/src/application.ts +++ b/test/automation/src/application.ts @@ -49,8 +49,8 @@ export class Application { return !!this.options.web; } - private _workspacePathOrFolder: string; - get workspacePathOrFolder(): string { + private _workspacePathOrFolder: string | undefined; + get workspacePathOrFolder(): string | undefined { return this._workspacePathOrFolder; } @@ -109,6 +109,7 @@ export class Application { private async startApplication(extraArgs: string[] = []): Promise { const code = this._code = await launch({ ...this.options, + workspacePath: this._workspacePathOrFolder, extraArgs: [...(this.options.extraArgs || []), ...extraArgs], }); diff --git a/test/automation/src/code.ts b/test/automation/src/code.ts index fe498419122d9..c61b23da7db92 100644 --- a/test/automation/src/code.ts +++ b/test/automation/src/code.ts @@ -18,7 +18,7 @@ export interface LaunchOptions { // Allows you to override the Playwright instance playwright?: typeof playwright; codePath?: string; - readonly workspacePath: string; + readonly workspacePath?: string; userDataDir?: string; readonly extensionsPath?: string; readonly logger: Logger; diff --git a/test/automation/src/electron.ts b/test/automation/src/electron.ts index a34e802ed5ad1..473ebf01ae8fc 100644 --- a/test/automation/src/electron.ts +++ b/test/automation/src/electron.ts @@ -22,8 +22,7 @@ export async function resolveElectronConfiguration(options: LaunchOptions): Prom const { codePath, workspacePath, extensionsPath, userDataDir, remote, logger, logsPath, crashesPath, extraArgs } = options; const env = { ...process.env }; - const args = [ - workspacePath, + const args: string[] = [ '--skip-release-notes', '--skip-welcome', '--disable-telemetry', @@ -35,6 +34,12 @@ export async function resolveElectronConfiguration(options: LaunchOptions): Prom '--disable-workspace-trust', `--logsPath=${logsPath}` ]; + + // Only add workspace path if provided + if (workspacePath) { + args.unshift(workspacePath); + } + if (options.useInMemorySecretStorage) { args.push('--use-inmemory-secretstorage'); } @@ -49,6 +54,9 @@ export async function resolveElectronConfiguration(options: LaunchOptions): Prom } if (remote) { + if (!workspacePath) { + throw new Error('Workspace path is required when running remote'); + } // Replace workspace path with URI args[0] = `--${workspacePath.endsWith('.code-workspace') ? 'file' : 'folder'}-uri=vscode-remote://test+test/${URI.file(workspacePath).path}`; diff --git a/test/automation/src/playwrightBrowser.ts b/test/automation/src/playwrightBrowser.ts index a459826b57163..3ca9894a95a0f 100644 --- a/test/automation/src/playwrightBrowser.ts +++ b/test/automation/src/playwrightBrowser.ts @@ -157,7 +157,15 @@ async function launchBrowser(options: LaunchOptions, endpoint: string) { `["logLevel","${options.verbose ? 'trace' : 'info'}"]` ].join(',')}]`; - const gotoPromise = measureAndLog(() => page.goto(`${endpoint}&${workspacePath.endsWith('.code-workspace') ? 'workspace' : 'folder'}=${URI.file(workspacePath!).path}&payload=${payloadParam}`), 'page.goto()', logger); + // Build URL with optional workspace path + let url = `${endpoint}&`; + if (workspacePath) { + const workspaceParam = workspacePath.endsWith('.code-workspace') ? 'workspace' : 'folder'; + url += `${workspaceParam}=${URI.file(workspacePath).path}&`; + } + url += `payload=${payloadParam}`; + + const gotoPromise = measureAndLog(() => page.goto(url), 'page.goto()', logger); const pageLoadedPromise = page.waitForLoadState('load'); await gotoPromise; diff --git a/test/mcp/src/application.ts b/test/mcp/src/application.ts index a60c7b9764dbf..fa8c2ff9dec42 100644 --- a/test/mcp/src/application.ts +++ b/test/mcp/src/application.ts @@ -232,7 +232,7 @@ async function setup(): Promise { logger.log('Smoketest setup done!\n'); } -export async function getApplication({ recordVideo }: { recordVideo?: boolean } = {}) { +export async function getApplication({ recordVideo, workspacePath }: { recordVideo?: boolean; workspacePath?: string } = {}) { const testCodePath = getDevElectronPath(); const electronPath = testCodePath; if (!fs.existsSync(electronPath || '')) { @@ -252,7 +252,8 @@ export async function getApplication({ recordVideo }: { recordVideo?: boolean } quality, version: parseVersion(version ?? '0.0.0'), codePath: opts.build, - workspacePath: rootPath, + // Use provided workspace path, or fall back to rootPath on CI (GitHub Actions) + workspacePath: workspacePath ?? (process.env.GITHUB_ACTIONS ? rootPath : undefined), logger, logsPath: logsRootPath, crashesPath: crashesRootPath, @@ -292,12 +293,12 @@ export class ApplicationService { return this._application; } - async getOrCreateApplication({ recordVideo }: { recordVideo?: boolean } = {}): Promise { + async getOrCreateApplication({ recordVideo, workspacePath }: { recordVideo?: boolean; workspacePath?: string } = {}): Promise { if (this._closing) { await this._closing; } if (!this._application) { - this._application = await getApplication({ recordVideo }); + this._application = await getApplication({ recordVideo, workspacePath }); this._application.code.driver.currentPage.on('close', () => { this._closing = (async () => { if (this._application) { diff --git a/test/mcp/src/automation.ts b/test/mcp/src/automation.ts index 9163af43e892b..3263081ecfc2c 100644 --- a/test/mcp/src/automation.ts +++ b/test/mcp/src/automation.ts @@ -18,17 +18,18 @@ export async function getServer(appService: ApplicationService): Promise server.tool( 'vscode_automation_start', - 'Start VS Code Build', + 'Start VS Code Build. If workspacePath is not provided, VS Code will open with the last used workspace or an empty window.', { - recordVideo: z.boolean().optional() + recordVideo: z.boolean().optional().describe('Whether to record a video of the session'), + workspacePath: z.string().optional().describe('Optional path to a workspace or folder to open. If not provided, opens the last used workspace.') }, - async ({ recordVideo }) => { - const app = await appService.getOrCreateApplication({ recordVideo }); + async ({ recordVideo, workspacePath }) => { + const app = await appService.getOrCreateApplication({ recordVideo, workspacePath }); await app.startTracing(); return { content: [{ type: 'text' as const, - text: app ? `VS Code started successfully` : `Failed to start VS Code` + text: app ? `VS Code started successfully${workspacePath ? ` with workspace: ${workspacePath}` : ''}` : `Failed to start VS Code` }] }; } diff --git a/test/mcp/src/automationTools/core.ts b/test/mcp/src/automationTools/core.ts index 591d743789633..d18adf35ef0a6 100644 --- a/test/mcp/src/automationTools/core.ts +++ b/test/mcp/src/automationTools/core.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { McpServer, RegisteredTool } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; import { ApplicationService } from '../application'; /** @@ -12,25 +13,26 @@ import { ApplicationService } from '../application'; export function applyCoreTools(server: McpServer, appService: ApplicationService): RegisteredTool[] { const tools: RegisteredTool[] = []; - // Playwright keeps using this as a start... maybe it needs some massaging - // server.tool( - // 'vscode_automation_restart', - // 'Restart VS Code with optional workspace or folder and extra arguments', - // { - // workspaceOrFolder: z.string().optional().describe('Optional path to workspace or folder to open'), - // extraArgs: z.array(z.string()).optional().describe('Optional extra command line arguments') - // }, - // async (args) => { - // const { workspaceOrFolder, extraArgs } = args; - // await app.restart({ workspaceOrFolder, extraArgs }); - // return { - // content: [{ - // type: 'text' as const, - // text: `VS Code restarted successfully${workspaceOrFolder ? ` with workspace: ${workspaceOrFolder}` : ''}` - // }] - // }; - // } - // ); + tools.push(server.tool( + 'vscode_automation_restart', + 'Restart VS Code with optional workspace or folder and extra command-line arguments', + { + workspaceOrFolder: z.string().optional().describe('Path to a workspace or folder to open on restart'), + extraArgs: z.array(z.string()).optional().describe('Extra CLI arguments to pass on restart') + }, + async ({ workspaceOrFolder, extraArgs }) => { + const app = await appService.getOrCreateApplication(); + await app.restart({ workspaceOrFolder, extraArgs }); + const workspaceText = workspaceOrFolder ? ` with workspace: ${workspaceOrFolder}` : ''; + const argsText = extraArgs?.length ? ` (args: ${extraArgs.join(' ')})` : ''; + return { + content: [{ + type: 'text' as const, + text: `VS Code restarted successfully${workspaceText}${argsText}` + }] + }; + } + )); tools.push(server.tool( 'vscode_automation_stop', diff --git a/test/smoke/src/areas/languages/languages.test.ts b/test/smoke/src/areas/languages/languages.test.ts index 9ec05b0c9e29e..508a35d9d4d61 100644 --- a/test/smoke/src/areas/languages/languages.test.ts +++ b/test/smoke/src/areas/languages/languages.test.ts @@ -15,7 +15,12 @@ export function setup(logger: Logger) { it('verifies quick outline (js)', async function () { const app = this.app as Application; - await app.workbench.quickaccess.openFile(join(app.workspacePathOrFolder, 'bin', 'www')); + const workspacePathOrFolder = app.workspacePathOrFolder; + if (!workspacePathOrFolder) { + throw new Error('This test requires a workspace to be open'); + } + + await app.workbench.quickaccess.openFile(join(workspacePathOrFolder, 'bin', 'www')); await app.workbench.quickaccess.openQuickOutline(); await app.workbench.quickinput.waitForQuickInputElements(names => names.length >= 6); @@ -24,7 +29,12 @@ export function setup(logger: Logger) { it('verifies quick outline (css)', async function () { const app = this.app as Application; - await app.workbench.quickaccess.openFile(join(app.workspacePathOrFolder, 'public', 'stylesheets', 'style.css')); + const workspacePathOrFolder = app.workspacePathOrFolder; + if (!workspacePathOrFolder) { + throw new Error('This test requires a workspace to be open'); + } + + await app.workbench.quickaccess.openFile(join(workspacePathOrFolder, 'public', 'stylesheets', 'style.css')); await app.workbench.quickaccess.openQuickOutline(); await app.workbench.quickinput.waitForQuickInputElements(names => names.length === 2); @@ -33,7 +43,12 @@ export function setup(logger: Logger) { it('verifies problems view (css)', async function () { const app = this.app as Application; - await app.workbench.quickaccess.openFile(join(app.workspacePathOrFolder, 'public', 'stylesheets', 'style.css')); + const workspacePathOrFolder = app.workspacePathOrFolder; + if (!workspacePathOrFolder) { + throw new Error('This test requires a workspace to be open'); + } + + await app.workbench.quickaccess.openFile(join(workspacePathOrFolder, 'public', 'stylesheets', 'style.css')); await app.workbench.editor.waitForTypeInEditor('style.css', '.foo{}'); await app.code.waitForElement(Problems.getSelectorInEditor(ProblemSeverity.WARNING)); @@ -45,8 +60,13 @@ export function setup(logger: Logger) { it('verifies settings (css)', async function () { const app = this.app as Application; + const workspacePathOrFolder = app.workspacePathOrFolder; + if (!workspacePathOrFolder) { + throw new Error('This test requires a workspace to be open'); + } + await app.workbench.settingsEditor.addUserSetting('css.lint.emptyRules', '"error"'); - await app.workbench.quickaccess.openFile(join(app.workspacePathOrFolder, 'public', 'stylesheets', 'style.css')); + await app.workbench.quickaccess.openFile(join(workspacePathOrFolder, 'public', 'stylesheets', 'style.css')); await app.code.waitForElement(Problems.getSelectorInEditor(ProblemSeverity.ERROR)); diff --git a/test/smoke/src/areas/multiroot/multiroot.test.ts b/test/smoke/src/areas/multiroot/multiroot.test.ts index cedbac51e7a34..f48f1cad1b75c 100644 --- a/test/smoke/src/areas/multiroot/multiroot.test.ts +++ b/test/smoke/src/areas/multiroot/multiroot.test.ts @@ -46,6 +46,9 @@ export function setup(logger: Logger) { // Shared before/after handling installAllHandlers(logger, opts => { + if (!opts.workspacePath) { + throw new Error('Multiroot tests require a workspace to be open'); + } const workspacePath = createWorkspaceFile(opts.workspacePath); return { ...opts, workspacePath }; }); diff --git a/test/smoke/src/areas/notebook/notebook.test.ts b/test/smoke/src/areas/notebook/notebook.test.ts index a0b81837266d4..b104ce26f76ff 100644 --- a/test/smoke/src/areas/notebook/notebook.test.ts +++ b/test/smoke/src/areas/notebook/notebook.test.ts @@ -21,8 +21,13 @@ export function setup(logger: Logger) { after(async function () { const app = this.app as Application; - cp.execSync('git checkout . --quiet', { cwd: app.workspacePathOrFolder }); - cp.execSync('git reset --hard HEAD --quiet', { cwd: app.workspacePathOrFolder }); + const workspacePathOrFolder = app.workspacePathOrFolder; + if (!workspacePathOrFolder) { + throw new Error('This test requires a workspace to be open'); + } + + cp.execSync('git checkout . --quiet', { cwd: workspacePathOrFolder }); + cp.execSync('git reset --hard HEAD --quiet', { cwd: workspacePathOrFolder }); }); // the heap snapshot fails to parse diff --git a/test/smoke/src/areas/search/search.test.ts b/test/smoke/src/areas/search/search.test.ts index f635ad827dfb8..8ac0bba570f69 100644 --- a/test/smoke/src/areas/search/search.test.ts +++ b/test/smoke/src/areas/search/search.test.ts @@ -15,8 +15,13 @@ export function setup(logger: Logger) { after(function () { const app = this.app as Application; - retry(async () => cp.execSync('git checkout . --quiet', { cwd: app.workspacePathOrFolder }), 0, 5); - retry(async () => cp.execSync('git reset --hard HEAD --quiet', { cwd: app.workspacePathOrFolder }), 0, 5); + const workspacePathOrFolder = app.workspacePathOrFolder; + if (!workspacePathOrFolder) { + throw new Error('This test requires a workspace to be open'); + } + + retry(async () => cp.execSync('git checkout . --quiet', { cwd: workspacePathOrFolder }), 0, 5); + retry(async () => cp.execSync('git reset --hard HEAD --quiet', { cwd: workspacePathOrFolder }), 0, 5); }); it('verifies the sidebar moves to the right', async function () { diff --git a/test/smoke/src/areas/statusbar/statusbar.test.ts b/test/smoke/src/areas/statusbar/statusbar.test.ts index ccfbeb5772f23..f681758562ec9 100644 --- a/test/smoke/src/areas/statusbar/statusbar.test.ts +++ b/test/smoke/src/areas/statusbar/statusbar.test.ts @@ -15,11 +15,16 @@ export function setup(logger: Logger) { it('verifies presence of all default status bar elements', async function () { const app = this.app as Application; + const workspacePathOrFolder = app.workspacePathOrFolder; + if (!workspacePathOrFolder) { + throw new Error('This test requires a workspace to be open'); + } + await app.workbench.statusbar.waitForStatusbarElement(StatusBarElement.BRANCH_STATUS); await app.workbench.statusbar.waitForStatusbarElement(StatusBarElement.SYNC_STATUS); await app.workbench.statusbar.waitForStatusbarElement(StatusBarElement.PROBLEMS_STATUS); - await app.workbench.quickaccess.openFile(join(app.workspacePathOrFolder, 'readme.md')); + await app.workbench.quickaccess.openFile(join(workspacePathOrFolder, 'readme.md')); await app.workbench.statusbar.waitForStatusbarElement(StatusBarElement.ENCODING_STATUS); await app.workbench.statusbar.waitForStatusbarElement(StatusBarElement.EOL_STATUS); await app.workbench.statusbar.waitForStatusbarElement(StatusBarElement.INDENTATION_STATUS); @@ -29,11 +34,16 @@ export function setup(logger: Logger) { it(`verifies that 'quick input' opens when clicking on status bar elements`, async function () { const app = this.app as Application; + const workspacePathOrFolder = app.workspacePathOrFolder; + if (!workspacePathOrFolder) { + throw new Error('This test requires a workspace to be open'); + } + await app.workbench.statusbar.clickOn(StatusBarElement.BRANCH_STATUS); await app.workbench.quickinput.waitForQuickInputOpened(); await app.workbench.quickinput.closeQuickInput(); - await app.workbench.quickaccess.openFile(join(app.workspacePathOrFolder, 'readme.md')); + await app.workbench.quickaccess.openFile(join(workspacePathOrFolder, 'readme.md')); await app.workbench.statusbar.clickOn(StatusBarElement.INDENTATION_STATUS); await app.workbench.quickinput.waitForQuickInputOpened(); await app.workbench.quickinput.closeQuickInput(); @@ -56,7 +66,12 @@ export function setup(logger: Logger) { it(`verifies if changing EOL is reflected in the status bar`, async function () { const app = this.app as Application; - await app.workbench.quickaccess.openFile(join(app.workspacePathOrFolder, 'readme.md')); + const workspacePathOrFolder = app.workspacePathOrFolder; + if (!workspacePathOrFolder) { + throw new Error('This test requires a workspace to be open'); + } + + await app.workbench.quickaccess.openFile(join(workspacePathOrFolder, 'readme.md')); await app.workbench.statusbar.clickOn(StatusBarElement.EOL_STATUS); await app.workbench.quickinput.selectQuickInputElement(1); diff --git a/test/smoke/src/areas/workbench/data-loss.test.ts b/test/smoke/src/areas/workbench/data-loss.test.ts index 3e27f1acba912..f876f8596bdfd 100644 --- a/test/smoke/src/areas/workbench/data-loss.test.ts +++ b/test/smoke/src/areas/workbench/data-loss.test.ts @@ -27,10 +27,15 @@ export function setup(ensureStableCode: () => { stableCodePath: string | undefin }); await app.start(); + const workspacePathOrFolder = app.workspacePathOrFolder; + if (!workspacePathOrFolder) { + throw new Error('This test requires a workspace to be open'); + } + // Open 3 editors - await app.workbench.quickaccess.openFile(join(app.workspacePathOrFolder, 'bin', 'www')); + await app.workbench.quickaccess.openFile(join(workspacePathOrFolder, 'bin', 'www')); await app.workbench.quickaccess.runCommand('View: Keep Editor'); - await app.workbench.quickaccess.openFile(join(app.workspacePathOrFolder, 'app.js')); + await app.workbench.quickaccess.openFile(join(workspacePathOrFolder, 'app.js')); await app.workbench.quickaccess.runCommand('View: Keep Editor'); await app.workbench.editors.newUntitledFile(); @@ -53,10 +58,15 @@ export function setup(ensureStableCode: () => { stableCodePath: string | undefin }); await app.start(); + const workspacePathOrFolder = app.workspacePathOrFolder; + if (!workspacePathOrFolder) { + throw new Error('This test requires a workspace to be open'); + } + const textToType = 'Hello, Code'; // open editor and type - await app.workbench.quickaccess.openFile(join(app.workspacePathOrFolder, 'app.js')); + await app.workbench.quickaccess.openFile(join(workspacePathOrFolder, 'app.js')); await app.workbench.editor.waitForTypeInEditor('app.js', textToType); await app.workbench.editors.waitForTab('app.js', true); @@ -94,6 +104,11 @@ export function setup(ensureStableCode: () => { stableCodePath: string | undefin }); await app.start(); + const workspacePathOrFolder = app.workspacePathOrFolder; + if (!workspacePathOrFolder) { + throw new Error('This test requires a workspace to be open'); + } + if (autoSave) { await app.workbench.settingsEditor.addUserSetting('files.autoSave', '"afterDelay"'); } @@ -105,7 +120,7 @@ export function setup(ensureStableCode: () => { stableCodePath: string | undefin await app.workbench.editors.waitForTab('Untitled-1', true); const textToType = 'Hello, Code'; - await app.workbench.quickaccess.openFile(join(app.workspacePathOrFolder, 'readme.md')); + await app.workbench.quickaccess.openFile(join(workspacePathOrFolder, 'readme.md')); await app.workbench.editor.waitForTypeInEditor('readme.md', textToType); await app.workbench.editors.waitForTab('readme.md', !autoSave); @@ -175,10 +190,15 @@ export function setup(ensureStableCode: () => { stableCodePath: string | undefin stableApp = new Application(stableOptions); await stableApp.start(); + const workspacePathOrFolder = stableApp.workspacePathOrFolder; + if (!workspacePathOrFolder) { + throw new Error('This test requires a workspace to be open'); + } + // Open 3 editors - await stableApp.workbench.quickaccess.openFile(join(stableApp.workspacePathOrFolder, 'bin', 'www')); + await stableApp.workbench.quickaccess.openFile(join(workspacePathOrFolder, 'bin', 'www')); await stableApp.workbench.quickaccess.runCommand('View: Keep Editor'); - await stableApp.workbench.quickaccess.openFile(join(stableApp.workspacePathOrFolder, 'app.js')); + await stableApp.workbench.quickaccess.openFile(join(workspacePathOrFolder, 'app.js')); await stableApp.workbench.quickaccess.runCommand('View: Keep Editor'); await stableApp.workbench.editors.newUntitledFile(); @@ -231,6 +251,11 @@ export function setup(ensureStableCode: () => { stableCodePath: string | undefin stableApp = new Application(stableOptions); await stableApp.start(); + const workspacePathOrFolder = stableApp.workspacePathOrFolder; + if (!workspacePathOrFolder) { + throw new Error('This test requires a workspace to be open'); + } + const textToTypeInUntitled = 'Hello from Untitled'; await stableApp.workbench.editors.newUntitledFile(); @@ -238,7 +263,7 @@ export function setup(ensureStableCode: () => { stableCodePath: string | undefin await stableApp.workbench.editors.waitForTab('Untitled-1', true); const textToType = 'Hello, Code'; - await stableApp.workbench.quickaccess.openFile(join(stableApp.workspacePathOrFolder, 'readme.md')); + await stableApp.workbench.quickaccess.openFile(join(workspacePathOrFolder, 'readme.md')); await stableApp.workbench.editor.waitForTypeInEditor('readme.md', textToType); await stableApp.workbench.editors.waitForTab('readme.md', true);