Skip to content

Commit

Permalink
Implement "secondary window" support for Electron (#12481)
Browse files Browse the repository at this point in the history
Fixes #11642

The main change is to prevent the secondary window from closing until
the extracted widget is removed from the window. This includes waiting
until any close handling (including dialogs) are finished.

To enable this properly, dialog support has been extended to work with
secondary windows, including support for the StylingService in secondary
windows.

Contributed on behalf of STMicroelectronics

Signed-off-by: Thomas Mäder <t.s.maeder@gmail.com>
  • Loading branch information
tsmaeder authored Jun 2, 2023
1 parent 0caea2c commit 7c47bee
Show file tree
Hide file tree
Showing 15 changed files with 215 additions and 101 deletions.
1 change: 1 addition & 0 deletions examples/electron/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
"@theia/scm": "1.38.0",
"@theia/scm-extra": "1.38.0",
"@theia/search-in-workspace": "1.38.0",
"@theia/secondary-window": "1.38.0",
"@theia/task": "1.38.0",
"@theia/terminal": "1.38.0",
"@theia/timeline": "1.38.0",
Expand Down
3 changes: 3 additions & 0 deletions examples/electron/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,9 @@
{
"path": "../../packages/search-in-workspace"
},
{
"path": "../../packages/secondary-window"
},
{
"path": "../../packages/task"
},
Expand Down
33 changes: 19 additions & 14 deletions packages/core/src/browser/dialogs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,10 +84,9 @@ export class DialogOverlayService implements FrontendApplicationContribution {

// eslint-disable-next-line @typescript-eslint/no-explicit-any
protected readonly dialogs: AbstractDialog<any>[] = [];
protected readonly documents: Document[] = [];

constructor() {
addKeyListener(document.body, Key.ENTER, e => this.handleEnter(e));
addKeyListener(document.body, Key.ESCAPE, e => this.handleEscape(e));
}

initialize(): void {
Expand All @@ -101,6 +100,11 @@ export class DialogOverlayService implements FrontendApplicationContribution {

// eslint-disable-next-line @typescript-eslint/no-explicit-any
push(dialog: AbstractDialog<any>): Disposable {
if (this.documents.findIndex(document => document === dialog.node.ownerDocument) < 0) {
addKeyListener(dialog.node.ownerDocument.body, Key.ENTER, e => this.handleEnter(e));
addKeyListener(dialog.node.ownerDocument.body, Key.ESCAPE, e => this.handleEscape(e));
this.documents.push(dialog.node.ownerDocument);
}
this.dialogs.unshift(dialog);
return Disposable.create(() => {
const index = this.dialogs.indexOf(dialog);
Expand Down Expand Up @@ -147,17 +151,18 @@ export abstract class AbstractDialog<T> extends BaseWidget {
protected activeElement: HTMLElement | undefined;

constructor(
@inject(DialogProps) protected readonly props: DialogProps
protected readonly props: DialogProps,
options?: Widget.IOptions
) {
super();
super(options);
this.id = 'theia-dialog-shell';
this.addClass('dialogOverlay');
this.toDispose.push(Disposable.create(() => {
if (this.reject) {
Widget.detach(this);
}
}));
const container = document.createElement('div');
const container = this.node.ownerDocument.createElement('div');
container.classList.add('dialogBlock');
if (props.maxWidth === undefined) {
container.setAttribute('style', 'max-width: none');
Expand All @@ -166,31 +171,31 @@ export abstract class AbstractDialog<T> extends BaseWidget {
}
this.node.appendChild(container);

const titleContentNode = document.createElement('div');
const titleContentNode = this.node.ownerDocument.createElement('div');
titleContentNode.classList.add('dialogTitle');
container.appendChild(titleContentNode);

this.titleNode = document.createElement('div');
this.titleNode = this.node.ownerDocument.createElement('div');
this.titleNode.textContent = props.title;
titleContentNode.appendChild(this.titleNode);

this.closeCrossNode = document.createElement('i');
this.closeCrossNode = this.node.ownerDocument.createElement('i');
this.closeCrossNode.classList.add(...codiconArray('close'));
this.closeCrossNode.classList.add('closeButton');
titleContentNode.appendChild(this.closeCrossNode);

this.contentNode = document.createElement('div');
this.contentNode = this.node.ownerDocument.createElement('div');
this.contentNode.classList.add('dialogContent');
if (props.wordWrap !== undefined) {
this.contentNode.setAttribute('style', `word-wrap: ${props.wordWrap}`);
}
container.appendChild(this.contentNode);

this.controlPanel = document.createElement('div');
this.controlPanel = this.node.ownerDocument.createElement('div');
this.controlPanel.classList.add('dialogControl');
container.appendChild(this.controlPanel);

this.errorMessageNode = document.createElement('div');
this.errorMessageNode = this.node.ownerDocument.createElement('div');
this.errorMessageNode.classList.add('error');
this.errorMessageNode.setAttribute('style', 'flex: 2');
this.controlPanel.appendChild(this.errorMessageNode);
Expand Down Expand Up @@ -255,7 +260,7 @@ export abstract class AbstractDialog<T> extends BaseWidget {
if (this.resolve) {
return Promise.reject(new Error('The dialog is already opened.'));
}
this.activeElement = window.document.activeElement as HTMLElement;
this.activeElement = this.node.ownerDocument.activeElement as HTMLElement;
return new Promise<T | undefined>((resolve, reject) => {
this.resolve = resolve;
this.reject = reject;
Expand All @@ -264,7 +269,7 @@ export abstract class AbstractDialog<T> extends BaseWidget {
this.reject = undefined;
}));

Widget.attach(this, document.body);
Widget.attach(this, this.node.ownerDocument.body);
this.activate();
});
}
Expand Down Expand Up @@ -388,7 +393,7 @@ export class ConfirmDialog extends AbstractDialog<boolean> {

protected createMessageNode(msg: string | HTMLElement): HTMLElement {
if (typeof msg === 'string') {
const messageNode = document.createElement('div');
const messageNode = this.node.ownerDocument.createElement('div');
messageNode.textContent = msg;
return messageNode;
}
Expand Down
4 changes: 3 additions & 1 deletion packages/core/src/browser/saveable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -287,9 +287,11 @@ export class ShouldSaveDialog extends AbstractDialog<boolean> {
constructor(widget: Widget) {
super({
title: nls.localizeByDefault('Do you want to save the changes you made to {0}?', widget.title.label || widget.title.caption)
}, {
node: widget.node.ownerDocument.createElement('div')
});

const messageNode = document.createElement('div');
const messageNode = this.node.ownerDocument.createElement('div');
messageNode.textContent = nls.localizeByDefault("Your changes will be lost if you don't save them.");
messageNode.setAttribute('style', 'flex: 1 100%; padding-bottom: calc(var(--theia-ui-padding)*3);');
this.contentNode.appendChild(messageNode);
Expand Down
45 changes: 7 additions & 38 deletions packages/core/src/browser/secondary-window-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { Emitter } from '../common/event';
import { SecondaryWindowService } from './window/secondary-window-service';
import { KeybindingRegistry } from './keybinding';
import { ColorApplicationContribution } from './color-application-contribution';
import { StylingService } from './styling-service';

/** Widget to be contained directly in a secondary window. */
class SecondaryWindowRootWidget extends Widget {
Expand Down Expand Up @@ -50,8 +51,6 @@ class SecondaryWindowRootWidget extends Widget {
*/
@injectable()
export class SecondaryWindowHandler {
/** List of currently open secondary windows. Window references should be removed once the window is closed. */
protected readonly secondaryWindows: Window[] = [];
/** List of widgets in secondary windows. */
protected readonly _widgets: ExtractableWidget[] = [];

Expand All @@ -63,6 +62,9 @@ export class SecondaryWindowHandler {
@inject(ColorApplicationContribution)
protected colorAppContribution: ColorApplicationContribution;

@inject(StylingService)
protected stylingService: StylingService;

protected readonly onDidAddWidgetEmitter = new Emitter<Widget>();
/** Subscribe to get notified when a widget is added to this handler, i.e. the widget was moved to an secondary window . */
readonly onDidAddWidget = this.onDidAddWidgetEmitter.event;
Expand Down Expand Up @@ -95,33 +97,6 @@ export class SecondaryWindowHandler {
return;
}
this.applicationShell = shell;

// Set up messaging with secondary windows
window.addEventListener('message', (event: MessageEvent) => {
console.trace('Message on main window', event);
if (event.data.fromSecondary) {
console.trace('Message comes from secondary window');
return;
}
if (event.data.fromMain) {
console.trace('Message has mainWindow marker, therefore ignore it');
return;
}

// Filter setImmediate messages. Do not forward because these come in with very high frequency.
// They are not needed in secondary windows because these messages are just a work around
// to make setImmediate work in the main window: https://developer.mozilla.org/en-US/docs/Web/API/Window/setImmediate
if (typeof event.data === 'string' && event.data.startsWith('setImmediate')) {
return;
}

console.trace('Delegate main window message to secondary windows', event);
this.secondaryWindows.forEach(secondaryWindow => {
if (!secondaryWindow.window.closed) {
secondaryWindow.window.postMessage({ ...event.data, fromMain: true }, '*');
}
});
});
}

/**
Expand All @@ -139,21 +114,13 @@ export class SecondaryWindowHandler {
return;
}

const newWindow = this.secondaryWindowService.createSecondaryWindow(closed => {
this.applicationShell.closeWidget(widget.id);
const extIndex = this.secondaryWindows.indexOf(closed);
if (extIndex > -1) {
this.secondaryWindows.splice(extIndex, 1);
}
});
const newWindow = this.secondaryWindowService.createSecondaryWindow(widget, this.applicationShell);

if (!newWindow) {
this.messageService.error('The widget could not be moved to a secondary window because the window creation failed. Please make sure to allow popups.');
return;
}

this.secondaryWindows.push(newWindow);

const mainWindowTitle = document.title;
newWindow.onload = () => {
this.keybindings.registerEventListeners(newWindow);
Expand All @@ -168,6 +135,7 @@ export class SecondaryWindowHandler {
return;
}
const unregisterWithColorContribution = this.colorAppContribution.registerWindow(newWindow);
const unregisterWithStylingService = this.stylingService.registerWindow(newWindow);

widget.secondaryWindow = newWindow;
const rootWidget = new SecondaryWindowRootWidget();
Expand All @@ -182,6 +150,7 @@ export class SecondaryWindowHandler {
// Close the window if the widget is disposed, e.g. by a command closing all widgets.
widget.disposed.connect(() => {
unregisterWithColorContribution.dispose();
unregisterWithStylingService.dispose();
this.removeWidget(widget);
if (!newWindow.closed) {
newWindow.close();
Expand Down
23 changes: 17 additions & 6 deletions packages/core/src/browser/styling-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { ColorRegistry } from './color-registry';
import { DecorationStyle } from './decoration-style';
import { FrontendApplicationContribution } from './frontend-application';
import { ThemeService } from './theming';
import { Disposable } from '../common';

export const StylingParticipant = Symbol('StylingParticipant');

Expand All @@ -40,8 +41,7 @@ export interface CssStyleCollector {

@injectable()
export class StylingService implements FrontendApplicationContribution {

protected cssElement = DecorationStyle.createStyleElement('contributedColorTheme');
protected cssElements = new Map<Window, HTMLStyleElement>();

@inject(ThemeService)
protected readonly themeService: ThemeService;
Expand All @@ -53,11 +53,22 @@ export class StylingService implements FrontendApplicationContribution {
protected readonly themingParticipants: ContributionProvider<StylingParticipant>;

onStart(): void {
this.applyStyling(this.themeService.getCurrentTheme());
this.themeService.onDidColorThemeChange(e => this.applyStyling(e.newTheme));
this.registerWindow(window);
this.themeService.onDidColorThemeChange(e => this.applyStylingToWindows(e.newTheme));
}

registerWindow(win: Window): Disposable {
const cssElement = DecorationStyle.createStyleElement('contributedColorTheme', win.document.head);
this.cssElements.set(win, cssElement);
this.applyStyling(this.themeService.getCurrentTheme(), cssElement);
return Disposable.create(() => this.cssElements.delete(win));
}

protected applyStylingToWindows(theme: Theme): void {
this.cssElements.forEach(cssElement => this.applyStyling(theme, cssElement));
}

protected applyStyling(theme: Theme): void {
protected applyStyling(theme: Theme, cssElement: HTMLStyleElement): void {
const rules: string[] = [];
const colorTheme: ColorTheme = {
type: theme.type,
Expand All @@ -71,6 +82,6 @@ export class StylingService implements FrontendApplicationContribution {
themingParticipant.registerThemeStyle(colorTheme, styleCollector);
}
const fullCss = rules.join('\n');
this.cssElement.innerText = fullCss;
cssElement.innerText = fullCss;
}
}
4 changes: 4 additions & 0 deletions packages/core/src/browser/widgets/widget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,10 @@ export class BaseWidget extends Widget {
protected scrollBar?: PerfectScrollbar;
protected scrollOptions?: PerfectScrollbar.Options;

constructor(options?: Widget.IOptions) {
super(options);
}

override dispose(): void {
if (this.isDisposed) {
return;
Expand Down
Loading

0 comments on commit 7c47bee

Please sign in to comment.