feat: introduced cloud state in sketchbook view

Closes #1879
Closes #1876
Closes #1899
Closes #1878

Signed-off-by: Akos Kitta <a.kitta@arduino.cc>
This commit is contained in:
Akos Kitta
2023-02-15 18:03:37 +01:00
committed by Akos Kitta
parent b09ae48536
commit 0ab28266df
53 changed files with 1971 additions and 659 deletions

View File

@@ -1,106 +1,324 @@
import {
ApplicationConnectionStatusContribution as TheiaApplicationConnectionStatusContribution,
ConnectionStatus,
FrontendConnectionStatusService as TheiaFrontendConnectionStatusService,
} from '@theia/core/lib/browser/connection-status-service';
import type { FrontendApplicationContribution } from '@theia/core/lib/browser/frontend-application';
import { StatusBarAlignment } from '@theia/core/lib/browser/status-bar/status-bar';
import { Disposable } from '@theia/core/lib/common/disposable';
import { Emitter, Event } from '@theia/core/lib/common/event';
import { MessageService } from '@theia/core/lib/common/message-service';
import { MessageType } from '@theia/core/lib/common/message-service-protocol';
import { nls } from '@theia/core/lib/common/nls';
import {
inject,
injectable,
postConstruct,
} from '@theia/core/shared/inversify';
import { Disposable } from '@theia/core/lib/common/disposable';
import { StatusBarAlignment } from '@theia/core/lib/browser/status-bar/status-bar';
import {
FrontendConnectionStatusService as TheiaFrontendConnectionStatusService,
ApplicationConnectionStatusContribution as TheiaApplicationConnectionStatusContribution,
ConnectionStatus,
} from '@theia/core/lib/browser/connection-status-service';
import { NotificationManager } from '@theia/messages/lib/browser/notifications-manager';
import { ArduinoDaemon } from '../../../common/protocol';
import { assertUnreachable } from '../../../common/utils';
import { CreateFeatures } from '../../create/create-features';
import { NotificationCenter } from '../../notification-center';
import { nls } from '@theia/core/lib/common';
import debounce = require('lodash.debounce');
import isOnline = require('is-online');
@injectable()
export class FrontendConnectionStatusService extends TheiaFrontendConnectionStatusService {
@inject(ArduinoDaemon)
protected readonly daemon: ArduinoDaemon;
export class IsOnline implements FrontendApplicationContribution {
private readonly onDidChangeOnlineEmitter = new Emitter<boolean>();
private _online = false;
private stopped = false;
@inject(NotificationCenter)
protected readonly notificationCenter: NotificationCenter;
onStart(): void {
const checkOnline = async () => {
if (!this.stopped) {
try {
const online = await isOnline();
this.setOnline(online);
} finally {
window.setTimeout(() => checkOnline(), 6_000); // 6 seconds poll interval
}
}
};
checkOnline();
}
protected connectedPort: string | undefined;
onStop(): void {
this.stopped = true;
this.onDidChangeOnlineEmitter.dispose();
}
@postConstruct()
protected override async init(): Promise<void> {
this.schedulePing();
try {
this.connectedPort = await this.daemon.tryGetPort();
} catch {}
this.notificationCenter.onDaemonDidStart(
(port) => (this.connectedPort = port)
);
this.notificationCenter.onDaemonDidStop(
() => (this.connectedPort = undefined)
);
const refresh = debounce(() => {
this.updateStatus(!!this.connectedPort);
this.schedulePing();
}, this.options.offlineTimeout - 10);
this.wsConnectionProvider.onIncomingMessageActivity(() => refresh());
get online(): boolean {
return this._online;
}
get onDidChangeOnline(): Event<boolean> {
return this.onDidChangeOnlineEmitter.event;
}
private setOnline(online: boolean) {
const oldOnline = this._online;
this._online = online;
if (!this.stopped && this._online !== oldOnline) {
this.onDidChangeOnlineEmitter.fire(this._online);
}
}
}
@injectable()
export class ApplicationConnectionStatusContribution extends TheiaApplicationConnectionStatusContribution {
export class DaemonPort implements FrontendApplicationContribution {
@inject(ArduinoDaemon)
protected readonly daemon: ArduinoDaemon;
private readonly daemon: ArduinoDaemon;
@inject(NotificationCenter)
protected readonly notificationCenter: NotificationCenter;
private readonly notificationCenter: NotificationCenter;
protected connectedPort: string | undefined;
private readonly onPortDidChangeEmitter = new Emitter<string | undefined>();
private _port: string | undefined;
onStart(): void {
this.daemon.tryGetPort().then(
(port) => this.setPort(port),
(reason) =>
console.warn('Could not retrieve the CLI daemon port.', reason)
);
this.notificationCenter.onDaemonDidStart((port) => this.setPort(port));
this.notificationCenter.onDaemonDidStop(() => this.setPort(undefined));
}
onStop(): void {
this.onPortDidChangeEmitter.dispose();
}
get port(): string | undefined {
return this._port;
}
get onDidChangePort(): Event<string | undefined> {
return this.onPortDidChangeEmitter.event;
}
private setPort(port: string | undefined): void {
const oldPort = this._port;
this._port = port;
if (this._port !== oldPort) {
this.onPortDidChangeEmitter.fire(this._port);
}
}
}
@injectable()
export class FrontendConnectionStatusService extends TheiaFrontendConnectionStatusService {
@inject(DaemonPort)
private readonly daemonPort: DaemonPort;
@inject(IsOnline)
private readonly isOnline: IsOnline;
@postConstruct()
protected async init(): Promise<void> {
protected override async init(): Promise<void> {
this.schedulePing();
const refresh = debounce(() => {
this.updateStatus(Boolean(this.daemonPort.port) && this.isOnline.online);
this.schedulePing();
}, this.options.offlineTimeout - 10);
this.wsConnectionProvider.onIncomingMessageActivity(() => refresh());
}
protected override async performPingRequest(): Promise<void> {
try {
this.connectedPort = await this.daemon.tryGetPort();
} catch {}
this.notificationCenter.onDaemonDidStart(
(port) => (this.connectedPort = port)
);
this.notificationCenter.onDaemonDidStop(
() => (this.connectedPort = undefined)
);
await this.pingService.ping();
this.updateStatus(this.isOnline.online);
} catch (e) {
this.updateStatus(false);
this.logger.error(e);
}
}
}
const connectionStatusStatusBar = 'connection-status';
const theiaOffline = 'theia-mod-offline';
export type OfflineConnectionStatus =
/**
* There is no websocket connection between the frontend and the backend.
*/
| 'backend'
/**
* The CLI daemon port is not available. Could not establish the gRPC connection between the backend and the CLI.
*/
| 'daemon'
/**
* Cloud not connect to the Internet from the browser.
*/
| 'internet';
@injectable()
export class ApplicationConnectionStatusContribution extends TheiaApplicationConnectionStatusContribution {
@inject(DaemonPort)
private readonly daemonPort: DaemonPort;
@inject(IsOnline)
private readonly isOnline: IsOnline;
@inject(MessageService)
private readonly messageService: MessageService;
@inject(NotificationManager)
private readonly notificationManager: NotificationManager;
@inject(CreateFeatures)
private readonly createFeatures: CreateFeatures;
private readonly offlineStatusDidChangeEmitter = new Emitter<
OfflineConnectionStatus | undefined
>();
private noInternetConnectionNotificationId: string | undefined;
private _offlineStatus: OfflineConnectionStatus | undefined;
get offlineStatus(): OfflineConnectionStatus | undefined {
return this._offlineStatus;
}
get onOfflineStatusDidChange(): Event<OfflineConnectionStatus | undefined> {
return this.offlineStatusDidChangeEmitter.event;
}
protected override onStateChange(state: ConnectionStatus): void {
if (!this.connectedPort && state === ConnectionStatus.ONLINE) {
if (
(!Boolean(this.daemonPort.port) || !this.isOnline.online) &&
state === ConnectionStatus.ONLINE
) {
return;
}
super.onStateChange(state);
}
protected override handleOffline(): void {
this.statusBar.setElement('connection-status', {
const params = {
port: this.daemonPort.port,
online: this.isOnline.online,
};
this._offlineStatus = offlineConnectionStatusType(params);
const { text, tooltip } = offlineMessage(params);
this.statusBar.setElement(connectionStatusStatusBar, {
alignment: StatusBarAlignment.LEFT,
text: this.connectedPort
? nls.localize('theia/core/offline', 'Offline')
: '$(bolt) ' +
nls.localize('theia/core/daemonOffline', 'CLI Daemon Offline'),
tooltip: this.connectedPort
? nls.localize(
'theia/core/cannotConnectBackend',
'Cannot connect to the backend.'
)
: nls.localize(
'theia/core/cannotConnectDaemon',
'Cannot connect to the CLI daemon.'
),
text,
tooltip,
priority: 5000,
});
this.toDisposeOnOnline.push(
Disposable.create(() => this.statusBar.removeElement('connection-status'))
);
document.body.classList.add('theia-mod-offline');
this.toDisposeOnOnline.push(
document.body.classList.add(theiaOffline);
this.toDisposeOnOnline.pushAll([
Disposable.create(() =>
document.body.classList.remove('theia-mod-offline')
)
);
this.statusBar.removeElement(connectionStatusStatusBar)
),
Disposable.create(() => document.body.classList.remove(theiaOffline)),
Disposable.create(() => {
this._offlineStatus = undefined;
this.fireStatusDidChange();
}),
]);
if (!this.isOnline.online) {
const text = nls.localize(
'arduino/connectionStatus/connectionLost',
"Connection lost. Cloud sketch actions and updates won't be available."
);
this.noInternetConnectionNotificationId = this.notificationManager[
'getMessageId'
]({ text, type: MessageType.Warning });
if (this.createFeatures.enabled) {
this.messageService.warn(text);
}
this.toDisposeOnOnline.push(
Disposable.create(() => this.clearNoInternetConnectionNotification())
);
}
this.fireStatusDidChange();
}
private clearNoInternetConnectionNotification(): void {
if (this.noInternetConnectionNotificationId) {
this.notificationManager.clear(this.noInternetConnectionNotificationId);
this.noInternetConnectionNotificationId = undefined;
}
}
private fireStatusDidChange(): void {
if (this.createFeatures.enabled) {
return this.offlineStatusDidChangeEmitter.fire(this._offlineStatus);
}
}
}
interface OfflineMessageParams {
readonly port: string | undefined;
readonly online: boolean;
}
interface OfflineMessage {
readonly text: string;
readonly tooltip: string;
}
/**
* (non-API) exported for testing
*
* The precedence of the offline states are the following:
* - No connection to the Theia backend,
* - CLI daemon is offline, and
* - There is no Internet connection.
*/
export function offlineMessage(params: OfflineMessageParams): OfflineMessage {
const statusType = offlineConnectionStatusType(params);
const text = getOfflineText(statusType);
const tooltip = getOfflineTooltip(statusType);
return { text, tooltip };
}
function offlineConnectionStatusType(
params: OfflineMessageParams
): OfflineConnectionStatus {
const { port, online } = params;
if (port && online) {
return 'backend';
}
if (!port) {
return 'daemon';
}
return 'internet';
}
export const backendOfflineText = nls.localize('theia/core/offline', 'Offline');
export const daemonOfflineText = nls.localize(
'theia/core/daemonOffline',
'CLI Daemon Offline'
);
export const offlineText = nls.localize('theia/core/offlineText', 'Offline');
export const backendOfflineTooltip = nls.localize(
'theia/core/cannotConnectBackend',
'Cannot connect to the backend.'
);
export const daemonOfflineTooltip = nls.localize(
'theia/core/cannotConnectDaemon',
'Cannot connect to the CLI daemon.'
);
export const offlineTooltip = offlineText;
function getOfflineText(statusType: OfflineConnectionStatus): string {
switch (statusType) {
case 'backend':
return backendOfflineText;
case 'daemon':
return '$(bolt) ' + daemonOfflineText;
case 'internet':
return '$(alert) ' + offlineText;
default:
assertUnreachable(statusType);
}
}
function getOfflineTooltip(statusType: OfflineConnectionStatus): string {
switch (statusType) {
case 'backend':
return backendOfflineTooltip;
case 'daemon':
return daemonOfflineTooltip;
case 'internet':
return offlineTooltip;
default:
assertUnreachable(statusType);
}
}

View File

@@ -10,17 +10,21 @@ import {
import * as React from '@theia/core/shared/react';
import { accountMenu } from '../../contributions/account';
import { CreateFeatures } from '../../create/create-features';
import { ApplicationConnectionStatusContribution } from './connection-status-service';
@injectable()
export class SidebarBottomMenuWidget extends TheiaSidebarBottomMenuWidget {
@inject(CreateFeatures)
private readonly createFeatures: CreateFeatures;
@inject(ApplicationConnectionStatusContribution)
private readonly connectionStatue: ApplicationConnectionStatusContribution;
@postConstruct()
protected init(): void {
this.toDispose.push(
this.createFeatures.onDidChangeSession(() => this.update())
);
this.toDispose.pushAll([
this.createFeatures.onDidChangeSession(() => this.update()),
this.connectionStatue.onOfflineStatusDidChange(() => this.update()),
]);
}
protected override onClick(
@@ -28,7 +32,7 @@ export class SidebarBottomMenuWidget extends TheiaSidebarBottomMenuWidget {
menuPath: MenuPath
): void {
const button = e.currentTarget.getBoundingClientRect();
this.contextMenuRenderer.render({
const options = {
menuPath,
includeAnchorArg: false,
anchor: {
@@ -37,7 +41,9 @@ export class SidebarBottomMenuWidget extends TheiaSidebarBottomMenuWidget {
// https://github.com/eclipse-theia/theia/discussions/12170
y: button.top,
},
});
showDisabled: true,
};
this.contextMenuRenderer.render(options);
}
protected override render(): React.ReactNode {
@@ -55,7 +61,9 @@ export class SidebarBottomMenuWidget extends TheiaSidebarBottomMenuWidget {
}
const arduinoAccount = menu.id === accountMenu.id;
const picture =
arduinoAccount && this.createFeatures.session?.account.picture;
arduinoAccount &&
this.connectionStatue.offlineStatus !== 'internet' &&
this.createFeatures.session?.account.picture;
const className = typeof picture === 'string' ? undefined : menu.iconClass;
return (
<i

View File

@@ -1,18 +1,28 @@
import * as remote from '@theia/core/electron-shared/@electron/remote';
import { FrontendApplication } from '@theia/core/lib/browser/frontend-application';
import { FrontendApplicationConfigProvider } from '@theia/core/lib/browser/frontend-application-config-provider';
import { NavigatableWidget } from '@theia/core/lib/browser/navigatable-types';
import { ApplicationShell } from '@theia/core/lib/browser/shell/application-shell';
import { Widget } from '@theia/core/lib/browser/widgets/widget';
import { WindowTitleUpdater as TheiaWindowTitleUpdater } from '@theia/core/lib/browser/window/window-title-updater';
import { ApplicationServer } from '@theia/core/lib/common/application-protocol';
import { DisposableCollection } from '@theia/core/lib/common/disposable';
import { nls } from '@theia/core/lib/common/nls';
import { isOSX } from '@theia/core/lib/common/os';
import {
inject,
injectable,
postConstruct,
} from '@theia/core/shared/inversify';
import { EditorManager } from '@theia/editor/lib/browser/editor-manager';
import { EditorWidget } from '@theia/editor/lib/browser/editor-widget';
import { WorkspaceService } from '@theia/workspace/lib/browser/workspace-service';
import { ConfigServiceClient } from '../../config/config-service-client';
import { CreateFeatures } from '../../create/create-features';
import {
CurrentSketch,
SketchesServiceClientImpl,
} from '../../sketches-service-client-impl';
@injectable()
export class WindowTitleUpdater extends TheiaWindowTitleUpdater {
@@ -22,12 +32,22 @@ export class WindowTitleUpdater extends TheiaWindowTitleUpdater {
private readonly applicationShell: ApplicationShell;
@inject(WorkspaceService)
private readonly workspaceService: WorkspaceService;
private _previousRepresentedFilename: string | undefined;
@inject(SketchesServiceClientImpl)
private readonly sketchesServiceClient: SketchesServiceClientImpl;
@inject(ConfigServiceClient)
private readonly configServiceClient: ConfigServiceClient;
@inject(CreateFeatures)
private readonly createFeatures: CreateFeatures;
@inject(EditorManager)
private readonly editorManager: EditorManager;
private readonly applicationName =
FrontendApplicationConfigProvider.get().applicationName;
private readonly toDispose = new DisposableCollection();
private previousRepresentedFilename: string | undefined;
private applicationVersion: string | undefined;
private hasCloudPrefix: boolean | undefined;
@postConstruct()
protected init(): void {
@@ -43,6 +63,22 @@ export class WindowTitleUpdater extends TheiaWindowTitleUpdater {
);
}
override onStart(app: FrontendApplication): void {
super.onStart(app);
this.toDispose.pushAll([
this.sketchesServiceClient.onCurrentSketchDidChange(() =>
this.maybeSetCloudPrefix()
),
this.configServiceClient.onDidChangeDataDirUri(() =>
this.maybeSetCloudPrefix()
),
]);
}
onStop(): void {
this.toDispose.dispose();
}
protected override handleWidgetChange(widget?: Widget | undefined): void {
if (isOSX) {
this.maybeUpdateRepresentedFilename(widget);
@@ -54,7 +90,7 @@ export class WindowTitleUpdater extends TheiaWindowTitleUpdater {
protected override updateTitleWidget(widget?: Widget | undefined): void {
let activeEditorShort = '';
const rootName = this.workspaceService.workspace?.name ?? '';
let rootName = this.workspaceService.workspace?.name ?? '';
let appName = `${this.applicationName}${
this.applicationVersion ? ` ${this.applicationVersion}` : ''
}`;
@@ -69,6 +105,12 @@ export class WindowTitleUpdater extends TheiaWindowTitleUpdater {
activeEditorShort = ` - ${base} `;
}
}
if (this.hasCloudPrefix) {
rootName = `[${nls.localize(
'arduino/title/cloud',
'Cloud'
)}] ${rootName}`;
}
this.windowTitleService.update({ rootName, appName, activeEditorShort });
}
@@ -77,10 +119,32 @@ export class WindowTitleUpdater extends TheiaWindowTitleUpdater {
const { uri } = widget.editor;
const filename = uri.path.toString();
// Do not necessarily require the current window if not needed. It's a synchronous, blocking call.
if (this._previousRepresentedFilename !== filename) {
if (this.previousRepresentedFilename !== filename) {
const currentWindow = remote.getCurrentWindow();
currentWindow.setRepresentedFilename(uri.path.toString());
this._previousRepresentedFilename = filename;
this.previousRepresentedFilename = filename;
}
}
}
private maybeSetCloudPrefix(): void {
if (typeof this.hasCloudPrefix === 'boolean') {
return;
}
const sketch = this.sketchesServiceClient.tryGetCurrentSketch();
if (!CurrentSketch.isValid(sketch)) {
return;
}
const dataDirUri = this.configServiceClient.tryGetDataDirUri();
if (!dataDirUri) {
return;
}
this.hasCloudPrefix = this.createFeatures.isCloud(sketch, dataDirUri);
if (typeof this.hasCloudPrefix === 'boolean') {
const editor =
this.editorManager.activeEditor ?? this.editorManager.currentEditor;
if (editor) {
this.updateTitleWidget(editor);
}
}
}

View File

@@ -1,6 +1,5 @@
import { CancellationToken } from '@theia/core/lib/common/cancellation';
import type {
Message,
ProgressMessage,
ProgressUpdate,
} from '@theia/core/lib/common/message-service-protocol';
@@ -46,11 +45,4 @@ export class NotificationManager extends TheiaNotificationManager {
}
return Math.min((update.work.done / update.work.total) * 100, 100);
}
/**
* For `public` visibility.
*/
override getMessageId(message: Message): string {
return super.getMessageId(message);
}
}