mirror of
https://github.com/microsoft/vscode.git
synced 2026-02-15 07:28:05 +00:00
Merge pull request #294703 from microsoft/tyriar/osc_notifications
Terminal notification support
This commit is contained in:
@@ -834,6 +834,7 @@ export interface ITerminalInstance extends IBaseTerminalInstance {
|
||||
readonly fixedCols?: number;
|
||||
readonly fixedRows?: number;
|
||||
readonly domElement: HTMLElement;
|
||||
readonly isVisible: boolean;
|
||||
readonly icon?: TerminalIcon;
|
||||
readonly color?: string;
|
||||
readonly reconnectionProperties?: IReconnectionProperties;
|
||||
|
||||
@@ -150,7 +150,6 @@ export class TerminalInstance extends Disposable implements ITerminalInstance {
|
||||
private _latestXtermParseData: number = 0;
|
||||
private _isExiting: boolean;
|
||||
private _hadFocusOnExit: boolean;
|
||||
private _isVisible: boolean;
|
||||
private _exitCode: number | undefined;
|
||||
private _exitReason: TerminalExitReason | undefined;
|
||||
private _skipTerminalCommands: string[];
|
||||
@@ -218,6 +217,9 @@ export class TerminalInstance extends Disposable implements ITerminalInstance {
|
||||
this._shellLaunchConfig.waitOnExit = value;
|
||||
}
|
||||
|
||||
private _isVisible: boolean;
|
||||
get isVisible(): boolean { return this._isVisible; }
|
||||
|
||||
private _targetRef: ImmortalReference<TerminalLocation | undefined> = new ImmortalReference(undefined);
|
||||
get targetRef(): IReference<TerminalLocation | undefined> { return this._targetRef; }
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import '../terminalContrib/commandGuide/browser/terminal.commandGuide.contributi
|
||||
import '../terminalContrib/history/browser/terminal.history.contribution.js';
|
||||
import '../terminalContrib/inlineHint/browser/terminal.initialHint.contribution.js';
|
||||
import '../terminalContrib/links/browser/terminal.links.contribution.js';
|
||||
import '../terminalContrib/notification/browser/terminal.notification.contribution.js';
|
||||
import '../terminalContrib/zoom/browser/terminal.zoom.contribution.js';
|
||||
import '../terminalContrib/stickyScroll/browser/terminal.stickyScroll.contribution.js';
|
||||
import '../terminalContrib/quickAccess/browser/terminal.quickAccess.contribution.js';
|
||||
|
||||
@@ -14,6 +14,7 @@ import { terminalCommandGuideConfiguration } from '../terminalContrib/commandGui
|
||||
import { TerminalDeveloperCommandId } from '../terminalContrib/developer/common/terminal.developer.js';
|
||||
import { defaultTerminalFindCommandToSkipShell } from '../terminalContrib/find/common/terminal.find.js';
|
||||
import { defaultTerminalHistoryCommandsToSkipShell, terminalHistoryConfiguration } from '../terminalContrib/history/common/terminal.history.js';
|
||||
import { terminalOscNotificationsConfiguration } from '../terminalContrib/notification/common/terminalNotificationConfiguration.js';
|
||||
import { TerminalStickyScrollSettingId, terminalStickyScrollConfiguration } from '../terminalContrib/stickyScroll/common/terminalStickyScrollConfiguration.js';
|
||||
import { defaultTerminalSuggestCommandsToSkipShell } from '../terminalContrib/suggest/common/terminal.suggest.js';
|
||||
import { TerminalSuggestSettingId, terminalSuggestConfiguration } from '../terminalContrib/suggest/common/terminalSuggestConfiguration.js';
|
||||
@@ -65,6 +66,7 @@ export const terminalContribConfiguration: IConfigurationNode['properties'] = {
|
||||
...terminalInitialHintConfiguration,
|
||||
...terminalCommandGuideConfiguration,
|
||||
...terminalHistoryConfiguration,
|
||||
...terminalOscNotificationsConfiguration,
|
||||
...terminalStickyScrollConfiguration,
|
||||
...terminalSuggestConfiguration,
|
||||
...terminalTypeAheadConfiguration,
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import type { Terminal as RawXtermTerminal } from '@xterm/xterm';
|
||||
import * as dom from '../../../../../base/browser/dom.js';
|
||||
import { Disposable } from '../../../../../base/common/lifecycle.js';
|
||||
import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js';
|
||||
import { INotificationService } from '../../../../../platform/notification/common/notification.js';
|
||||
import { ITerminalLogService } from '../../../../../platform/terminal/common/terminal.js';
|
||||
import type { ITerminalContribution, ITerminalInstance, IXtermTerminal } from '../../../terminal/browser/terminal.js';
|
||||
import { registerTerminalContribution, type ITerminalContributionContext } from '../../../terminal/browser/terminalExtensions.js';
|
||||
import { TerminalOscNotificationsSettingId } from '../common/terminalNotificationConfiguration.js';
|
||||
import { TerminalNotificationHandler } from './terminalNotificationHandler.js';
|
||||
|
||||
|
||||
class TerminalOscNotificationsContribution extends Disposable implements ITerminalContribution {
|
||||
static readonly ID = 'terminal.oscNotifications';
|
||||
|
||||
private readonly _handler: TerminalNotificationHandler;
|
||||
|
||||
constructor(
|
||||
private readonly _ctx: ITerminalContributionContext,
|
||||
@IConfigurationService private readonly _configurationService: IConfigurationService,
|
||||
@INotificationService private readonly _notificationService: INotificationService,
|
||||
@ITerminalLogService private readonly _logService: ITerminalLogService,
|
||||
) {
|
||||
super();
|
||||
this._handler = this._register(new TerminalNotificationHandler({
|
||||
isEnabled: () => this._configurationService.getValue<boolean>(TerminalOscNotificationsSettingId.EnableNotifications) === true,
|
||||
isWindowFocused: () => dom.getActiveWindow().document.hasFocus(),
|
||||
isTerminalVisible: () => this._ctx.instance.isVisible,
|
||||
focusTerminal: () => this._ctx.instance.focus(true),
|
||||
notify: notification => this._notificationService.notify(notification),
|
||||
updateEnableNotifications: value => this._configurationService.updateValue(TerminalOscNotificationsSettingId.EnableNotifications, value),
|
||||
logWarn: message => this._logService.warn(message),
|
||||
writeToProcess: data => { void this._ctx.instance.sendText(data, false); }
|
||||
}));
|
||||
}
|
||||
|
||||
xtermReady(xterm: IXtermTerminal & { raw: RawXtermTerminal }): void {
|
||||
this._register(xterm.raw.parser.registerOscHandler(99, data => this._handler.handleSequence(data)));
|
||||
}
|
||||
}
|
||||
|
||||
registerTerminalContribution(TerminalOscNotificationsContribution.ID, TerminalOscNotificationsContribution);
|
||||
|
||||
export function getTerminalOscNotifications(instance: ITerminalInstance): TerminalOscNotificationsContribution | null {
|
||||
return instance.getContribution<TerminalOscNotificationsContribution>(TerminalOscNotificationsContribution.ID);
|
||||
}
|
||||
@@ -0,0 +1,528 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { Action, IAction } from '../../../../../base/common/actions.js';
|
||||
import { disposableTimeout } from '../../../../../base/common/async.js';
|
||||
import { decodeBase64 } from '../../../../../base/common/buffer.js';
|
||||
import { Disposable, DisposableStore, type IDisposable } from '../../../../../base/common/lifecycle.js';
|
||||
import { localize } from '../../../../../nls.js';
|
||||
import { NotificationPriority, Severity, type INotification, type INotificationHandle } from '../../../../../platform/notification/common/notification.js';
|
||||
|
||||
const enum Osc99PayloadType {
|
||||
Title = 'title',
|
||||
Body = 'body',
|
||||
Buttons = 'buttons',
|
||||
Close = 'close',
|
||||
Query = '?',
|
||||
Alive = 'alive'
|
||||
}
|
||||
|
||||
type Osc99Occasion = 'always' | 'unfocused' | 'invisible';
|
||||
type Osc99CloseReason = 'button' | 'secondary' | 'auto' | 'protocol';
|
||||
|
||||
interface IOsc99NotificationState {
|
||||
id: string | undefined;
|
||||
title: string;
|
||||
body: string;
|
||||
buttonsPayload: string;
|
||||
focusOnActivate: boolean;
|
||||
reportOnActivate: boolean;
|
||||
reportOnClose: boolean;
|
||||
urgency: number | undefined;
|
||||
autoCloseMs: number | undefined;
|
||||
occasion: Osc99Occasion | undefined;
|
||||
}
|
||||
|
||||
interface IOsc99ActiveNotification {
|
||||
id: string | undefined;
|
||||
handle: INotificationHandle;
|
||||
actionStore: DisposableStore;
|
||||
autoCloseDisposable: IDisposable | undefined;
|
||||
reportOnActivate: boolean;
|
||||
reportOnClose: boolean;
|
||||
focusOnActivate: boolean;
|
||||
closeReason: Osc99CloseReason | undefined;
|
||||
}
|
||||
|
||||
export interface IOsc99NotificationHost {
|
||||
isEnabled(): boolean;
|
||||
isWindowFocused(): boolean;
|
||||
isTerminalVisible(): boolean;
|
||||
focusTerminal(): void;
|
||||
notify(notification: INotification): INotificationHandle;
|
||||
updateEnableNotifications(value: boolean): Promise<void>;
|
||||
logWarn(message: string): void;
|
||||
writeToProcess(data: string): void;
|
||||
}
|
||||
|
||||
export class TerminalNotificationHandler extends Disposable {
|
||||
private readonly _osc99PendingNotifications = new Map<string, IOsc99NotificationState>();
|
||||
private _osc99PendingAnonymous: IOsc99NotificationState | undefined;
|
||||
private readonly _osc99ActiveNotifications = new Map<string, IOsc99ActiveNotification>();
|
||||
|
||||
constructor(
|
||||
private readonly _host: IOsc99NotificationHost
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
handleSequence(data: string): boolean {
|
||||
const { metadata, payload } = this._splitOsc99Data(data);
|
||||
const metadataEntries = this._parseOsc99Metadata(metadata);
|
||||
const payloadTypes = metadataEntries.get('p');
|
||||
const rawPayloadType = payloadTypes && payloadTypes.length > 0 ? payloadTypes[payloadTypes.length - 1] : undefined;
|
||||
const payloadType = rawPayloadType && rawPayloadType.length > 0 ? rawPayloadType : Osc99PayloadType.Title;
|
||||
const id = this._sanitizeOsc99Id(metadataEntries.get('i')?.[0]);
|
||||
|
||||
if (!this._host.isEnabled()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
switch (payloadType) {
|
||||
case Osc99PayloadType.Query:
|
||||
this._sendOsc99QueryResponse(id);
|
||||
return true;
|
||||
case Osc99PayloadType.Alive:
|
||||
this._sendOsc99AliveResponse(id);
|
||||
return true;
|
||||
case Osc99PayloadType.Close:
|
||||
this._closeOsc99Notification(id);
|
||||
return true;
|
||||
}
|
||||
|
||||
const state = this._getOrCreateOsc99State(id);
|
||||
this._updateOsc99StateFromMetadata(state, metadataEntries);
|
||||
|
||||
const isEncoded = metadataEntries.get('e')?.[0] === '1';
|
||||
const payloadText = this._decodeOsc99Payload(payload, isEncoded);
|
||||
const isDone = metadataEntries.get('d')?.[0] !== '0';
|
||||
|
||||
switch (payloadType) {
|
||||
case Osc99PayloadType.Title:
|
||||
state.title += payloadText;
|
||||
break;
|
||||
case Osc99PayloadType.Body:
|
||||
state.body += payloadText;
|
||||
break;
|
||||
case Osc99PayloadType.Buttons:
|
||||
state.buttonsPayload += payloadText;
|
||||
break;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!isDone) {
|
||||
return true;
|
||||
}
|
||||
if (!this._shouldHonorOsc99Occasion(state.occasion)) {
|
||||
this._clearOsc99PendingState(id);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (this._showOsc99Notification(state)) {
|
||||
this._clearOsc99PendingState(id);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private _splitOsc99Data(data: string): { metadata: string; payload: string } {
|
||||
const separatorIndex = data.indexOf(';');
|
||||
if (separatorIndex === -1) {
|
||||
return { metadata: data, payload: '' };
|
||||
}
|
||||
return {
|
||||
metadata: data.substring(0, separatorIndex),
|
||||
payload: data.substring(separatorIndex + 1)
|
||||
};
|
||||
}
|
||||
|
||||
private _parseOsc99Metadata(metadata: string): Map<string, string[]> {
|
||||
const result = new Map<string, string[]>();
|
||||
if (!metadata) {
|
||||
return result;
|
||||
}
|
||||
for (const entry of metadata.split(':')) {
|
||||
if (!entry) {
|
||||
continue;
|
||||
}
|
||||
const separatorIndex = entry.indexOf('=');
|
||||
if (separatorIndex === -1) {
|
||||
continue;
|
||||
}
|
||||
const key = entry.substring(0, separatorIndex);
|
||||
const value = entry.substring(separatorIndex + 1);
|
||||
if (!key) {
|
||||
continue;
|
||||
}
|
||||
let values = result.get(key);
|
||||
if (!values) {
|
||||
values = [];
|
||||
result.set(key, values);
|
||||
}
|
||||
values.push(value);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private _decodeOsc99Payload(payload: string, isEncoded: boolean): string {
|
||||
if (!isEncoded) {
|
||||
return payload;
|
||||
}
|
||||
try {
|
||||
return decodeBase64(payload).toString();
|
||||
} catch {
|
||||
this._host.logWarn('Failed to decode OSC 99 payload');
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
private _sanitizeOsc99Id(rawId: string | undefined): string | undefined {
|
||||
if (!rawId) {
|
||||
return undefined;
|
||||
}
|
||||
const sanitized = rawId.replace(/[^a-zA-Z0-9_\-+.]/g, '');
|
||||
return sanitized.length > 0 ? sanitized : undefined;
|
||||
}
|
||||
|
||||
private _sanitizeOsc99MessageText(text: string): string {
|
||||
return text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1');
|
||||
}
|
||||
|
||||
private _getOrCreateOsc99State(id: string | undefined): IOsc99NotificationState {
|
||||
if (!id) {
|
||||
if (!this._osc99PendingAnonymous) {
|
||||
this._osc99PendingAnonymous = this._createOsc99State(undefined);
|
||||
}
|
||||
return this._osc99PendingAnonymous;
|
||||
}
|
||||
let state = this._osc99PendingNotifications.get(id);
|
||||
if (!state) {
|
||||
state = this._createOsc99State(id);
|
||||
this._osc99PendingNotifications.set(id, state);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
private _createOsc99State(id: string | undefined): IOsc99NotificationState {
|
||||
return {
|
||||
id,
|
||||
title: '',
|
||||
body: '',
|
||||
buttonsPayload: '',
|
||||
focusOnActivate: true,
|
||||
reportOnActivate: false,
|
||||
reportOnClose: false,
|
||||
urgency: undefined,
|
||||
autoCloseMs: undefined,
|
||||
occasion: undefined
|
||||
};
|
||||
}
|
||||
|
||||
private _clearOsc99PendingState(id: string | undefined): void {
|
||||
if (!id) {
|
||||
this._osc99PendingAnonymous = undefined;
|
||||
return;
|
||||
}
|
||||
this._osc99PendingNotifications.delete(id);
|
||||
}
|
||||
|
||||
private _updateOsc99StateFromMetadata(state: IOsc99NotificationState, metadataEntries: Map<string, string[]>): void {
|
||||
const actionValues = metadataEntries.get('a');
|
||||
const actionValue = actionValues && actionValues.length > 0 ? actionValues[actionValues.length - 1] : undefined;
|
||||
if (actionValue !== undefined) {
|
||||
const actions = this._parseOsc99Actions(actionValue);
|
||||
state.focusOnActivate = actions.focusOnActivate;
|
||||
state.reportOnActivate = actions.reportOnActivate;
|
||||
}
|
||||
const closeValues = metadataEntries.get('c');
|
||||
const closeValue = closeValues && closeValues.length > 0 ? closeValues[closeValues.length - 1] : undefined;
|
||||
if (closeValue !== undefined) {
|
||||
state.reportOnClose = closeValue === '1';
|
||||
}
|
||||
const urgencyValues = metadataEntries.get('u');
|
||||
const urgencyValue = urgencyValues && urgencyValues.length > 0 ? urgencyValues[urgencyValues.length - 1] : undefined;
|
||||
if (urgencyValue !== undefined) {
|
||||
const urgency = Number.parseInt(urgencyValue, 10);
|
||||
if (!Number.isNaN(urgency)) {
|
||||
state.urgency = urgency;
|
||||
}
|
||||
}
|
||||
const autoCloseValues = metadataEntries.get('w');
|
||||
const autoCloseValue = autoCloseValues && autoCloseValues.length > 0 ? autoCloseValues[autoCloseValues.length - 1] : undefined;
|
||||
if (autoCloseValue !== undefined) {
|
||||
const autoClose = Number.parseInt(autoCloseValue, 10);
|
||||
if (!Number.isNaN(autoClose)) {
|
||||
state.autoCloseMs = autoClose;
|
||||
}
|
||||
}
|
||||
const occasionValues = metadataEntries.get('o');
|
||||
const occasionValue = occasionValues && occasionValues.length > 0 ? occasionValues[occasionValues.length - 1] : undefined;
|
||||
if (occasionValue === 'always' || occasionValue === 'unfocused' || occasionValue === 'invisible') {
|
||||
state.occasion = occasionValue;
|
||||
}
|
||||
}
|
||||
|
||||
private _parseOsc99Actions(value: string): { focusOnActivate: boolean; reportOnActivate: boolean } {
|
||||
let focusOnActivate = true;
|
||||
let reportOnActivate = false;
|
||||
for (const token of value.split(',')) {
|
||||
switch (token) {
|
||||
case 'focus':
|
||||
focusOnActivate = true;
|
||||
break;
|
||||
case '-focus':
|
||||
focusOnActivate = false;
|
||||
break;
|
||||
case 'report':
|
||||
reportOnActivate = true;
|
||||
break;
|
||||
case '-report':
|
||||
reportOnActivate = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return { focusOnActivate, reportOnActivate };
|
||||
}
|
||||
|
||||
private _shouldHonorOsc99Occasion(occasion: Osc99Occasion | undefined): boolean {
|
||||
if (!occasion || occasion === 'always') {
|
||||
return true;
|
||||
}
|
||||
const windowFocused = this._host.isWindowFocused();
|
||||
switch (occasion) {
|
||||
case 'unfocused':
|
||||
return !windowFocused;
|
||||
case 'invisible':
|
||||
return !windowFocused && !this._host.isTerminalVisible();
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private _showOsc99Notification(state: IOsc99NotificationState): boolean {
|
||||
const message = this._getOsc99NotificationMessage(state);
|
||||
if (!message) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const severity = state.urgency === 2 ? Severity.Warning : Severity.Info;
|
||||
const priority = this._getOsc99NotificationPriority(state.urgency);
|
||||
const source = {
|
||||
id: 'terminal',
|
||||
label: localize('terminalNotificationSource', 'Terminal')
|
||||
};
|
||||
const buttons = state.buttonsPayload.length > 0 ? state.buttonsPayload.split('\u2028') : [];
|
||||
const actionStore = this._register(new DisposableStore());
|
||||
|
||||
const handleRef: { current: INotificationHandle | undefined } = { current: undefined };
|
||||
const activeRef: { current: IOsc99ActiveNotification | undefined } = { current: undefined };
|
||||
const reportActivation = (buttonIndex?: number, forceFocus?: boolean) => {
|
||||
if (forceFocus || state.focusOnActivate) {
|
||||
this._host.focusTerminal();
|
||||
}
|
||||
if (state.reportOnActivate) {
|
||||
this._sendOsc99ActivationReport(state.id, buttonIndex);
|
||||
}
|
||||
};
|
||||
|
||||
const primaryActions: IAction[] = [];
|
||||
for (let i = 0; i < buttons.length; i++) {
|
||||
const label = buttons[i];
|
||||
if (!label) {
|
||||
continue;
|
||||
}
|
||||
const action = actionStore.add(new Action(`terminal.osc99.button.${i}`, label, undefined, true, () => {
|
||||
if (activeRef.current) {
|
||||
activeRef.current.closeReason = 'button';
|
||||
}
|
||||
reportActivation(i + 1);
|
||||
handleRef.current?.close();
|
||||
}));
|
||||
primaryActions.push(action);
|
||||
}
|
||||
|
||||
const secondaryActions: IAction[] = [];
|
||||
secondaryActions.push(actionStore.add(new Action(
|
||||
'terminal.osc99.dismiss',
|
||||
localize('terminalNotificationDismiss', 'Dismiss'),
|
||||
undefined,
|
||||
true,
|
||||
() => {
|
||||
if (activeRef.current) {
|
||||
activeRef.current.closeReason = 'secondary';
|
||||
}
|
||||
handleRef.current?.close();
|
||||
}
|
||||
)));
|
||||
secondaryActions.push(actionStore.add(new Action(
|
||||
'terminal.osc99.disable',
|
||||
localize('terminalNotificationDisable', 'Disable Terminal Notifications'),
|
||||
undefined,
|
||||
true,
|
||||
async () => {
|
||||
await this._host.updateEnableNotifications(false);
|
||||
if (activeRef.current) {
|
||||
activeRef.current.closeReason = 'secondary';
|
||||
}
|
||||
handleRef.current?.close();
|
||||
}
|
||||
)));
|
||||
|
||||
const actions = { primary: primaryActions, secondary: secondaryActions };
|
||||
|
||||
if (state.id) {
|
||||
const existing = this._osc99ActiveNotifications.get(state.id);
|
||||
if (existing) {
|
||||
activeRef.current = existing;
|
||||
handleRef.current = existing.handle;
|
||||
existing.handle.updateMessage(message);
|
||||
existing.handle.updateSeverity(severity);
|
||||
existing.handle.updateActions(actions);
|
||||
existing.actionStore.dispose();
|
||||
existing.actionStore = actionStore;
|
||||
existing.focusOnActivate = state.focusOnActivate;
|
||||
existing.reportOnActivate = state.reportOnActivate;
|
||||
existing.reportOnClose = state.reportOnClose;
|
||||
existing.autoCloseDisposable?.dispose();
|
||||
existing.autoCloseDisposable = this._scheduleOsc99AutoClose(existing, state.autoCloseMs);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const handle = this._host.notify({
|
||||
id: state.id ? `terminal.osc99.${state.id}` : undefined,
|
||||
severity,
|
||||
message,
|
||||
source,
|
||||
actions,
|
||||
priority
|
||||
});
|
||||
handleRef.current = handle;
|
||||
|
||||
const active: IOsc99ActiveNotification = {
|
||||
id: state.id,
|
||||
handle,
|
||||
actionStore,
|
||||
autoCloseDisposable: undefined,
|
||||
reportOnActivate: state.reportOnActivate,
|
||||
reportOnClose: state.reportOnClose,
|
||||
focusOnActivate: state.focusOnActivate,
|
||||
closeReason: undefined
|
||||
};
|
||||
activeRef.current = active;
|
||||
active.autoCloseDisposable = this._scheduleOsc99AutoClose(active, state.autoCloseMs);
|
||||
this._register(handle.onDidClose(() => {
|
||||
if (active.reportOnActivate && active.closeReason === undefined) {
|
||||
if (active.focusOnActivate) {
|
||||
this._host.focusTerminal();
|
||||
}
|
||||
this._sendOsc99ActivationReport(active.id);
|
||||
}
|
||||
if (active.reportOnClose) {
|
||||
this._sendOsc99CloseReport(active.id);
|
||||
}
|
||||
active.actionStore.dispose();
|
||||
active.autoCloseDisposable?.dispose();
|
||||
if (active.id) {
|
||||
this._osc99ActiveNotifications.delete(active.id);
|
||||
}
|
||||
}));
|
||||
|
||||
if (active.id) {
|
||||
this._osc99ActiveNotifications.set(active.id, active);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private _getOsc99NotificationMessage(state: IOsc99NotificationState): string | undefined {
|
||||
const title = this._sanitizeOsc99MessageText(state.title);
|
||||
const body = this._sanitizeOsc99MessageText(state.body);
|
||||
const hasTitle = title.trim().length > 0;
|
||||
const hasBody = body.trim().length > 0;
|
||||
if (hasTitle && hasBody) {
|
||||
return `${title}: ${body}`;
|
||||
}
|
||||
if (hasTitle) {
|
||||
return title;
|
||||
}
|
||||
if (hasBody) {
|
||||
return body;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private _getOsc99NotificationPriority(urgency: number | undefined): NotificationPriority | undefined {
|
||||
switch (urgency) {
|
||||
case 0:
|
||||
return NotificationPriority.SILENT;
|
||||
case 1:
|
||||
return NotificationPriority.DEFAULT;
|
||||
case 2:
|
||||
return NotificationPriority.URGENT;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private _scheduleOsc99AutoClose(active: IOsc99ActiveNotification, autoCloseMs: number | undefined): IDisposable | undefined {
|
||||
if (autoCloseMs === undefined || autoCloseMs <= 0) {
|
||||
return undefined;
|
||||
}
|
||||
return disposableTimeout(() => {
|
||||
active.closeReason = 'auto';
|
||||
active.handle.close();
|
||||
}, autoCloseMs, this._store);
|
||||
}
|
||||
|
||||
private _closeOsc99Notification(id: string | undefined): void {
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
const active = this._osc99ActiveNotifications.get(id);
|
||||
if (active) {
|
||||
active.closeReason = 'protocol';
|
||||
active.handle.close();
|
||||
}
|
||||
this._osc99PendingNotifications.delete(id);
|
||||
}
|
||||
|
||||
private _sendOsc99QueryResponse(id: string | undefined): void {
|
||||
const requestId = id ?? '0';
|
||||
this._sendOsc99Response([
|
||||
`i=${requestId}`,
|
||||
'p=?',
|
||||
'a=report,focus',
|
||||
'c=1',
|
||||
'o=always,unfocused,invisible',
|
||||
'p=title,body,buttons,close,alive,?',
|
||||
'u=0,1,2',
|
||||
'w=1'
|
||||
]);
|
||||
}
|
||||
|
||||
private _sendOsc99AliveResponse(id: string | undefined): void {
|
||||
const requestId = id ?? '0';
|
||||
const aliveIds = Array.from(this._osc99ActiveNotifications.keys()).join(',');
|
||||
this._sendOsc99Response([
|
||||
`i=${requestId}`,
|
||||
'p=alive'
|
||||
], aliveIds);
|
||||
}
|
||||
|
||||
private _sendOsc99ActivationReport(id: string | undefined, buttonIndex?: number): void {
|
||||
const reportId = id ?? '0';
|
||||
this._sendOsc99Response([`i=${reportId}`], buttonIndex !== undefined ? String(buttonIndex) : '');
|
||||
}
|
||||
|
||||
private _sendOsc99CloseReport(id: string | undefined): void {
|
||||
const reportId = id ?? '0';
|
||||
this._sendOsc99Response([`i=${reportId}`, 'p=close']);
|
||||
}
|
||||
|
||||
private _sendOsc99Response(metadataParts: string[], payload: string = ''): void {
|
||||
const metadata = metadataParts.join(':');
|
||||
this._host.writeToProcess(`\x1b]99;${metadata};${payload}\x1b\\`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import type { IStringDictionary } from '../../../../../base/common/collections.js';
|
||||
import { localize } from '../../../../../nls.js';
|
||||
import type { IConfigurationPropertySchema } from '../../../../../platform/configuration/common/configurationRegistry.js';
|
||||
|
||||
export const enum TerminalOscNotificationsSettingId {
|
||||
EnableNotifications = 'terminal.integrated.enableNotifications',
|
||||
}
|
||||
|
||||
export const terminalOscNotificationsConfiguration: IStringDictionary<IConfigurationPropertySchema> = {
|
||||
[TerminalOscNotificationsSettingId.EnableNotifications]: {
|
||||
description: localize('terminal.integrated.enableNotifications', "Controls whether notifications sent from the terminal via OSC 99 are shown. This uses notifications inside the product instead of desktop notifications. Sounds, icons and filtering are not supported."),
|
||||
type: 'boolean',
|
||||
default: true
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,262 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { strictEqual } from 'assert';
|
||||
import { Emitter, Event } from '../../../../../../base/common/event.js';
|
||||
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js';
|
||||
import { NotificationPriority, Severity, type INotification, type INotificationActions, type INotificationHandle, type INotificationProgress, type NotificationMessage } from '../../../../../../platform/notification/common/notification.js';
|
||||
import { TerminalNotificationHandler, type IOsc99NotificationHost } from '../../browser/terminalNotificationHandler.js';
|
||||
|
||||
class TestNotificationProgress implements INotificationProgress {
|
||||
infinite(): void { }
|
||||
total(_value: number): void { }
|
||||
worked(_value: number): void { }
|
||||
done(): void { }
|
||||
}
|
||||
|
||||
class TestNotificationHandle implements INotificationHandle {
|
||||
private readonly _onDidClose = new Emitter<void>();
|
||||
readonly onDidClose = this._onDidClose.event;
|
||||
readonly onDidChangeVisibility = Event.None;
|
||||
readonly progress = new TestNotificationProgress();
|
||||
closed = false;
|
||||
message: NotificationMessage;
|
||||
severity: Severity;
|
||||
actions?: INotificationActions;
|
||||
priority?: NotificationPriority;
|
||||
source?: string | { id: string; label: string };
|
||||
|
||||
constructor(notification: INotification) {
|
||||
this.message = notification.message;
|
||||
this.severity = notification.severity;
|
||||
this.actions = notification.actions;
|
||||
this.priority = notification.priority;
|
||||
this.source = notification.source;
|
||||
}
|
||||
|
||||
updateSeverity(severity: Severity): void {
|
||||
this.severity = severity;
|
||||
}
|
||||
|
||||
updateMessage(message: NotificationMessage): void {
|
||||
this.message = message;
|
||||
}
|
||||
|
||||
updateActions(actions?: INotificationActions): void {
|
||||
this._disposeActions(this.actions);
|
||||
this.actions = actions;
|
||||
}
|
||||
|
||||
close(): void {
|
||||
if (this.closed) {
|
||||
return;
|
||||
}
|
||||
this.closed = true;
|
||||
this._disposeActions(this.actions);
|
||||
this._onDidClose.fire();
|
||||
}
|
||||
|
||||
private _disposeActions(actions: INotificationActions | undefined): void {
|
||||
for (const action of actions?.primary ?? []) {
|
||||
const disposable = action as { dispose?: () => void };
|
||||
if (typeof disposable.dispose === 'function') {
|
||||
disposable.dispose();
|
||||
}
|
||||
}
|
||||
for (const action of actions?.secondary ?? []) {
|
||||
const disposable = action as { dispose?: () => void };
|
||||
if (typeof disposable.dispose === 'function') {
|
||||
disposable.dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class TestOsc99Host implements IOsc99NotificationHost {
|
||||
enabled = true;
|
||||
windowFocused = false;
|
||||
terminalVisible = false;
|
||||
writes: string[] = [];
|
||||
notifications: TestNotificationHandle[] = [];
|
||||
focusCalls = 0;
|
||||
updatedEnableNotifications: boolean[] = [];
|
||||
logMessages: string[] = [];
|
||||
|
||||
isEnabled(): boolean {
|
||||
return this.enabled;
|
||||
}
|
||||
|
||||
isWindowFocused(): boolean {
|
||||
return this.windowFocused;
|
||||
}
|
||||
|
||||
isTerminalVisible(): boolean {
|
||||
return this.terminalVisible;
|
||||
}
|
||||
|
||||
focusTerminal(): void {
|
||||
this.focusCalls++;
|
||||
}
|
||||
|
||||
notify(notification: INotification): INotificationHandle {
|
||||
const handle = new TestNotificationHandle(notification);
|
||||
this.notifications.push(handle);
|
||||
return handle;
|
||||
}
|
||||
|
||||
async updateEnableNotifications(value: boolean): Promise<void> {
|
||||
this.enabled = value;
|
||||
this.updatedEnableNotifications.push(value);
|
||||
}
|
||||
|
||||
logWarn(message: string): void {
|
||||
this.logMessages.push(message);
|
||||
}
|
||||
|
||||
writeToProcess(data: string): void {
|
||||
this.writes.push(data);
|
||||
}
|
||||
}
|
||||
|
||||
suite('Terminal OSC 99 notifications', () => {
|
||||
const store = ensureNoDisposablesAreLeakedInTestSuite();
|
||||
|
||||
let host: TestOsc99Host;
|
||||
let handler: TerminalNotificationHandler;
|
||||
|
||||
setup(() => {
|
||||
host = new TestOsc99Host();
|
||||
handler = store.add(new TerminalNotificationHandler(host));
|
||||
});
|
||||
|
||||
teardown(() => {
|
||||
for (const notification of host.notifications) {
|
||||
notification.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('ignores notifications when disabled', () => {
|
||||
host.enabled = false;
|
||||
|
||||
handler.handleSequence(';Hello');
|
||||
strictEqual(host.notifications.length, 0);
|
||||
strictEqual(host.writes.length, 0);
|
||||
});
|
||||
|
||||
test('creates notification for title and body and updates', () => {
|
||||
handler.handleSequence('i=1:d=0:p=title;Hello');
|
||||
strictEqual(host.notifications.length, 0);
|
||||
|
||||
handler.handleSequence('i=1:p=body;World');
|
||||
strictEqual(host.notifications.length, 1);
|
||||
strictEqual(host.notifications[0].message, 'Hello: World');
|
||||
});
|
||||
|
||||
test('decodes base64 payloads', () => {
|
||||
handler.handleSequence('e=1:p=title;SGVsbG8=');
|
||||
strictEqual(host.notifications.length, 1);
|
||||
strictEqual(host.notifications[0].message, 'Hello');
|
||||
});
|
||||
|
||||
test('sanitizes markdown links in payloads', () => {
|
||||
handler.handleSequence('i=link:d=0:p=title;Click [run](command:workbench.action.reloadWindow)');
|
||||
handler.handleSequence('i=link:p=body;See [docs](https://example.com)');
|
||||
strictEqual(host.notifications.length, 1);
|
||||
strictEqual(host.notifications[0].message, 'Click run: See docs');
|
||||
});
|
||||
|
||||
test('defers display until done', () => {
|
||||
handler.handleSequence('i=chunk:d=0:p=title;Hello ');
|
||||
strictEqual(host.notifications.length, 0);
|
||||
|
||||
handler.handleSequence('i=chunk:d=1:p=title;World');
|
||||
strictEqual(host.notifications.length, 1);
|
||||
strictEqual(host.notifications[0].message, 'Hello World');
|
||||
});
|
||||
|
||||
test('reports activation on button click', async () => {
|
||||
handler.handleSequence('i=btn:d=0:a=report:p=title;Hi');
|
||||
handler.handleSequence('i=btn:p=buttons;Yes');
|
||||
|
||||
const actions = host.notifications[0].actions;
|
||||
if (!actions?.primary || actions.primary.length === 0) {
|
||||
throw new Error('Expected primary actions');
|
||||
}
|
||||
await actions.primary[0].run();
|
||||
strictEqual(host.writes[0], '\x1b]99;i=btn;1\x1b\\');
|
||||
});
|
||||
|
||||
test('supports buttons before title and reports body activation', async () => {
|
||||
handler.handleSequence('i=btn:p=buttons;One\u2028Two');
|
||||
handler.handleSequence('i=btn:a=report;Buttons test');
|
||||
|
||||
strictEqual(host.notifications.length, 1);
|
||||
const actions = host.notifications[0].actions;
|
||||
if (!actions?.primary || actions.primary.length !== 2) {
|
||||
throw new Error('Expected two primary actions');
|
||||
}
|
||||
strictEqual(actions.primary[0].label, 'One');
|
||||
strictEqual(actions.primary[1].label, 'Two');
|
||||
|
||||
await actions.primary[1].run();
|
||||
strictEqual(host.writes[0], '\x1b]99;i=btn;2\x1b\\');
|
||||
});
|
||||
|
||||
test('reports activation when notification closes without button action', () => {
|
||||
handler.handleSequence('i=btn:p=buttons;One\u2028Two');
|
||||
handler.handleSequence('i=btn:a=report;Buttons test');
|
||||
|
||||
host.notifications[0].close();
|
||||
strictEqual(host.writes[0], '\x1b]99;i=btn;\x1b\\');
|
||||
});
|
||||
|
||||
test('sends close report when requested', () => {
|
||||
handler.handleSequence('i=close:c=1:p=title;Bye');
|
||||
strictEqual(host.notifications.length, 1);
|
||||
host.notifications[0].close();
|
||||
strictEqual(host.writes[0], '\x1b]99;i=close:p=close;\x1b\\');
|
||||
});
|
||||
|
||||
test('responds to query and alive', () => {
|
||||
handler.handleSequence('i=a:p=title;A');
|
||||
handler.handleSequence('i=b:p=title;B');
|
||||
handler.handleSequence('i=q:p=?;');
|
||||
handler.handleSequence('i=q:p=alive;');
|
||||
|
||||
strictEqual(host.writes[0], '\x1b]99;i=q:p=?:a=report,focus:c=1:o=always,unfocused,invisible:p=title,body,buttons,close,alive,?:u=0,1,2:w=1;\x1b\\');
|
||||
strictEqual(host.writes[1], '\x1b]99;i=q:p=alive;a,b\x1b\\');
|
||||
});
|
||||
|
||||
test('honors occasion for visibility and focus', () => {
|
||||
host.windowFocused = true;
|
||||
host.terminalVisible = true;
|
||||
handler.handleSequence('o=unfocused:p=title;Hidden');
|
||||
strictEqual(host.notifications.length, 0);
|
||||
|
||||
host.windowFocused = false;
|
||||
host.terminalVisible = true;
|
||||
handler.handleSequence('o=invisible:p=title;Hidden');
|
||||
strictEqual(host.notifications.length, 0);
|
||||
|
||||
host.terminalVisible = false;
|
||||
handler.handleSequence('o=invisible:p=title;Shown');
|
||||
strictEqual(host.notifications.length, 1);
|
||||
});
|
||||
|
||||
test('closes notifications via close payload', () => {
|
||||
handler.handleSequence('i=closeme:p=title;Close');
|
||||
strictEqual(host.notifications.length, 1);
|
||||
strictEqual(host.notifications[0].closed, false);
|
||||
|
||||
handler.handleSequence('i=closeme:p=close;');
|
||||
strictEqual(host.notifications[0].closed, true);
|
||||
});
|
||||
|
||||
test('maps urgency to severity and priority', () => {
|
||||
handler.handleSequence('u=2:p=title;Urgent');
|
||||
strictEqual(host.notifications[0].severity, Severity.Warning);
|
||||
strictEqual(host.notifications[0].priority, NotificationPriority.URGENT);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user